diff --git a/Makefile b/Makefile
index e2e35c9..e010366 100644
--- a/Makefile
+++ b/Makefile
@@ -15,6 +15,7 @@ ifeq ($(UNAME_S),Linux)
endif
CFLAGS ?= -std=c99 $(POSIX_CFLAGS) -Wall -Wextra -Wpedantic -O2 -pthread
TESTCFLAGS ?= -std=c99 $(POSIX_CFLAGS) -Wall -Wextra -Wpedantic -O0 -g -pthread
+SAN ?= address
CONPTY_WIN_CC ?= x86_64-w64-mingw32-gcc
CONPTY_WIN_CFLAGS ?= -std=c99 -Wall -Wextra -Wpedantic -Werror -D_WIN32_WINNT=0x0A00
@@ -40,7 +41,7 @@ LIB_SECTIONS := $(wildcard $(SRCDIR)/timui_*.c) $(SRCDIR)/timui_int.h \
# link flags, so it is built by a dedicated rule below — keep it out of the
# generic single-file example pattern.
EXAMPLES := $(filter-out $(BLDDIR)/radio,$(patsubst $(EXADIR)/%.c,$(BLDDIR)/%,$(wildcard $(EXADIR)/*.c)))
-TEST_SRCS := $(SRCDIR)/timui.c $(TSTDIR)/test_main.c $(TSTDIR)/test_rect.c $(TSTDIR)/test_result.c $(TSTDIR)/test_arena.c $(TSTDIR)/test_strings.c $(TSTDIR)/test_id_stack.c $(TSTDIR)/test_msgq.c $(TSTDIR)/test_mpsc.c $(TSTDIR)/test_transport.c $(TSTDIR)/test_screen.c $(TSTDIR)/test_input.c $(TSTDIR)/test_mouse.c $(TSTDIR)/test_termios.c $(TSTDIR)/test_size.c $(TSTDIR)/test_caps.c $(TSTDIR)/test_kitty.c $(TSTDIR)/test_sync.c $(TSTDIR)/test_cells.c $(TSTDIR)/test_utf8.c $(TSTDIR)/test_draw.c $(TSTDIR)/test_render.c $(TSTDIR)/test_cursor.c $(TSTDIR)/test_frame.c $(TSTDIR)/test_interact.c $(TSTDIR)/test_theme.c $(TSTDIR)/test_stylesheet.c $(TSTDIR)/test_button.c $(TSTDIR)/test_widgets.c $(TSTDIR)/test_input_widget.c $(TSTDIR)/test_listbox.c $(TSTDIR)/test_grid_widget.c $(TSTDIR)/test_dialog.c $(TSTDIR)/test_fuzz.c $(TSTDIR)/test_clip.c $(TSTDIR)/test_menus.c $(TSTDIR)/test_modal.c $(TSTDIR)/test_hyperlink.c $(TSTDIR)/test_esc_timeout.c $(TSTDIR)/test_scroll.c $(TSTDIR)/test_v02_batch.c $(TSTDIR)/test_v02_widgets.c $(TSTDIR)/test_v02_more.c $(TSTDIR)/test_images_pty.c $(TSTDIR)/test_review_critical.c $(TSTDIR)/test_snapshot.c $(TSTDIR)/test_coverage_z7.c $(TSTDIR)/test_render_stream.c
+TEST_SRCS := $(SRCDIR)/timui.c $(TSTDIR)/test_main.c $(TSTDIR)/test_rect.c $(TSTDIR)/test_result.c $(TSTDIR)/test_arena.c $(TSTDIR)/test_strings.c $(TSTDIR)/test_id_stack.c $(TSTDIR)/test_msgq.c $(TSTDIR)/test_mpsc.c $(TSTDIR)/test_transport.c $(TSTDIR)/test_screen.c $(TSTDIR)/test_input.c $(TSTDIR)/test_mouse.c $(TSTDIR)/test_termios.c $(TSTDIR)/test_size.c $(TSTDIR)/test_caps.c $(TSTDIR)/test_kitty.c $(TSTDIR)/test_sync.c $(TSTDIR)/test_cells.c $(TSTDIR)/test_utf8.c $(TSTDIR)/test_draw.c $(TSTDIR)/test_render.c $(TSTDIR)/test_cursor.c $(TSTDIR)/test_frame.c $(TSTDIR)/test_interact.c $(TSTDIR)/test_theme.c $(TSTDIR)/test_stylesheet.c $(TSTDIR)/test_button.c $(TSTDIR)/test_widgets.c $(TSTDIR)/test_input_widget.c $(TSTDIR)/test_listbox.c $(TSTDIR)/test_grid_widget.c $(TSTDIR)/test_dialog.c $(TSTDIR)/test_fuzz.c $(TSTDIR)/test_clip.c $(TSTDIR)/test_menus.c $(TSTDIR)/test_modal.c $(TSTDIR)/test_hyperlink.c $(TSTDIR)/test_esc_timeout.c $(TSTDIR)/test_scroll.c $(TSTDIR)/test_v02_batch.c $(TSTDIR)/test_v02_widgets.c $(TSTDIR)/test_v02_more.c $(TSTDIR)/test_images_pty.c $(TSTDIR)/test_review_critical.c $(TSTDIR)/test_snapshot.c $(TSTDIR)/test_coverage_z7.c $(TSTDIR)/test_render_stream.c $(TSTDIR)/test_async_scan.c
TEST_BIN := $(BLDDIR)/test_unit
GOLDEN_BIN := $(BLDDIR)/gen_golden
@@ -134,7 +135,7 @@ endif
# 2. BUILD RULES — help/build · example pattern rule · test & tool binaries · subsystem objects
# ============================================================================
-.PHONY: help build test test-san run www check-www check-www-assets amalgamate release-check fmt check clean goldens vt-test check-no-images check-conpty check-conpty-posix check-conpty-win32-compile check-conpty-win32-smoke-compile check-chat-highlight check-chat-text man install-man check-chat-text-sheenbidi check-radio smoke-radio run-radio check-sqlite-tui run-sqlite-tui smoke-sqlite-tui check-grid check-layout check-tabs check-chart check-syntax run-gallery smoke-gallery check-image-smoke smoke-image-live smoke-image-live-auto smoke-image-live-kitty smoke-image-live-sixel smoke-image-live-iterm2 smoke-image-live-none smoke-conpty-win32 check-irc run-irc smoke-irc
+.PHONY: help build test test-san run www check-www check-www-assets check-phase1-5-docs check-hosted-visual-windows check-conpty-evidence-artifacts verify-conpty-evidence amalgamate release-check fmt check clean goldens vt-test check-no-images check-conpty check-conpty-posix check-conpty-smoke-tool check-conpty-source-order check-conpty-win32-compile check-conpty-win32-smoke-compile check-chat-highlight check-chat-text man install-man check-chat-text-sheenbidi check-radio smoke-radio run-radio check-sqlite-tui run-sqlite-tui smoke-sqlite-tui check-grid check-layout check-tabs check-chart check-syntax run-gallery smoke-gallery check-image-smoke smoke-image-live smoke-image-live-auto smoke-image-live-kitty smoke-image-live-sixel smoke-image-live-iterm2 smoke-image-live-none smoke-conpty-win32 check-irc run-irc smoke-irc
.PHONY: accept check-vt-gif check-vt-gif-glyphs check-vt-gif-cjk check-vt-gif-emoji check-vt-gif-output check-vt-gif-golden gen-golden-vtgif check-vt-gif-style check-vt-gif-all
.PHONY: run-chat-demo rec-chat-demo gif-chat-demo webp-chat-demo gen-font-ttf gen-emoji gen-cjk
@@ -292,7 +293,7 @@ rec-chat-demo: $(BLDDIR)/chat ## Screen-record hint, then autoplay the chat demo
# 5. CHECK — unit tests · goldens · acceptance · per-subsystem standalone checks
# ============================================================================
-check: build test check-no-images check-conpty-win32-compile check-conpty-win32-smoke-compile check-www ## Build + test gate
+check: build test check-no-images check-conpty-smoke-tool check-conpty-source-order check-conpty-win32-compile check-conpty-win32-smoke-compile check-hosted-visual-windows check-conpty-evidence-artifacts check-www check-phase1-5-docs ## Build + test gate
@printf "$(C_GREEN)✓ check passed$(C_RESET)\n"
test: $(TEST_BIN) ## Compile and run the unit tests
@@ -309,6 +310,43 @@ vt-test: build ## Compile + run unit tests WITH vterm round-trip tests (needs li
@printf "$(C_YELL)▶ running vt-tests$(C_RESET)\n"
@./$(VT_BIN)
+check-phase1-5-docs: ## Verify Phase 1.5 evidence docs agree
+ @awk '{$$1=$$1; printf "%s ", $$0}' docs/runbooks/phase1-5-live-evidence.md | grep -Fq -- 'Run `28986249841` is the accepted hosted macOS iTerm2 baseline'
+ @grep -Fq -- 'Hosted macOS iTerm2 now has accepted timui OSC 1337 visual evidence.' docs/handoff/2026-07-09-hosted-visual-probe.md
+ @awk '{$$1=$$1; printf "%s ", $$0}' docs/backlog.md | grep -Fq -- '- [x] iTerm2 terminal evidence via hosted macOS iTerm2 run `28986249841`.'
+ @! grep -Fq -- '- [ ] iTerm2 terminal evidence.' docs/backlog.md
+ @awk '{$$1=$$1; printf "%s ", $$0}' docs/gaps.md | grep -Fq -- 'Image protocol live evidence is accepted for Windows Terminal Sixel and hosted macOS iTerm2.'
+ @grep -Fq -- 'DONE: iTerm2 terminal evidence via hosted macOS iTerm2 run `28986249841`.' docs/goals/phase1_5-platform-widgets-style-text-image.goal.txt
+ @grep -Fq -- 'DONE: hosted Windows ConPTY smoke evidence via run `29226547099`' docs/goals/phase1_5-platform-widgets-style-text-image.goal.txt
+ @awk '{$$1=$$1; printf "%s ", $$0}' docs/runbooks/phase1-5-live-evidence.md | grep -Fq -- 'Run `29226547099` is the accepted hosted Windows ConPTY smoke baseline'
+ @awk '{$$1=$$1; printf "%s ", $$0}' docs/backlog.md | grep -Fq -- '- [x] Hosted Windows ConPTY smoke evidence via run `29226547099`.'
+ @awk '{$$1=$$1; printf "%s ", $$0}' docs/gaps.md | grep -Fq -- 'Windows ConPTY live evidence is accepted in hosted run `29226547099`'
+ @awk '{$$1=$$1; printf "%s ", $$0}' docs/TERMINAL_PROTOCOLS.md | grep -Fq -- 'Hosted Windows Terminal Sixel evidence is accepted in run `28982641529`; hosted macOS iTerm2 evidence is accepted in run `28986249841`.'
+ @awk '{$$1=$$1; printf "%s ", $$0}' docs/TERMINAL_PROTOCOLS.md | grep -Fq -- 'Hosted Windows ConPTY smoke evidence is accepted in run `29226547099`'
+ @grep -Fq -- 'make verify-conpty-evidence ARTIFACT_DIR=' docs/runbooks/phase1-5-live-evidence.md
+ @printf "$(C_GREEN)✓ Phase 1.5 evidence docs$(C_RESET)\n"
+
+check-hosted-visual-windows: tools/ci/hosted_visual_windows.ps1 ## Verify hosted Windows probe emits ConPTY acceptance artifacts
+ @grep -Fq -- 'conpty-acceptance.json' tools/ci/hosted_visual_windows.ps1
+ @grep -Fq -- 'conpty-smoke.command.txt' tools/ci/hosted_visual_windows.ps1
+ @grep -Fq -- 'conpty-smoke.meta.txt' tools/ci/hosted_visual_windows.ps1
+ @grep -Fq -- 'passTokenPresent' tools/ci/hosted_visual_windows.ps1
+ @grep -Fq -- 'accepted' tools/ci/hosted_visual_windows.ps1
+ @grep -Fq -- 'PASS conpty smoke: observed TIMUI_CONPTY_SMOKE' tools/ci/hosted_visual_windows.ps1
+ @grep -Fq -- 'ConvertTo-Json' tools/ci/hosted_visual_windows.ps1
+ @grep -Fq -- 'return (Invoke-Captured $$Name' tools/ci/hosted_visual_windows.ps1
+ @printf "$(C_GREEN)✓ hosted Windows ConPTY evidence manifest$(C_RESET)\n"
+
+check-conpty-evidence-artifacts: tests/test_conpty_evidence.py tools/verify_conpty_evidence.py ## Verify hosted ConPTY artifact acceptance predicates
+ @TIMUI_TEST_COMMIT=$$(git rev-parse HEAD) python3 tests/test_conpty_evidence.py
+ @printf "$(C_GREEN)✓ hosted ConPTY evidence artifact verifier$(C_RESET)\n"
+
+verify-conpty-evidence: tools/verify_conpty_evidence.py ## Validate downloaded hosted ConPTY evidence (ARTIFACT_DIR=... [COMMIT=...])
+ @[ -n "$(ARTIFACT_DIR)" ] || { printf "$(C_YELL)ARTIFACT_DIR is required$(C_RESET)\n"; exit 2; }
+ @commit="$(COMMIT)"; \
+ if [ -z "$$commit" ]; then commit=$$(git rev-parse HEAD); fi; \
+ python3 tools/verify_conpty_evidence.py --artifact-dir "$(ARTIFACT_DIR)" --commit "$$commit"
+
check-no-images: $(TSTDIR)/test_no_images.c $(HEADER) $(LIB_SECTIONS) ## Test TIMUI_NO_IMAGES keeps API but disables terminal image escapes
@mkdir -p $(BLDDIR)
@printf "$(C_CYAN)build$(C_RESET) no-images test\n"
@@ -321,6 +359,16 @@ check-conpty-posix: $(TEST_BIN) ## Run POSIX ConPTY fallback/helper coverage
@printf "$(C_YELL)▶ running ConPTY POSIX helper tests$(C_RESET)\n"
@./$(TEST_BIN)
+check-conpty-smoke-tool: $(TSTDIR)/test_conpty_smoke_tool.c $(TOOLDIR)/conpty_smoke_win32.c $(HEADER) $(LIB_SECTIONS) ## Test portable ConPTY smoke-tool helpers
+ @mkdir -p $(BLDDIR)
+ @printf "$(C_CYAN)build$(C_RESET) ConPTY smoke helper tests\n"
+ @$(CC) $(CFLAGS) -I$(INCDIR) $(TSTDIR)/test_conpty_smoke_tool.c -o $(BLDDIR)/test_conpty_smoke_tool
+ @./$(BLDDIR)/test_conpty_smoke_tool
+
+check-conpty-source-order: $(TSTDIR)/test_conpty_source.py $(SRCDIR)/timui_conpty.c ## Verify ConPTY handle lifetime ordering
+ @python3 $(TSTDIR)/test_conpty_source.py
+ @printf "$(C_GREEN)✓ ConPTY source ordering$(C_RESET)\n"
+
check-conpty-win32-compile: ## Cross-compile the isolated Win32 ConPTY backend when mingw is available
@mkdir -p $(BLDDIR)
@if ! command -v $(CONPTY_WIN_CC) >/dev/null 2>&1; then \
@@ -341,7 +389,7 @@ check-conpty-win32-smoke-compile: ## Cross-compile the operator Win32 ConPTY smo
@$(CONPTY_WIN_CC) $(CONPTY_WIN_CFLAGS) -I$(INCDIR) $(TOOLDIR)/conpty_smoke_win32.c -o $(BLDDIR)/conpty_smoke_win32.exe
@printf "$(C_GREEN)✓ Win32 ConPTY operator smoke runner compiles$(C_RESET)\n"
-check-conpty: check-conpty-posix check-conpty-win32-compile ## Run ConPTY POSIX helper + optional Win32 compile checks
+check-conpty: check-conpty-posix check-conpty-smoke-tool check-conpty-source-order check-conpty-win32-compile ## Run ConPTY helper + optional Win32 compile checks
goldens: $(GOLDEN_BIN) ## Regenerate tests/golden/*.txt snapshots
@mkdir -p tests/golden
@@ -674,11 +722,11 @@ smoke-image-live-iterm2: smoke-image-live ## Live image smoke forcing iTerm2 inl
smoke-image-live-none: PROTO=none
smoke-image-live-none: smoke-image-live ## Live image smoke forcing text placeholders
-smoke-conpty-win32: check-conpty-win32-smoke-compile ## Run the ConPTY smoke runner on Windows Terminal only
+smoke-conpty-win32: check-conpty-win32-smoke-compile ## Run the ConPTY smoke runner on Windows only
@if [ "$${OS:-}" = "Windows_NT" ]; then \
./$(BLDDIR)/conpty_smoke_win32.exe; \
else \
- printf "$(C_YELL)SKIP$(C_RESET) smoke-conpty-win32 must run inside Windows Terminal on Windows\n"; \
+ printf "$(C_YELL)SKIP$(C_RESET) smoke-conpty-win32 must run on Windows\n"; \
fi
# ============================================================================
@@ -744,13 +792,14 @@ www: amalgamate ## Refresh static website assets under www/
@$(MAKE) check-www-assets
@printf "$(C_GREEN)✓ refreshed $(WWW_HEADER) and $(WWW_LICENSE)$(C_RESET)\n"
-check-www: check-www-assets ## Verify static website license, agent links, and assets
+check-www: amalgamate check-www-assets ## Verify static website license, agent links, header freshness, and assets
@grep -q '
LICENSE
' $(WWWDIR)/index.html
@grep -q 'href="LICENSE"' $(WWWDIR)/index.html
@grep -q 'Apache-2.0' $(WWWDIR)/index.html
@grep -q '^## License$$' $(WWWDIR)/llms.txt
@grep -q 'https://timui.dev/LICENSE' $(WWWDIR)/llms.txt
@grep -q 'SPDX-License-Identifier: Apache-2.0' $(WWW_HEADER)
+ @cmp -s $(RELDIR)/timui.h $(WWW_HEADER) || { printf "$(C_YELL)✗ www$(C_RESET) timui.h is stale; run make www\n"; exit 1; }
@cmp -s LICENSE $(WWW_LICENSE)
@printf "$(C_GREEN)✓ website license links$(C_RESET)\n"
diff --git a/docs/API.md b/docs/API.md
index 6262be2..4bc5b48 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -59,8 +59,8 @@ calling `ResizePseudoConsole`.
On non-Windows builds, ConPTY APIs return `TIMUI_ERR_UNSUPPORTED` after
clearing output handles. The Win32 path is enforced by the MinGW compile seam in
-`make check`; live Windows Terminal smoke evidence is still required before
-documenting Windows as supported.
+`make check`; hosted Windows ConPTY smoke evidence is accepted in run
+`29226547099` at commit `44bb495b1f7937357f117b42563b50ba08c08e08`.
## Layout
diff --git a/docs/TERMINAL_PROTOCOLS.md b/docs/TERMINAL_PROTOCOLS.md
index 67d170f..b3877ee 100644
--- a/docs/TERMINAL_PROTOCOLS.md
+++ b/docs/TERMINAL_PROTOCOLS.md
@@ -58,16 +58,17 @@ set for tests or user overrides. With `TIMUI_NO_IMAGES`, image caps are stripped
after detection and force masks; protocol selection always returns
`TIMUI_IMAGE_PROTOCOL_NONE`.
-## Planned
+## Platform Evidence
- **Windows ConPTY**: implemented as a `_WIN32` transport/lifecycle backend
with runtime symbol probing and MinGW compile coverage in `make check`. It
- belongs below the protocol layer, not as a graphics abstraction. Live Windows
- Terminal smoke evidence is still pending before claiming supported Windows
- operation.
+ belongs below the protocol layer, not as a graphics abstraction. Hosted
+ Windows ConPTY smoke evidence is accepted in run `29226547099` at commit
+ `44bb495b1f7937357f117b42563b50ba08c08e08`.
- **Sixel parity**: the RGBA emitter is implemented with exact palettes,
bounded 16-colour quantization, clipping, and scaling when terminal
cell-pixel geometry is known. Plain PNG images are decoded lazily through the
bounded PNG-only `stb_image` path for Sixel, while `timui_image_from_png_rgba`
- lets applications provide exact decoded pixels directly. Deferred work:
- real-terminal evidence.
+ lets applications provide exact decoded pixels directly. Hosted Windows
+ Terminal Sixel evidence is accepted in run `28982641529`; hosted macOS iTerm2
+ evidence is accepted in run `28986249841`.
diff --git a/docs/backlog.md b/docs/backlog.md
index 84668f0..3abb68a 100644
--- a/docs/backlog.md
+++ b/docs/backlog.md
@@ -27,14 +27,14 @@ I/O via `timui_post`, offline `--demo`/`--replay`, `make check-irc`, and
This is the next planned sweep. It is library/platform work, not a large app
push. Detailed pickup prompt: `docs/goals/phase1_5-platform-widgets-style-text-image.goal.txt`.
-- [ ] **Windows ConPTY backend** - replace the unsupported stub with a real
+- [x] **Windows ConPTY backend** - replace the unsupported stub with a real
`CreatePseudoConsole` transport: pipes, process lifetime, resize, close,
runtime symbol probing, short-write/read handling, and `DWORD` chunking.
Do not promise graphics as part of ConPTY itself; graphics are selected by
terminal image protocol capability above the transport.
- [x] `_WIN32` backend implementation, resize API, POSIX fallback tests,
and MinGW compile seam in `make check`.
- - [ ] Real Windows Terminal smoke run and captured operator evidence.
+ - [x] Hosted Windows ConPTY smoke evidence via run `29226547099`.
- [x] **Submit-capable multi-line text area** - add a result-returning,
controlled API plus `_mut` convenience wrapper, preserving the current
void `timui_text_area` compatibility wrapper. Refactor `examples/chat.c`
@@ -57,7 +57,7 @@ push. Detailed pickup prompt: `docs/goals/phase1_5-platform-widgets-style-text-i
- [x] **Grapheme clustering foundation** - next/prev/width helpers integrated
into editing and truncation for ZWJ emoji, skin tones, regional
indicators, combining marks, VS16, and CRLF.
-- [ ] **Protocol-neutral image layer + Sixel + iTerm2** - keep Kitty support,
+- [x] **Protocol-neutral image layer + Sixel + iTerm2** - keep Kitty support,
but refactor image emission behind protocol capabilities. Add Sixel first
(including Windows Terminal usefulness), then iTerm2 inline images, with
fallback placeholders preserved.
@@ -81,11 +81,15 @@ push. Detailed pickup prompt: `docs/goals/phase1_5-platform-widgets-style-text-i
- [x] Operator smoke harnesses exist for live image protocols
(`nix develop -c make smoke-image-live PROTO=... FRAMES=N`) and
Windows ConPTY (`nix develop -c make smoke-conpty-win32`);
- compile/headless harness checks are wired, but live evidence is
- still pending. Use `docs/runbooks/phase1-5-live-evidence.md`.
+ compile/headless harness checks are wired. Use
+ `docs/runbooks/phase1-5-live-evidence.md` for protocol recapture
+ and evidence audits.
- [x] Sixel terminal evidence via hosted Windows Terminal run
`28982641529`.
- - [ ] iTerm2 terminal evidence.
+ - [x] iTerm2 terminal evidence via hosted macOS iTerm2 run
+ `28986249841`.
+ - [x] Windows ConPTY smoke evidence via hosted Windows run
+ `29226547099`.
## Phase 2 backlog - architecture and applications
diff --git a/docs/gaps.md b/docs/gaps.md
index 6479b75..2c77867 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -49,22 +49,24 @@ These are known limitations promoted into the next planned follow-up. See
- **G10/W13** Windows ConPTY is implemented behind `_WIN32`, runtime-probed for
ConPTY entry points, and covered by the MinGW compile seam in `make check`
- plus POSIX fallback/helper tests. `make smoke-conpty-win32` is available for
- operator evidence, but do not claim Windows support until a real Windows
- Terminal smoke run is green and recorded per
- `docs/runbooks/phase1-5-live-evidence.md`.
-- **Image protocol emitters** still need iTerm2 terminal evidence.
- Kitty graphics, iTerm2 inline PNG images, raw-RGBA Sixel, PNG+RGBA sidecar
- Sixel, and built-in bounded plain-PNG-to-Sixel decode are in tree. Hosted
- Windows Terminal Sixel evidence is accepted in run `28982641529` and recorded
- per `docs/runbooks/phase1-5-live-evidence.md`. Remaining image gap: real
- iTerm2 terminal capture via `nix develop -c make smoke-image-live
- PROTO=iterm2 FRAMES=N`, or deterministic iTerm2 protocol evidence if hosted
- macOS TCC keeps blocking GUI capture. Multiplexer image passthrough is
- deliberately conservative for Phase 1.5: image caps are stripped under
- tmux/screen/zellij unless a later pass designs and tests a narrower
- passthrough contract.
- ConPTY should stay a transport, not a Windows-specific graphics abstraction.
+ plus POSIX fallback/helper tests. Windows ConPTY live evidence is accepted in
+ hosted run `29226547099` at commit
+ `44bb495b1f7937357f117b42563b50ba08c08e08`, verified with
+ `make verify-conpty-evidence` against the downloaded Windows artifact.
+- **Image protocol live evidence is accepted for Windows Terminal Sixel and
+ hosted macOS iTerm2.** Kitty graphics, iTerm2 inline PNG images, raw-RGBA
+ Sixel, PNG+RGBA sidecar Sixel, and built-in bounded plain-PNG-to-Sixel decode
+ are in tree. Hosted Windows Terminal Sixel evidence is accepted in run
+ `28982641529`; hosted macOS iTerm2 evidence is accepted in run
+ `28986249841`. Both are recorded per
+ `docs/runbooks/phase1-5-live-evidence.md` and
+ `docs/handoff/2026-07-09-hosted-visual-probe.md`. The Windows ConPTY smoke
+ gate is now accepted separately in hosted run `29226547099`; it remains a
+ transport gate, not an image protocol gate.
+ Multiplexer image passthrough is deliberately conservative for Phase 1.5:
+ image caps are stripped under tmux/screen/zellij unless a later pass designs
+ and tests a narrower passthrough contract. ConPTY should stay a transport,
+ not a Windows-specific graphics abstraction.
## Still open (documented limitations, not Phase 1.5)
diff --git a/docs/goals/phase1_5-platform-widgets-style-text-image.goal.txt b/docs/goals/phase1_5-platform-widgets-style-text-image.goal.txt
index 0c716bc..2a26454 100644
--- a/docs/goals/phase1_5-platform-widgets-style-text-image.goal.txt
+++ b/docs/goals/phase1_5-platform-widgets-style-text-image.goal.txt
@@ -58,9 +58,9 @@ WORKSTREAM A - Windows ConPTY backend:
cross compiler is available.
- DONE: `make smoke-conpty-win32` and
`make check-conpty-win32-smoke-compile` exist for operator evidence capture.
-- PENDING: a real Windows Terminal smoke run recorded per
- `docs/runbooks/phase1-5-live-evidence.md` before claiming supported Windows
- operation; `make test` and sanitizers remain green on POSIX.
+- DONE: hosted Windows ConPTY smoke evidence via run `29226547099` at commit
+ `44bb495b1f7937357f117b42563b50ba08c08e08`, verified locally with
+ `make verify-conpty-evidence` against the downloaded artifact.
WORKSTREAM B - Widgets:
- Textarea first. Add a result-returning controlled API, plus `_mut`
@@ -145,6 +145,8 @@ WORKSTREAM E - Protocol-neutral images, Sixel, iTerm2:
Kitty/Sixel/iTerm2 visual evidence capture, and `make check-image-smoke`
checks the harness headlessly without claiming protocol evidence. The accepted
operator procedure is `docs/runbooks/phase1-5-live-evidence.md`.
+- DONE: iTerm2 terminal evidence via hosted macOS iTerm2 run `28986249841`.
+- DONE: Windows ConPTY smoke evidence via hosted Windows run `29226547099`.
- Preserve Kitty behavior and fallback placeholder behavior. The public image
API has PNG, raw-RGBA, and PNG+RGBA sidecar constructors; Sixel uses decoded
pixels from raw RGBA, sidecars, or the bounded plain-PNG decoder.
@@ -162,10 +164,10 @@ DOCUMENTATION:
- Update `docs/gaps.md` as each selected gap is closed.
- Update `docs/API.md`, `docs/TERMINAL_PROTOCOLS.md`, `docs/website-product-brief.md`,
and the website copy only when behavior is actually implemented.
-- Add handoff docs under `docs/handoff/` if Windows smoke evidence, protocol
- evidence, or dependency/licensing research remains incomplete.
-- Use `docs/runbooks/phase1-5-live-evidence.md` for the remaining Windows,
- Sixel, and iTerm2 live evidence gates.
+- Add handoff docs under `docs/handoff/` if protocol evidence,
+ dependency/licensing research, or future platform evidence remains incomplete.
+- Use `docs/runbooks/phase1-5-live-evidence.md` for evidence audits and any
+ future protocol or platform recaptures.
VERIFICATION GATES:
- `nix develop -c make test`
diff --git a/docs/handoff/2026-07-09-hosted-visual-probe.md b/docs/handoff/2026-07-09-hosted-visual-probe.md
index cdc1384..6b76483 100644
--- a/docs/handoff/2026-07-09-hosted-visual-probe.md
+++ b/docs/handoff/2026-07-09-hosted-visual-probe.md
@@ -83,9 +83,10 @@ date: 2026-07-09
Run `28982372688` showed direct Sixel rendered, while timui emitted three
`4x4` rasters because the smoke fixture was 4x4 and MSYS/Windows Terminal did
not report cell pixel geometry.
-- Hosted Windows ConPTY smoke is still rejected. `conpty-smoke.status` is `2`;
- the runner compiles and starts `cmd.exe`, but the sentinel
- `TIMUI_CONPTY_SMOKE` is not observed.
+- Historical hosted Windows ConPTY smoke in this run was rejected.
+ `conpty-smoke.status` is `2`; the runner compiled and started `cmd.exe`, but
+ the sentinel `TIMUI_CONPTY_SMOKE` was not observed. Follow-up run
+ `29226547099` is the accepted ConPTY smoke baseline.
## Artifact Results
diff --git a/docs/handoff/2026-07-13-conpty-live-rca.md b/docs/handoff/2026-07-13-conpty-live-rca.md
new file mode 100644
index 0000000..204e49e
--- /dev/null
+++ b/docs/handoff/2026-07-13-conpty-live-rca.md
@@ -0,0 +1,114 @@
+---
+type: Report
+title: Windows ConPTY Live Smoke RCA
+date: 2026-07-13
+---
+
+# Windows ConPTY Live Smoke RCA
+
+## Landing State
+
+- Branch: `conpty-evidence-manifest-2026-07-13`
+- Base commit: `ae58a8aea26fd1cde394642a6cdb3d842b666aed`
+ (`docs: stamp conpty rca handoff`)
+- RCA commit: `8098b11a58e6a5538ecc8590d0e748ab49517aec`
+- Evidence manifest commit: `4105026d23c3a5e705334b7e5af30a9815ca3eb3`
+- Evidence verifier commit: `72d6abd82fdc313badd9008ed84fba29fde0fe6b`
+- Input/handle RCA commits:
+ `ebf8826ad3dc68fa5db5db9cacc802c8312f1ac6`,
+ `7ee14ce1e61fb57f83ce3d1200c30a39102ddf75`, and
+ `44bb495b1f7937357f117b42563b50ba08c08e08`.
+- Accepted hosted run: `29226547099` on branch
+ `phase1-5-conpty-evidence-ci`, commit
+ `44bb495b1f7937357f117b42563b50ba08c08e08`.
+- Merge/push status: pushed to PR branch `phase1-5-conpty-evidence-ci`; do not
+ push directly to `master`.
+- Downloaded hosted artifact: `artifacts/gh-runs/28982641529/` (gitignored
+ scratch, fetched with `gh run download 28982641529 --repo zw3rk/timui.h
+ --name hosted-visual-windows-terminal`).
+- Accepted hosted artifact: `artifacts/gh-runs/29226547099/` (gitignored
+ scratch, fetched with `gh run download 29226547099 --repo zw3rk/timui.h
+ --name hosted-visual-windows-terminal`).
+
+## Read First
+
+- `tools/conpty_smoke_win32.c`
+- `docs/runbooks/phase1-5-live-evidence.md`
+- `docs/research/conpty/REVIEW.md`
+- `docs/handoff/2026-07-09-hosted-visual-probe.md`
+
+## Accepted State
+
+- Windows ConPTY backend implementation has accepted hosted smoke evidence for
+ Phase 1.5. The accepted machine predicate is:
+ `nix develop -c make verify-conpty-evidence ARTIFACT_DIR=artifacts/gh-runs/29226547099 COMMIT=44bb495b1f7937357f117b42563b50ba08c08e08`.
+- Hosted run `29226547099` accepted: `conpty-acceptance.json` records status
+ `0`, `passTokenPresent: true`, `accepted: true`, Windows runner metadata, and
+ the exact `smoke-conpty-win32` command.
+- Historical hosted run `28982641529` is useful RCA evidence, not accepted ConPTY
+ evidence. `conpty-smoke.stdout` contains the initial `cmd.exe` banner and
+ prompt, while `conpty-smoke.stderr` says:
+ `conpty smoke: sentinel not observed; captured 166 bytes`.
+- That artifact proves the smoke reached a live child shell and read initial
+ ConPTY output. It does not prove that input written to the ConPTY was consumed
+ by the shell.
+
+## Change Made
+
+- `tools/conpty_smoke_win32.c` now writes only `echo TIMUI_CONPTY_SMOKE` first,
+ using LF newlines to match Microsoft's ConPTY input example. It writes `exit`
+ only after the sentinel read attempt.
+- If LF does not produce the sentinel, the smoke closes that session and retries
+ the previous CRLF form in a fresh ConPTY session.
+- Short writes are treated as failures. Failure output now includes attempts,
+ last write count, and a bounded escaped excerpt of captured bytes.
+- `make check-conpty-smoke-tool` compiles a portable helper test for the smoke
+ script selection, exact-write helper, and sentinel matching. Both
+ `make check` and `make check-conpty` run it.
+- `tools/ci/hosted_visual_windows.ps1` now emits
+ `conpty-acceptance.json`, `conpty-smoke.command.txt`, and
+ `conpty-smoke.meta.txt` so hosted Windows runs have a machine-readable
+ acceptance predicate in addition to stdout/stderr.
+- `make verify-conpty-evidence ARTIFACT_DIR=... COMMIT=...` validates a
+ downloaded hosted Windows artifact against that predicate. The fixture test
+ rejects missing/malformed manifests, wrong commits, false acceptance flags,
+ nonzero status files, missing PASS tokens, evidence.md mismatch, and unsafe
+ manifest paths. It now also rejects compile-only commands and artifacts that
+ do not record Windows host metadata.
+- Follow-up RCA fixed two hosted-run defects: the smoke now submits the
+ `cmd.exe` line with carriage return first, and `timui_conpty_open` sets
+ `STARTF_USESTDHANDLES` with null std handles so a redirected parent process
+ cannot leak stdout/stdin/stderr into the ConPTY child.
+
+## Verification Already Run
+
+- Red: `nix develop -c make check-conpty-smoke-tool` failed before
+ `smoke_script_for_attempt` existed.
+- Red: `nix develop -c make check-conpty-smoke-tool` failed again after the
+ test was tightened to require split echo/exit scripts and exact-write
+ behavior.
+- Green: `nix develop -c make check-conpty-smoke-tool`.
+- Green: `nix develop -c make check-conpty-win32-smoke-compile`.
+- Green: `nix develop -c make check-hosted-visual-windows`.
+- Green: `nix develop -c make check-conpty-evidence-artifacts`.
+- Green: `nix develop -c make check-conpty`.
+- Green: `nix develop -c make man`.
+- Green: `nix develop -c make check`.
+- Green hosted evidence: run `29226547099` completed on GitHub Actions at
+ commit `44bb495b1f7937357f117b42563b50ba08c08e08`.
+- Green local artifact verification:
+ `nix develop -c make verify-conpty-evidence ARTIFACT_DIR=artifacts/gh-runs/29226547099 COMMIT=44bb495b1f7937357f117b42563b50ba08c08e08`.
+
+## Blocker
+
+- None for the Phase 1.5 ConPTY smoke gate. Future changes touching
+ `src/timui_conpty.c`, `tools/conpty_smoke_win32.c`, or
+ `tools/ci/hosted_visual_windows.ps1` should recapture or explicitly justify
+ why the accepted run remains representative.
+
+## Next Safe Move
+
+- Keep the accepted run linked from the Phase 1.5 docs. If recapturing, trigger
+ `Hosted visual probes`, download `hosted-visual-windows-terminal`, and run
+ `nix develop -c make verify-conpty-evidence ARTIFACT_DIR=... COMMIT=...`
+ before updating any accepted-state language.
diff --git a/docs/research/conpty/REVIEW.md b/docs/research/conpty/REVIEW.md
index 588a083..3b2b8c4 100644
--- a/docs/research/conpty/REVIEW.md
+++ b/docs/research/conpty/REVIEW.md
@@ -12,6 +12,8 @@ date: 2026-07-08
- Microsoft Learn, CreatePseudoConsole:
+- Microsoft Windows Command Line blog, ConPTY introduction:
+
- Microsoft Learn, ClosePseudoConsole:
- Microsoft Learn, ResizePseudoConsole:
@@ -55,6 +57,14 @@ instead of creating a binary that fails to load.
- `HPCON` is closed with `ClosePseudoConsole`, not `CloseHandle`.
- Parent pipe handles kept by the transport are the write side of ConPTY input
and the read side of ConPTY output.
+- The pipe ends passed to `CreatePseudoConsole` stay open until after
+ `CreateProcessW` succeeds. Microsoft documents closing those ConPTY-owned
+ ends after the child is created; closing them immediately after
+ `CreatePseudoConsole` can leave the later child attach path without a live
+ input/output endpoint.
+- The child `STARTUPINFOEXW` sets `STARTF_USESTDHANDLES` with null stdin,
+ stdout, and stderr handles. This prevents redirected parent std handles in
+ hosted CI from leaking into `cmd.exe` and bypassing the pseudoconsole stream.
- `conpty_read` uses `PeekNamedPipe` before `ReadFile` so the frame loop does
not block indefinitely when no child output is available.
- `conpty_write` chunks `size_t` payloads into bounded `DWORD` writes and also
@@ -79,9 +89,20 @@ instead of creating a binary that fails to load.
resizes the pseudoconsole, writes an echo sentinel through the transport,
reads it back, and closes twice to exercise idempotent cleanup when run on
Windows.
-- `make smoke-conpty-win32` is the live Windows Terminal target. A non-Windows
- skip or a MinGW compile is not live evidence.
+- The hosted Windows artifact from run `28982641529` showed partial ConPTY
+ success: `cmd.exe` started and the smoke read initial ConPTY output, but the
+ sentinel was not observed. Follow-up RCA found two hosted-run issues: command
+ submission to `cmd.exe` should try carriage return first, and redirected
+ parent std handles must be blocked with null startup std handles.
+- Hosted Windows run `29226547099` is accepted ConPTY smoke evidence at commit
+ `44bb495b1f7937357f117b42563b50ba08c08e08`. The downloaded artifact verifies
+ with `make verify-conpty-evidence`; its manifest records status `0`,
+ `passTokenPresent: true`, `accepted: true`, Windows runner metadata, and the
+ exact `smoke-conpty-win32` command.
+- `make smoke-conpty-win32` is the live Windows host target. A non-Windows skip
+ or a MinGW compile is not live evidence.
- `docs/runbooks/phase1-5-live-evidence.md` defines the accepted Windows
evidence record and what host/compiler/terminal details to capture.
-- These compile checks are not live Windows evidence. Do not claim supported
- Windows operation until a real Windows Terminal smoke run is captured.
+- Compile checks alone are not live Windows evidence. The accepted hosted
+ Windows run above is the Phase 1.5 live smoke baseline; recapture after
+ future ConPTY backend, smoke harness, or hosted evidence workflow changes.
diff --git a/docs/runbooks/phase1-5-live-evidence.md b/docs/runbooks/phase1-5-live-evidence.md
index d7ebd0f..fc0eee1 100644
--- a/docs/runbooks/phase1-5-live-evidence.md
+++ b/docs/runbooks/phase1-5-live-evidence.md
@@ -135,7 +135,9 @@ Hosted runner probe:
Prerequisites:
-- Windows Terminal on Windows.
+- A Windows host with ConPTY support. Windows Terminal is useful for hosted GUI
+ context and the Sixel visual probe, but the ConPTY smoke itself is a
+ byte-stream predicate over `conpty-smoke.stdout`.
- A Windows-capable build environment that can produce
`build/conpty_smoke_win32.exe` from the Makefile target, or an equivalent
already-built executable from the same commit.
@@ -157,8 +159,10 @@ Accept if all are true:
- The command exits with status 0.
- The output includes `PASS conpty smoke: observed TIMUI_CONPTY_SMOKE`.
-- The note records Windows build, Windows Terminal version, compiler, command,
- commit, and whether this was run from PowerShell, cmd, MSYS, or another shell.
+- The note records Windows build, compiler, command, commit, and whether this
+ was run from PowerShell, cmd, MSYS, or another shell. Record Windows Terminal
+ version too when the smoke is collected through the hosted visual workflow or
+ an interactive Windows Terminal session.
Reject or mark inconclusive if:
@@ -176,6 +180,30 @@ Hosted runner probe:
- `conpty-smoke.stdout` may count for the ConPTY smoke if it contains
`PASS conpty smoke: observed TIMUI_CONPTY_SMOKE` and `evidence.md` records
the same commit.
+- `conpty-acceptance.json` is the machine-readable summary for hosted runs. It
+ must name the same commit, set `passTokenPresent: true`, set `accepted: true`,
+ and reference `conpty-smoke.stdout`, `conpty-smoke.stderr`, and
+ `conpty-smoke.status`. If it is absent, fall back to the stdout predicate
+ above and record that the manifest was unavailable.
+- `conpty-smoke.command.txt` and `conpty-smoke.meta.txt` are diagnostics for
+ the exact hosted MSYS2 invocation and host/toolchain metadata.
+- Run `29226547099` is the accepted hosted Windows ConPTY smoke baseline:
+ commit `44bb495b1f7937357f117b42563b50ba08c08e08`, Windows hosted runner,
+ MSYS2/UCRT64 `/ucrt64/bin/gcc`, `conpty-acceptance.json` with
+ `passTokenPresent: true` and `accepted: true`, verified locally with
+ `make verify-conpty-evidence`.
+- For current artifacts, validate the machine predicate before updating any
+ docs:
+
+ ```sh
+ nix develop -c make verify-conpty-evidence ARTIFACT_DIR=artifacts/gh-runs/ COMMIT=
+ ```
+
+ The verifier requires `conpty-acceptance.json`, `evidence.md`,
+ `conpty-smoke.stdout`, `conpty-smoke.stderr`, `conpty-smoke.status`,
+ `conpty-smoke.command.txt`, and `conpty-smoke.meta.txt` to agree on the same
+ commit, status 0, the exact ConPTY PASS token, a `smoke-conpty-win32`
+ command, and `os_env=Windows_NT` host metadata.
- `windows-terminal-sixel-*.png` is supplemental Sixel evidence only if it
visibly shows the Windows Terminal live smoke with image tiles and the
session diagnostics show an interactive desktop. Run `28982641529` is the
diff --git a/docs/timui.1.md b/docs/timui.1.md
index ebdc2e7..c788d5b 100644
--- a/docs/timui.1.md
+++ b/docs/timui.1.md
@@ -79,7 +79,8 @@ The following `make` targets are the primary interface. Each is invoked as
**check**
: The build-plus-test gate: runs **build**, **test**, no-image API coverage,
- Win32 ConPTY compile seams, and static website license/link checks.
+ Win32 ConPTY compile seams, hosted evidence-script checks, and static website
+ license/link checks.
**test-san** \[*SAN=address*]
: Compile and run the unit tests under a sanitizer, e.g.
@@ -124,9 +125,9 @@ The following `make` targets are the primary interface. Each is invoked as
These targets exist to collect live evidence. They are intentionally outside
**check**; headless pty captures prove byte streams and final cell text, not
-that a real terminal consumed an image protocol or that Windows ConPTY works in
-Windows Terminal. The canonical evidence procedure and recording template live
-in `docs/runbooks/phase1-5-live-evidence.md`.
+that a real terminal consumed an image protocol or that Windows ConPTY passed on
+a Windows host. The canonical evidence procedure and recording template live in
+`docs/runbooks/phase1-5-live-evidence.md`.
**check-image-smoke**
: Headless sanity check for the image smoke harness. It forces protocol `none`,
@@ -149,11 +150,34 @@ in `docs/runbooks/phase1-5-live-evidence.md`.
: Cross-compile the Win32 ConPTY smoke runner when MinGW is available. This is
compile evidence only.
+**check-conpty-smoke-tool**
+: Compile and run the portable helper tests for the Win32 ConPTY smoke runner:
+ script line-ending selection, sentinel matching, and helper behavior that can
+ be checked without a Windows host.
+
+**check-hosted-visual-windows**
+: Verify the hosted Windows visual probe script still emits the ConPTY
+ acceptance manifest (`conpty-acceptance.json`) and its command/meta sidecars.
+
+**check-conpty-evidence-artifacts**
+: Run synthetic positive and negative fixture tests for the hosted ConPTY
+ evidence verifier. This proves the verifier rejects wrong commits, malformed
+ JSON, false acceptance flags, nonzero status, missing PASS tokens, and unsafe
+ manifest paths, plus compile-only commands or non-Windows metadata.
+
+**verify-conpty-evidence**
+: Validate a downloaded hosted Windows ConPTY artifact directory:
+ `make verify-conpty-evidence ARTIFACT_DIR=... COMMIT=...`. The target checks
+ `conpty-acceptance.json` and its stdout/stderr/status/meta/evidence sidecars
+ against one expected commit, the real smoke target, and Windows host metadata.
+ It does not create Windows evidence; it only accepts or rejects an artifact
+ already collected from a Windows host.
+
**smoke-conpty-win32**
-: Run the Win32 ConPTY smoke runner inside Windows Terminal on Windows. It opens
- the default shell via `timui_conpty_open`, writes an echo sentinel through the
- transport, reads it back, resizes once, and closes twice. A non-Windows skip is
- not Windows evidence.
+: Run the Win32 ConPTY smoke runner on Windows. It opens the default shell via
+ `timui_conpty_open`, writes an echo sentinel through the transport, reads it
+ back, resizes once, and closes twice. A non-Windows skip is not Windows
+ evidence.
## Release header
diff --git a/docs/website-product-brief.md b/docs/website-product-brief.md
index d827f0d..62269b7 100644
--- a/docs/website-product-brief.md
+++ b/docs/website-product-brief.md
@@ -77,8 +77,9 @@ Core:
- Split development build via `src/timui.c` and included sections.
- C99 baseline, libc hard dependency, optional pthreads.
- POSIX raw-mode terminal backend.
-- Win32 ConPTY is implemented behind `_WIN32`, runtime-probed, and
- compile-checked, but live Windows Terminal smoke evidence is still pending.
+- Win32 ConPTY is implemented behind `_WIN32`, runtime-probed,
+ compile-checked, and covered by hosted Windows smoke evidence in run
+ `29226547099`.
Rendering and terminal:
@@ -321,7 +322,8 @@ int main(void) {
- Single-header C99 immediate-mode TUI.
- No ncurses dependency.
- POSIX terminal backend works; Windows ConPTY is implemented, runtime-probed,
- and compile-checked, with live Windows smoke evidence still pending.
+ compile-checked, and covered by hosted Windows smoke evidence in run
+ `29226547099`.
- Truecolor diff renderer and synchronized output support.
- Incremental input parser for legacy CSI, Kitty keyboard, SGR mouse, paste,
focus, text, and UTF-8.
@@ -337,8 +339,9 @@ int main(void) {
## Claims to avoid or qualify
-- Do not say "works on Windows" yet. ConPTY is implemented, runtime-probed, and
- compile-checked, but the live Windows Terminal smoke run is still pending.
+- Do not imply broad Windows terminal parity. The ConPTY transport has accepted
+ hosted smoke evidence, but graphics and terminal behavior are still selected
+ by the actual terminal protocol capability.
- Do not say "full Unicode" or "full bidi." The chat example has a useful
Hebrew/Arabic approximation and CJK/emoji rendering support, but full UAX #9,
grapheme clusters, ZWJ emoji, and skin-tone sequences remain future work.
diff --git a/examples/async_scan.c b/examples/async_scan.c
index 9db8cb4..5c71331 100644
--- a/examples/async_scan.c
+++ b/examples/async_scan.c
@@ -9,27 +9,25 @@
*/
#define TIMUI_IMPLEMENTATION
#include "timui.h"
+#include "async_scan_state.h"
#include
#include /* nanosleep */
-typedef struct { int progress; int done; } Model;
-enum { MSG_PROGRESS = 1, MSG_DONE };
-
static void *worker(void *arg){
Timui *ui = (Timui *)arg;
int i;
for(i = 0; i <= 10; i++){
int p = i * 10;
- timui_post(ui, MSG_PROGRESS, &p, sizeof p);
+ timui_post(ui, ASYNC_SCAN_MSG_PROGRESS, &p, sizeof p);
{ struct timespec ts = {0, 100 * 1000 * 1000}; nanosleep(&ts, NULL); } /* 100ms */
}
- { int d = 1; timui_post(ui, MSG_DONE, &d, sizeof d); }
+ timui_post(ui, ASYNC_SCAN_MSG_DONE, NULL, 0);
return NULL;
}
static void view(TimuiFrame *f, void *m){
- Model *mdl = (Model *)m;
+ AsyncScanModel *mdl = (AsyncScanModel *)m;
TimuiCellBuffer *buf = timui_frame_buffer(f);
TimuiRect root = timui_root(f);
TimuiRect bar;
@@ -46,17 +44,15 @@ static void view(TimuiFrame *f, void *m){
}
static void update(void *m, uint32_t type, const void *msg, size_t sz){
- Model *mdl = (Model *)m;
- (void)sz;
- if(type == MSG_PROGRESS) mdl->progress = *(const int *)msg;
- else if(type == MSG_DONE){ mdl->progress = 100; mdl->done = 1; }
+ (void)async_scan_update((AsyncScanModel *)m, type, msg, sz);
}
int main(void){
TimuiConfig cfg = {0};
Timui *ui = NULL;
- Model mdl = {0};
+ AsyncScanModel mdl = {0};
pthread_t th;
+ int thread_started = 0;
cfg.title = "timui.h async scan";
cfg.input_fd = 0;
cfg.output_fd = 1;
@@ -65,21 +61,24 @@ int main(void){
cfg.theme = TIMUI_THEME_DOS_BLUE;
if(timui_open(&cfg, &ui) != TIMUI_OK) return 1;
- pthread_create(&th, NULL, worker, ui);
+ if(pthread_create(&th, NULL, worker, ui) == 0) thread_started = 1;
+ else mdl.done = 1;
while(!timui_should_quit(ui)){
TimuiFrame *f = NULL;
uint32_t type = 0;
int val = 0;
- size_t sz = sizeof val;
if(!timui_begin(ui, &f)) break;
- while(timui_recv(ui, &type, &val, &sz)) update(&mdl, type, &val, sz);
+ for(;;){
+ size_t sz = sizeof val;
+ if(!timui_recv(ui, &type, &val, &sz)) break;
+ update(&mdl, type, sz == 0 ? NULL : &val, sz);
+ }
if(mdl.done) timui_quit(ui); /* W2: terminate on completion */
view(f, &mdl);
if(timui_key_pressed(f, TIMUI_KEY_ESCAPE)) timui_quit(ui);
- sz = sizeof val;
timui_end(f);
}
- pthread_join(th, NULL);
+ if(thread_started) pthread_join(th, NULL);
timui_close(ui);
return 0;
}
diff --git a/examples/async_scan_state.h b/examples/async_scan_state.h
new file mode 100644
index 0000000..e7e7340
--- /dev/null
+++ b/examples/async_scan_state.h
@@ -0,0 +1,41 @@
+/*
+ * async_scan_state.h — pure state helper for examples/async_scan.c.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright 2026 Moritz Angermann , zw3rk pte. ltd.
+ */
+#ifndef TIMUI_ASYNC_SCAN_STATE_H
+#define TIMUI_ASYNC_SCAN_STATE_H
+
+#include
+#include
+#include
+
+typedef struct { int progress; int done; } AsyncScanModel;
+enum { ASYNC_SCAN_MSG_PROGRESS = 1, ASYNC_SCAN_MSG_DONE };
+
+static int async_scan_clamp_progress_(int progress){
+ if(progress < 0) return 0;
+ if(progress > 100) return 100;
+ return progress;
+}
+
+static int async_scan_update(AsyncScanModel *m, uint32_t type, const void *msg, size_t sz){
+ int progress;
+ if(!m) return 0;
+ if(type == ASYNC_SCAN_MSG_PROGRESS){
+ if(!msg || sz != sizeof progress) return 0;
+ memcpy(&progress, msg, sizeof progress);
+ m->progress = async_scan_clamp_progress_(progress);
+ return 1;
+ }
+ if(type == ASYNC_SCAN_MSG_DONE){
+ if(msg || sz != 0) return 0;
+ m->progress = 100;
+ m->done = 1;
+ return 1;
+ }
+ return 0;
+}
+
+#endif /* TIMUI_ASYNC_SCAN_STATE_H */
diff --git a/flake.nix b/flake.nix
index 442cc1f..d55a697 100644
--- a/flake.nix
+++ b/flake.nix
@@ -12,7 +12,7 @@
forPkgs = system: import nixpkgs { inherit system; };
version = "0.2.0";
src = nixpkgs.lib.cleanSource ./.;
- nativeInputs = pkgs: [ pkgs.clang pkgs.gawk pkgs.gnumake pkgs.pkg-config ];
+ nativeInputs = pkgs: [ pkgs.clang pkgs.gawk pkgs.gnumake pkgs.pkg-config pkgs.python3 ];
conptyCrossInputs = pkgs: [ pkgs.pkgsCross.mingwW64.stdenv.cc ];
buildInputs = pkgs: [ pkgs.libvterm-neovim ];
mkWww = system:
@@ -108,7 +108,7 @@
# libvterm API used by the Tier A round-trip tests.
# asciinema records a real terminal session's raw byte stream to a
# .cast for `make rec-` (feedable to the render verifier).
- nativeBuildInputs = [ pkgs.gnumake pkgs.pkg-config pkgs.asciinema ]
+ nativeBuildInputs = [ pkgs.gnumake pkgs.pkg-config pkgs.asciinema pkgs.python3 ]
++ conptyCrossInputs pkgs;
buildInputs = [ pkgs.clang ]
++ buildInputs pkgs;
diff --git a/include/timui.h b/include/timui.h
index 186e201..de6d3ac 100644
--- a/include/timui.h
+++ b/include/timui.h
@@ -330,6 +330,11 @@ typedef struct {
TimuiUpdateFn update;
} TimuiApp;
+/* Run one functional app frame on an already-open Timui. Useful when the caller
+ * owns the outer loop; returns 1 when a frame was rendered, 0 on invalid args,
+ * a quit state, or begin failure. Posted messages are delivered to update()
+ * after timui_end(), so terminal/image flushes see the model used by view(). */
+TIMUI_API int timui_app_frame(Timui *ui, TimuiApp *app);
TIMUI_API int timui_run(const TimuiConfig *cfg, TimuiApp *app);
TIMUI_API bool timui_emit(TimuiFrame *f, uint32_t type, const void *data, size_t size);
TIMUI_API bool timui_recv(Timui *ui, uint32_t *out_type, void *out_buf, size_t *inout_size);
@@ -1238,8 +1243,7 @@ TIMUI_API TimuiImage *timui_image_from_rgba(Timui *ui, const void *rgba,
/* Original PNG bytes plus caller-supplied decoded RGBA pixels. This lets apps
* avoid the lazy PNG decode path while preserving PNG passthrough for
* Kitty/iTerm2 and giving Sixel exact pixels for emission and clipping. The
- * supplied RGBA dimensions are expected to match the PNG and drive source
- * cropping. */
+ * supplied RGBA dimensions must match the PNG and drive source cropping. */
TIMUI_API TimuiImage *timui_image_from_png_rgba(Timui *ui, const void *png,
size_t png_size,
const void *rgba,
diff --git a/src/timui_app.c b/src/timui_app.c
index a2305fe..ea38c39 100644
--- a/src/timui_app.c
+++ b/src/timui_app.c
@@ -1,9 +1,5 @@
/* ---- optional functional runner --------------------------------------- *
* UI-thread message queue (emit during view, recv into update) + the runner. */
-/* timui_run delivers each posted message via a fixed internal buffer. Messages
- * larger than TIMUI_RUN_BUF are truncated (W5) — keep posts small, or call
- * timui_recv directly with your own buffer for large payloads. */
-#define TIMUI_RUN_BUF 4096
TIMUI_API bool timui_emit(TimuiFrame *f, uint32_t type, const void *data, size_t size){
return f && f->ui && timui_mpsc_post(&f->ui->postq, type, data, size) != 0;
}
@@ -16,25 +12,29 @@ TIMUI_API bool timui_recv(Timui *ui, uint32_t *out_type, void *out_buf, size_t *
TIMUI_API void timui_frame_quit(TimuiFrame *f){
if(f && f->ui) timui_quit(f->ui);
}
+static void timui_app_drain_updates_(Timui *ui, TimuiApp *app){
+ TimuiMpscNode *n;
+ if(!ui || !app) return;
+ while((n = timui_mpsc_pop_node_(&ui->postq)) != NULL){
+ if(app->update) app->update(app->model, n->type, n->data, n->size);
+ timui_mpsc_free_node_(&ui->postq, n);
+ }
+}
+TIMUI_API int timui_app_frame(Timui *ui, TimuiApp *app){
+ TimuiFrame *f = NULL;
+ if(!ui || !app || !app->view || timui_should_quit(ui)) return 0;
+ if(!timui_begin(ui, &f)) return 0;
+ app->view(f, app->model);
+ timui_end(f);
+ timui_app_drain_updates_(ui, app);
+ return 1;
+}
TIMUI_API int timui_run(const TimuiConfig *cfg, TimuiApp *app){
Timui *ui = NULL;
if(!cfg || !app || !app->view || timui_open(cfg, &ui) != TIMUI_OK) return 1;
while(!timui_should_quit(ui)){
- TimuiFrame *f = NULL;
- uint32_t type = 0;
- unsigned char buf[TIMUI_RUN_BUF];
- size_t sz;
- if(!timui_begin(ui, &f)) break;
- app->view(f, app->model);
- sz = sizeof buf;
- while(timui_recv(ui, &type, buf, &sz)){
- if(sz > sizeof buf) sz = sizeof buf; /* clamp to prevent stack over-read */
- if(app->update) app->update(app->model, type, buf, sz);
- sz = sizeof buf;
- }
- timui_end(f);
+ if(!timui_app_frame(ui, app)) break;
}
timui_close(ui);
return 0;
}
-#undef TIMUI_RUN_BUF /* Z10: impl-only macro must not leak into the consumer TU */
diff --git a/src/timui_clip.c b/src/timui_clip.c
index f6c2a2e..110842f 100644
--- a/src/timui_clip.c
+++ b/src/timui_clip.c
@@ -27,11 +27,21 @@ TIMUI_API void timui_push_clip(TimuiFrame *f, TimuiRect rect){
if(!f || !f->ui) return;
ui = f->ui;
b = &ui->curr;
- if(ui->clip_count < 8){
- ui->clip_stack[ui->clip_count].clip = b->clip;
- ui->clip_stack[ui->clip_count].has_clip = b->has_clip;
- ui->clip_count++;
+ if(ui->clip_count >= ui->clip_cap){
+ int nc;
+ void *ns;
+ if(ui->clip_cap > INT_MAX / 2) return;
+ nc = ui->clip_cap ? ui->clip_cap * 2 : 8;
+ ns = ui->alloc.realloc(ui->alloc.userdata, ui->clip_stack,
+ (size_t)ui->clip_cap * sizeof(*ui->clip_stack),
+ (size_t)nc * sizeof(*ui->clip_stack));
+ if(!ns) return;
+ ui->clip_stack = ns;
+ ui->clip_cap = nc;
}
+ ui->clip_stack[ui->clip_count].clip = b->clip;
+ ui->clip_stack[ui->clip_count].has_clip = b->has_clip;
+ ui->clip_count++;
active = b->has_clip ? b->clip : TIMUI_RECT(0, 0, b->w, b->h);
b->clip = clip_intersect(active, rect);
b->has_clip = 1;
diff --git a/src/timui_cmdpal.c b/src/timui_cmdpal.c
index 7d41b37..7768b6b 100644
--- a/src/timui_cmdpal.c
+++ b/src/timui_cmdpal.c
@@ -20,6 +20,16 @@ static int cmd_matches(TimuiStr cmd, const char *filter){
}
return 0;
}
+static int cmd_filter_(const TimuiStr *commands, int count, const char *filter,
+ int *matched_idx, int max){
+ int i, matched_count = 0;
+ if(!commands || !matched_idx || max <= 0 || count <= 0) return 0;
+ for(i = 0; i < count && matched_count < max; i++){
+ TimuiStr cmd = commands[i].ptr ? commands[i] : (TimuiStr){ "", 0 };
+ if(cmd_matches(cmd, filter)) matched_idx[matched_count++] = i;
+ }
+ return matched_count;
+}
TIMUI_API TimuiCmdPaletteResult timui_command_palette(TimuiFrame *f, TimuiId id, TimuiRect r,
const TimuiStr *commands, int count, TimuiCmdPaletteState state){
TimuiCmdPaletteResult res;
@@ -36,13 +46,23 @@ TIMUI_API TimuiCmdPaletteResult timui_command_palette(TimuiFrame *f, TimuiId id,
input_r = TIMUI_RECT(r.x + 1, r.y + 1, r.w - 2, 1);
list_r = TIMUI_RECT(r.x + 1, r.y + 2, r.w - 2, r.h - 3);
timui_input_line_buf(f, id + 1, input_r, state.filter, sizeof state.filter);
- /* filter */
- for(i = 0; i < count && matched_count < 256; i++){
- TimuiStr cmd = commands[i].ptr ? commands[i] : (TimuiStr){ "", 0 };
- if(cmd_matches(cmd, state.filter)) matched_idx[matched_count++] = i;
- }
+ matched_count = cmd_filter_(commands, count, state.filter, matched_idx,
+ (int)(sizeof matched_idx / sizeof matched_idx[0]));
if(state.selected < 0) state.selected = 0;
if(state.selected >= matched_count) state.selected = matched_count > 0 ? matched_count - 1 : 0;
+ /* V18: only steer the palette when its filter input is focused, so drawing
+ * the palette without focusing it doesn't swallow arrow/Enter from siblings.
+ * Process before drawing so selection/activation state and pixels agree. */
+ if(ui->ia.focus == id + 1){
+ state.selected = timui_updown_nav_(f, state.selected, matched_count);
+ if(timui_key_pressed(f, TIMUI_KEY_ENTER) && matched_count > 0){
+ res.activated = matched_idx[state.selected];
+ state.filter[0] = '\0';
+ state.selected = 0;
+ matched_count = cmd_filter_(commands, count, state.filter, matched_idx,
+ (int)(sizeof matched_idx / sizeof matched_idx[0]));
+ }
+ }
/* draw matched commands */
{ TimuiRect content = timui_scroll_begin(f, list_r, 0);
for(i = 0; i < matched_count; i++){
@@ -54,16 +74,6 @@ TIMUI_API TimuiCmdPaletteResult timui_command_palette(TimuiFrame *f, TimuiId id,
}
timui_scroll_end(f);
}
- /* V18: only steer the palette when its filter input is focused, so drawing
- * the palette without focusing it doesn't swallow arrow/Enter from siblings. */
- if(ui->ia.focus == id + 1){
- state.selected = timui_updown_nav_(f, state.selected, matched_count);
- if(timui_key_pressed(f, TIMUI_KEY_ENTER) && matched_count > 0){
- res.activated = matched_idx[state.selected];
- state.filter[0] = '\0';
- state.selected = 0;
- }
- }
timui_panel_end(f);
res.state = state;
res.state_changed = (state.selected != sel_in ||
diff --git a/src/timui_combobox.c b/src/timui_combobox.c
index d116694..7d6dea8 100644
--- a/src/timui_combobox.c
+++ b/src/timui_combobox.c
@@ -87,7 +87,9 @@ TIMUI_API TimuiComboboxResult timui_combobox(TimuiFrame *f, TimuiId id, TimuiRec
res.focused = 0;
if(!f || !f->ui || !state.query || state.cap == 0 || count < 0) return res;
ui = f->ui;
- if(state.cursor >= state.cap) state.cursor = state.cap - 1;
+ { size_t len = text_len_bounded_(state.query, state.cap);
+ if(len >= state.cap){ len = state.cap - 1; state.query[len] = '\0'; }
+ if(state.cursor > len) state.cursor = len; }
field = TIMUI_RECT(r.x, r.y, r.w, r.h > 0 ? 1 : 0);
popup = TIMUI_RECT(r.x, r.y + 1, r.w, r.h > 1 ? r.h - 1 : 0);
ir = timui_interact_button(&ui->ia, id, r);
diff --git a/src/timui_conpty.c b/src/timui_conpty.c
index 6c1f574..97af992 100644
--- a/src/timui_conpty.c
+++ b/src/timui_conpty.c
@@ -194,12 +194,14 @@ TIMUI_API TimuiResult timui_conpty_open(TimuiTransport *out_transport, int *out_
size.X = 80;
size.Y = 24;
if(FAILED(api.create_pseudo_console(size, in_read, out_write, 0, &ctx->hpc))) goto fail;
- conpty_close_handle_(&in_read);
- conpty_close_handle_(&out_write);
memset(&si, 0, sizeof si);
memset(&pi, 0, sizeof pi);
si.StartupInfo.cb = sizeof(STARTUPINFOEXW);
+ si.StartupInfo.dwFlags |= STARTF_USESTDHANDLES;
+ si.StartupInfo.hStdInput = NULL;
+ si.StartupInfo.hStdOutput = NULL;
+ si.StartupInfo.hStdError = NULL;
InitializeProcThreadAttributeList(NULL, 1, 0, &attr_bytes);
if(attr_bytes == 0) goto fail;
si.lpAttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST)calloc(1, attr_bytes);
@@ -214,6 +216,8 @@ TIMUI_API TimuiResult timui_conpty_open(TimuiTransport *out_transport, int *out_
if(!cmd[0]) goto fail_attr;
if(!CreateProcessW(NULL, cmd, NULL, NULL, FALSE, EXTENDED_STARTUPINFO_PRESENT,
NULL, NULL, &si.StartupInfo, &pi)) goto fail_attr;
+ conpty_close_handle_(&in_read);
+ conpty_close_handle_(&out_write);
DeleteProcThreadAttributeList(si.lpAttributeList);
free(si.lpAttributeList);
si.lpAttributeList = NULL;
diff --git a/src/timui_core.c b/src/timui_core.c
index 175a5ef..ea497d9 100644
--- a/src/timui_core.c
+++ b/src/timui_core.c
@@ -63,36 +63,139 @@ static void ui_event_cb(void *ctx, const TimuiEvent *ev){
else
ui->events_dropped++;
}
-static void timui_append_text_cp_(Timui *ui, uint32_t cp){
+static void timui_edit_add_text_(Timui *ui, int start, int len){
+ TimuiEditOp *op;
+ if(!ui || len <= 0) return;
+ if(ui->edit_count >= (int)(sizeof(ui->edit_ops) / sizeof(ui->edit_ops[0]))){
+ ui->events_dropped++;
+ return;
+ }
+ op = &ui->edit_ops[ui->edit_count++];
+ op->kind = TIMUI_EDIT_TEXT;
+ op->key = 0;
+ op->start = start;
+ op->len = len;
+ op->mods = 0;
+}
+static void timui_edit_add_key_(Timui *ui, unsigned key, uint32_t mods){
+ TimuiEditOp *op;
+ if(!ui || key == 0) return;
+ if(ui->edit_count >= (int)(sizeof(ui->edit_ops) / sizeof(ui->edit_ops[0]))){
+ ui->events_dropped++;
+ return;
+ }
+ op = &ui->edit_ops[ui->edit_count++];
+ op->kind = TIMUI_EDIT_KEY;
+ op->key = key;
+ op->start = 0;
+ op->len = 0;
+ op->mods = mods;
+}
+static void timui_edit_rebuild_from_text_(Timui *ui){
+ int j = 0, e;
+ if(!ui) return;
+ ui->edit_count = 0;
+ for(e = 0; e < ui->enter_count; e++){
+ int at = ui->enter_at[e];
+ if(at < j) at = j;
+ if(at > ui->text_in_len) at = ui->text_in_len;
+ timui_edit_add_text_(ui, j, at - j);
+ timui_edit_add_key_(ui, TIMUI_EDIT_KEY_ENTER_, ui->enter_mods[e]);
+ j = at;
+ }
+ timui_edit_add_text_(ui, j, ui->text_in_len - j);
+}
+static void timui_defer_edit_ops_after_(Timui *ui, int first){
+ int i, text_len = 0, enter_count = 0;
+ if(!ui) return;
+ if(first < 0) first = 0;
+ if(first > ui->edit_count) first = ui->edit_count;
+ for(i = first; i < ui->edit_count; i++){
+ TimuiEditOp *op = &ui->edit_ops[i];
+ if(op->kind == TIMUI_EDIT_TEXT && op->len > 0){
+ int n = op->len;
+ if(n > (int)sizeof(ui->pending_in) - text_len) n = (int)sizeof(ui->pending_in) - text_len;
+ if(n > 0){
+ memcpy(ui->pending_in + text_len, ui->text_in + op->start, (size_t)n);
+ text_len += n;
+ }
+ if(n < op->len) ui->events_dropped++;
+ } else if(op->kind == TIMUI_EDIT_KEY && op->key == TIMUI_EDIT_KEY_ENTER_){
+ if(enter_count < (int)(sizeof(ui->pending_enter_at) / sizeof(ui->pending_enter_at[0]))){
+ ui->pending_enter_at[enter_count] = text_len;
+ ui->pending_enter_mods[enter_count] = op->mods;
+ enter_count++;
+ } else ui->events_dropped++;
+ }
+ }
+ ui->pending_in_len = text_len;
+ ui->pending_enter_count = enter_count;
+}
+static int timui_append_text_cp_(Timui *ui, uint32_t cp){
char enc[4];
int enclen;
- if(!ui) return;
- if(cp < 0x20 || cp == 0x7f || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) return;
+ if(!ui) return 0;
+ if(cp < 0x20 || cp == 0x7f || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) return 0;
enclen = timui_utf8_encode_(cp, enc);
if(enclen > 0 && ui->text_in_len + enclen <= (int)sizeof(ui->text_in)){
int ei;
for(ei = 0; ei < enclen; ei++) ui->text_in[ui->text_in_len++] = enc[ei];
- }
+ return enclen;
+ } else if(enclen > 0) ui->events_dropped++;
+ return 0;
}
-static void timui_append_paste_bytes_(Timui *ui, const char *ptr, size_t len){
- size_t pk = 0;
- while(ui && pk < len && ui->text_in_len < (int)sizeof(ui->text_in)){
- unsigned char pc = (unsigned char)ptr[pk];
+static int timui_append_paste_bytes_(Timui *ui, const char *ptr, size_t len){
+ char bytes[sizeof(((Timui *)0)->paste_buf) + 4];
+ size_t total = 0, pk = 0;
+ int start;
+ if(!ui || (!ptr && len > 0)) return 0;
+ start = ui->text_in_len;
+ if(ui->paste_utf8_tail_len > 0){
+ memcpy(bytes, ui->paste_utf8_tail, (size_t)ui->paste_utf8_tail_len);
+ total = (size_t)ui->paste_utf8_tail_len;
+ ui->paste_utf8_tail_len = 0;
+ }
+ if(len > sizeof(bytes) - total){
+ len = sizeof(bytes) - total;
+ ui->events_dropped++;
+ }
+ if(len > 0){
+ memcpy(bytes + total, ptr, len);
+ total += len;
+ }
+ while(pk < total){
+ unsigned char pc = (unsigned char)bytes[pk];
uint32_t cp = 0;
int adv;
if(pc == 0 || pc == 0x7f){ pk++; continue; }
if(pc < 0x20){
if(pc != '\n' && pc != '\r' && pc != '\t'){ pk++; continue; }
if(ui->text_in_len < (int)sizeof(ui->text_in)) ui->text_in[ui->text_in_len++] = (char)pc;
+ else ui->events_dropped++;
pk++;
continue;
}
- adv = timui_utf8_decode(ptr + pk, len - pk, &cp);
- if(adv == 0){ cp = 0xFFFD; adv = (int)(len - pk); }
+ adv = timui_utf8_decode(bytes + pk, total - pk, &cp);
+ if(adv == 0){
+ size_t rem = total - pk;
+ if(rem > sizeof(ui->paste_utf8_tail)) rem = sizeof(ui->paste_utf8_tail);
+ memcpy(ui->paste_utf8_tail, bytes + pk, rem);
+ ui->paste_utf8_tail_len = (int)rem;
+ break;
+ }
if(adv < 0) adv = 1;
- timui_append_text_cp_(ui, cp);
+ (void)timui_append_text_cp_(ui, cp);
pk += (size_t)adv;
}
+ return ui->text_in_len - start;
+}
+static void timui_flush_paste_utf8_tail_(Timui *ui){
+ int start, n;
+ if(!ui || ui->paste_utf8_tail_len <= 0) return;
+ start = ui->text_in_len;
+ ui->paste_utf8_tail_len = 0;
+ n = timui_append_text_cp_(ui, 0xFFFD);
+ if(n > 0) timui_edit_add_text_(ui, start, n);
}
/* Write ALL n bytes to fd. The output fd typically SHARES its open file
* description with the input fd (fd 0/1 on a tty), which we set O_NONBLOCK for
@@ -185,8 +288,9 @@ static void timui_set_terminal_pixels_(Timui *ui, int cols, int rows, int px_w,
TIMUI_API TimuiResult timui_open_for_test(Timui **out_ui, TimuiTransport transport, int w, int h, const TimuiAllocator *alloc){
Timui *ui;
TimuiResult r;
- if(!out_ui || w <= 0 || h <= 0 || !timui_allocator_valid_(alloc)) return TIMUI_ERR_INVALID_ARGUMENT;
+ if(!out_ui) return TIMUI_ERR_INVALID_ARGUMENT;
*out_ui = NULL;
+ if(w <= 0 || h <= 0 || !timui_allocator_valid_(alloc)) return TIMUI_ERR_INVALID_ARGUMENT;
ui = (Timui *)alloc->alloc(alloc->userdata, sizeof(Timui));
if(!ui) return TIMUI_ERR_OUT_OF_MEMORY;
memset(ui, 0, sizeof *ui);
@@ -198,7 +302,6 @@ TIMUI_API TimuiResult timui_open_for_test(Timui **out_ui, TimuiTransport transpo
timui_caps_detect(&ui->caps, NULL, NULL, NULL);
r = timui_setup(ui, w, h);
if(r != TIMUI_OK){
- if(transport.close) transport.close(&transport);
alloc->free(alloc->userdata, ui, sizeof *ui);
return r;
}
@@ -317,8 +420,9 @@ TIMUI_API TimuiResult timui_open(const TimuiConfig *cfg, Timui **out_ui){
int w = 80, h = 24;
int px_w = 0, px_h = 0;
TimuiResult r;
- if(!cfg || !out_ui) return TIMUI_ERR_INVALID_ARGUMENT;
+ if(!out_ui) return TIMUI_ERR_INVALID_ARGUMENT;
*out_ui = NULL;
+ if(!cfg) return TIMUI_ERR_INVALID_ARGUMENT;
if(cfg->input_fd < 0 || cfg->output_fd < 0) return TIMUI_ERR_INVALID_ARGUMENT;
input_flags = fcntl(cfg->input_fd, F_GETFL, 0);
if(input_flags < 0) return TIMUI_ERR_OS;
@@ -395,6 +499,9 @@ TIMUI_API void timui_close(Timui *ui){
if(ui->have_postq) timui_mpsc_destroy(&ui->postq);
timui_interact_destroy(&ui->ia); /* V24: free the dynamic tab_order */
if(ui->have_ids) timui_id_stack_destroy(&ui->ids);
+ if(ui->clip_stack)
+ ui->alloc.free(ui->alloc.userdata, ui->clip_stack,
+ (size_t)ui->clip_cap * sizeof(*ui->clip_stack));
if(ui->trace_fd >= 0) close(ui->trace_fd);
if(ui->have_transport && ui->transport.close) ui->transport.close(&ui->transport);
al = ui->alloc;
@@ -445,14 +552,18 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
ui->enter_count = ui->pending_enter_count;
ui->pending_in_len = 0;
ui->pending_enter_count = 0;
+ timui_edit_rebuild_from_text_(ui);
} else {
ui->text_in_len = 0;
ui->enter_count = 0;
+ ui->edit_count = 0;
}
ui->key_in = 0;
ui->key_pressed = TIMUI_KEY_UNKNOWN;
ui->key_mods = 0;
ui->mouse_wheel = 0;
+ ui->mouse_wheel_x = -1;
+ ui->mouse_wheel_y = -1;
ui->mouse_clicked = 0;
{
TimuiEvent ev;
@@ -464,6 +575,10 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
int mx = ev.as.mouse.x - 1;
int my = ev.as.mouse.y - 1;
ui->mouse_wheel += ev.as.mouse.wheel_y; /* expose wheel to the app */
+ if(ev.as.mouse.wheel_y){
+ ui->mouse_wheel_x = mx;
+ ui->mouse_wheel_y = my;
+ }
ui->mouse_x = mx; ui->mouse_y = my;
if(ev.as.mouse.wheel_y == 0 &&
(ev.as.mouse.motion || ev.as.mouse.button == 0 || ev.as.mouse.released)){
@@ -484,6 +599,7 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
if(ev.as.key.key == TIMUI_KEY_TAB) timui_interact_set_keys(&ui->ia, 1, 0);
else if(ev.as.key.key == TIMUI_KEY_ENTER){
timui_interact_set_keys(&ui->ia, 0, 1);
+ timui_edit_add_key_(ui, TIMUI_EDIT_KEY_ENTER_, ev.as.key.mods);
/* record the Enter's position in the text stream (input_field
* segments submits on these; excess past the cap just merges). */
if(ui->enter_count < (int)(sizeof(ui->enter_at)/sizeof(ui->enter_at[0]))){
@@ -492,31 +608,33 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
ui->enter_count++;
}
}
- else if(ev.as.key.key == TIMUI_KEY_BACKSPACE) ui->key_in |= TIMUI_KEYIN_BACKSPACE;
- else if(ev.as.key.key == TIMUI_KEY_LEFT) ui->key_in |= TIMUI_KEYIN_LEFT;
- else if(ev.as.key.key == TIMUI_KEY_RIGHT) ui->key_in |= TIMUI_KEYIN_RIGHT;
- else if(ev.as.key.key == TIMUI_KEY_HOME) ui->key_in |= TIMUI_KEYIN_HOME;
- else if(ev.as.key.key == TIMUI_KEY_END) ui->key_in |= TIMUI_KEYIN_END;
- else if(ev.as.key.key == TIMUI_KEY_DELETE) ui->key_in |= TIMUI_KEYIN_DELETE;
+ else if(ev.as.key.key == TIMUI_KEY_BACKSPACE){ ui->key_in |= TIMUI_KEYIN_BACKSPACE; timui_edit_add_key_(ui, TIMUI_KEYIN_BACKSPACE, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_LEFT) { ui->key_in |= TIMUI_KEYIN_LEFT; timui_edit_add_key_(ui, TIMUI_KEYIN_LEFT, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_RIGHT) { ui->key_in |= TIMUI_KEYIN_RIGHT; timui_edit_add_key_(ui, TIMUI_KEYIN_RIGHT, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_HOME) { ui->key_in |= TIMUI_KEYIN_HOME; timui_edit_add_key_(ui, TIMUI_KEYIN_HOME, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_END) { ui->key_in |= TIMUI_KEYIN_END; timui_edit_add_key_(ui, TIMUI_KEYIN_END, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_DELETE) { ui->key_in |= TIMUI_KEYIN_DELETE; timui_edit_add_key_(ui, TIMUI_KEYIN_DELETE, ev.as.key.mods); }
else if(ev.as.key.key == TIMUI_KEY_UP) ui->key_in |= TIMUI_KEYIN_UP;
else if(ev.as.key.key == TIMUI_KEY_DOWN) ui->key_in |= TIMUI_KEYIN_DOWN;
else if(ev.as.key.key == TIMUI_KEY_UNKNOWN &&
(ev.as.key.mods & ~TIMUI_MOD_SHIFT) == TIMUI_MOD_NONE){
uint32_t cp = ev.as.key.codepoint;
- timui_append_text_cp_(ui, cp);
+ int start = ui->text_in_len;
+ int n = timui_append_text_cp_(ui, cp);
+ if(n > 0) timui_edit_add_text_(ui, start, n);
}
else if(ev.as.key.key == TIMUI_KEY_UNKNOWN && (ev.as.key.mods & TIMUI_MOD_CTRL)){
/* emacs / readline line editing (ubiquitous on macOS). Ctrl-H
* (backspace) already arrives as KEY_BACKSPACE from the parser. */
switch(ev.as.key.codepoint){
- case 'a': ui->key_in |= TIMUI_KEYIN_HOME; break; /* start of line */
- case 'e': ui->key_in |= TIMUI_KEYIN_END; break; /* end of line */
- case 'b': ui->key_in |= TIMUI_KEYIN_LEFT; break; /* back one char */
- case 'f': ui->key_in |= TIMUI_KEYIN_RIGHT; break; /* forward */
- case 'd': ui->key_in |= TIMUI_KEYIN_DELETE; break; /* delete at cursor */
- case 'k': ui->key_in |= TIMUI_KEYIN_KILL_EOL; break; /* kill to EOL */
- case 'u': ui->key_in |= TIMUI_KEYIN_KILL_BOL; break; /* kill to BOL */
- case 'w': ui->key_in |= TIMUI_KEYIN_KILL_WORD; break; /* kill word back */
+ case 'a': ui->key_in |= TIMUI_KEYIN_HOME; timui_edit_add_key_(ui, TIMUI_KEYIN_HOME, ev.as.key.mods); break; /* start of line */
+ case 'e': ui->key_in |= TIMUI_KEYIN_END; timui_edit_add_key_(ui, TIMUI_KEYIN_END, ev.as.key.mods); break; /* end of line */
+ case 'b': ui->key_in |= TIMUI_KEYIN_LEFT; timui_edit_add_key_(ui, TIMUI_KEYIN_LEFT, ev.as.key.mods); break; /* back one char */
+ case 'f': ui->key_in |= TIMUI_KEYIN_RIGHT; timui_edit_add_key_(ui, TIMUI_KEYIN_RIGHT, ev.as.key.mods); break; /* forward */
+ case 'd': ui->key_in |= TIMUI_KEYIN_DELETE; timui_edit_add_key_(ui, TIMUI_KEYIN_DELETE, ev.as.key.mods); break; /* delete at cursor */
+ case 'k': ui->key_in |= TIMUI_KEYIN_KILL_EOL; timui_edit_add_key_(ui, TIMUI_KEYIN_KILL_EOL, ev.as.key.mods); break; /* kill to EOL */
+ case 'u': ui->key_in |= TIMUI_KEYIN_KILL_BOL; timui_edit_add_key_(ui, TIMUI_KEYIN_KILL_BOL, ev.as.key.mods); break; /* kill to BOL */
+ case 'w': ui->key_in |= TIMUI_KEYIN_KILL_WORD; timui_edit_add_key_(ui, TIMUI_KEYIN_KILL_WORD, ev.as.key.mods); break; /* kill word back */
default: break;
}
}
@@ -524,14 +642,19 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
/* UTF-8 encode the codepoint into text_in (supports international
* input) via the single shared encoder (Z6). */
uint32_t cp = ev.as.text.codepoint;
- timui_append_text_cp_(ui, cp);
+ int start = ui->text_in_len;
+ int n = timui_append_text_cp_(ui, cp);
+ if(n > 0) timui_edit_add_text_(ui, start, n);
} else if(ev.kind == TIMUI_EVENT_PASTE){
- timui_append_paste_bytes_(ui, ev.as.paste.ptr, ev.as.paste.len);
+ int start = ui->text_in_len;
+ int n = timui_append_paste_bytes_(ui, ev.as.paste.ptr, ev.as.paste.len);
+ if(n > 0) timui_edit_add_text_(ui, start, n);
} else if(ev.kind == TIMUI_EVENT_FOCUS){
if(focus_count < (int)(sizeof(focus_events) / sizeof(focus_events[0])))
focus_events[focus_count++] = ev;
}
}
+ if(!ui->input.pasting) timui_flush_paste_utf8_tail_(ui);
if(focus_count > 0){
int fi;
ui->event_count = 0;
@@ -931,9 +1054,30 @@ TIMUI_API TimuiResult timui_mpsc_init(TimuiMpsc *q, const TimuiAllocator *alloc)
#endif
return TIMUI_OK;
}
+static TimuiMpscNode *timui_mpsc_pop_node_(TimuiMpsc *q){
+ TimuiMpscNode *n;
+ if(!q) return NULL;
+#ifndef TIMUI_NO_THREADS
+ if(!q->lock) return NULL;
+#endif
+ TIMUI_MPSC_LOCK(q);
+ n = q->head;
+ if(n){
+ q->head = n->next;
+ if(!q->head) q->tail = NULL;
+ q->pending--;
+ }
+ TIMUI_MPSC_UNLOCK(q);
+ return n;
+}
+static void timui_mpsc_free_node_(TimuiMpsc *q, TimuiMpscNode *n){
+ if(!q || !n) return;
+ TIMUI_MPSC_LOCK(q);
+ q->alloc.free(q->alloc.userdata, n, sizeof(*n) + n->size);
+ TIMUI_MPSC_UNLOCK(q);
+}
TIMUI_API void timui_mpsc_destroy(TimuiMpsc *q){
- uint32_t t;
- size_t s = 0;
+ TimuiMpscNode *n;
if(!q) return;
#ifndef TIMUI_NO_THREADS
if(!q->lock){
@@ -943,7 +1087,8 @@ TIMUI_API void timui_mpsc_destroy(TimuiMpsc *q){
return;
}
#endif
- while(timui_mpsc_recv(q, &t, NULL, &s)){ } /* drain remaining nodes */
+ while((n = timui_mpsc_pop_node_(q)) != NULL)
+ timui_mpsc_free_node_(q, n); /* drain remaining nodes */
#ifndef TIMUI_NO_THREADS
if(q->lock){
pthread_mutex_destroy((pthread_mutex_t *)q->lock);
@@ -978,18 +1123,7 @@ TIMUI_API int timui_mpsc_post(TimuiMpsc *q, uint32_t type, const void *data, siz
TIMUI_API int timui_mpsc_recv(TimuiMpsc *q, uint32_t *out_type, void *out_buf, size_t *inout_size){
TimuiMpscNode *n;
size_t copy;
- if(!q) return 0;
-#ifndef TIMUI_NO_THREADS
- if(!q->lock) return 0;
-#endif
- TIMUI_MPSC_LOCK(q);
- n = q->head;
- if(n){
- q->head = n->next;
- if(!q->head) q->tail = NULL;
- q->pending--;
- }
- TIMUI_MPSC_UNLOCK(q);
+ n = timui_mpsc_pop_node_(q);
if(!n) return 0;
if(out_type) *out_type = n->type;
if(inout_size){
@@ -998,9 +1132,7 @@ TIMUI_API int timui_mpsc_recv(TimuiMpsc *q, uint32_t *out_type, void *out_buf, s
if(out_buf && copy > 0) memcpy(out_buf, n->data, copy);
*inout_size = n->size;
}
- TIMUI_MPSC_LOCK(q);
- q->alloc.free(q->alloc.userdata, n, sizeof(*n) + n->size);
- TIMUI_MPSC_UNLOCK(q);
+ timui_mpsc_free_node_(q, n);
return 1;
}
TIMUI_API int timui_mpsc_empty(TimuiMpsc *q){
diff --git a/src/timui_images.c b/src/timui_images.c
index 026d543..55ce8d7 100644
--- a/src/timui_images.c
+++ b/src/timui_images.c
@@ -261,9 +261,16 @@ TIMUI_API TimuiImage *timui_image_from_png_rgba(Timui *ui, const void *png,
TimuiAllocator al;
size_t row = 0, total = 0;
const unsigned char *src;
+#ifndef TIMUI_NO_IMAGES
+ int png_w = 0, png_h = 0;
+#endif
(void)ui;
if(!png || png_size == 0 || !rgba || !image_rgba_size_(w, h, stride, &row, &total))
return NULL;
+#ifndef TIMUI_NO_IMAGES
+ if(!image_png_header_(png, png_size, &png_w, &png_h)) return NULL;
+ if(png_w != w || png_h != h) return NULL;
+#endif
al = timui_default_allocator();
img = (TimuiImage *)al.alloc(al.userdata, sizeof(TimuiImage));
if(!img) return NULL;
@@ -815,6 +822,8 @@ static void image_record_(Timui *ui, TimuiImage *img, TimuiRect visible, TimuiRe
ui->img_place[ui->img_place_count].rect = visible;
ui->img_place[ui->img_place_count].full = full;
ui->img_place_count++;
+ } else {
+ image_placeholder_(ui, visible);
}
} else {
image_placeholder_(ui, visible);
diff --git a/src/timui_input.c b/src/timui_input.c
index 347c0b5..d2f067f 100644
--- a/src/timui_input.c
+++ b/src/timui_input.c
@@ -368,6 +368,7 @@ TIMUI_API size_t timui_input_feed(TimuiInputParser *p, const void *data, size_t
break;
}
p->state = 0; /* unexpected: resync */
+ if(c >= 0x80) i--; /* non-ASCII may be a UTF-8 lead; do not drop it */
break;
case 3: /* SS3 (ESC O X) */
/* Z3: an ESC here aborts the truncated SS3 and restarts a fresh
@@ -377,6 +378,7 @@ TIMUI_API size_t timui_input_feed(TimuiInputParser *p, const void *data, size_t
TimuiKey k = ss3_final(c);
if(k != TIMUI_KEY_UNKNOWN){ emit_key(cb, ctx, k, 0, 0); count++; }
p->state = 0;
+ if(k == TIMUI_KEY_UNKNOWN && c >= 0x80) i--;
}
break;
case 4: /* UTF-8 continuation */
diff --git a/src/timui_int.h b/src/timui_int.h
index 87b07ad..895234b 100644
--- a/src/timui_int.h
+++ b/src/timui_int.h
@@ -16,6 +16,16 @@
/* Internal structs (completed only in the implementing TU). */
typedef struct { int read_fd; int write_fd; } TimuiFdCtx;
+enum { TIMUI_EDIT_TEXT = 1, TIMUI_EDIT_KEY = 2 };
+#define TIMUI_EDIT_KEY_ENTER_ 0x80000000u
+typedef struct TimuiEditOp {
+ int kind;
+ unsigned key;
+ int start;
+ int len;
+ uint32_t mods;
+} TimuiEditOp;
+
struct TimuiFrame { Timui *ui; };
struct Timui {
@@ -54,6 +64,8 @@ struct Timui {
char paste_buf[256]; /* bracketed-paste accumulator (ev ptr is transient; a paste
* can also span several reads -> several events per frame) */
int paste_len;
+ char paste_utf8_tail[4];
+ int paste_utf8_tail_len;
int trace_fd; /* TIMUI_TRACE input trace fd, -1 = off */
/* Submit segmentation for timui_input_field: byte offsets in text_in where
* Enter fired this frame, in order. Lets the field submit ONE segment per
@@ -67,10 +79,13 @@ struct Timui {
int pending_enter_at[32];
uint32_t pending_enter_mods[32];
int pending_enter_count;
+ TimuiEditOp edit_ops[512];
+ int edit_count;
unsigned key_in;
TimuiKey key_pressed;
uint32_t key_mods; /* modifiers of the last key event */
int mouse_wheel; /* accumulated wheel delta this frame (+up/-down) */
+ int mouse_wheel_x, mouse_wheel_y; /* cell of the wheel event */
int mouse_x, mouse_y; /* last reported cell (0-based) */
int mouse_clicked; /* a button press occurred this frame */
int mouse_click_x, mouse_click_y; /* press cell for mouse_clicked */
@@ -87,8 +102,9 @@ struct Timui {
* deferred ESC — 16 dropped all but the first 16 chars of a dropped path. */
TimuiEvent events[512];
int event_count;
- struct { TimuiRect clip; int has_clip; } clip_stack[8];
+ struct TimuiClipSnapshot { TimuiRect clip; int has_clip; } *clip_stack;
int clip_count;
+ int clip_cap;
/* Terminal-image placements recorded this frame by timui_image_draw;
* emitted ON TOP of the cell diff in timui_end, so they compose with the
* renderer. Protocol-specific lifecycle state is tracked separately. */
@@ -159,6 +175,11 @@ static int timui_rect_contains_(TimuiRect r, int x, int y){
(int64_t)y >= (int64_t)r.y && (int64_t)y < ry2;
}
+static int timui_mouse_wheel_over_(const Timui *ui, TimuiRect r){
+ return ui && ui->mouse_wheel &&
+ timui_rect_contains_(r, ui->mouse_wheel_x, ui->mouse_wheel_y);
+}
+
static void timui_draw_text_clipped_(TimuiCellBuffer *buf, TimuiRect clip, int x, int y,
TimuiStr text, TimuiStyle st){
TimuiRect old_clip, active;
diff --git a/src/timui_layout.c b/src/timui_layout.c
index 9357669..032aa89 100644
--- a/src/timui_layout.c
+++ b/src/timui_layout.c
@@ -25,10 +25,16 @@
* rather than silently truncated. */
#define TIMUI_LAYOUT_MAX 128
+static int timui_sat_i64_(int64_t v){
+ if(v < INT_MIN) return INT_MIN;
+ if(v > INT_MAX) return INT_MAX;
+ return (int)v;
+}
+
/* Round a * num / den to the nearest integer (den > 0; a, num >= 0). */
-static int timui_round_div_(long a, long num, long den){
+static int64_t timui_round_div_(int64_t a, int64_t num, int64_t den){
if(den <= 0) return 0;
- return (int)((a * num + den / 2) / den);
+ return (a * num + den / 2) / den;
}
/* A flexible constraint shares in the leftover space (FLEX/MIN/MAX). */
@@ -38,19 +44,20 @@ static int timui_con_is_flex_(TimuiConstraintKind k){
/* Weight of a flexible constraint: FLEX uses its value (clamped >= 0); MIN/MAX
* are single-weight fill segments. */
-static int timui_con_weight_(const TimuiConstraint *c){
+static int64_t timui_con_weight_(const TimuiConstraint *c){
if(c->kind == TIMUI_CON_FLEX) return c->value > 0 ? c->value : 0;
return 1; /* MIN / MAX */
}
TIMUI_API int timui_split_ex(TimuiRect area, TimuiAxis axis, const TimuiConstraint *cons,
int n, TimuiLayoutOpts opts, TimuiRect *out){
- int size[TIMUI_LAYOUT_MAX];
+ int64_t size[TIMUI_LAYOUT_MAX];
char locked[TIMUI_LAYOUT_MAX];
- int i, gap, margin;
- int axis_start, axis_len, cross_start, cross_len;
- int inner_start, inner_len, cross_inner_start, cross_inner_len;
- int avail, fixed_sum, leftover, remaining, pos, end;
+ int i;
+ int64_t gap, margin;
+ int64_t axis_start, axis_len, cross_start, cross_len;
+ int64_t inner_start, inner_len, cross_inner_start, cross_inner_len;
+ int64_t avail, fixed_sum, leftover, remaining, pos, end;
if(!cons || !out || n <= 0 || n > TIMUI_LAYOUT_MAX) return 0;
@@ -81,7 +88,7 @@ TIMUI_API int timui_split_ex(TimuiRect area, TimuiAxis axis, const TimuiConstrai
size[i] = cons[i].value > 0 ? cons[i].value : 0;
fixed_sum += size[i];
} else if(cons[i].kind == TIMUI_CON_PCT){
- int p = cons[i].value < 0 ? 0 : cons[i].value;
+ int64_t p = cons[i].value < 0 ? 0 : cons[i].value;
size[i] = timui_round_div_(avail, p, 100);
fixed_sum += size[i];
} else {
@@ -95,13 +102,15 @@ TIMUI_API int timui_split_ex(TimuiRect area, TimuiAxis axis, const TimuiConstrai
* honouring MIN/MAX bounds via freeze-and-redistribute. */
remaining = leftover;
for(;;){
- int free_weight = 0, last_free = -1, assigned = 0, changed = 0;
+ int64_t free_weight = 0, assigned = 0;
+ int last_free = -1, changed = 0;
for(i = 0; i < n; i++)
if(timui_con_is_flex_(cons[i].kind) && !locked[i]){
free_weight += timui_con_weight_(&cons[i]);
last_free = i;
}
if(last_free < 0) break; /* no free flexible children left */
+ if(free_weight <= 0) break; /* all remaining flex children asked for zero */
/* Provisional shares — the last free child gets the exact remainder so
* the free children always sum to `remaining` (no rounding gap). */
@@ -136,17 +145,17 @@ TIMUI_API int timui_split_ex(TimuiRect area, TimuiAxis axis, const TimuiConstrai
pos = inner_start;
end = inner_start + inner_len;
for(i = 0; i < n; i++){
- int s = size[i], rem;
+ int64_t s = size[i], rem;
if(i > 0) pos += gap;
rem = end - pos; if(rem < 0) rem = 0;
if(s > rem) s = rem;
if(s < 0) s = 0;
if(axis == TIMUI_AXIS_V){
- out[i].x = cross_inner_start; out[i].w = cross_inner_len;
- out[i].y = pos; out[i].h = s;
+ out[i].x = timui_sat_i64_(cross_inner_start); out[i].w = timui_sat_i64_(cross_inner_len);
+ out[i].y = timui_sat_i64_(pos); out[i].h = timui_sat_i64_(s);
} else {
- out[i].x = pos; out[i].w = s;
- out[i].y = cross_inner_start; out[i].h = cross_inner_len;
+ out[i].x = timui_sat_i64_(pos); out[i].w = timui_sat_i64_(s);
+ out[i].y = timui_sat_i64_(cross_inner_start); out[i].h = timui_sat_i64_(cross_inner_len);
}
pos += s;
}
@@ -168,7 +177,8 @@ TIMUI_API int timui_grid_ex(TimuiRect area, const TimuiConstraint *rows, int nr,
const TimuiConstraint *cols, int nc, TimuiLayoutOpts opts, TimuiRect *out){
TimuiRect rowrects[TIMUI_LAYOUT_MAX];
int r;
- if(!rows || !cols || !out || nr <= 0 || nc <= 0 || nr > TIMUI_LAYOUT_MAX) return 0;
+ if(!rows || !cols || !out || nr <= 0 || nc <= 0 ||
+ nr > TIMUI_LAYOUT_MAX || nc > TIMUI_LAYOUT_MAX || nr > INT_MAX / nc) return 0;
/* Rows carve `area` into vertical bands; each band is then split into cells
* by the column constraints. Output is row-major: out[r*nc + c]. */
if(timui_split_ex(area, TIMUI_AXIS_V, rows, nr, opts, rowrects) != nr) return 0;
diff --git a/src/timui_render.c b/src/timui_render.c
index ee76779..51044dc 100644
--- a/src/timui_render.c
+++ b/src/timui_render.c
@@ -117,6 +117,30 @@ TIMUI_API int timui_utf8_width(uint32_t cp){
}
/* ---- drawing primitives ----------------------------------------------- */
+static void clear_cell_default_(TimuiCellBuffer *buf, int x, int y){
+ TimuiCell c;
+ if(!timui_cells_get(buf, x, y)) return;
+ memset(&c, 0, sizeof c);
+ c.fg = TIMUI_COLOR_DEFAULT;
+ c.bg = TIMUI_COLOR_DEFAULT;
+ timui_cells_put(buf, x, y, &c);
+}
+
+static void clear_wide_pair_touching_(TimuiCellBuffer *buf, int x, int y){
+ TimuiCell *c = timui_cells_get(buf, x, y);
+ if(!c) return;
+ if(c->flags & TIMUI_CELL_CONTINUATION){
+ if(x > 0){
+ TimuiCell *lead = timui_cells_get(buf, x - 1, y);
+ if(lead && lead->width >= 2) clear_cell_default_(buf, x - 1, y);
+ }
+ clear_cell_default_(buf, x, y);
+ } else if(c->width >= 2){
+ clear_cell_default_(buf, x, y);
+ clear_cell_default_(buf, x + 1, y);
+ }
+}
+
/* Z7: the single glyph-emit primitive. Writes cp at (x,y) with style st and an
* optional hyperlink id, and blanks the continuation cell for a wide glyph.
* This is the one place the subtle wide-glyph continuation logic lives (the
@@ -132,6 +156,8 @@ static void put_glyph_link(TimuiCellBuffer *buf, int x, int y, uint32_t cp, Timu
w = timui_utf8_width(cp);
if(w > 1 && (x + 1 >= buf->w ||
(buf->has_clip && (x + 1 < buf->clip.x || x + 1 >= buf->clip.x + buf->clip.w)))) return;
+ clear_wide_pair_touching_(buf, x, y);
+ if(w > 1) clear_wide_pair_touching_(buf, x + 1, y);
c.codepoint = cp;
c.fg = st.fg;
c.bg = st.bg;
diff --git a/src/timui_table.c b/src/timui_table.c
index 14a6dd5..788dc33 100644
--- a/src/timui_table.c
+++ b/src/timui_table.c
@@ -65,10 +65,22 @@ TIMUI_API int timui_fit_cell(const char *s, int width, char *out, size_t cap, in
return 0;
}
if(full <= width){ /* fits whole — copy verbatim */
- size_t n = len < cap - 1 ? len : cap - 1;
- memcpy(out, s, n);
- out[n] = '\0';
- return full;
+ for(i = 0; i < len;){
+ size_t n = timui_grapheme_next(s, len, i);
+ int gw;
+ if(n <= i) n = i + 1;
+ if(o + (n - i) >= cap){
+ if(ellipsis) *ellipsis = 1;
+ break;
+ }
+ gw = timui_grapheme_width(s + i, n - i);
+ memcpy(out + o, s + i, n - i);
+ o += n - i;
+ used += gw;
+ i = n;
+ }
+ out[o] = '\0';
+ return used;
}
if(cap < sizeof(TIMUI_ELLIPSIS_)){ /* no room for ellipsis + NUL */
if(ellipsis) *ellipsis = 1;
@@ -145,6 +157,14 @@ TIMUI_API TimuiTableResult timui_table(TimuiFrame *f, TimuiId id, TimuiRect r,
if(state.scroll < 0) state.scroll = 0;
if(state.selected < state.scroll) state.scroll = state.selected;
if(state.selected >= state.scroll + vis) state.scroll = state.selected - vis + 1;
+ /* Keyboard nav only when focused — process before drawing so the highlight
+ * and returned state agree in the same frame. */
+ { TimuiInteractResult tir = timui_interact_button(&ui->ia, id, r);
+ res.focused = tir.focused;
+ if(tir.focused) state.selected = timui_updown_nav_(f, state.selected, nrows);
+ }
+ if(state.selected < state.scroll) state.scroll = state.selected;
+ if(state.selected >= state.scroll + vis) state.scroll = state.selected - vis + 1;
/* header row */
{ TimuiStyle hs = timui_widget_style_(ui, TIMUI_WIDGET_TABLE, TIMUI_SLOT_PANEL_TITLE, 0);
x = r.x;
@@ -169,12 +189,6 @@ TIMUI_API TimuiTableResult timui_table(TimuiFrame *f, TimuiId id, TimuiRect r,
}
}
timui_scroll_end(f);
- /* keyboard nav only when focused — call interact_button once to avoid
- * double-registering the table id in the tab order */
- { TimuiInteractResult tir = timui_interact_button(&ui->ia, id, r);
- res.focused = tir.focused;
- if(tir.focused) state.selected = timui_updown_nav_(f, state.selected, nrows);
- }
res.state = state;
res.state_changed = (state.selected != orig);
return res;
@@ -278,7 +292,7 @@ TIMUI_API TimuiTableResult timui_table_ex(TimuiFrame *f, TimuiId id, TimuiRect r
/* Mouse wheel scrolls the body and drags the selection into the new window. */
wh = timui_mouse_wheel(f);
- if(wh){
+ if(wh && timui_mouse_wheel_over_(ui, r)){
scroll -= wh;
scroll = timui_page_slice(nrows, vis, scroll).first;
if(sel < scroll) sel = scroll;
diff --git a/src/timui_term.c b/src/timui_term.c
index 7150f62..aef1d2c 100644
--- a/src/timui_term.c
+++ b/src/timui_term.c
@@ -194,10 +194,13 @@ static int caps_is_kitty_family(const char *tp){
return caps_streq(tp, "kitty") || caps_streq(tp, "xterm-kitty")
|| caps_streq(tp, "ghostty") || caps_streq(tp, "xterm-ghostty");
}
+static int caps_is_iterm2(const char *tp){
+ return caps_streq(tp, "iTerm.app") || caps_streq(tp, "iTerm2");
+}
static int caps_is_modern(const char *tp){
return caps_is_kitty_family(tp) || caps_streq(tp, "WezTerm")
|| caps_streq(tp, "alacritty") || caps_streq(tp, "foot")
- || caps_streq(tp, "rio");
+ || caps_streq(tp, "rio") || caps_is_iterm2(tp);
}
static void caps_set_str(char *dst, size_t cap, const char *src){
size_t n;
@@ -230,6 +233,8 @@ TIMUI_API void timui_caps_detect(TimuiCaps *c, const char *term, const char *ter
if(caps_is_kitty_family(term_program) || caps_is_kitty_family(term)){
c->flags |= TIMUI_CAP_KITTY_KEYBOARD | TIMUI_CAP_KITTY_GRAPHICS | TIMUI_CAP_UNICODE_CORE;
}
+ if(caps_is_iterm2(term_program) || caps_is_iterm2(term))
+ c->flags |= TIMUI_CAP_ITERM2_IMAGES | TIMUI_CAP_UNICODE_CORE;
} else if(term && strstr(term, "256color")){
c->flags |= TIMUI_CAP_256_COLOR;
c->colors = 256;
diff --git a/src/timui_textarea.c b/src/timui_textarea.c
index 3db8eec..c7211d6 100644
--- a/src/timui_textarea.c
+++ b/src/timui_textarea.c
@@ -21,42 +21,57 @@ static int text_area_insert_newline_(TimuiTextAreaState *st){
st->cursor++;
return 1;
}
-static int text_area_enter_submits_(const Timui *ui, int enter_index, uint32_t flags){
+static int text_area_enter_submits_(uint32_t mods, uint32_t flags){
if(!(flags & TIMUI_TEXT_AREA_ENTER_SUBMITS)) return 0;
- return !(ui->enter_mods[enter_index] & TIMUI_MOD_SHIFT);
+ return !(mods & TIMUI_MOD_SHIFT);
}
-static void text_area_defer_after_enter_(Timui *ui, int at, int enter_index){
- int tail = ui->text_in_len - at;
- int k;
- if(tail < 0) tail = 0;
- if(tail > (int)sizeof(ui->pending_in)) tail = (int)sizeof(ui->pending_in);
- memcpy(ui->pending_in, ui->text_in + at, (size_t)tail);
- ui->pending_in_len = tail;
- ui->pending_enter_count = ui->enter_count - enter_index - 1;
- if(ui->pending_enter_count < 0) ui->pending_enter_count = 0;
- for(k = 0; k < ui->pending_enter_count; k++){
- ui->pending_enter_at[k] = ui->enter_at[enter_index + 1 + k] - at;
- ui->pending_enter_mods[k] = ui->enter_mods[enter_index + 1 + k];
+static void text_area_apply_key_(TimuiTextAreaState *st, unsigned key, TimuiTextAreaResult *res){
+ if(key == TIMUI_KEYIN_LEFT) st->cursor = utf8_drop_last(st->text, st->cursor);
+ if(key == TIMUI_KEYIN_RIGHT) st->cursor = utf8_next_(st->text, st->cursor, strlen(st->text));
+ if(key == TIMUI_KEYIN_HOME) st->cursor = line_start_(st->text, st->cursor);
+ if(key == TIMUI_KEYIN_END) st->cursor = line_end_(st->text, st->cursor);
+ if(key == TIMUI_KEYIN_BACKSPACE && st->cursor > 0){
+ size_t prev = utf8_drop_last(st->text, st->cursor);
+ st->cursor = text_erase_(st->text, prev, st->cursor);
+ res->changed = 1;
+ }
+ if(key == TIMUI_KEYIN_DELETE){
+ size_t nxt = utf8_next_(st->text, st->cursor, strlen(st->text));
+ if(nxt > st->cursor){
+ (void)text_erase_(st->text, st->cursor, nxt);
+ res->changed = 1;
+ }
}
}
-static void text_area_process_text_(Timui *ui, TimuiTextAreaState *st,
- uint32_t flags, TimuiTextAreaResult *res){
- int j = 0;
- int e;
- for(e = 0; e < ui->enter_count; e++){
- int at = ui->enter_at[e];
- if(at < j) at = j;
- if(at > ui->text_in_len) at = ui->text_in_len;
- if(text_area_insert_span_(st, ui->text_in + j, at - j)) res->changed = 1;
- if(text_area_enter_submits_(ui, e, flags)){
- res->submitted = 1;
- text_area_defer_after_enter_(ui, at, e);
- return;
+static void text_area_process_edit_ops_(Timui *ui, TimuiTextAreaState *st,
+ uint32_t flags, TimuiTextAreaResult *res){
+ int oi;
+ for(oi = 0; oi < ui->edit_count; oi++){
+ TimuiEditOp *op = &ui->edit_ops[oi];
+ if(op->kind == TIMUI_EDIT_TEXT){
+ if(text_area_insert_span_(st, ui->text_in + op->start, op->len)) res->changed = 1;
+ } else if(op->kind == TIMUI_EDIT_KEY && op->key == TIMUI_EDIT_KEY_ENTER_){
+ if(text_area_enter_submits_(op->mods, flags)){
+ res->submitted = 1;
+ timui_defer_edit_ops_after_(ui, oi + 1);
+ return;
+ }
+ if(text_area_insert_newline_(st)) res->changed = 1;
+ } else if(op->kind == TIMUI_EDIT_KEY){
+ text_area_apply_key_(st, op->key, res);
+ }
+ }
+}
+static int text_area_cursor_row_(const TimuiTextAreaState *st){
+ int cursor_row = 0;
+ size_t ci;
+ for(ci = 0; ci < st->cursor && ci < st->cap; ci++){
+ if(st->text[ci] == '\n' || st->text[ci] == '\r'){
+ cursor_row++;
+ if(st->text[ci] == '\r' && ci + 1 < st->cap && st->text[ci + 1] == '\n') ci++;
}
- if(text_area_insert_newline_(st)) res->changed = 1;
- j = at;
}
- if(text_area_insert_span_(st, ui->text_in + j, ui->text_in_len - j)) res->changed = 1;
+ return cursor_row;
}
TIMUI_API TimuiTextAreaResult timui_text_area_ex(TimuiFrame *f, TimuiId id, TimuiRect r,
TimuiTextAreaState st, uint32_t flags){
@@ -78,39 +93,21 @@ TIMUI_API TimuiTextAreaResult timui_text_area_ex(TimuiFrame *f, TimuiId id, Timu
ir = timui_interact_button(&ui->ia, id, r);
res.focused = ir.focused;
if(ir.focused){
- /* Insert typed codepoints at the cursor. Deletion/movement below is
- * grapheme-aware; insertion remains codepoint-by-codepoint and
- * cap-bounded, so invalid partial UTF-8 is not created.
- * The edit helpers live in the widgets section, in scope via the unity
- * build. */
- text_area_process_text_(ui, &st, flags, &res);
- /* cursor movement (one step per frame — the key_in bitmask can't count
- * repeats; key auto-repeat delivers one per frame). */
- if(ui->key_in & TIMUI_KEYIN_LEFT) st.cursor = utf8_drop_last(st.text, st.cursor);
- if(ui->key_in & TIMUI_KEYIN_RIGHT) st.cursor = utf8_next_(st.text, st.cursor, strlen(st.text));
- if(ui->key_in & TIMUI_KEYIN_HOME) st.cursor = line_start_(st.text, st.cursor);
- if(ui->key_in & TIMUI_KEYIN_END) st.cursor = line_end_(st.text, st.cursor);
- /* deletion: backspace removes the cluster before the cursor, DELETE
- * the one at the cursor. */
- if((ui->key_in & TIMUI_KEYIN_BACKSPACE) && st.cursor > 0){
- size_t prev = utf8_drop_last(st.text, st.cursor);
- st.cursor = text_erase_(st.text, prev, st.cursor);
- res.changed = 1;
- }
- if(ui->key_in & TIMUI_KEYIN_DELETE){
- size_t nxt = utf8_next_(st.text, st.cursor, strlen(st.text));
- if(nxt > st.cursor){
- (void)text_erase_(st.text, st.cursor, nxt);
- res.changed = 1;
- }
- }
+ text_area_process_edit_ops_(ui, &st, flags, &res);
ui->text_in_len = 0;
ui->enter_count = 0;
+ ui->edit_count = 0;
ui->key_in = 0;
}
{ TimuiStyle sst = timui_widget_style_(ui, TIMUI_WIDGET_TEXT_AREA,
ir.focused ? TIMUI_SLOT_INPUT_FOCUSED : TIMUI_SLOT_INPUT,
ir.focused ? TIMUI_STYLE_STATE_FOCUSED : 0);
+ { int cursor_row = text_area_cursor_row_(&st);
+ if(st.scroll_y < 0) st.scroll_y = 0;
+ if(cursor_row < st.scroll_y) st.scroll_y = cursor_row;
+ if(cursor_row >= st.scroll_y + r.h) st.scroll_y = cursor_row - r.h + 1;
+ if(st.scroll_y < 0) st.scroll_y = 0;
+ }
content = timui_scroll_begin(f, r, st.scroll_y);
i = 0;
while(i < st.cap && st.text[i]){
@@ -127,18 +124,6 @@ TIMUI_API TimuiTextAreaResult timui_text_area_ex(TimuiFrame *f, TimuiId id, Timu
}
y++;
}
- /* auto-scroll to keep the cursor visible (computed before scroll_begin next frame) */
- { int cursor_row = 0;
- size_t ci;
- for(ci = 0; ci < st.cursor && ci < st.cap; ci++)
- if(st.text[ci] == '\n' || st.text[ci] == '\r'){
- cursor_row++;
- if(st.text[ci] == '\r' && ci + 1 < st.cap && st.text[ci+1] == '\n') ci++;
- }
- if(cursor_row < st.scroll_y) st.scroll_y = cursor_row;
- if(cursor_row >= st.scroll_y + r.h) st.scroll_y = cursor_row - r.h + 1;
- if(st.scroll_y < 0) st.scroll_y = 0;
- }
timui_scroll_end(f);
if(ir.focused){ /* F1.4: request the hardware cursor */
int crow, ccol;
diff --git a/src/timui_tree.c b/src/timui_tree.c
index c23c6c4..5890179 100644
--- a/src/timui_tree.c
+++ b/src/timui_tree.c
@@ -73,6 +73,12 @@ TIMUI_API TimuiTreeResult timui_tree(TimuiFrame *f, TimuiId id, TimuiRect r,
if(selected < 0) selected = 0;
if(selected >= count) selected = count - 1;
orig = selected; /* post-clamp: a pure clamp is not a change */
+ /* Keyboard nav only when focused — process before drawing so the visible
+ * selection and returned value cannot diverge for a frame. */
+ { TimuiInteractResult ir2 = timui_interact_button(&ui->ia, id, r);
+ res.focused = ir2.focused;
+ if(ir2.focused) selected = timui_updown_nav_(f, selected, count);
+ }
content = timui_scroll_begin(f, r, 0);
for(i = 0; i < count; i++){
int y = content.y + i;
@@ -82,11 +88,6 @@ TIMUI_API TimuiTreeResult timui_tree(TimuiFrame *f, TimuiId id, TimuiRect r,
timui_tree_draw_node_(ui, &nodes[i], TIMUI_RECT(content.x, y, r.w, 1), y, st);
}
timui_scroll_end(f);
- /* keyboard nav only when focused */
- { TimuiInteractResult ir2 = timui_interact_button(&ui->ia, id, r);
- res.focused = ir2.focused;
- if(ir2.focused) selected = timui_updown_nav_(f, selected, count);
- }
res.selected = selected;
res.state_changed = (selected != orig);
return res;
@@ -128,7 +129,7 @@ TIMUI_API TimuiTreeScrollResult timui_tree_scroll(TimuiFrame *f, TimuiId id, Tim
scroll = state.scroll < 0 ? 0 : state.scroll;
wh = timui_mouse_wheel(f);
- if(wh){
+ if(wh && timui_mouse_wheel_over_(ui, r)){
scroll -= wh;
scroll = timui_page_slice(nvis, vis, scroll).first;
if(sel < scroll) sel = scroll;
diff --git a/src/timui_widgets.c b/src/timui_widgets.c
index 2fcc370..aa12abe 100644
--- a/src/timui_widgets.c
+++ b/src/timui_widgets.c
@@ -345,6 +345,50 @@ static void text_pos_(const char *buf, size_t cursor, int *out_row, int *out_col
*out_row = row;
*out_col = display_col_(buf + line_start, cursor - line_start);
}
+static void input_field_insert_span_(TimuiInputState *st, const char *src, int nbytes){
+ int j = 0;
+ while(st && src && j < nbytes){
+ int n = utf8_lead_len((unsigned char)src[j]);
+ size_t m = (size_t)(n > 0 ? n : 1);
+ if(!single_line_text_byte_((unsigned char)src[j])){ j++; continue; }
+ if(j + (int)m > nbytes) m = (size_t)(nbytes - j);
+ if(!text_insert_(st->text, st->cap, st->cursor, src + j, m)) break;
+ st->cursor += m;
+ j += (int)m;
+ }
+}
+static void input_field_apply_key_(TimuiInputState *st, unsigned key){
+ size_t len;
+ if(!st || !st->text) return;
+ len = strlen(st->text);
+ if(key == TIMUI_KEYIN_LEFT) st->cursor = utf8_drop_last(st->text, st->cursor);
+ if(key == TIMUI_KEYIN_RIGHT) st->cursor = utf8_next_(st->text, st->cursor, len);
+ if(key == TIMUI_KEYIN_HOME) st->cursor = 0;
+ if(key == TIMUI_KEYIN_END) st->cursor = len;
+ if(key == TIMUI_KEYIN_BACKSPACE && st->cursor > 0){
+ size_t prev = utf8_drop_last(st->text, st->cursor);
+ st->cursor = text_erase_(st->text, prev, st->cursor);
+ }
+ if(key == TIMUI_KEYIN_DELETE){
+ size_t nxt = utf8_next_(st->text, st->cursor, strlen(st->text));
+ (void)text_erase_(st->text, st->cursor, nxt);
+ }
+ if(key == TIMUI_KEYIN_KILL_EOL){
+ st->text[st->cursor] = '\0';
+ }
+ if(key == TIMUI_KEYIN_KILL_BOL){
+ size_t rest = strlen(st->text + st->cursor);
+ memmove(st->text, st->text + st->cursor, rest + 1);
+ st->cursor = 0;
+ }
+ if(key == TIMUI_KEYIN_KILL_WORD){
+ size_t c = st->cursor, w = c;
+ while(w > 0 && st->text[w-1] == ' ') w--;
+ while(w > 0 && st->text[w-1] != ' ') w--;
+ memmove(st->text + w, st->text + c, strlen(st->text + c) + 1);
+ st->cursor = w;
+ }
+}
static bool input_field_core(TimuiFrame *f, TimuiId id, TimuiRect r, TimuiInputState *st,
const TimuiStyle *ovr){
Timui *ui;
@@ -359,66 +403,22 @@ static bool input_field_core(TimuiFrame *f, TimuiId id, TimuiRect r, TimuiInputS
{
ir = timui_interact_button(&ui->ia, id, r);
if(ir.focused){
- /* Insert typed text UP TO the first Enter this frame; on an Enter,
- * submit and DEFER the post-Enter tail (and any further Enters) to
- * the next frame — one submit per frame, so a burst "a\rb\r" yields
- * "a" then "b" instead of the merged "ab". */
- int first_enter = (ui->enter_count > 0) ? ui->enter_at[0] : -1;
- int upto = (first_enter >= 0) ? first_enter : ui->text_in_len;
- int j = 0;
- size_t len;
- if(upto > ui->text_in_len) upto = ui->text_in_len;
- while(j < upto){
- int n = utf8_lead_len((unsigned char)ui->text_in[j]);
- size_t m = (size_t)(n > 0 ? n : 1);
- if(!single_line_text_byte_((unsigned char)ui->text_in[j])){ j++; continue; }
- if(j + (int)m > upto) m = (size_t)(upto - j);
- if(!text_insert_(st->text, st->cap, st->cursor, ui->text_in + j, m)) break;
- st->cursor += m; j += (int)m;
- }
- len = strlen(st->text);
- if(ui->key_in & TIMUI_KEYIN_LEFT) st->cursor = utf8_drop_last(st->text, st->cursor);
- if(ui->key_in & TIMUI_KEYIN_RIGHT) st->cursor = utf8_next_(st->text, st->cursor, len);
- if(ui->key_in & TIMUI_KEYIN_HOME) st->cursor = 0; /* single line */
- if(ui->key_in & TIMUI_KEYIN_END) st->cursor = len;
- if((ui->key_in & TIMUI_KEYIN_BACKSPACE) && st->cursor > 0){
- size_t prev = utf8_drop_last(st->text, st->cursor);
- st->cursor = text_erase_(st->text, prev, st->cursor);
- }
- if(ui->key_in & TIMUI_KEYIN_DELETE){
- size_t nxt = utf8_next_(st->text, st->cursor, strlen(st->text));
- (void)text_erase_(st->text, st->cursor, nxt);
- }
- if(ui->key_in & TIMUI_KEYIN_KILL_EOL){ /* Ctrl-K: cursor..end */
- st->text[st->cursor] = '\0'; /* cursor is a cluster boundary */
- }
- if(ui->key_in & TIMUI_KEYIN_KILL_BOL){ /* Ctrl-U: start..cursor */
- size_t rest = strlen(st->text + st->cursor);
- memmove(st->text, st->text + st->cursor, rest + 1);
- st->cursor = 0;
- }
- if(ui->key_in & TIMUI_KEYIN_KILL_WORD){ /* Ctrl-W: the word before the cursor */
- size_t c = st->cursor, w = c;
- while(w > 0 && st->text[w-1] == ' ') w--; /* trailing spaces */
- while(w > 0 && st->text[w-1] != ' ') w--; /* the word */
- memmove(st->text + w, st->text + c, strlen(st->text + c) + 1);
- st->cursor = w;
- }
- if(first_enter >= 0){
- int tail = ui->text_in_len - upto, k;
- submitted = true;
- if(tail < 0) tail = 0;
- if(tail > (int)sizeof(ui->pending_in)) tail = (int)sizeof(ui->pending_in);
- memcpy(ui->pending_in, ui->text_in + upto, (size_t)tail);
- ui->pending_in_len = tail;
- ui->pending_enter_count = ui->enter_count - 1;
- for(k = 0; k < ui->pending_enter_count; k++){
- ui->pending_enter_at[k] = ui->enter_at[k + 1] - upto;
- ui->pending_enter_mods[k] = ui->enter_mods[k + 1];
+ int oi;
+ for(oi = 0; oi < ui->edit_count; oi++){
+ TimuiEditOp *op = &ui->edit_ops[oi];
+ if(op->kind == TIMUI_EDIT_TEXT){
+ input_field_insert_span_(st, ui->text_in + op->start, op->len);
+ } else if(op->kind == TIMUI_EDIT_KEY && op->key == TIMUI_EDIT_KEY_ENTER_){
+ submitted = true;
+ timui_defer_edit_ops_after_(ui, oi + 1);
+ break;
+ } else if(op->kind == TIMUI_EDIT_KEY){
+ input_field_apply_key_(st, op->key);
}
}
ui->text_in_len = 0;
ui->enter_count = 0;
+ ui->edit_count = 0;
ui->key_in = 0;
}
}
diff --git a/tests/test.h b/tests/test.h
index 2bd4bc0..eb81c5b 100644
--- a/tests/test.h
+++ b/tests/test.h
@@ -69,6 +69,8 @@ void test_fake_capture_output(void);
void test_fake_inject_input(void);
void test_fake_grows(void);
void test_transport_close_hook(void);
+void test_open_failure_clears_output_handle(void);
+void test_open_for_test_failure_does_not_close_injected_transport(void);
void test_write_all_under_backpressure(void);
void test_screen_enter_emits_modes(void);
void test_screen_exit_reverses(void);
@@ -94,6 +96,7 @@ void test_focus_events(void);
void test_bracketed_paste(void);
void test_input_esc_resync_no_loss(void);
void test_input_utf8_resync_no_loss(void);
+void test_input_csi_ss3_non_ascii_resync_no_loss(void);
void test_mouse_wheel_with_mods(void);
void test_input_nul_ignored(void);
void test_paste_cross_feed_three_fragments(void);
@@ -124,6 +127,7 @@ void test_caps_multiplexer_kitty_passthrough(void);
void test_caps_unknown_fallback(void);
void test_caps_force_masks(void);
void test_caps_image_protocol_selection(void);
+void test_caps_iterm2_detects_image_protocol(void);
void test_kitty_csi_u_plain(void);
void test_kitty_csi_u_with_mods(void);
void test_kitty_special_codes(void);
@@ -155,6 +159,7 @@ void test_render_diff_exact(void);
void test_render_unchanged_emits_nothing(void);
void test_render_diff_narrow_to_wide(void);
void test_render_diff_wide_to_narrow(void);
+void test_draw_overwrites_repair_wide_pairs(void);
void test_render_hyperlink_uri_change(void);
void test_render_hyperlink_closes_at_frame_end(void);
void test_render_controls_are_not_emitted_as_glyphs(void);
@@ -205,12 +210,14 @@ void test_input_line_grapheme_backspace(void);
void test_input_line_buf_clipped(void);
void test_input_line_buf_clamps_unterminated(void);
void test_input_field_edit(void);
+void test_input_field_same_frame_edit_order(void);
void test_input_field_grapheme_edit(void);
void test_input_field_cursor_clamped_to_text(void);
void test_input_field_clamps_unterminated(void);
void test_input_field_multi_submit(void);
void test_input_field_paste(void);
void test_input_field_paste_split(void);
+void test_input_field_paste_split_utf8(void);
void test_input_field_paste_drops_controls(void);
void test_input_field_paste_invalid_utf8_replaced(void);
void test_paste_preserves_text_order(void);
@@ -237,6 +244,7 @@ void test_fuzz_parser_random_stream(void);
void test_fuzz_parser_adversarial(void);
void test_clip_restricts_drawing(void);
void test_clip_nested_intersect(void);
+void test_clip_stack_overflow_preserves_pop_symmetry(void);
void test_clip_wide_glyph_requires_full_width(void);
void test_clip_extreme_rect_does_not_overflow(void);
void test_clip_pop_underflow_safe(void);
@@ -255,21 +263,27 @@ void test_keymap_bind(void);
void test_keymap_bind_overflow(void);
void test_keymap_hit_multi_binding(void);
void test_table_renders(void);
+void test_table_draws_keyboard_selection_same_frame(void);
void test_tree_renders(void);
+void test_tree_draws_keyboard_selection_same_frame(void);
void test_tree_deep_safe(void);
void test_tree_controlled_no_write_without_change(void);
void test_table_ex_renders(void);
void test_table_ex_down_key(void);
void test_table_ex_hscroll(void);
+void test_table_ex_wheel_only_when_hovered(void);
void test_tree_scroll_hides_collapsed(void);
+void test_tree_scroll_wheel_only_when_hovered(void);
void test_tree_scroll_windows(void);
void test_cmd_palette_filter(void);
+void test_cmd_palette_draws_keyboard_selection_same_frame(void);
void test_combobox_filter_select_activate(void);
void test_combobox_no_match_and_clamp(void);
void test_combobox_mouse_accept_duplicate(void);
void test_combobox_query_cap_utf8_no_split(void);
void test_combobox_escape_closes_without_clearing_query(void);
void test_combobox_cursor_movement_updates_state(void);
+void test_combobox_clamps_cursor_to_query_len_before_edit(void);
void test_combobox_guards_empty_options(void);
void test_combobox_accept_cap_limited(void);
void test_toast_order_timeout_dismiss(void);
@@ -307,6 +321,8 @@ void test_text_area_cursor_overcap_safe(void);
void test_text_area_cursor_clamped_to_text(void);
void test_text_area_clamps_unterminated(void);
void test_text_area_cursor_edit(void);
+void test_text_area_same_frame_edit_order(void);
+void test_text_area_scroll_applies_before_draw(void);
void test_text_area_cursor_utf8(void);
void test_text_area_grapheme_edit(void);
void test_text_area_submit_plain_enter(void);
@@ -331,6 +347,7 @@ void test_kitty_clipped_invalid_visible_no_image_escape(void);
void test_kitty_graphics_clip_horizontal_source_rect(void);
void test_kitty_graphics_respects_active_clip(void);
void test_kitty_graphics_placeholder(void);
+void test_kitty_placement_overflow_placeholder(void);
void test_image_protocol_force_none_placeholder(void);
void test_sixel_malformed_png_still_placeholder(void);
void test_sixel_plain_png_decodes_to_dcs(void);
@@ -380,8 +397,13 @@ void test_function_bar_clipped(void);
void test_interact_tab_grow_oom(void);
void test_hyperlink_set_edges(void);
void test_run_negative_guards(void);
+void test_app_frame_updates_after_end(void);
+void test_app_frame_delivers_large_messages(void);
void test_getters(void);
void test_render_stream_chat_like(void);
void test_frame_synchronized_output(void);
+void test_async_scan_state_valid_messages(void);
+void test_async_scan_state_rejects_malformed_progress(void);
+void test_async_scan_state_clamps_progress(void);
#endif /* TIMUI_TEST_H */
diff --git a/tests/test_async_scan.c b/tests/test_async_scan.c
new file mode 100644
index 0000000..cb6f54f
--- /dev/null
+++ b/tests/test_async_scan.c
@@ -0,0 +1,40 @@
+/*
+ * test_async_scan.c — pure state tests for examples/async_scan.c.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+#include "test.h"
+#include "../examples/async_scan_state.h"
+
+#include
+
+TIMUI_TEST(test_async_scan_state_valid_messages){
+ AsyncScanModel m = {0, 0};
+ int p = 40;
+ TIMUI_CHECK(async_scan_update(&m, ASYNC_SCAN_MSG_PROGRESS, &p, sizeof p));
+ TIMUI_CHECK(m.progress == 40 && !m.done);
+ TIMUI_CHECK(async_scan_update(&m, ASYNC_SCAN_MSG_DONE, NULL, 0));
+ TIMUI_CHECK(m.progress == 100 && m.done);
+}
+
+TIMUI_TEST(test_async_scan_state_rejects_malformed_progress){
+ AsyncScanModel m = {30, 0};
+ unsigned char tiny[1] = { 99 };
+ int p = 70;
+ TIMUI_CHECK(!async_scan_update(&m, ASYNC_SCAN_MSG_PROGRESS, tiny, sizeof tiny));
+ TIMUI_CHECK(m.progress == 30 && !m.done);
+ TIMUI_CHECK(!async_scan_update(&m, ASYNC_SCAN_MSG_PROGRESS, &p, sizeof p - 1));
+ TIMUI_CHECK(m.progress == 30 && !m.done);
+ TIMUI_CHECK(!async_scan_update(&m, ASYNC_SCAN_MSG_PROGRESS, NULL, sizeof p));
+ TIMUI_CHECK(m.progress == 30 && !m.done);
+}
+
+TIMUI_TEST(test_async_scan_state_clamps_progress){
+ AsyncScanModel m = {30, 0};
+ int p = -10;
+ TIMUI_CHECK(async_scan_update(&m, ASYNC_SCAN_MSG_PROGRESS, &p, sizeof p));
+ TIMUI_CHECK(m.progress == 0);
+ p = 130;
+ TIMUI_CHECK(async_scan_update(&m, ASYNC_SCAN_MSG_PROGRESS, &p, sizeof p));
+ TIMUI_CHECK(m.progress == 100);
+}
diff --git a/tests/test_caps.c b/tests/test_caps.c
index c0fc8bb..03eaa5f 100644
--- a/tests/test_caps.c
+++ b/tests/test_caps.c
@@ -79,3 +79,15 @@ TIMUI_TEST(test_caps_image_protocol_selection){
c.flags = TIMUI_CAP_KITTY_GRAPHICS | TIMUI_CAP_SIXEL_GRAPHICS | TIMUI_CAP_ITERM2_IMAGES;
TIMUI_CHECK(timui_caps_image_protocol(&c) == TIMUI_IMAGE_PROTOCOL_KITTY);
}
+
+TIMUI_TEST(test_caps_iterm2_detects_image_protocol){
+ TimuiCaps c;
+
+ timui_caps_detect(&c, "xterm-256color", "iTerm.app", "truecolor");
+ TIMUI_CHECK(timui_caps_has(&c, TIMUI_CAP_ITERM2_IMAGES));
+ TIMUI_CHECK(timui_caps_image_protocol(&c) == TIMUI_IMAGE_PROTOCOL_ITERM2);
+
+ timui_caps_detect(&c, "tmux-256color", "iTerm.app", "truecolor");
+ TIMUI_CHECK(!timui_caps_has(&c, TIMUI_CAP_ITERM2_IMAGES));
+ TIMUI_CHECK(timui_caps_image_protocol(&c) == TIMUI_IMAGE_PROTOCOL_NONE);
+}
diff --git a/tests/test_clip.c b/tests/test_clip.c
index 4c30f4e..d4cf160 100644
--- a/tests/test_clip.c
+++ b/tests/test_clip.c
@@ -55,6 +55,30 @@ TIMUI_TEST(test_clip_nested_intersect){
timui_close(ui);
}
+TIMUI_TEST(test_clip_stack_overflow_preserves_pop_symmetry){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL; TimuiCellBuffer *buf;
+ int i;
+ timui_fake_init(&fake, &al);
+ t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 20, 3, &al);
+ timui_begin(ui, &f);
+ buf = timui_frame_buffer(f);
+
+ for(i = 0; i < 9; i++) timui_push_clip(f, TIMUI_RECT(i, 0, 20 - i, 3));
+ timui_pop_clip(f);
+
+ timui_draw_text(buf, 6, 0, TIMUI_STR_LIT("X"), timui_style_make(0xFFFFFF, TIMUI_COLOR_DEFAULT, 0));
+ timui_draw_text(buf, 7, 0, TIMUI_STR_LIT("Y"), timui_style_make(0xFFFFFF, TIMUI_COLOR_DEFAULT, 0));
+
+ TIMUI_CHECK(timui_cells_get(buf, 6, 0)->codepoint == 0);
+ TIMUI_CHECK(timui_cells_get(buf, 7, 0)->codepoint == 'Y');
+
+ timui_end(f);
+ timui_close(ui);
+}
+
TIMUI_TEST(test_clip_wide_glyph_requires_full_width){
TimuiAllocator al = timui_default_allocator();
TimuiFakeTransport fake; TimuiTransport t;
diff --git a/tests/test_conpty_evidence.py b/tests/test_conpty_evidence.py
new file mode 100644
index 0000000..b59fc27
--- /dev/null
+++ b/tests/test_conpty_evidence.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+
+PASS_NEEDLE = "PASS conpty smoke: observed TIMUI_CONPTY_SMOKE"
+
+
+def write_artifact(root: Path, commit: str, *, manifest=None, stdout=None, status="0",
+ evidence_commit=None, command=None, meta=None):
+ root.mkdir(parents=True, exist_ok=True)
+ if manifest is None:
+ manifest = {
+ "commit": commit,
+ "status": 0,
+ "passTokenPresent": True,
+ "accepted": True,
+ "stdout": "conpty-smoke.stdout",
+ "stderr": "conpty-smoke.stderr",
+ "statusFile": "conpty-smoke.status",
+ }
+ (root / "conpty-acceptance.json").write_text(json.dumps(manifest), encoding="utf-8")
+ (root / "conpty-smoke.stdout").write_text(stdout if stdout is not None else PASS_NEEDLE + "\n", encoding="utf-8")
+ (root / "conpty-smoke.stderr").write_text("", encoding="utf-8")
+ (root / "conpty-smoke.status").write_text(status + "\n", encoding="ascii")
+ (root / "conpty-smoke.command.txt").write_text(
+ command if command is not None else "make smoke-conpty-win32\n",
+ encoding="utf-8",
+ )
+ if meta is None:
+ meta = "\n".join([
+ f"commit={commit}",
+ "os_env=Windows_NT",
+ "status=0",
+ "passTokenPresent=True",
+ "accepted=True",
+ "",
+ ])
+ (root / "conpty-smoke.meta.txt").write_text(meta, encoding="utf-8")
+ (root / "evidence.md").write_text(f"- Commit: {evidence_commit or commit}\n", encoding="utf-8")
+
+
+def run_verify(repo: Path, artifact: Path, commit: str):
+ return subprocess.run(
+ [
+ sys.executable,
+ str(repo / "tools" / "verify_conpty_evidence.py"),
+ "--artifact-dir",
+ str(artifact),
+ "--commit",
+ commit,
+ ],
+ cwd=repo,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=False,
+ )
+
+
+def expect_pass(name: str, proc):
+ if proc.returncode != 0:
+ raise AssertionError(f"{name}: expected pass, got {proc.returncode}\nstdout={proc.stdout}\nstderr={proc.stderr}")
+
+
+def expect_fail(name: str, proc):
+ if proc.returncode == 0:
+ raise AssertionError(f"{name}: expected failure\nstdout={proc.stdout}\nstderr={proc.stderr}")
+
+
+def main():
+ repo = Path(__file__).resolve().parents[1]
+ commit = os.environ.get("TIMUI_TEST_COMMIT", "0123456789abcdef")
+ tmp = Path(tempfile.mkdtemp(prefix="timui-conpty-evidence-"))
+ try:
+ valid = tmp / "valid"
+ write_artifact(valid, commit)
+ expect_pass("valid hosted artifact", run_verify(repo, valid, commit))
+
+ missing_manifest = tmp / "missing-manifest"
+ write_artifact(missing_manifest, commit)
+ (missing_manifest / "conpty-acceptance.json").unlink()
+ expect_fail("missing manifest", run_verify(repo, missing_manifest, commit))
+
+ malformed = tmp / "malformed-json"
+ write_artifact(malformed, commit)
+ (malformed / "conpty-acceptance.json").write_text("{not json", encoding="utf-8")
+ expect_fail("malformed json", run_verify(repo, malformed, commit))
+
+ wrong_commit = tmp / "wrong-commit"
+ write_artifact(wrong_commit, "badc0ffee")
+ expect_fail("wrong commit", run_verify(repo, wrong_commit, commit))
+
+ accepted_false = tmp / "accepted-false"
+ write_artifact(accepted_false, commit, manifest={
+ "commit": commit,
+ "status": 0,
+ "passTokenPresent": True,
+ "accepted": False,
+ "stdout": "conpty-smoke.stdout",
+ "stderr": "conpty-smoke.stderr",
+ "statusFile": "conpty-smoke.status",
+ })
+ expect_fail("accepted false", run_verify(repo, accepted_false, commit))
+
+ pass_false = tmp / "pass-token-false"
+ write_artifact(pass_false, commit, manifest={
+ "commit": commit,
+ "status": 0,
+ "passTokenPresent": False,
+ "accepted": True,
+ "stdout": "conpty-smoke.stdout",
+ "stderr": "conpty-smoke.stderr",
+ "statusFile": "conpty-smoke.status",
+ })
+ expect_fail("pass token false", run_verify(repo, pass_false, commit))
+
+ bad_status = tmp / "bad-status"
+ write_artifact(bad_status, commit, status="2")
+ expect_fail("status file nonzero", run_verify(repo, bad_status, commit))
+
+ missing_stdout_token = tmp / "missing-stdout-token"
+ write_artifact(missing_stdout_token, commit, stdout="cmd banner only\n")
+ expect_fail("stdout missing pass token", run_verify(repo, missing_stdout_token, commit))
+
+ evidence_mismatch = tmp / "evidence-mismatch"
+ write_artifact(evidence_mismatch, commit, evidence_commit="feedface")
+ expect_fail("evidence commit mismatch", run_verify(repo, evidence_mismatch, commit))
+
+ path_escape = tmp / "path-escape"
+ write_artifact(path_escape, commit, manifest={
+ "commit": commit,
+ "status": 0,
+ "passTokenPresent": True,
+ "accepted": True,
+ "stdout": "../conpty-smoke.stdout",
+ "stderr": "conpty-smoke.stderr",
+ "statusFile": "conpty-smoke.status",
+ })
+ expect_fail("manifest path escape", run_verify(repo, path_escape, commit))
+
+ wrong_command = tmp / "wrong-command"
+ write_artifact(wrong_command, commit, command="make check-conpty-win32-smoke-compile\n")
+ expect_fail("wrong command", run_verify(repo, wrong_command, commit))
+
+ missing_windows_meta = tmp / "missing-windows-meta"
+ write_artifact(missing_windows_meta, commit, meta="\n".join([
+ f"commit={commit}",
+ "status=0",
+ "passTokenPresent=True",
+ "accepted=True",
+ "",
+ ]))
+ expect_fail("missing Windows metadata", run_verify(repo, missing_windows_meta, commit))
+ finally:
+ shutil.rmtree(tmp)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_conpty_smoke_tool.c b/tests/test_conpty_smoke_tool.c
new file mode 100644
index 0000000..07abb91
--- /dev/null
+++ b/tests/test_conpty_smoke_tool.c
@@ -0,0 +1,83 @@
+/*
+ * test_conpty_smoke_tool.c -- portable helper tests for the Win32 ConPTY smoke.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+#include
+#include
+
+#define main timui_conpty_smoke_main_for_test
+#include "../tools/conpty_smoke_win32.c"
+#undef main
+
+#define CHECK(expr) do { \
+ if(!(expr)){ \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); \
+ return 1; \
+ } \
+} while(0)
+
+typedef struct {
+ int writes;
+ int short_write;
+ char data[128];
+ size_t len;
+} FakeSmokeWrite;
+
+static int fake_smoke_write(TimuiTransport *t, const void *d, size_t n){
+ FakeSmokeWrite *fake = (FakeSmokeWrite *)t->ctx;
+ size_t copy;
+ fake->writes++;
+ copy = n;
+ if(copy > sizeof fake->data - fake->len) copy = sizeof fake->data - fake->len;
+ if(copy > 0){
+ memcpy(fake->data + fake->len, d, copy);
+ fake->len += copy;
+ }
+ if(fake->short_write && n > 0) return (int)n - 1;
+ return (int)n;
+}
+
+int main(void){
+ FakeSmokeWrite fake;
+ TimuiTransport tr;
+ int last_write = 0;
+ const char *primary = smoke_echo_script_for_attempt(0);
+ const char *lf_fallback = smoke_echo_script_for_attempt(1);
+ const char *crlf_fallback = smoke_echo_script_for_attempt(2);
+
+ CHECK(primary != NULL);
+ CHECK(strcmp(primary, "echo " TOKEN "\r") == 0);
+ CHECK(strstr(primary, "exit") == NULL);
+ CHECK(strcmp(smoke_exit_script_for_attempt(0), "exit\r") == 0);
+
+ CHECK(lf_fallback != NULL);
+ CHECK(strcmp(lf_fallback, "echo " TOKEN "\n") == 0);
+ CHECK(strstr(lf_fallback, "exit") == NULL);
+ CHECK(strcmp(smoke_exit_script_for_attempt(1), "exit\n") == 0);
+
+ CHECK(crlf_fallback != NULL);
+ CHECK(strcmp(crlf_fallback, "echo " TOKEN "\r\n") == 0);
+ CHECK(strstr(crlf_fallback, "exit") == NULL);
+ CHECK(strcmp(smoke_exit_script_for_attempt(2), "exit\r\n") == 0);
+ CHECK(smoke_echo_script_for_attempt(3) == NULL);
+ CHECK(smoke_exit_script_for_attempt(3) == NULL);
+
+ memset(&fake, 0, sizeof fake);
+ memset(&tr, 0, sizeof tr);
+ tr.write = fake_smoke_write;
+ tr.ctx = &fake;
+ CHECK(smoke_write_exact(&tr, "abc", 3, &last_write));
+ CHECK(last_write == 3);
+ CHECK(fake.writes == 1);
+ CHECK(fake.len == 3);
+ CHECK(memcmp(fake.data, "abc", 3) == 0);
+
+ fake.short_write = 1;
+ CHECK(!smoke_write_exact(&tr, "def", 3, &last_write));
+ CHECK(last_write == 2);
+
+ CHECK(smoke_contains("xx" TOKEN "yy", sizeof("xx" TOKEN "yy") - 1, TOKEN));
+ CHECK(!smoke_contains("xxTIMUI_CONPTY_SMO", sizeof("xxTIMUI_CONPTY_SMO") - 1, TOKEN));
+ return 0;
+}
diff --git a/tests/test_conpty_source.py b/tests/test_conpty_source.py
new file mode 100644
index 0000000..39def76
--- /dev/null
+++ b/tests/test_conpty_source.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src" / "timui_conpty.c"
+
+
+def main():
+ src = SRC.read_text(encoding="utf-8")
+ create_pc = src.index("api.create_pseudo_console")
+ create_proc = src.index("CreateProcessW", create_pc)
+ between = src[create_pc:create_proc]
+ forbidden = (
+ "conpty_close_handle_(&in_read)",
+ "conpty_close_handle_(&out_write)",
+ )
+ for needle in forbidden:
+ if needle in between:
+ raise SystemExit(
+ f"{SRC}: {needle} appears before CreateProcessW; "
+ "ConPTY setup must keep those pipe ends alive until the child is created"
+ )
+ required_before_create_process = (
+ "si.StartupInfo.dwFlags |= STARTF_USESTDHANDLES",
+ "si.StartupInfo.hStdInput = NULL",
+ "si.StartupInfo.hStdOutput = NULL",
+ "si.StartupInfo.hStdError = NULL",
+ )
+ for needle in required_before_create_process:
+ if needle not in between:
+ raise SystemExit(
+ f"{SRC}: {needle} is missing before CreateProcessW; "
+ "ConPTY children must not inherit redirected parent std handles"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_coverage_z7.c b/tests/test_coverage_z7.c
index 6ed6f2c..bc79cd3 100644
--- a/tests/test_coverage_z7.c
+++ b/tests/test_coverage_z7.c
@@ -286,6 +286,95 @@ TIMUI_TEST(test_run_negative_guards){
TIMUI_CHECK(timui_run(&cfg, &app) == 1);
}
+typedef struct {
+ TimuiFakeTransport *fake;
+ size_t out_len_at_update;
+ size_t msg_size;
+ unsigned char first;
+ unsigned char last;
+ int updates;
+} AppFrameProbe;
+
+static void app_frame_view(TimuiFrame *f, void *model){
+ AppFrameProbe *p = (AppFrameProbe *)model;
+ (void)p;
+ timui_label(f, 0, 0, TIMUI_STR_LIT("X"), timui_style_make(0xFFFFFF, 0x000000, 0));
+ timui_emit(f, 11, "z", 1);
+}
+
+static void app_frame_large_view(TimuiFrame *f, void *model){
+ AppFrameProbe *p = (AppFrameProbe *)model;
+ unsigned char payload[5000];
+ size_t i;
+ for(i = 0; i < sizeof payload; i++) payload[i] = (unsigned char)(i & 0xffu);
+ timui_label(f, 0, 0, TIMUI_STR_LIT("L"), timui_style_make(0xFFFFFF, 0x000000, 0));
+ timui_emit(f, 22, payload, sizeof payload);
+ (void)p;
+}
+
+static void app_frame_update(void *model, uint32_t type, const void *msg, size_t msg_size){
+ AppFrameProbe *p = (AppFrameProbe *)model;
+ TimuiStr out = timui_fake_output(p->fake);
+ p->updates++;
+ p->out_len_at_update = out.len;
+ p->msg_size = msg_size;
+ if(msg_size > 0 && msg){
+ const unsigned char *b = (const unsigned char *)msg;
+ p->first = b[0];
+ p->last = b[msg_size - 1];
+ }
+ (void)type;
+}
+
+TIMUI_TEST(test_app_frame_updates_after_end){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake;
+ TimuiTransport t;
+ Timui *ui = NULL;
+ AppFrameProbe probe;
+ TimuiApp app;
+ memset(&probe, 0, sizeof probe);
+ memset(&app, 0, sizeof app);
+ timui_fake_init(&fake, &al);
+ t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 20, 5, &al);
+ probe.fake = &fake;
+ app.model = &probe;
+ app.view = app_frame_view;
+ app.update = app_frame_update;
+ TIMUI_CHECK(timui_app_frame(ui, &app) == 1);
+ TIMUI_CHECK(probe.updates == 1);
+ TIMUI_CHECK(probe.out_len_at_update > 0);
+ TIMUI_CHECK(probe.msg_size == 1 && probe.first == 'z' && probe.last == 'z');
+ timui_close(ui);
+ timui_fake_destroy(&fake);
+}
+
+TIMUI_TEST(test_app_frame_delivers_large_messages){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake;
+ TimuiTransport t;
+ Timui *ui = NULL;
+ AppFrameProbe probe;
+ TimuiApp app;
+ memset(&probe, 0, sizeof probe);
+ memset(&app, 0, sizeof app);
+ timui_fake_init(&fake, &al);
+ t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 20, 5, &al);
+ probe.fake = &fake;
+ app.model = &probe;
+ app.view = app_frame_large_view;
+ app.update = app_frame_update;
+ TIMUI_CHECK(timui_app_frame(ui, &app) == 1);
+ TIMUI_CHECK(probe.updates == 1);
+ TIMUI_CHECK(probe.msg_size == 5000);
+ TIMUI_CHECK(probe.first == 0);
+ TIMUI_CHECK(probe.last == (unsigned char)((5000 - 1) & 0xff));
+ timui_close(ui);
+ timui_fake_destroy(&fake);
+}
+
/* Z24: the trivial getters str_len / now_ms. */
TIMUI_TEST(test_getters){
uint64_t a, b;
diff --git a/tests/test_grid.c b/tests/test_grid.c
index 50532ad..a00b809 100644
--- a/tests/test_grid.c
+++ b/tests/test_grid.c
@@ -165,6 +165,21 @@ static void test_fit_cell(void)
CHECK(e == 1);
CHECK(out[0] == '\0' && out[1] == 'Y');
CHECK(out[2] == 'G'); }
+
+ /* adversarial: the full-fit path must also copy by whole grapheme, not by
+ * cap-1 raw bytes. "é" is 2 bytes but one display column; out[2] can hold
+ * only one byte plus NUL, so emitting C3 00 would be invalid UTF-8. */
+ { char out[2] = { 'X', 'G' }; int e = 0;
+ int c = timui_fit_cell("\xC3\xA9", 2, out, sizeof out, &e);
+ CHECK(c == 0);
+ CHECK(e == 1);
+ CHECK(out[0] == '\0');
+ CHECK(out[1] == 'G'); }
+ { char out[3] = { 'X', 'Y', 'G' }; int e = 0;
+ int c = timui_fit_cell("\xC3\xA9", 2, out, sizeof out, &e);
+ CHECK(c == 1);
+ CHECK(e == 0);
+ CHECK(strcmp(out, "\xC3\xA9") == 0); }
}
/* ----------------------------------------------------------------------- */
diff --git a/tests/test_grid_widget.c b/tests/test_grid_widget.c
index 52a02a6..2a47395 100644
--- a/tests/test_grid_widget.c
+++ b/tests/test_grid_widget.c
@@ -117,6 +117,28 @@ TIMUI_TEST(test_table_ex_hscroll){
timui_close(ui);
}
+TIMUI_TEST(test_table_ex_wheel_only_when_hovered){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ TimuiStr hdrs[2] = { TIMUI_STR_LIT("A"), TIMUI_STR_LIT("B") };
+ TimuiTableModel m = {0};
+ TimuiTableState top = {0, 0, 0};
+ TimuiTableState bottom = {0, 0, 0};
+ m.headers = hdrs; m.ncols = 2; m.nrows = 20; m.cell_fn = gx_cell;
+
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 30, 12, &al);
+ SETIN(&fake, "\x1b[<65;2;7M\x1b[<32;2;2M"); /* wheel bottom, then motion top */
+ timui_begin(ui, &f);
+ timui_table_ex_mut(f, TIMUI_ID("top"), TIMUI_RECT(0, 0, 20, 4), &m, &top);
+ timui_table_ex_mut(f, TIMUI_ID("bottom"), TIMUI_RECT(0, 5, 20, 4), &m, &bottom);
+ timui_end(f);
+ TIMUI_CHECK(top.scroll == 0);
+ TIMUI_CHECK(bottom.scroll == 1);
+ timui_close(ui);
+}
+
/* ---- timui_tree_scroll: a collapsed node hides its subtree ---------------- */
TIMUI_TEST(test_tree_scroll_hides_collapsed){
TimuiAllocator al = timui_default_allocator();
@@ -143,6 +165,29 @@ TIMUI_TEST(test_tree_scroll_hides_collapsed){
timui_close(ui);
}
+TIMUI_TEST(test_tree_scroll_wheel_only_when_hovered){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ TimuiTreeNode nodes[] = {
+ {0, "n0", 0, 0}, {0, "n1", 0, 0}, {0, "n2", 0, 0}, {0, "n3", 0, 0},
+ {0, "n4", 0, 0}, {0, "n5", 0, 0}, {0, "n6", 0, 0}, {0, "n7", 0, 0}
+ };
+ TimuiTreeState top = {0, 0};
+ TimuiTreeState bottom = {0, 0};
+
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 30, 12, &al);
+ SETIN(&fake, "\x1b[<65;2;7M\x1b[<32;2;2M"); /* wheel bottom, then motion top */
+ timui_begin(ui, &f);
+ timui_tree_scroll_mut(f, TIMUI_ID("toptr"), TIMUI_RECT(0, 0, 20, 4), nodes, 8, &top);
+ timui_tree_scroll_mut(f, TIMUI_ID("bottr"), TIMUI_RECT(0, 5, 20, 4), nodes, 8, &bottom);
+ timui_end(f);
+ TIMUI_CHECK(top.scroll == 0);
+ TIMUI_CHECK(bottom.scroll == 1);
+ timui_close(ui);
+}
+
/* ---- timui_tree_scroll: viewport windowing drops off-screen rows ---------- */
TIMUI_TEST(test_tree_scroll_windows){
TimuiAllocator al = timui_default_allocator();
diff --git a/tests/test_images_pty.c b/tests/test_images_pty.c
index ee0ad86..5114350 100644
--- a/tests/test_images_pty.c
+++ b/tests/test_images_pty.c
@@ -428,6 +428,40 @@ TIMUI_TEST(test_kitty_graphics_placeholder){
timui_close(ui);
}
+TIMUI_TEST(test_kitty_placement_overflow_placeholder){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake;
+ TimuiTransport t;
+ Timui *ui = NULL;
+ TimuiFrame *f = NULL;
+ TimuiImage *img;
+ TimuiCellBuffer *buf;
+ TimuiStr out;
+ unsigned char png[24];
+ int i;
+
+ timui_fake_init(&fake, &al);
+ t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 30, 12, &al);
+ timui_force_image_protocol(ui, TIMUI_IMAGE_PROTOCOL_KITTY);
+
+ fake_png_ihdr(png, sizeof png, 1, 1);
+ img = timui_image_from_png(ui, png, sizeof png);
+ TIMUI_CHECK(img != NULL);
+
+ timui_begin(ui, &f);
+ buf = timui_frame_buffer(f);
+ timui_fake_clear_output(&fake);
+ for(i = 0; i < 9; i++) timui_image_draw(f, img, TIMUI_RECT(0, i, 5, 1));
+ TIMUI_CHECK(timui_cells_get(buf, 0, 8)->codepoint == '[');
+ timui_end(f);
+ out = timui_fake_output(&fake);
+ TIMUI_CHECK(bytes_count(out.ptr, out.len, "a=p") == 8);
+
+ timui_image_free(ui, img);
+ timui_close(ui);
+}
+
TIMUI_TEST(test_image_protocol_force_none_placeholder){
TimuiAllocator al = timui_default_allocator();
TimuiFakeTransport fake;
@@ -625,7 +659,7 @@ TIMUI_TEST(test_image_from_png_rgba_copies_png_and_pixels){
TimuiTransport t;
Timui *ui = NULL;
TimuiImage *img;
- unsigned char png[] = { 0x89, 0x50, 0x4E, 0x47 };
+ unsigned char png[24];
unsigned char rgba[2 * 8] = {
0xff, 0x00, 0x00, 0xff, 0xee, 0xee, 0xee, 0xee,
0x00, 0xff, 0x00, 0xff, 0xdd, 0xdd, 0xdd, 0xdd
@@ -634,6 +668,7 @@ TIMUI_TEST(test_image_from_png_rgba_copies_png_and_pixels){
timui_fake_init(&fake, &al);
t = timui_fake_transport(&fake);
timui_open_for_test(&ui, t, 30, 10, &al);
+ fake_png_ihdr(png, sizeof png, 1, 2);
img = timui_image_from_png_rgba(ui, png, sizeof png, rgba, 1, 2, 8);
TIMUI_CHECK(img != NULL);
TIMUI_CHECK(img->kind == TIMUI_IMAGE_KIND_PNG_RGBA);
@@ -658,15 +693,19 @@ TIMUI_TEST(test_image_from_png_rgba_rejects_invalid_inputs){
TimuiFakeTransport fake;
TimuiTransport t;
Timui *ui = NULL;
- unsigned char png[] = { 0x89, 0x50, 0x4E, 0x47 };
+ unsigned char png[24];
+ unsigned char bad_png[] = { 0x89, 0x50, 0x4E, 0x47 };
unsigned char px[4] = { 0xff, 0x00, 0x00, 0xff };
timui_fake_init(&fake, &al);
t = timui_fake_transport(&fake);
timui_open_for_test(&ui, t, 30, 10, &al);
+ fake_png_ihdr(png, sizeof png, 1, 1);
TIMUI_CHECK(timui_image_from_png_rgba(ui, NULL, sizeof png, px, 1, 1, 4) == NULL);
TIMUI_CHECK(timui_image_from_png_rgba(ui, png, 0, px, 1, 1, 4) == NULL);
TIMUI_CHECK(timui_image_from_png_rgba(ui, png, sizeof png, NULL, 1, 1, 4) == NULL);
+ TIMUI_CHECK(timui_image_from_png_rgba(ui, bad_png, sizeof bad_png, px, 1, 1, 4) == NULL);
+ TIMUI_CHECK(timui_image_from_png_rgba(ui, png, sizeof png, px, 1, 2, 4) == NULL);
TIMUI_CHECK(timui_image_from_png_rgba(ui, png, sizeof png, px, 0, 1, 4) == NULL);
TIMUI_CHECK(timui_image_from_png_rgba(ui, png, sizeof png, px, 1, 0, 4) == NULL);
TIMUI_CHECK(timui_image_from_png_rgba(ui, png, sizeof png, px, 2, 1, 7) == NULL);
@@ -742,6 +781,7 @@ TIMUI_TEST(test_sixel_png_rgba_emits_dcs_from_pixels){
t = timui_fake_transport(&fake);
timui_open_for_test(&ui, t, 30, 10, &al);
timui_force_image_protocol(ui, TIMUI_IMAGE_PROTOCOL_SIXEL);
+ fake_png_ihdr(png, sizeof png, 1, 6);
img = timui_image_from_png_rgba(ui, png, sizeof png, rgba, 1, 6, 4);
TIMUI_CHECK(img != NULL);
@@ -1074,6 +1114,7 @@ TIMUI_TEST(test_sixel_clipped_png_rgba_emits_cropped_dcs){
t = timui_fake_transport(&fake);
timui_open_for_test(&ui, t, 30, 10, &al);
timui_force_image_protocol(ui, TIMUI_IMAGE_PROTOCOL_SIXEL);
+ fake_png_ihdr(png, sizeof png, 4, 6);
img = timui_image_from_png_rgba(ui, png, sizeof png, rgba, 4, 6, 4 * 4);
TIMUI_CHECK(img != NULL);
@@ -1340,13 +1381,14 @@ TIMUI_TEST(test_iterm2_png_rgba_uses_png_payload){
TimuiImage *img;
TimuiCellBuffer *buf;
TimuiStr out;
- static const unsigned char png[] = { 0x89, 0x50, 0x4E, 0x47 };
+ unsigned char png[24];
unsigned char rgba[4] = { 0xff, 0x00, 0x00, 0xff };
timui_fake_init(&fake, &al);
t = timui_fake_transport(&fake);
timui_open_for_test(&ui, t, 30, 10, &al);
timui_force_image_protocol(ui, TIMUI_IMAGE_PROTOCOL_ITERM2);
+ fake_png_ihdr(png, sizeof png, 1, 1);
img = timui_image_from_png_rgba(ui, png, sizeof png, rgba, 1, 1, 4);
TIMUI_CHECK(img != NULL);
@@ -1358,8 +1400,8 @@ TIMUI_TEST(test_iterm2_png_rgba_uses_png_payload){
timui_end(f);
out = timui_fake_output(&fake);
TIMUI_CHECK(bytes_contain(out.ptr, out.len, "\x1b]1337;File="));
- TIMUI_CHECK(bytes_contain(out.ptr, out.len, "size=4"));
- TIMUI_CHECK(bytes_contain(out.ptr, out.len, ":iVBORw==\x1b\\"));
+ TIMUI_CHECK(bytes_contain(out.ptr, out.len, "size=24"));
+ TIMUI_CHECK(bytes_contain(out.ptr, out.len, ":iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB\x1b\\"));
TIMUI_CHECK(!bytes_contain(out.ptr, out.len, "\x1b_G"));
TIMUI_CHECK(!bytes_contain(out.ptr, out.len, "\x1bP0;1;0q"));
diff --git a/tests/test_input.c b/tests/test_input.c
index 7875221..d2d5a44 100644
--- a/tests/test_input.c
+++ b/tests/test_input.c
@@ -164,6 +164,25 @@ TIMUI_TEST(test_input_utf8_resync_no_loss){
TIMUI_CHECK(s.ev[1].kind == TIMUI_EVENT_TEXT && s.ev[1].as.text.codepoint == 'A');
}
+TIMUI_TEST(test_input_csi_ss3_non_ascii_resync_no_loss){
+ TimuiInputParser p;
+ Sink s;
+
+ s.n = 0;
+ timui_input_init(&p);
+ timui_input_feed(&p, "\x1b[", 2, sink_cb, &s);
+ timui_input_feed(&p, "\xC3\xA9", 2, sink_cb, &s);
+ TIMUI_CHECK(s.n == 1);
+ TIMUI_CHECK(s.ev[0].kind == TIMUI_EVENT_TEXT && s.ev[0].as.text.codepoint == 0xE9);
+
+ s.n = 0;
+ timui_input_init(&p);
+ timui_input_feed(&p, "\x1bO", 2, sink_cb, &s);
+ timui_input_feed(&p, "\xC3\xA9", 2, sink_cb, &s);
+ TIMUI_CHECK(s.n == 1);
+ TIMUI_CHECK(s.ev[0].kind == TIMUI_EVENT_TEXT && s.ev[0].as.text.codepoint == 0xE9);
+}
+
/* V5: modifier-tagged mouse wheel must keep its direction. Shift+wheel-up is
* SGR code 0x40|0x04 = 68; old exact-match (==64) zeroed the delta. */
TIMUI_TEST(test_mouse_wheel_with_mods){
diff --git a/tests/test_input_widget.c b/tests/test_input_widget.c
index ece462f..4a9e92f 100644
--- a/tests/test_input_widget.c
+++ b/tests/test_input_widget.c
@@ -231,6 +231,25 @@ TIMUI_TEST(test_input_field_edit){
timui_close(ui);
}
+TIMUI_TEST(test_input_field_same_frame_edit_order){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ char text[16] = "ab";
+ TimuiInputState is = { text, sizeof text, 2, 0 };
+ TimuiRect r = TIMUI_RECT(0, 0, 20, 1);
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 30, 5, &al);
+#define IO_FRAME() do{ timui_begin(ui,&f); (void)timui_input_field(f, TIMUI_ID("io"), r, &is); timui_end(f); }while(0)
+ SETIN(&fake, "\x1b[<0;2;1M"); IO_FRAME();
+ SETIN(&fake, "\x1b[<0;2;1m"); IO_FRAME();
+ SETIN(&fake, "\x7f""c"); IO_FRAME();
+ TIMUI_CHECK(strcmp(text, "ac") == 0);
+ TIMUI_CHECK(is.cursor == 2);
+#undef IO_FRAME
+ timui_close(ui);
+}
+
TIMUI_TEST(test_input_field_grapheme_edit){
TimuiAllocator al = timui_default_allocator();
TimuiFakeTransport fake; TimuiTransport t;
@@ -382,6 +401,27 @@ TIMUI_TEST(test_input_field_paste_split){
timui_close(ui);
}
+TIMUI_TEST(test_input_field_paste_split_utf8){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ char text[64] = {0};
+ TimuiInputState is = { text, sizeof text, 0, 0 };
+ TimuiRect r = TIMUI_RECT(0, 0, 40, 1);
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 50, 5, &al);
+#define PFU() do{ timui_begin(ui,&f); (void)timui_input_field(f, TIMUI_ID("in"), r, &is); timui_end(f); }while(0)
+ SETIN(&fake, "\x1b[<0;2;1M"); PFU();
+ SETIN(&fake, "\x1b[<0;2;1m"); PFU();
+ SETIN(&fake, "\x1b[200~\xC3"); PFU();
+ TIMUI_CHECK(text[0] == '\0');
+ SETIN(&fake, "\xA9\x1b[201~"); PFU();
+ TIMUI_CHECK(strcmp(text, "\xC3\xA9") == 0);
+ TIMUI_CHECK(is.cursor == 2);
+#undef PFU
+ timui_close(ui);
+}
+
TIMUI_TEST(test_input_field_paste_drops_controls){
TimuiAllocator al = timui_default_allocator();
TimuiFakeTransport fake; TimuiTransport t;
diff --git a/tests/test_layout.c b/tests/test_layout.c
index 9768673..9668761 100644
--- a/tests/test_layout.c
+++ b/tests/test_layout.c
@@ -133,6 +133,31 @@ static void test_split_edges(void){
timui_split(TIMUI_RECT(0, 0, 100, 5), TIMUI_AXIS_H, c, 1, o);
CHECK_RECT(o[0], 0, 0, 20, 5); } /* fixed underfill: trailing space unused */
+ /* Zero-weight flex children are explicit spacers of size 0; the rounding
+ * remainder must not be handed to the last zero-weight child. */
+ { TimuiConstraint c[] = { TIMUI_FLEX(0), TIMUI_FLEX(0) };
+ TimuiRect o[2];
+ CHECK(timui_split(TIMUI_RECT(0, 0, 100, 1), TIMUI_AXIS_H, c, 2, o) == 2);
+ CHECK_RECT(o[0], 0, 0, 0, 1);
+ CHECK_RECT(o[1], 0, 0, 0, 1); }
+
+ /* Extreme, still-valid public geometry must not use signed-overflow
+ * arithmetic while placing contiguous children. */
+ { TimuiConstraint c[] = { TIMUI_LEN(5), TIMUI_FLEX(1) };
+ TimuiRect o[2];
+ CHECK(timui_split(TIMUI_RECT(INT_MAX - 2, 0, 10, 1), TIMUI_AXIS_H, c, 2, o) == 2);
+ CHECK_RECT(o[0], INT_MAX - 2, 0, 5, 1);
+ CHECK_RECT(o[1], INT_MAX, 0, 5, 1); }
+
+ /* Huge gap/margin options collapse the inner area safely instead of
+ * overflowing 2*margin or gap*(n-1). */
+ { TimuiConstraint c[] = { TIMUI_FLEX(1), TIMUI_FLEX(1) };
+ TimuiLayoutOpts opts; TimuiRect o[2];
+ opts.gap = INT_MAX; opts.margin = INT_MAX;
+ CHECK(timui_split_ex(TIMUI_RECT(0, 0, 10, 10), TIMUI_AXIS_H, c, 2, opts, o) == 2);
+ CHECK_RECT(o[0], INT_MAX, INT_MAX, 0, 0);
+ CHECK_RECT(o[1], INT_MAX, INT_MAX, 0, 0); }
+
/* n == 0 and bad pointers return 0 and write nothing. */
{ TimuiConstraint c[] = { TIMUI_FLEX(1) };
TimuiRect o[1] = { { -1, -1, -1, -1 } };
@@ -198,6 +223,16 @@ static void test_grid(void){
CHECK_RECT(g[1], 20, 0, 20, 2); /* row0 col1 (flex 20) */
CHECK_RECT(g[2], 0, 2, 20, 5); /* row1 (flex h = 10-2-3 = 5) col0 */
CHECK_RECT(g[5], 20, 7, 20, 3); } /* row2 (h3, y=7) col1 */
+
+ /* Column count is capped before row-major pointer arithmetic. */
+ { TimuiConstraint r1[] = { TIMUI_FLEX(1) };
+ TimuiConstraint many[129];
+ TimuiRect sentinel = { -1, -1, -1, -1 };
+ int i;
+ for(i = 0; i < 129; i++) many[i] = TIMUI_FLEX(1);
+ o[0] = sentinel;
+ CHECK(timui_grid(TIMUI_RECT(0, 0, 10, 10), r1, 1, many, 129, o) == 0);
+ CHECK(rect_eq(o[0], -1, -1, -1, -1)); }
}
/* ---- timui_border: inner rect + drawn glyphs --------------------------- */
diff --git a/tests/test_main.c b/tests/test_main.c
index 70cbef1..a6bcc77 100644
--- a/tests/test_main.c
+++ b/tests/test_main.c
@@ -58,6 +58,8 @@ int main(void){
test_fake_inject_input,
test_fake_grows,
test_transport_close_hook,
+ test_open_failure_clears_output_handle,
+ test_open_for_test_failure_does_not_close_injected_transport,
test_write_all_under_backpressure,
test_screen_enter_emits_modes,
test_screen_exit_reverses,
@@ -83,6 +85,7 @@ int main(void){
test_bracketed_paste,
test_input_esc_resync_no_loss,
test_input_utf8_resync_no_loss,
+ test_input_csi_ss3_non_ascii_resync_no_loss,
test_mouse_wheel_with_mods,
test_input_nul_ignored,
test_paste_cross_feed_three_fragments,
@@ -113,6 +116,7 @@ int main(void){
test_caps_unknown_fallback,
test_caps_force_masks,
test_caps_image_protocol_selection,
+ test_caps_iterm2_detects_image_protocol,
test_kitty_csi_u_plain,
test_kitty_csi_u_with_mods,
test_kitty_special_codes,
@@ -144,6 +148,7 @@ int main(void){
test_render_unchanged_emits_nothing,
test_render_diff_narrow_to_wide,
test_render_diff_wide_to_narrow,
+ test_draw_overwrites_repair_wide_pairs,
test_render_hyperlink_uri_change,
test_render_hyperlink_closes_at_frame_end,
test_render_controls_are_not_emitted_as_glyphs,
@@ -194,12 +199,14 @@ int main(void){
test_input_line_buf_clipped,
test_input_line_buf_clamps_unterminated,
test_input_field_edit,
+ test_input_field_same_frame_edit_order,
test_input_field_grapheme_edit,
test_input_field_cursor_clamped_to_text,
test_input_field_clamps_unterminated,
test_input_field_multi_submit,
test_input_field_paste,
test_input_field_paste_split,
+ test_input_field_paste_split_utf8,
test_input_field_paste_drops_controls,
test_input_field_paste_invalid_utf8_replaced,
test_paste_preserves_text_order,
@@ -226,6 +233,7 @@ int main(void){
test_fuzz_parser_adversarial,
test_clip_restricts_drawing,
test_clip_nested_intersect,
+ test_clip_stack_overflow_preserves_pop_symmetry,
test_clip_wide_glyph_requires_full_width,
test_clip_extreme_rect_does_not_overflow,
test_clip_pop_underflow_safe,
@@ -244,21 +252,27 @@ int main(void){
test_keymap_bind_overflow,
test_keymap_hit_multi_binding,
test_table_renders,
+ test_table_draws_keyboard_selection_same_frame,
test_tree_renders,
+ test_tree_draws_keyboard_selection_same_frame,
test_tree_deep_safe,
test_tree_controlled_no_write_without_change,
test_table_ex_renders,
test_table_ex_down_key,
test_table_ex_hscroll,
+ test_table_ex_wheel_only_when_hovered,
test_tree_scroll_hides_collapsed,
+ test_tree_scroll_wheel_only_when_hovered,
test_tree_scroll_windows,
test_cmd_palette_filter,
+ test_cmd_palette_draws_keyboard_selection_same_frame,
test_combobox_filter_select_activate,
test_combobox_no_match_and_clamp,
test_combobox_mouse_accept_duplicate,
test_combobox_query_cap_utf8_no_split,
test_combobox_escape_closes_without_clearing_query,
test_combobox_cursor_movement_updates_state,
+ test_combobox_clamps_cursor_to_query_len_before_edit,
test_combobox_guards_empty_options,
test_combobox_accept_cap_limited,
test_toast_order_timeout_dismiss,
@@ -299,6 +313,8 @@ int main(void){
test_text_area_cursor_clamped_to_text,
test_text_area_clamps_unterminated,
test_text_area_cursor_edit,
+ test_text_area_same_frame_edit_order,
+ test_text_area_scroll_applies_before_draw,
test_text_area_cursor_utf8,
test_text_area_grapheme_edit,
test_text_area_submit_plain_enter,
@@ -323,6 +339,7 @@ int main(void){
test_kitty_graphics_clip_horizontal_source_rect,
test_kitty_graphics_respects_active_clip,
test_kitty_graphics_placeholder,
+ test_kitty_placement_overflow_placeholder,
test_image_protocol_force_none_placeholder,
test_sixel_malformed_png_still_placeholder,
test_sixel_plain_png_decodes_to_dcs,
@@ -372,9 +389,14 @@ int main(void){
test_interact_tab_grow_oom,
test_hyperlink_set_edges,
test_run_negative_guards,
+ test_app_frame_updates_after_end,
+ test_app_frame_delivers_large_messages,
test_getters,
test_render_stream_chat_like,
test_frame_synchronized_output,
+ test_async_scan_state_valid_messages,
+ test_async_scan_state_rejects_malformed_progress,
+ test_async_scan_state_clamps_progress,
};
size_t i;
size_t n = sizeof(tests) / sizeof(tests[0]);
diff --git a/tests/test_render.c b/tests/test_render.c
index 32b8313..4ca5461 100644
--- a/tests/test_render.c
+++ b/tests/test_render.c
@@ -108,6 +108,45 @@ TIMUI_TEST(test_render_diff_wide_to_narrow){
timui_cells_destroy(&prev); timui_cells_destroy(&curr); timui_fake_destroy(&f);
}
+TIMUI_TEST(test_draw_overwrites_repair_wide_pairs){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiCellBuffer prev, curr;
+ TimuiFakeTransport f;
+ TimuiTransport t;
+ TimuiRenderer r;
+ TimuiStr out;
+ TimuiStyle s = timui_style_make(0xffffff, TIMUI_COLOR_DEFAULT, 0);
+
+ timui_cells_init(&prev, 4, 1, &al);
+ timui_cells_init(&curr, 4, 1, &al);
+ timui_draw_text(&prev, 0, 0, TIMUI_STR_LIT("ZZ"), s);
+ timui_draw_text(&curr, 0, 0, TIMUI_STR_LIT("\xE4\xB8\xAD"), s); /* 中 */
+ timui_draw_text(&curr, 0, 0, TIMUI_STR_LIT("A"), s);
+
+ TIMUI_CHECK(timui_cells_get(&curr, 0, 0)->codepoint == 'A');
+ TIMUI_CHECK(timui_cells_get(&curr, 0, 0)->width == 1);
+ TIMUI_CHECK(timui_cells_get(&curr, 1, 0)->flags == TIMUI_CELL_EMPTY);
+
+ timui_fake_init(&f, &al);
+ t = timui_fake_transport(&f);
+ timui_renderer_reset(&r);
+ timui_render_diff(&t, &prev, &curr, &r);
+ out = timui_fake_output(&f);
+ TIMUI_CHECK(memchr(out.ptr, ' ', out.len) != NULL);
+ timui_fake_destroy(&f);
+
+ timui_cells_clear(&curr);
+ timui_draw_text(&curr, 0, 0, TIMUI_STR_LIT("\xE4\xB8\xAD"), s); /* 中 */
+ timui_draw_text(&curr, 1, 0, TIMUI_STR_LIT("B"), s);
+ TIMUI_CHECK(timui_cells_get(&curr, 0, 0)->codepoint == 0);
+ TIMUI_CHECK(timui_cells_get(&curr, 0, 0)->flags == TIMUI_CELL_EMPTY);
+ TIMUI_CHECK(timui_cells_get(&curr, 1, 0)->codepoint == 'B');
+ TIMUI_CHECK(timui_cells_get(&curr, 1, 0)->width == 1);
+
+ timui_cells_destroy(&prev);
+ timui_cells_destroy(&curr);
+}
+
static int r_contains(const char *h, size_t hl, const char *needle){
size_t nl = strlen(needle), i;
if(nl == 0 || hl < nl) return 0;
diff --git a/tests/test_transport.c b/tests/test_transport.c
index f81768f..46bf8cd 100644
--- a/tests/test_transport.c
+++ b/tests/test_transport.c
@@ -92,6 +92,72 @@ TIMUI_TEST(test_transport_close_hook){
TIMUI_CHECK(p.closed == 1);
}
+TIMUI_TEST(test_open_failure_clears_output_handle){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport f;
+ TimuiTransport t;
+ char stale;
+ Timui *ui = (Timui *)&stale;
+
+ TIMUI_CHECK(timui_fake_init(&f, &al) == TIMUI_OK);
+ t = timui_fake_transport(&f);
+ TIMUI_CHECK(timui_open_for_test(&ui, t, 0, 1, &al) == TIMUI_ERR_INVALID_ARGUMENT);
+ TIMUI_CHECK(ui == NULL);
+
+ ui = (Timui *)&stale;
+ TIMUI_CHECK(timui_open(NULL, &ui) == TIMUI_ERR_INVALID_ARGUMENT);
+ TIMUI_CHECK(ui == NULL);
+ timui_fake_destroy(&f);
+}
+
+typedef struct FailAllocProbe {
+ TimuiAllocator base;
+ int calls;
+ int fail_on;
+} FailAllocProbe;
+
+static void *fail_probe_alloc(void *userdata, size_t size){
+ FailAllocProbe *p = (FailAllocProbe *)userdata;
+ p->calls++;
+ if(p->calls == p->fail_on) return NULL;
+ return p->base.alloc(p->base.userdata, size);
+}
+static void *fail_probe_realloc(void *userdata, void *ptr, size_t old_size, size_t new_size){
+ FailAllocProbe *p = (FailAllocProbe *)userdata;
+ return p->base.realloc(p->base.userdata, ptr, old_size, new_size);
+}
+static void fail_probe_free(void *userdata, void *ptr, size_t size){
+ FailAllocProbe *p = (FailAllocProbe *)userdata;
+ p->base.free(p->base.userdata, ptr, size);
+}
+
+TIMUI_TEST(test_open_for_test_failure_does_not_close_injected_transport){
+ TimuiAllocator base = timui_default_allocator();
+ FailAllocProbe fp;
+ TimuiAllocator fail_al;
+ CloseProbe cp = {0};
+ TimuiTransport t;
+ Timui *ui = NULL;
+
+ fp.base = base;
+ fp.calls = 0;
+ fp.fail_on = 2; /* Timui allocation succeeds; first cell-buffer allocation fails. */
+ fail_al.alloc = fail_probe_alloc;
+ fail_al.realloc = fail_probe_realloc;
+ fail_al.free = fail_probe_free;
+ fail_al.userdata = &fp;
+
+ t.write = close_probe_write;
+ t.read = close_probe_read;
+ t.flush = close_probe_flush;
+ t.close = close_probe_close;
+ t.ctx = &cp;
+
+ TIMUI_CHECK(timui_open_for_test(&ui, t, 4, 4, &fail_al) == TIMUI_ERR_OUT_OF_MEMORY);
+ TIMUI_CHECK(ui == NULL);
+ TIMUI_CHECK(cp.closed == 0);
+}
+
/* The real-fd transport must write EVERY byte even when the fd is non-blocking
* and its buffer is full (heavy render + fast typing) — a single write() that
* dropped the remainder loses render bytes and garbles the screen. Drive a pipe
diff --git a/tests/test_v02_more.c b/tests/test_v02_more.c
index 4f95e8c..4a92de6 100644
--- a/tests/test_v02_more.c
+++ b/tests/test_v02_more.c
@@ -149,6 +149,46 @@ TIMUI_TEST(test_text_area_cursor_edit){
timui_close(ui);
}
+TIMUI_TEST(test_text_area_same_frame_edit_order){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ char text[16] = "ab";
+ TimuiTextAreaState tas = { text, sizeof text, 2, 0 };
+ TimuiRect r = TIMUI_RECT(0, 0, 20, 3);
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 30, 10, &al);
+#define TAO_FRAME() do{ timui_begin(ui,&f); timui_text_area(f, TIMUI_ID("tao"), r, &tas); timui_end(f); }while(0)
+ SETIN(&fake, "\x1b[<0;2;1M"); TAO_FRAME();
+ SETIN(&fake, "\x1b[<0;2;1m"); TAO_FRAME();
+ SETIN(&fake, "\x7f""c"); TAO_FRAME();
+ TIMUI_CHECK(strcmp(text, "ac") == 0);
+ TIMUI_CHECK(tas.cursor == 2);
+#undef TAO_FRAME
+ timui_close(ui);
+}
+
+TIMUI_TEST(test_text_area_scroll_applies_before_draw){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ TimuiCellBuffer *buf;
+ char text[16] = "a\nb\nc";
+ TimuiTextAreaState tas = { text, sizeof text, 5, 0 };
+ TimuiRect r = TIMUI_RECT(0, 0, 8, 2);
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 20, 5, &al);
+
+ timui_begin(ui, &f);
+ buf = timui_frame_buffer(f);
+ timui_text_area(f, TIMUI_ID("tas"), r, &tas);
+ TIMUI_CHECK(tas.scroll_y == 1);
+ TIMUI_CHECK(timui_cells_get(buf, 0, 0)->codepoint == 'b');
+ TIMUI_CHECK(timui_cells_get(buf, 0, 1)->codepoint == 'c');
+ timui_end(f);
+ timui_close(ui);
+}
+
/* F1.3: cursor movement and DELETE step whole UTF-8 codepoints. */
TIMUI_TEST(test_text_area_cursor_utf8){
TimuiAllocator al = timui_default_allocator();
diff --git a/tests/test_v02_widgets.c b/tests/test_v02_widgets.c
index 9c5acf3..78b49f5 100644
--- a/tests/test_v02_widgets.c
+++ b/tests/test_v02_widgets.c
@@ -38,6 +38,29 @@ TIMUI_TEST(test_table_renders){
timui_close(ui);
}
+TIMUI_TEST(test_table_draws_keyboard_selection_same_frame){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ TimuiTableState ts = {0, 0, 0};
+ TimuiTableResult res;
+ TimuiStr hdrs[2] = { TIMUI_STR_LIT("Name"), TIMUI_STR_LIT("Val") };
+ TimuiCellBuffer *buf;
+ TimuiTheme th = timui_theme_builtin(TIMUI_THEME_DOS_BLUE);
+ uint32_t sel_bg = timui_theme_style(&th, TIMUI_SLOT_SELECTION).bg;
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 30, 10, &al);
+ SETIN(&fake, "\x1b[B");
+ timui_begin(ui, &f); buf = timui_frame_buffer(f);
+ timui_set_focus(f, TIMUI_ID("t"));
+ res = timui_table_mut(f, TIMUI_ID("t"), TIMUI_RECT(0, 0, 20, 5), hdrs, 2, 3, tbl_cell, 0, &ts);
+ TIMUI_CHECK(res.state.selected == 1 && ts.selected == 1);
+ TIMUI_CHECK(timui_cells_get(buf, 0, 1)->bg != sel_bg);
+ TIMUI_CHECK(timui_cells_get(buf, 0, 2)->bg == sel_bg);
+ timui_end(f);
+ timui_close(ui);
+}
+
/* ---- tree (#48) ---- */
TIMUI_TEST(test_tree_renders){
TimuiAllocator al = timui_default_allocator();
@@ -61,6 +84,31 @@ TIMUI_TEST(test_tree_renders){
timui_close(ui);
}
+TIMUI_TEST(test_tree_draws_keyboard_selection_same_frame){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ TimuiTreeNode nodes[] = {
+ {0, "root", 1, 1}, {1, "child", 0, 0}, {1, "peer", 0, 0}
+ };
+ TimuiTreeResult res;
+ TimuiCellBuffer *buf;
+ TimuiTheme th = timui_theme_builtin(TIMUI_THEME_DOS_BLUE);
+ uint32_t sel_bg = timui_theme_style(&th, TIMUI_SLOT_SELECTION).bg;
+ int sel = 0;
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 30, 10, &al);
+ SETIN(&fake, "\x1b[B");
+ timui_begin(ui, &f); buf = timui_frame_buffer(f);
+ timui_set_focus(f, TIMUI_ID("tr"));
+ res = timui_tree_mut(f, TIMUI_ID("tr"), TIMUI_RECT(0, 0, 20, 5), nodes, 3, &sel);
+ TIMUI_CHECK(res.selected == 1 && sel == 1);
+ TIMUI_CHECK(timui_cells_get(buf, 0, 0)->bg != sel_bg);
+ TIMUI_CHECK(timui_cells_get(buf, 0, 1)->bg == sel_bg);
+ timui_end(f);
+ timui_close(ui);
+}
+
/* Pass-3: a deeply-nested node (depth 20) must not overflow the prefix
* buffer; the label still renders. */
TIMUI_TEST(test_tree_deep_safe){
@@ -100,6 +148,29 @@ TIMUI_TEST(test_cmd_palette_filter){
timui_close(ui);
}
+TIMUI_TEST(test_cmd_palette_draws_keyboard_selection_same_frame){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ TimuiStr cmds[3] = { TIMUI_STR_LIT("Save"), TIMUI_STR_LIT("Open"), TIMUI_STR_LIT("Quit") };
+ TimuiCmdPaletteState cps = {0};
+ TimuiCmdPaletteResult res;
+ TimuiCellBuffer *buf;
+ TimuiTheme th = timui_theme_builtin(TIMUI_THEME_DOS_BLUE);
+ uint32_t sel_bg = timui_theme_style(&th, TIMUI_SLOT_SELECTION).bg;
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 30, 10, &al);
+ SETIN(&fake, "\x1b[B");
+ timui_begin(ui, &f); buf = timui_frame_buffer(f);
+ timui_set_focus(f, TIMUI_ID("cp") + 1);
+ res = timui_command_palette_mut(f, TIMUI_ID("cp"), TIMUI_RECT(0, 0, 20, 6), cmds, 3, &cps);
+ TIMUI_CHECK(res.state.selected == 1 && cps.selected == 1);
+ TIMUI_CHECK(timui_cells_get(buf, 1, 2)->bg != sel_bg);
+ TIMUI_CHECK(timui_cells_get(buf, 1, 3)->bg == sel_bg);
+ timui_end(f);
+ timui_close(ui);
+}
+
/* ---- combobox / autocomplete (Phase 1.5) ---- */
TIMUI_TEST(test_combobox_filter_select_activate){
TimuiAllocator al = timui_default_allocator();
@@ -225,6 +296,26 @@ TIMUI_TEST(test_combobox_cursor_movement_updates_state){
timui_close(ui);
}
+TIMUI_TEST(test_combobox_clamps_cursor_to_query_len_before_edit){
+ TimuiAllocator al = timui_default_allocator();
+ TimuiFakeTransport fake; TimuiTransport t;
+ Timui *ui = NULL; TimuiFrame *f = NULL;
+ TimuiStr opts[1] = { TIMUI_STR_LIT("abcQ") };
+ char query[8] = { 'a', 'b', 'c', '\0', 'X', 'Y', 'Z', '\0' };
+ TimuiComboboxState st = { query, sizeof query, 6, 0, 0, 0, 0 };
+ TimuiComboboxResult res;
+ timui_fake_init(&fake, &al); t = timui_fake_transport(&fake);
+ timui_open_for_test(&ui, t, 40, 10, &al);
+ SETIN(&fake, "Q");
+ timui_begin(ui, &f);
+ timui_set_focus(f, TIMUI_ID("stale"));
+ res = timui_combobox_mut(f, TIMUI_ID("stale"), TIMUI_RECT(0, 0, 20, 3), opts, 1, &st);
+ timui_end(f);
+ TIMUI_CHECK(res.query_changed && strcmp(query, "abcQ") == 0);
+ TIMUI_CHECK(st.cursor == 4);
+ timui_close(ui);
+}
+
TIMUI_TEST(test_combobox_guards_empty_options){
TimuiComboboxState st;
TimuiComboboxResult res;
diff --git a/tools/ci/hosted_visual_windows.ps1 b/tools/ci/hosted_visual_windows.ps1
index 0a2fc6e..7985fe8 100644
--- a/tools/ci/hosted_visual_windows.ps1
+++ b/tools/ci/hosted_visual_windows.ps1
@@ -47,6 +47,89 @@ function Invoke-Captured {
return $status
}
+function Read-TextOrEmpty {
+ param([string]$Path)
+ if (Test-Path $Path) {
+ return [System.IO.File]::ReadAllText($Path)
+ }
+ return ""
+}
+
+function Write-ConptyAcceptance {
+ param(
+ [int]$Status,
+ [string]$Command,
+ [string]$Compiler
+ )
+
+ $stdoutName = "conpty-smoke.stdout"
+ $stderrName = "conpty-smoke.stderr"
+ $statusName = "conpty-smoke.status"
+ $stdoutPath = Join-Path $Out $stdoutName
+ $stderrPath = Join-Path $Out $stderrName
+ $statusPath = Join-Path $Out $statusName
+ $passNeedle = "PASS conpty smoke: observed TIMUI_CONPTY_SMOKE"
+ $stdoutText = Read-TextOrEmpty $stdoutPath
+ $stderrText = Read-TextOrEmpty $stderrPath
+ $passTokenPresent = $stdoutText.Contains($passNeedle)
+ $accepted = ($Status -eq 0 -and $passTokenPresent)
+ $windowsVersion = (Read-TextOrEmpty (Join-Path $Out "windows-version.txt")).Trim()
+ $queryUser = (Read-TextOrEmpty (Join-Path $Out "query-user.txt")).Trim()
+ $qwinsta = (Read-TextOrEmpty (Join-Path $Out "qwinsta.txt")).Trim()
+ $wtVersion = (Read-TextOrEmpty (Join-Path $Out "wt-version-command.stdout")).Trim()
+ if (-not $wtVersion) {
+ $wtVersion = "unknown"
+ }
+
+ Set-Content -Path (Join-Path $Out "conpty-smoke.command.txt") -Value $Command -Encoding utf8
+ Set-Content -Path (Join-Path $Out "conpty-smoke.meta.txt") -Encoding utf8 -Value @(
+ "commit=$commit",
+ "runner_os=$($env:RUNNER_OS)",
+ "os_env=$($env:OS)",
+ "shell=PowerShell $($PSVersionTable.PSVersion)",
+ "msys2_location=$Msys2Location",
+ "msys2_bash=$Bash",
+ "msys2_root=$MsysRoot",
+ "compiler=$Compiler",
+ "windows_version=$windowsVersion",
+ "windows_terminal_version=$wtVersion",
+ "status=$Status",
+ "passTokenPresent=$passTokenPresent",
+ "accepted=$accepted"
+ )
+
+ [ordered]@{
+ commit = $commit
+ runnerOS = $env:RUNNER_OS
+ osEnv = $env:OS
+ shell = "PowerShell $($PSVersionTable.PSVersion)"
+ msys2Location = $Msys2Location
+ msys2Bash = "$Bash"
+ msys2Root = $MsysRoot
+ compiler = $Compiler
+ command = $Command
+ status = $Status
+ passTokenPresent = $passTokenPresent
+ accepted = $accepted
+ stdout = $stdoutName
+ stderr = $stderrName
+ statusFile = $statusName
+ stdoutBytes = if (Test-Path $stdoutPath) { (Get-Item $stdoutPath).Length } else { 0 }
+ stderrBytes = if (Test-Path $stderrPath) { (Get-Item $stderrPath).Length } else { 0 }
+ stdoutExcerpt = if ($stdoutText.Length -gt 2048) { $stdoutText.Substring(0, 2048) } else { $stdoutText }
+ stderrExcerpt = if ($stderrText.Length -gt 2048) { $stderrText.Substring(0, 2048) } else { $stderrText }
+ windowsVersion = $windowsVersion
+ windowsTerminalVersion = $wtVersion
+ queryUser = $queryUser
+ qwinsta = $qwinsta
+ } | ConvertTo-Json -Depth 4 |
+ Set-Content -Path (Join-Path $Out "conpty-acceptance.json") -Encoding utf8
+
+ Add-Evidence "- ConPTY acceptance manifest: conpty-acceptance.json"
+ Add-Evidence "- ConPTY PASS token present: $passTokenPresent"
+ Add-Evidence "- ConPTY accepted: $accepted"
+}
+
function Write-DcsMetrics {
param(
[string]$Path,
@@ -200,13 +283,17 @@ function Invoke-Msys {
[string]$Name,
[string]$Command
)
- Invoke-Captured $Name { & $Bash --noprofile --norc -lc $Command } | Out-Null
+ return (Invoke-Captured $Name { & $Bash --noprofile --norc -lc $Command })
}
Add-Evidence "## Build and stream diagnostics"
-Invoke-Msys "image-smoke-build" "cd '$MsysRoot' && PATH=/usr/bin:/bin:`$PATH make build/image_smoke CC=/usr/bin/gcc POSIX_CFLAGS='-D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE=700'"
-Invoke-Msys "conpty-smoke" "cd '$MsysRoot' && OS=Windows_NT PATH=/ucrt64/bin:/usr/bin:/bin:`$PATH make smoke-conpty-win32 CONPTY_WIN_CC=/ucrt64/bin/gcc"
-Invoke-Msys "sixel-diagnostic" "cd '$MsysRoot' && mkdir -p '$MsysOut' && if [ -x ./build/image_smoke ] && command -v script >/dev/null 2>&1; then script -q -c './build/image_smoke --protocol sixel --frames 1' '$MsysOut/sixel.typescript'; elif [ ! -x ./build/image_smoke ]; then echo image-smoke-missing > '$MsysOut/sixel-diagnostic.skip'; else echo script-not-available > '$MsysOut/sixel-diagnostic.skip'; fi"
+$ImageSmokeCommand = "cd '$MsysRoot' && PATH=/usr/bin:/bin:`$PATH make build/image_smoke CC=/usr/bin/gcc POSIX_CFLAGS='-D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE=700'"
+$ConptyCommand = "cd '$MsysRoot' && OS=Windows_NT PATH=/ucrt64/bin:/usr/bin:/bin:`$PATH make smoke-conpty-win32 CONPTY_WIN_CC=/ucrt64/bin/gcc"
+$SixelDiagnosticCommand = "cd '$MsysRoot' && mkdir -p '$MsysOut' && if [ -x ./build/image_smoke ] && command -v script >/dev/null 2>&1; then script -q -c './build/image_smoke --protocol sixel --frames 1' '$MsysOut/sixel.typescript'; elif [ ! -x ./build/image_smoke ]; then echo image-smoke-missing > '$MsysOut/sixel-diagnostic.skip'; else echo script-not-available > '$MsysOut/sixel-diagnostic.skip'; fi"
+$ImageSmokeStatus = Invoke-Msys "image-smoke-build" $ImageSmokeCommand
+$ConptyStatus = Invoke-Msys "conpty-smoke" $ConptyCommand
+Write-ConptyAcceptance -Status $ConptyStatus -Command $ConptyCommand -Compiler "/ucrt64/bin/gcc"
+$SixelDiagnosticStatus = Invoke-Msys "sixel-diagnostic" $SixelDiagnosticCommand
Write-DcsMetrics (Join-Path $Out "sixel.typescript") "timui-sixel"
$DirectFixture = Join-Path $Out "direct-sixel-fixture.sh"
@@ -228,7 +315,7 @@ $DirectFixtureLines = @(
'if [ "$sleep_s" -gt 0 ]; then sleep "$sleep_s"; fi'
)
[System.IO.File]::WriteAllText($DirectFixture, ($DirectFixtureLines -join "`n") + "`n", [System.Text.Encoding]::ASCII)
-Invoke-Msys "direct-sixel-diagnostic" "bash '$MsysOut/direct-sixel-fixture.sh' 0 > '$MsysOut/direct.sixel'"
+$DirectSixelStatus = Invoke-Msys "direct-sixel-diagnostic" "bash '$MsysOut/direct-sixel-fixture.sh' 0 > '$MsysOut/direct.sixel'"
Write-DcsMetrics (Join-Path $Out "direct.sixel") "direct-sixel"
Add-Evidence ""
@@ -265,6 +352,7 @@ if ($Wt) {
Set-Content -Path (Join-Path $Out "wt-version.status") -Value 0 -Encoding ascii
Add-Evidence "- wt-version: captured file metadata"
Invoke-Captured "wt-version-command" { & $Wt.Source --version } | Out-Null
+ Write-ConptyAcceptance -Status $ConptyStatus -Command $ConptyCommand -Compiler "/ucrt64/bin/gcc"
Add-Evidence ""
Add-Evidence "### Direct Sixel control"
diff --git a/tools/conpty_smoke_win32.c b/tools/conpty_smoke_win32.c
index 3f087e5..fa50533 100644
--- a/tools/conpty_smoke_win32.c
+++ b/tools/conpty_smoke_win32.c
@@ -16,11 +16,11 @@
#define TOKEN "TIMUI_CONPTY_SMOKE"
-static long smoke_now_ms(void){
+static unsigned long long smoke_now_ms(void){
#ifdef _WIN32
- return (long)GetTickCount64();
+ return (unsigned long long)GetTickCount64();
#else
- return (long)((clock() * 1000) / CLOCKS_PER_SEC);
+ return (unsigned long long)((clock() * 1000) / CLOCKS_PER_SEC);
#endif
}
@@ -47,6 +47,61 @@ static void smoke_append(char *dst, size_t *dst_len, size_t dst_cap,
}
}
+static const char *smoke_echo_script_for_attempt(int attempt){
+ switch(attempt){
+ case 0:
+ /* cmd.exe console input treats Enter as carriage return. */
+ return "echo " TOKEN "\r";
+ case 1:
+ return "echo " TOKEN "\n";
+ case 2:
+ return "echo " TOKEN "\r\n";
+ default:
+ return NULL;
+ }
+}
+
+static const char *smoke_exit_script_for_attempt(int attempt){
+ switch(attempt){
+ case 0:
+ return "exit\r";
+ case 1:
+ return "exit\n";
+ case 2:
+ return "exit\r\n";
+ default:
+ return NULL;
+ }
+}
+
+static int smoke_write_exact(TimuiTransport *t, const char *script, size_t len,
+ int *out_last_write){
+ int n;
+ if(out_last_write) *out_last_write = 0;
+ if(!t || !t->write || !script || len > (size_t)INT_MAX) return 0;
+ n = t->write(t, script, len);
+ if(out_last_write) *out_last_write = n;
+ return n == (int)len;
+}
+
+static void smoke_dump_excerpt(FILE *f, const char *buf, size_t len){
+ size_t i, limit;
+ if(!f || !buf) return;
+ limit = len < 512u ? len : 512u;
+ fprintf(f, "conpty smoke: captured excerpt (%zu/%zu bytes): ", limit, len);
+ for(i = 0; i < limit; i++){
+ unsigned char c = (unsigned char)buf[i];
+ if(c == '\r') fputs("\\r", f);
+ else if(c == '\n') fputs("\\n", f);
+ else if(c == '\t') fputs("\\t", f);
+ else if(c == 0x1b) fputs("\\x1b", f);
+ else if(c >= 0x20 && c < 0x7f) fputc((int)c, f);
+ else fprintf(f, "\\x%02x", (unsigned)c);
+ }
+ if(limit < len) fputs("...", f);
+ fputc('\n', f);
+}
+
static const char *smoke_result_name(TimuiResult r){
switch(r){
case TIMUI_OK: return "TIMUI_OK";
@@ -72,67 +127,88 @@ static void smoke_sleep_short(void){
}
int main(void){
- TimuiTransport t;
- TimuiResult r;
- int pid = 0, ok = 0;
+ int ok = 0, attempt = 0, attempts_run = 0, last_write = 0;
char seen[8192];
size_t seen_len = 0;
- long deadline;
- const char script[] = "echo " TOKEN "\r\nexit\r\n";
- memset(&t, 0, sizeof t);
memset(seen, 0, sizeof seen);
- r = timui_conpty_open(&t, &pid);
- if(r != TIMUI_OK){
- fprintf(stderr, "conpty smoke: timui_conpty_open failed: %s\n",
- smoke_result_name(r));
- return r == TIMUI_ERR_UNSUPPORTED ? 77 : 1;
- }
- r = timui_conpty_resize(&t, 80, 24);
- if(r != TIMUI_OK){
- fprintf(stderr, "conpty smoke: resize failed: %s\n", smoke_result_name(r));
- timui_conpty_close(&t, pid);
- return 1;
- }
- deadline = smoke_now_ms() + 2000;
- while(smoke_now_ms() < deadline){
- char tmp[512];
- int n = t.read(&t, tmp, sizeof tmp);
- if(n > 0){
- smoke_append(seen, &seen_len, sizeof seen, tmp, (size_t)n);
- if(memchr(tmp, '>', (size_t)n) || memchr(tmp, '$', (size_t)n))
- break;
- } else {
- smoke_sleep_short();
+ for(attempt = 0; !ok && smoke_echo_script_for_attempt(attempt); attempt++){
+ TimuiTransport t;
+ TimuiResult r;
+ int pid = 0;
+ unsigned long long deadline;
+ const char *echo_script = smoke_echo_script_for_attempt(attempt);
+ const char *exit_script = smoke_exit_script_for_attempt(attempt);
+
+ attempts_run++;
+ memset(&t, 0, sizeof t);
+ r = timui_conpty_open(&t, &pid);
+ if(r != TIMUI_OK){
+ fprintf(stderr, "conpty smoke: timui_conpty_open failed: %s\n",
+ smoke_result_name(r));
+ return r == TIMUI_ERR_UNSUPPORTED ? 77 : 1;
+ }
+ r = timui_conpty_resize(&t, 80, 24);
+ if(r != TIMUI_OK){
+ fprintf(stderr, "conpty smoke: resize failed: %s\n", smoke_result_name(r));
+ timui_conpty_close(&t, pid);
+ return 1;
+ }
+ if(!t.write || !t.read){
+ fprintf(stderr, "conpty smoke: transport callbacks missing\n");
+ timui_conpty_close(&t, pid);
+ return 1;
}
- }
- if(!t.write || !t.read || t.write(&t, script, sizeof script - 1) <= 0){
- fprintf(stderr, "conpty smoke: write failed\n");
- timui_conpty_close(&t, pid);
- return 1;
- }
+ deadline = smoke_now_ms() + 2000;
+ while(smoke_now_ms() < deadline){
+ char tmp[512];
+ int n = t.read(&t, tmp, sizeof tmp);
+ if(n > 0){
+ smoke_append(seen, &seen_len, sizeof seen, tmp, (size_t)n);
+ if(memchr(tmp, '>', (size_t)n) || memchr(tmp, '$', (size_t)n))
+ break;
+ } else {
+ smoke_sleep_short();
+ }
+ }
- deadline = smoke_now_ms() + 5000;
- while(smoke_now_ms() < deadline){
- char tmp[512];
- int n = t.read(&t, tmp, sizeof tmp);
- if(n > 0){
- smoke_append(seen, &seen_len, sizeof seen, tmp, (size_t)n);
- if(smoke_contains(seen, seen_len, TOKEN)){
- ok = 1;
- break;
+ if(!smoke_write_exact(&t, echo_script, strlen(echo_script), &last_write)){
+ fprintf(stderr,
+ "conpty smoke: write failed or short on attempt %d; last_write=%d\n",
+ attempt, last_write);
+ timui_conpty_close(&t, pid);
+ return 1;
+ }
+
+ deadline = smoke_now_ms() + 5000;
+ while(smoke_now_ms() < deadline){
+ char tmp[512];
+ int n = t.read(&t, tmp, sizeof tmp);
+ if(n > 0){
+ smoke_append(seen, &seen_len, sizeof seen, tmp, (size_t)n);
+ if(smoke_contains(seen, seen_len, TOKEN)){
+ ok = 1;
+ break;
+ }
+ } else {
+ smoke_sleep_short();
}
- } else {
- smoke_sleep_short();
}
- }
- timui_conpty_close(&t, pid);
- timui_conpty_close(&t, pid);
+ if(exit_script){
+ int exit_write = 0;
+ (void)smoke_write_exact(&t, exit_script, strlen(exit_script), &exit_write);
+ }
+ timui_conpty_close(&t, pid);
+ timui_conpty_close(&t, pid);
+ }
if(!ok){
- fprintf(stderr, "conpty smoke: sentinel not observed; captured %zu bytes\n", seen_len);
+ fprintf(stderr,
+ "conpty smoke: sentinel not observed; captured %zu bytes; attempts=%d; last_write=%d\n",
+ seen_len, attempts_run, last_write);
+ smoke_dump_excerpt(stderr, seen, seen_len);
return 1;
}
printf("PASS conpty smoke: observed %s\n", TOKEN);
diff --git a/tools/verify_conpty_evidence.py b/tools/verify_conpty_evidence.py
new file mode 100644
index 0000000..6d24929
--- /dev/null
+++ b/tools/verify_conpty_evidence.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+import argparse
+import json
+import sys
+from pathlib import Path
+
+
+PASS_NEEDLE = "PASS conpty smoke: observed TIMUI_CONPTY_SMOKE"
+REQUIRED_FILES = (
+ "conpty-acceptance.json",
+ "conpty-smoke.command.txt",
+ "conpty-smoke.meta.txt",
+ "evidence.md",
+)
+
+
+def fail(message):
+ print(f"FAIL conpty evidence: {message}", file=sys.stderr)
+ return 1
+
+
+def read_text(path):
+ try:
+ return path.read_text(encoding="utf-8", errors="replace")
+ except OSError as exc:
+ raise ValueError(f"cannot read {path.name}: {exc}") from exc
+
+
+def manifest_path(artifact_dir, value, field):
+ if not isinstance(value, str) or not value:
+ raise ValueError(f"{field} must be a non-empty relative path")
+ path = Path(value)
+ if path.is_absolute() or ".." in path.parts:
+ raise ValueError(f"{field} must stay inside the artifact directory")
+ return artifact_dir / path
+
+
+def require_exact(mapping, key, expected):
+ got = mapping.get(key)
+ if got != expected:
+ raise ValueError(f"{key} is {got!r}, expected {expected!r}")
+
+
+def require_bool_true(mapping, key):
+ got = mapping.get(key)
+ if got is not True:
+ raise ValueError(f"{key} must be JSON true")
+
+
+def verify(artifact_dir, commit):
+ artifact_dir = artifact_dir.resolve()
+ if not artifact_dir.is_dir():
+ raise ValueError(f"artifact dir does not exist: {artifact_dir}")
+
+ for name in REQUIRED_FILES:
+ if not (artifact_dir / name).is_file():
+ raise ValueError(f"missing {name}")
+
+ try:
+ manifest = json.loads(read_text(artifact_dir / "conpty-acceptance.json"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"conpty-acceptance.json is not valid JSON: {exc}") from exc
+ if not isinstance(manifest, dict):
+ raise ValueError("conpty-acceptance.json must contain an object")
+
+ require_exact(manifest, "commit", commit)
+ require_bool_true(manifest, "passTokenPresent")
+ require_bool_true(manifest, "accepted")
+ status = manifest.get("status")
+ if type(status) is not int or status != 0:
+ raise ValueError(f"status is {status!r}, expected integer 0")
+
+ stdout_path = manifest_path(artifact_dir, manifest.get("stdout"), "stdout")
+ stderr_path = manifest_path(artifact_dir, manifest.get("stderr"), "stderr")
+ status_path = manifest_path(artifact_dir, manifest.get("statusFile"), "statusFile")
+ for path in (stdout_path, stderr_path, status_path):
+ if not path.is_file():
+ raise ValueError(f"missing manifest sidecar {path.name}")
+
+ stdout = read_text(stdout_path)
+ if PASS_NEEDLE not in stdout:
+ raise ValueError(f"{stdout_path.name} does not contain the ConPTY PASS token")
+
+ status_text = read_text(status_path).strip()
+ if status_text != "0":
+ raise ValueError(f"{status_path.name} is {status_text!r}, expected '0'")
+
+ evidence = read_text(artifact_dir / "evidence.md")
+ if commit not in evidence:
+ raise ValueError("evidence.md does not record the expected commit")
+
+ command = read_text(artifact_dir / "conpty-smoke.command.txt").strip()
+ if not command:
+ raise ValueError("conpty-smoke.command.txt is empty")
+ if "smoke-conpty-win32" not in command:
+ raise ValueError("conpty-smoke.command.txt does not run smoke-conpty-win32")
+
+ meta = read_text(artifact_dir / "conpty-smoke.meta.txt")
+ for needle in (
+ f"commit={commit}",
+ "os_env=Windows_NT",
+ "status=0",
+ "passTokenPresent=True",
+ "accepted=True",
+ ):
+ if needle not in meta:
+ raise ValueError(f"conpty-smoke.meta.txt does not contain {needle!r}")
+
+ print(f"PASS conpty evidence: {artifact_dir} commit={commit}")
+ return 0
+
+
+def main(argv):
+ parser = argparse.ArgumentParser(
+ description="Validate a hosted Windows ConPTY evidence artifact directory."
+ )
+ parser.add_argument("--artifact-dir", required=True, type=Path)
+ parser.add_argument("--commit", required=True)
+ args = parser.parse_args(argv)
+
+ try:
+ return verify(args.artifact_dir, args.commit)
+ except ValueError as exc:
+ return fail(str(exc))
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/www/timui.h b/www/timui.h
index 8850850..235ae2f 100644
--- a/www/timui.h
+++ b/www/timui.h
@@ -331,6 +331,11 @@ typedef struct {
TimuiUpdateFn update;
} TimuiApp;
+/* Run one functional app frame on an already-open Timui. Useful when the caller
+ * owns the outer loop; returns 1 when a frame was rendered, 0 on invalid args,
+ * a quit state, or begin failure. Posted messages are delivered to update()
+ * after timui_end(), so terminal/image flushes see the model used by view(). */
+TIMUI_API int timui_app_frame(Timui *ui, TimuiApp *app);
TIMUI_API int timui_run(const TimuiConfig *cfg, TimuiApp *app);
TIMUI_API bool timui_emit(TimuiFrame *f, uint32_t type, const void *data, size_t size);
TIMUI_API bool timui_recv(Timui *ui, uint32_t *out_type, void *out_buf, size_t *inout_size);
@@ -1239,8 +1244,7 @@ TIMUI_API TimuiImage *timui_image_from_rgba(Timui *ui, const void *rgba,
/* Original PNG bytes plus caller-supplied decoded RGBA pixels. This lets apps
* avoid the lazy PNG decode path while preserving PNG passthrough for
* Kitty/iTerm2 and giving Sixel exact pixels for emission and clipping. The
- * supplied RGBA dimensions are expected to match the PNG and drive source
- * cropping. */
+ * supplied RGBA dimensions must match the PNG and drive source cropping. */
TIMUI_API TimuiImage *timui_image_from_png_rgba(Timui *ui, const void *png,
size_t png_size,
const void *rgba,
@@ -1397,6 +1401,16 @@ TIMUI_API void timui_code(TimuiFrame *f, TimuiRect r, const char *src, int l
/* Internal structs (completed only in the implementing TU). */
typedef struct { int read_fd; int write_fd; } TimuiFdCtx;
+enum { TIMUI_EDIT_TEXT = 1, TIMUI_EDIT_KEY = 2 };
+#define TIMUI_EDIT_KEY_ENTER_ 0x80000000u
+typedef struct TimuiEditOp {
+ int kind;
+ unsigned key;
+ int start;
+ int len;
+ uint32_t mods;
+} TimuiEditOp;
+
struct TimuiFrame { Timui *ui; };
struct Timui {
@@ -1435,6 +1449,8 @@ struct Timui {
char paste_buf[256]; /* bracketed-paste accumulator (ev ptr is transient; a paste
* can also span several reads -> several events per frame) */
int paste_len;
+ char paste_utf8_tail[4];
+ int paste_utf8_tail_len;
int trace_fd; /* TIMUI_TRACE input trace fd, -1 = off */
/* Submit segmentation for timui_input_field: byte offsets in text_in where
* Enter fired this frame, in order. Lets the field submit ONE segment per
@@ -1448,10 +1464,13 @@ struct Timui {
int pending_enter_at[32];
uint32_t pending_enter_mods[32];
int pending_enter_count;
+ TimuiEditOp edit_ops[512];
+ int edit_count;
unsigned key_in;
TimuiKey key_pressed;
uint32_t key_mods; /* modifiers of the last key event */
int mouse_wheel; /* accumulated wheel delta this frame (+up/-down) */
+ int mouse_wheel_x, mouse_wheel_y; /* cell of the wheel event */
int mouse_x, mouse_y; /* last reported cell (0-based) */
int mouse_clicked; /* a button press occurred this frame */
int mouse_click_x, mouse_click_y; /* press cell for mouse_clicked */
@@ -1468,8 +1487,9 @@ struct Timui {
* deferred ESC — 16 dropped all but the first 16 chars of a dropped path. */
TimuiEvent events[512];
int event_count;
- struct { TimuiRect clip; int has_clip; } clip_stack[8];
+ struct TimuiClipSnapshot { TimuiRect clip; int has_clip; } *clip_stack;
int clip_count;
+ int clip_cap;
/* Terminal-image placements recorded this frame by timui_image_draw;
* emitted ON TOP of the cell diff in timui_end, so they compose with the
* renderer. Protocol-specific lifecycle state is tracked separately. */
@@ -1540,6 +1560,11 @@ static int timui_rect_contains_(TimuiRect r, int x, int y){
(int64_t)y >= (int64_t)r.y && (int64_t)y < ry2;
}
+static int timui_mouse_wheel_over_(const Timui *ui, TimuiRect r){
+ return ui && ui->mouse_wheel &&
+ timui_rect_contains_(r, ui->mouse_wheel_x, ui->mouse_wheel_y);
+}
+
static void timui_draw_text_clipped_(TimuiCellBuffer *buf, TimuiRect clip, int x, int y,
TimuiStr text, TimuiStyle st){
TimuiRect old_clip, active;
@@ -1634,36 +1659,139 @@ static void ui_event_cb(void *ctx, const TimuiEvent *ev){
else
ui->events_dropped++;
}
-static void timui_append_text_cp_(Timui *ui, uint32_t cp){
+static void timui_edit_add_text_(Timui *ui, int start, int len){
+ TimuiEditOp *op;
+ if(!ui || len <= 0) return;
+ if(ui->edit_count >= (int)(sizeof(ui->edit_ops) / sizeof(ui->edit_ops[0]))){
+ ui->events_dropped++;
+ return;
+ }
+ op = &ui->edit_ops[ui->edit_count++];
+ op->kind = TIMUI_EDIT_TEXT;
+ op->key = 0;
+ op->start = start;
+ op->len = len;
+ op->mods = 0;
+}
+static void timui_edit_add_key_(Timui *ui, unsigned key, uint32_t mods){
+ TimuiEditOp *op;
+ if(!ui || key == 0) return;
+ if(ui->edit_count >= (int)(sizeof(ui->edit_ops) / sizeof(ui->edit_ops[0]))){
+ ui->events_dropped++;
+ return;
+ }
+ op = &ui->edit_ops[ui->edit_count++];
+ op->kind = TIMUI_EDIT_KEY;
+ op->key = key;
+ op->start = 0;
+ op->len = 0;
+ op->mods = mods;
+}
+static void timui_edit_rebuild_from_text_(Timui *ui){
+ int j = 0, e;
+ if(!ui) return;
+ ui->edit_count = 0;
+ for(e = 0; e < ui->enter_count; e++){
+ int at = ui->enter_at[e];
+ if(at < j) at = j;
+ if(at > ui->text_in_len) at = ui->text_in_len;
+ timui_edit_add_text_(ui, j, at - j);
+ timui_edit_add_key_(ui, TIMUI_EDIT_KEY_ENTER_, ui->enter_mods[e]);
+ j = at;
+ }
+ timui_edit_add_text_(ui, j, ui->text_in_len - j);
+}
+static void timui_defer_edit_ops_after_(Timui *ui, int first){
+ int i, text_len = 0, enter_count = 0;
+ if(!ui) return;
+ if(first < 0) first = 0;
+ if(first > ui->edit_count) first = ui->edit_count;
+ for(i = first; i < ui->edit_count; i++){
+ TimuiEditOp *op = &ui->edit_ops[i];
+ if(op->kind == TIMUI_EDIT_TEXT && op->len > 0){
+ int n = op->len;
+ if(n > (int)sizeof(ui->pending_in) - text_len) n = (int)sizeof(ui->pending_in) - text_len;
+ if(n > 0){
+ memcpy(ui->pending_in + text_len, ui->text_in + op->start, (size_t)n);
+ text_len += n;
+ }
+ if(n < op->len) ui->events_dropped++;
+ } else if(op->kind == TIMUI_EDIT_KEY && op->key == TIMUI_EDIT_KEY_ENTER_){
+ if(enter_count < (int)(sizeof(ui->pending_enter_at) / sizeof(ui->pending_enter_at[0]))){
+ ui->pending_enter_at[enter_count] = text_len;
+ ui->pending_enter_mods[enter_count] = op->mods;
+ enter_count++;
+ } else ui->events_dropped++;
+ }
+ }
+ ui->pending_in_len = text_len;
+ ui->pending_enter_count = enter_count;
+}
+static int timui_append_text_cp_(Timui *ui, uint32_t cp){
char enc[4];
int enclen;
- if(!ui) return;
- if(cp < 0x20 || cp == 0x7f || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) return;
+ if(!ui) return 0;
+ if(cp < 0x20 || cp == 0x7f || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) return 0;
enclen = timui_utf8_encode_(cp, enc);
if(enclen > 0 && ui->text_in_len + enclen <= (int)sizeof(ui->text_in)){
int ei;
for(ei = 0; ei < enclen; ei++) ui->text_in[ui->text_in_len++] = enc[ei];
- }
+ return enclen;
+ } else if(enclen > 0) ui->events_dropped++;
+ return 0;
}
-static void timui_append_paste_bytes_(Timui *ui, const char *ptr, size_t len){
- size_t pk = 0;
- while(ui && pk < len && ui->text_in_len < (int)sizeof(ui->text_in)){
- unsigned char pc = (unsigned char)ptr[pk];
+static int timui_append_paste_bytes_(Timui *ui, const char *ptr, size_t len){
+ char bytes[sizeof(((Timui *)0)->paste_buf) + 4];
+ size_t total = 0, pk = 0;
+ int start;
+ if(!ui || (!ptr && len > 0)) return 0;
+ start = ui->text_in_len;
+ if(ui->paste_utf8_tail_len > 0){
+ memcpy(bytes, ui->paste_utf8_tail, (size_t)ui->paste_utf8_tail_len);
+ total = (size_t)ui->paste_utf8_tail_len;
+ ui->paste_utf8_tail_len = 0;
+ }
+ if(len > sizeof(bytes) - total){
+ len = sizeof(bytes) - total;
+ ui->events_dropped++;
+ }
+ if(len > 0){
+ memcpy(bytes + total, ptr, len);
+ total += len;
+ }
+ while(pk < total){
+ unsigned char pc = (unsigned char)bytes[pk];
uint32_t cp = 0;
int adv;
if(pc == 0 || pc == 0x7f){ pk++; continue; }
if(pc < 0x20){
if(pc != '\n' && pc != '\r' && pc != '\t'){ pk++; continue; }
if(ui->text_in_len < (int)sizeof(ui->text_in)) ui->text_in[ui->text_in_len++] = (char)pc;
+ else ui->events_dropped++;
pk++;
continue;
}
- adv = timui_utf8_decode(ptr + pk, len - pk, &cp);
- if(adv == 0){ cp = 0xFFFD; adv = (int)(len - pk); }
+ adv = timui_utf8_decode(bytes + pk, total - pk, &cp);
+ if(adv == 0){
+ size_t rem = total - pk;
+ if(rem > sizeof(ui->paste_utf8_tail)) rem = sizeof(ui->paste_utf8_tail);
+ memcpy(ui->paste_utf8_tail, bytes + pk, rem);
+ ui->paste_utf8_tail_len = (int)rem;
+ break;
+ }
if(adv < 0) adv = 1;
- timui_append_text_cp_(ui, cp);
+ (void)timui_append_text_cp_(ui, cp);
pk += (size_t)adv;
}
+ return ui->text_in_len - start;
+}
+static void timui_flush_paste_utf8_tail_(Timui *ui){
+ int start, n;
+ if(!ui || ui->paste_utf8_tail_len <= 0) return;
+ start = ui->text_in_len;
+ ui->paste_utf8_tail_len = 0;
+ n = timui_append_text_cp_(ui, 0xFFFD);
+ if(n > 0) timui_edit_add_text_(ui, start, n);
}
/* Write ALL n bytes to fd. The output fd typically SHARES its open file
* description with the input fd (fd 0/1 on a tty), which we set O_NONBLOCK for
@@ -1756,8 +1884,9 @@ static void timui_set_terminal_pixels_(Timui *ui, int cols, int rows, int px_w,
TIMUI_API TimuiResult timui_open_for_test(Timui **out_ui, TimuiTransport transport, int w, int h, const TimuiAllocator *alloc){
Timui *ui;
TimuiResult r;
- if(!out_ui || w <= 0 || h <= 0 || !timui_allocator_valid_(alloc)) return TIMUI_ERR_INVALID_ARGUMENT;
+ if(!out_ui) return TIMUI_ERR_INVALID_ARGUMENT;
*out_ui = NULL;
+ if(w <= 0 || h <= 0 || !timui_allocator_valid_(alloc)) return TIMUI_ERR_INVALID_ARGUMENT;
ui = (Timui *)alloc->alloc(alloc->userdata, sizeof(Timui));
if(!ui) return TIMUI_ERR_OUT_OF_MEMORY;
memset(ui, 0, sizeof *ui);
@@ -1769,7 +1898,6 @@ TIMUI_API TimuiResult timui_open_for_test(Timui **out_ui, TimuiTransport transpo
timui_caps_detect(&ui->caps, NULL, NULL, NULL);
r = timui_setup(ui, w, h);
if(r != TIMUI_OK){
- if(transport.close) transport.close(&transport);
alloc->free(alloc->userdata, ui, sizeof *ui);
return r;
}
@@ -1888,8 +2016,9 @@ TIMUI_API TimuiResult timui_open(const TimuiConfig *cfg, Timui **out_ui){
int w = 80, h = 24;
int px_w = 0, px_h = 0;
TimuiResult r;
- if(!cfg || !out_ui) return TIMUI_ERR_INVALID_ARGUMENT;
+ if(!out_ui) return TIMUI_ERR_INVALID_ARGUMENT;
*out_ui = NULL;
+ if(!cfg) return TIMUI_ERR_INVALID_ARGUMENT;
if(cfg->input_fd < 0 || cfg->output_fd < 0) return TIMUI_ERR_INVALID_ARGUMENT;
input_flags = fcntl(cfg->input_fd, F_GETFL, 0);
if(input_flags < 0) return TIMUI_ERR_OS;
@@ -1966,6 +2095,9 @@ TIMUI_API void timui_close(Timui *ui){
if(ui->have_postq) timui_mpsc_destroy(&ui->postq);
timui_interact_destroy(&ui->ia); /* V24: free the dynamic tab_order */
if(ui->have_ids) timui_id_stack_destroy(&ui->ids);
+ if(ui->clip_stack)
+ ui->alloc.free(ui->alloc.userdata, ui->clip_stack,
+ (size_t)ui->clip_cap * sizeof(*ui->clip_stack));
if(ui->trace_fd >= 0) close(ui->trace_fd);
if(ui->have_transport && ui->transport.close) ui->transport.close(&ui->transport);
al = ui->alloc;
@@ -2016,14 +2148,18 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
ui->enter_count = ui->pending_enter_count;
ui->pending_in_len = 0;
ui->pending_enter_count = 0;
+ timui_edit_rebuild_from_text_(ui);
} else {
ui->text_in_len = 0;
ui->enter_count = 0;
+ ui->edit_count = 0;
}
ui->key_in = 0;
ui->key_pressed = TIMUI_KEY_UNKNOWN;
ui->key_mods = 0;
ui->mouse_wheel = 0;
+ ui->mouse_wheel_x = -1;
+ ui->mouse_wheel_y = -1;
ui->mouse_clicked = 0;
{
TimuiEvent ev;
@@ -2035,6 +2171,10 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
int mx = ev.as.mouse.x - 1;
int my = ev.as.mouse.y - 1;
ui->mouse_wheel += ev.as.mouse.wheel_y; /* expose wheel to the app */
+ if(ev.as.mouse.wheel_y){
+ ui->mouse_wheel_x = mx;
+ ui->mouse_wheel_y = my;
+ }
ui->mouse_x = mx; ui->mouse_y = my;
if(ev.as.mouse.wheel_y == 0 &&
(ev.as.mouse.motion || ev.as.mouse.button == 0 || ev.as.mouse.released)){
@@ -2055,6 +2195,7 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
if(ev.as.key.key == TIMUI_KEY_TAB) timui_interact_set_keys(&ui->ia, 1, 0);
else if(ev.as.key.key == TIMUI_KEY_ENTER){
timui_interact_set_keys(&ui->ia, 0, 1);
+ timui_edit_add_key_(ui, TIMUI_EDIT_KEY_ENTER_, ev.as.key.mods);
/* record the Enter's position in the text stream (input_field
* segments submits on these; excess past the cap just merges). */
if(ui->enter_count < (int)(sizeof(ui->enter_at)/sizeof(ui->enter_at[0]))){
@@ -2063,31 +2204,33 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
ui->enter_count++;
}
}
- else if(ev.as.key.key == TIMUI_KEY_BACKSPACE) ui->key_in |= TIMUI_KEYIN_BACKSPACE;
- else if(ev.as.key.key == TIMUI_KEY_LEFT) ui->key_in |= TIMUI_KEYIN_LEFT;
- else if(ev.as.key.key == TIMUI_KEY_RIGHT) ui->key_in |= TIMUI_KEYIN_RIGHT;
- else if(ev.as.key.key == TIMUI_KEY_HOME) ui->key_in |= TIMUI_KEYIN_HOME;
- else if(ev.as.key.key == TIMUI_KEY_END) ui->key_in |= TIMUI_KEYIN_END;
- else if(ev.as.key.key == TIMUI_KEY_DELETE) ui->key_in |= TIMUI_KEYIN_DELETE;
+ else if(ev.as.key.key == TIMUI_KEY_BACKSPACE){ ui->key_in |= TIMUI_KEYIN_BACKSPACE; timui_edit_add_key_(ui, TIMUI_KEYIN_BACKSPACE, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_LEFT) { ui->key_in |= TIMUI_KEYIN_LEFT; timui_edit_add_key_(ui, TIMUI_KEYIN_LEFT, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_RIGHT) { ui->key_in |= TIMUI_KEYIN_RIGHT; timui_edit_add_key_(ui, TIMUI_KEYIN_RIGHT, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_HOME) { ui->key_in |= TIMUI_KEYIN_HOME; timui_edit_add_key_(ui, TIMUI_KEYIN_HOME, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_END) { ui->key_in |= TIMUI_KEYIN_END; timui_edit_add_key_(ui, TIMUI_KEYIN_END, ev.as.key.mods); }
+ else if(ev.as.key.key == TIMUI_KEY_DELETE) { ui->key_in |= TIMUI_KEYIN_DELETE; timui_edit_add_key_(ui, TIMUI_KEYIN_DELETE, ev.as.key.mods); }
else if(ev.as.key.key == TIMUI_KEY_UP) ui->key_in |= TIMUI_KEYIN_UP;
else if(ev.as.key.key == TIMUI_KEY_DOWN) ui->key_in |= TIMUI_KEYIN_DOWN;
else if(ev.as.key.key == TIMUI_KEY_UNKNOWN &&
(ev.as.key.mods & ~TIMUI_MOD_SHIFT) == TIMUI_MOD_NONE){
uint32_t cp = ev.as.key.codepoint;
- timui_append_text_cp_(ui, cp);
+ int start = ui->text_in_len;
+ int n = timui_append_text_cp_(ui, cp);
+ if(n > 0) timui_edit_add_text_(ui, start, n);
}
else if(ev.as.key.key == TIMUI_KEY_UNKNOWN && (ev.as.key.mods & TIMUI_MOD_CTRL)){
/* emacs / readline line editing (ubiquitous on macOS). Ctrl-H
* (backspace) already arrives as KEY_BACKSPACE from the parser. */
switch(ev.as.key.codepoint){
- case 'a': ui->key_in |= TIMUI_KEYIN_HOME; break; /* start of line */
- case 'e': ui->key_in |= TIMUI_KEYIN_END; break; /* end of line */
- case 'b': ui->key_in |= TIMUI_KEYIN_LEFT; break; /* back one char */
- case 'f': ui->key_in |= TIMUI_KEYIN_RIGHT; break; /* forward */
- case 'd': ui->key_in |= TIMUI_KEYIN_DELETE; break; /* delete at cursor */
- case 'k': ui->key_in |= TIMUI_KEYIN_KILL_EOL; break; /* kill to EOL */
- case 'u': ui->key_in |= TIMUI_KEYIN_KILL_BOL; break; /* kill to BOL */
- case 'w': ui->key_in |= TIMUI_KEYIN_KILL_WORD; break; /* kill word back */
+ case 'a': ui->key_in |= TIMUI_KEYIN_HOME; timui_edit_add_key_(ui, TIMUI_KEYIN_HOME, ev.as.key.mods); break; /* start of line */
+ case 'e': ui->key_in |= TIMUI_KEYIN_END; timui_edit_add_key_(ui, TIMUI_KEYIN_END, ev.as.key.mods); break; /* end of line */
+ case 'b': ui->key_in |= TIMUI_KEYIN_LEFT; timui_edit_add_key_(ui, TIMUI_KEYIN_LEFT, ev.as.key.mods); break; /* back one char */
+ case 'f': ui->key_in |= TIMUI_KEYIN_RIGHT; timui_edit_add_key_(ui, TIMUI_KEYIN_RIGHT, ev.as.key.mods); break; /* forward */
+ case 'd': ui->key_in |= TIMUI_KEYIN_DELETE; timui_edit_add_key_(ui, TIMUI_KEYIN_DELETE, ev.as.key.mods); break; /* delete at cursor */
+ case 'k': ui->key_in |= TIMUI_KEYIN_KILL_EOL; timui_edit_add_key_(ui, TIMUI_KEYIN_KILL_EOL, ev.as.key.mods); break; /* kill to EOL */
+ case 'u': ui->key_in |= TIMUI_KEYIN_KILL_BOL; timui_edit_add_key_(ui, TIMUI_KEYIN_KILL_BOL, ev.as.key.mods); break; /* kill to BOL */
+ case 'w': ui->key_in |= TIMUI_KEYIN_KILL_WORD; timui_edit_add_key_(ui, TIMUI_KEYIN_KILL_WORD, ev.as.key.mods); break; /* kill word back */
default: break;
}
}
@@ -2095,14 +2238,19 @@ TIMUI_API bool timui_begin(Timui *ui, TimuiFrame **out_frame){
/* UTF-8 encode the codepoint into text_in (supports international
* input) via the single shared encoder (Z6). */
uint32_t cp = ev.as.text.codepoint;
- timui_append_text_cp_(ui, cp);
+ int start = ui->text_in_len;
+ int n = timui_append_text_cp_(ui, cp);
+ if(n > 0) timui_edit_add_text_(ui, start, n);
} else if(ev.kind == TIMUI_EVENT_PASTE){
- timui_append_paste_bytes_(ui, ev.as.paste.ptr, ev.as.paste.len);
+ int start = ui->text_in_len;
+ int n = timui_append_paste_bytes_(ui, ev.as.paste.ptr, ev.as.paste.len);
+ if(n > 0) timui_edit_add_text_(ui, start, n);
} else if(ev.kind == TIMUI_EVENT_FOCUS){
if(focus_count < (int)(sizeof(focus_events) / sizeof(focus_events[0])))
focus_events[focus_count++] = ev;
}
}
+ if(!ui->input.pasting) timui_flush_paste_utf8_tail_(ui);
if(focus_count > 0){
int fi;
ui->event_count = 0;
@@ -2502,9 +2650,30 @@ TIMUI_API TimuiResult timui_mpsc_init(TimuiMpsc *q, const TimuiAllocator *alloc)
#endif
return TIMUI_OK;
}
+static TimuiMpscNode *timui_mpsc_pop_node_(TimuiMpsc *q){
+ TimuiMpscNode *n;
+ if(!q) return NULL;
+#ifndef TIMUI_NO_THREADS
+ if(!q->lock) return NULL;
+#endif
+ TIMUI_MPSC_LOCK(q);
+ n = q->head;
+ if(n){
+ q->head = n->next;
+ if(!q->head) q->tail = NULL;
+ q->pending--;
+ }
+ TIMUI_MPSC_UNLOCK(q);
+ return n;
+}
+static void timui_mpsc_free_node_(TimuiMpsc *q, TimuiMpscNode *n){
+ if(!q || !n) return;
+ TIMUI_MPSC_LOCK(q);
+ q->alloc.free(q->alloc.userdata, n, sizeof(*n) + n->size);
+ TIMUI_MPSC_UNLOCK(q);
+}
TIMUI_API void timui_mpsc_destroy(TimuiMpsc *q){
- uint32_t t;
- size_t s = 0;
+ TimuiMpscNode *n;
if(!q) return;
#ifndef TIMUI_NO_THREADS
if(!q->lock){
@@ -2514,7 +2683,8 @@ TIMUI_API void timui_mpsc_destroy(TimuiMpsc *q){
return;
}
#endif
- while(timui_mpsc_recv(q, &t, NULL, &s)){ } /* drain remaining nodes */
+ while((n = timui_mpsc_pop_node_(q)) != NULL)
+ timui_mpsc_free_node_(q, n); /* drain remaining nodes */
#ifndef TIMUI_NO_THREADS
if(q->lock){
pthread_mutex_destroy((pthread_mutex_t *)q->lock);
@@ -2549,18 +2719,7 @@ TIMUI_API int timui_mpsc_post(TimuiMpsc *q, uint32_t type, const void *data, siz
TIMUI_API int timui_mpsc_recv(TimuiMpsc *q, uint32_t *out_type, void *out_buf, size_t *inout_size){
TimuiMpscNode *n;
size_t copy;
- if(!q) return 0;
-#ifndef TIMUI_NO_THREADS
- if(!q->lock) return 0;
-#endif
- TIMUI_MPSC_LOCK(q);
- n = q->head;
- if(n){
- q->head = n->next;
- if(!q->head) q->tail = NULL;
- q->pending--;
- }
- TIMUI_MPSC_UNLOCK(q);
+ n = timui_mpsc_pop_node_(q);
if(!n) return 0;
if(out_type) *out_type = n->type;
if(inout_size){
@@ -2569,9 +2728,7 @@ TIMUI_API int timui_mpsc_recv(TimuiMpsc *q, uint32_t *out_type, void *out_buf, s
if(out_buf && copy > 0) memcpy(out_buf, n->data, copy);
*inout_size = n->size;
}
- TIMUI_MPSC_LOCK(q);
- q->alloc.free(q->alloc.userdata, n, sizeof(*n) + n->size);
- TIMUI_MPSC_UNLOCK(q);
+ timui_mpsc_free_node_(q, n);
return 1;
}
TIMUI_API int timui_mpsc_empty(TimuiMpsc *q){
@@ -2855,6 +3012,30 @@ TIMUI_API int timui_utf8_width(uint32_t cp){
}
/* ---- drawing primitives ----------------------------------------------- */
+static void clear_cell_default_(TimuiCellBuffer *buf, int x, int y){
+ TimuiCell c;
+ if(!timui_cells_get(buf, x, y)) return;
+ memset(&c, 0, sizeof c);
+ c.fg = TIMUI_COLOR_DEFAULT;
+ c.bg = TIMUI_COLOR_DEFAULT;
+ timui_cells_put(buf, x, y, &c);
+}
+
+static void clear_wide_pair_touching_(TimuiCellBuffer *buf, int x, int y){
+ TimuiCell *c = timui_cells_get(buf, x, y);
+ if(!c) return;
+ if(c->flags & TIMUI_CELL_CONTINUATION){
+ if(x > 0){
+ TimuiCell *lead = timui_cells_get(buf, x - 1, y);
+ if(lead && lead->width >= 2) clear_cell_default_(buf, x - 1, y);
+ }
+ clear_cell_default_(buf, x, y);
+ } else if(c->width >= 2){
+ clear_cell_default_(buf, x, y);
+ clear_cell_default_(buf, x + 1, y);
+ }
+}
+
/* Z7: the single glyph-emit primitive. Writes cp at (x,y) with style st and an
* optional hyperlink id, and blanks the continuation cell for a wide glyph.
* This is the one place the subtle wide-glyph continuation logic lives (the
@@ -2870,6 +3051,8 @@ static void put_glyph_link(TimuiCellBuffer *buf, int x, int y, uint32_t cp, Timu
w = timui_utf8_width(cp);
if(w > 1 && (x + 1 >= buf->w ||
(buf->has_clip && (x + 1 < buf->clip.x || x + 1 >= buf->clip.x + buf->clip.w)))) return;
+ clear_wide_pair_touching_(buf, x, y);
+ if(w > 1) clear_wide_pair_touching_(buf, x + 1, y);
c.codepoint = cp;
c.fg = st.fg;
c.bg = st.bg;
@@ -4000,10 +4183,13 @@ static int caps_is_kitty_family(const char *tp){
return caps_streq(tp, "kitty") || caps_streq(tp, "xterm-kitty")
|| caps_streq(tp, "ghostty") || caps_streq(tp, "xterm-ghostty");
}
+static int caps_is_iterm2(const char *tp){
+ return caps_streq(tp, "iTerm.app") || caps_streq(tp, "iTerm2");
+}
static int caps_is_modern(const char *tp){
return caps_is_kitty_family(tp) || caps_streq(tp, "WezTerm")
|| caps_streq(tp, "alacritty") || caps_streq(tp, "foot")
- || caps_streq(tp, "rio");
+ || caps_streq(tp, "rio") || caps_is_iterm2(tp);
}
static void caps_set_str(char *dst, size_t cap, const char *src){
size_t n;
@@ -4036,6 +4222,8 @@ TIMUI_API void timui_caps_detect(TimuiCaps *c, const char *term, const char *ter
if(caps_is_kitty_family(term_program) || caps_is_kitty_family(term)){
c->flags |= TIMUI_CAP_KITTY_KEYBOARD | TIMUI_CAP_KITTY_GRAPHICS | TIMUI_CAP_UNICODE_CORE;
}
+ if(caps_is_iterm2(term_program) || caps_is_iterm2(term))
+ c->flags |= TIMUI_CAP_ITERM2_IMAGES | TIMUI_CAP_UNICODE_CORE;
} else if(term && strstr(term, "256color")){
c->flags |= TIMUI_CAP_256_COLOR;
c->colors = 256;
@@ -4469,6 +4657,7 @@ TIMUI_API size_t timui_input_feed(TimuiInputParser *p, const void *data, size_t
break;
}
p->state = 0; /* unexpected: resync */
+ if(c >= 0x80) i--; /* non-ASCII may be a UTF-8 lead; do not drop it */
break;
case 3: /* SS3 (ESC O X) */
/* Z3: an ESC here aborts the truncated SS3 and restarts a fresh
@@ -4478,6 +4667,7 @@ TIMUI_API size_t timui_input_feed(TimuiInputParser *p, const void *data, size_t
TimuiKey k = ss3_final(c);
if(k != TIMUI_KEY_UNKNOWN){ emit_key(cb, ctx, k, 0, 0); count++; }
p->state = 0;
+ if(k == TIMUI_KEY_UNKNOWN && c >= 0x80) i--;
}
break;
case 4: /* UTF-8 continuation */
@@ -4868,6 +5058,50 @@ static void text_pos_(const char *buf, size_t cursor, int *out_row, int *out_col
*out_row = row;
*out_col = display_col_(buf + line_start, cursor - line_start);
}
+static void input_field_insert_span_(TimuiInputState *st, const char *src, int nbytes){
+ int j = 0;
+ while(st && src && j < nbytes){
+ int n = utf8_lead_len((unsigned char)src[j]);
+ size_t m = (size_t)(n > 0 ? n : 1);
+ if(!single_line_text_byte_((unsigned char)src[j])){ j++; continue; }
+ if(j + (int)m > nbytes) m = (size_t)(nbytes - j);
+ if(!text_insert_(st->text, st->cap, st->cursor, src + j, m)) break;
+ st->cursor += m;
+ j += (int)m;
+ }
+}
+static void input_field_apply_key_(TimuiInputState *st, unsigned key){
+ size_t len;
+ if(!st || !st->text) return;
+ len = strlen(st->text);
+ if(key == TIMUI_KEYIN_LEFT) st->cursor = utf8_drop_last(st->text, st->cursor);
+ if(key == TIMUI_KEYIN_RIGHT) st->cursor = utf8_next_(st->text, st->cursor, len);
+ if(key == TIMUI_KEYIN_HOME) st->cursor = 0;
+ if(key == TIMUI_KEYIN_END) st->cursor = len;
+ if(key == TIMUI_KEYIN_BACKSPACE && st->cursor > 0){
+ size_t prev = utf8_drop_last(st->text, st->cursor);
+ st->cursor = text_erase_(st->text, prev, st->cursor);
+ }
+ if(key == TIMUI_KEYIN_DELETE){
+ size_t nxt = utf8_next_(st->text, st->cursor, strlen(st->text));
+ (void)text_erase_(st->text, st->cursor, nxt);
+ }
+ if(key == TIMUI_KEYIN_KILL_EOL){
+ st->text[st->cursor] = '\0';
+ }
+ if(key == TIMUI_KEYIN_KILL_BOL){
+ size_t rest = strlen(st->text + st->cursor);
+ memmove(st->text, st->text + st->cursor, rest + 1);
+ st->cursor = 0;
+ }
+ if(key == TIMUI_KEYIN_KILL_WORD){
+ size_t c = st->cursor, w = c;
+ while(w > 0 && st->text[w-1] == ' ') w--;
+ while(w > 0 && st->text[w-1] != ' ') w--;
+ memmove(st->text + w, st->text + c, strlen(st->text + c) + 1);
+ st->cursor = w;
+ }
+}
static bool input_field_core(TimuiFrame *f, TimuiId id, TimuiRect r, TimuiInputState *st,
const TimuiStyle *ovr){
Timui *ui;
@@ -4882,66 +5116,22 @@ static bool input_field_core(TimuiFrame *f, TimuiId id, TimuiRect r, TimuiInputS
{
ir = timui_interact_button(&ui->ia, id, r);
if(ir.focused){
- /* Insert typed text UP TO the first Enter this frame; on an Enter,
- * submit and DEFER the post-Enter tail (and any further Enters) to
- * the next frame — one submit per frame, so a burst "a\rb\r" yields
- * "a" then "b" instead of the merged "ab". */
- int first_enter = (ui->enter_count > 0) ? ui->enter_at[0] : -1;
- int upto = (first_enter >= 0) ? first_enter : ui->text_in_len;
- int j = 0;
- size_t len;
- if(upto > ui->text_in_len) upto = ui->text_in_len;
- while(j < upto){
- int n = utf8_lead_len((unsigned char)ui->text_in[j]);
- size_t m = (size_t)(n > 0 ? n : 1);
- if(!single_line_text_byte_((unsigned char)ui->text_in[j])){ j++; continue; }
- if(j + (int)m > upto) m = (size_t)(upto - j);
- if(!text_insert_(st->text, st->cap, st->cursor, ui->text_in + j, m)) break;
- st->cursor += m; j += (int)m;
- }
- len = strlen(st->text);
- if(ui->key_in & TIMUI_KEYIN_LEFT) st->cursor = utf8_drop_last(st->text, st->cursor);
- if(ui->key_in & TIMUI_KEYIN_RIGHT) st->cursor = utf8_next_(st->text, st->cursor, len);
- if(ui->key_in & TIMUI_KEYIN_HOME) st->cursor = 0; /* single line */
- if(ui->key_in & TIMUI_KEYIN_END) st->cursor = len;
- if((ui->key_in & TIMUI_KEYIN_BACKSPACE) && st->cursor > 0){
- size_t prev = utf8_drop_last(st->text, st->cursor);
- st->cursor = text_erase_(st->text, prev, st->cursor);
- }
- if(ui->key_in & TIMUI_KEYIN_DELETE){
- size_t nxt = utf8_next_(st->text, st->cursor, strlen(st->text));
- (void)text_erase_(st->text, st->cursor, nxt);
- }
- if(ui->key_in & TIMUI_KEYIN_KILL_EOL){ /* Ctrl-K: cursor..end */
- st->text[st->cursor] = '\0'; /* cursor is a cluster boundary */
- }
- if(ui->key_in & TIMUI_KEYIN_KILL_BOL){ /* Ctrl-U: start..cursor */
- size_t rest = strlen(st->text + st->cursor);
- memmove(st->text, st->text + st->cursor, rest + 1);
- st->cursor = 0;
- }
- if(ui->key_in & TIMUI_KEYIN_KILL_WORD){ /* Ctrl-W: the word before the cursor */
- size_t c = st->cursor, w = c;
- while(w > 0 && st->text[w-1] == ' ') w--; /* trailing spaces */
- while(w > 0 && st->text[w-1] != ' ') w--; /* the word */
- memmove(st->text + w, st->text + c, strlen(st->text + c) + 1);
- st->cursor = w;
- }
- if(first_enter >= 0){
- int tail = ui->text_in_len - upto, k;
- submitted = true;
- if(tail < 0) tail = 0;
- if(tail > (int)sizeof(ui->pending_in)) tail = (int)sizeof(ui->pending_in);
- memcpy(ui->pending_in, ui->text_in + upto, (size_t)tail);
- ui->pending_in_len = tail;
- ui->pending_enter_count = ui->enter_count - 1;
- for(k = 0; k < ui->pending_enter_count; k++){
- ui->pending_enter_at[k] = ui->enter_at[k + 1] - upto;
- ui->pending_enter_mods[k] = ui->enter_mods[k + 1];
+ int oi;
+ for(oi = 0; oi < ui->edit_count; oi++){
+ TimuiEditOp *op = &ui->edit_ops[oi];
+ if(op->kind == TIMUI_EDIT_TEXT){
+ input_field_insert_span_(st, ui->text_in + op->start, op->len);
+ } else if(op->kind == TIMUI_EDIT_KEY && op->key == TIMUI_EDIT_KEY_ENTER_){
+ submitted = true;
+ timui_defer_edit_ops_after_(ui, oi + 1);
+ break;
+ } else if(op->kind == TIMUI_EDIT_KEY){
+ input_field_apply_key_(st, op->key);
}
}
ui->text_in_len = 0;
ui->enter_count = 0;
+ ui->edit_count = 0;
ui->key_in = 0;
}
}
@@ -5307,11 +5497,21 @@ TIMUI_API void timui_push_clip(TimuiFrame *f, TimuiRect rect){
if(!f || !f->ui) return;
ui = f->ui;
b = &ui->curr;
- if(ui->clip_count < 8){
- ui->clip_stack[ui->clip_count].clip = b->clip;
- ui->clip_stack[ui->clip_count].has_clip = b->has_clip;
- ui->clip_count++;
+ if(ui->clip_count >= ui->clip_cap){
+ int nc;
+ void *ns;
+ if(ui->clip_cap > INT_MAX / 2) return;
+ nc = ui->clip_cap ? ui->clip_cap * 2 : 8;
+ ns = ui->alloc.realloc(ui->alloc.userdata, ui->clip_stack,
+ (size_t)ui->clip_cap * sizeof(*ui->clip_stack),
+ (size_t)nc * sizeof(*ui->clip_stack));
+ if(!ns) return;
+ ui->clip_stack = ns;
+ ui->clip_cap = nc;
}
+ ui->clip_stack[ui->clip_count].clip = b->clip;
+ ui->clip_stack[ui->clip_count].has_clip = b->has_clip;
+ ui->clip_count++;
active = b->has_clip ? b->clip : TIMUI_RECT(0, 0, b->w, b->h);
b->clip = clip_intersect(active, rect);
b->has_clip = 1;
@@ -5487,10 +5687,22 @@ TIMUI_API int timui_fit_cell(const char *s, int width, char *out, size_t cap, in
return 0;
}
if(full <= width){ /* fits whole — copy verbatim */
- size_t n = len < cap - 1 ? len : cap - 1;
- memcpy(out, s, n);
- out[n] = '\0';
- return full;
+ for(i = 0; i < len;){
+ size_t n = timui_grapheme_next(s, len, i);
+ int gw;
+ if(n <= i) n = i + 1;
+ if(o + (n - i) >= cap){
+ if(ellipsis) *ellipsis = 1;
+ break;
+ }
+ gw = timui_grapheme_width(s + i, n - i);
+ memcpy(out + o, s + i, n - i);
+ o += n - i;
+ used += gw;
+ i = n;
+ }
+ out[o] = '\0';
+ return used;
}
if(cap < sizeof(TIMUI_ELLIPSIS_)){ /* no room for ellipsis + NUL */
if(ellipsis) *ellipsis = 1;
@@ -5567,6 +5779,14 @@ TIMUI_API TimuiTableResult timui_table(TimuiFrame *f, TimuiId id, TimuiRect r,
if(state.scroll < 0) state.scroll = 0;
if(state.selected < state.scroll) state.scroll = state.selected;
if(state.selected >= state.scroll + vis) state.scroll = state.selected - vis + 1;
+ /* Keyboard nav only when focused — process before drawing so the highlight
+ * and returned state agree in the same frame. */
+ { TimuiInteractResult tir = timui_interact_button(&ui->ia, id, r);
+ res.focused = tir.focused;
+ if(tir.focused) state.selected = timui_updown_nav_(f, state.selected, nrows);
+ }
+ if(state.selected < state.scroll) state.scroll = state.selected;
+ if(state.selected >= state.scroll + vis) state.scroll = state.selected - vis + 1;
/* header row */
{ TimuiStyle hs = timui_widget_style_(ui, TIMUI_WIDGET_TABLE, TIMUI_SLOT_PANEL_TITLE, 0);
x = r.x;
@@ -5591,12 +5811,6 @@ TIMUI_API TimuiTableResult timui_table(TimuiFrame *f, TimuiId id, TimuiRect r,
}
}
timui_scroll_end(f);
- /* keyboard nav only when focused — call interact_button once to avoid
- * double-registering the table id in the tab order */
- { TimuiInteractResult tir = timui_interact_button(&ui->ia, id, r);
- res.focused = tir.focused;
- if(tir.focused) state.selected = timui_updown_nav_(f, state.selected, nrows);
- }
res.state = state;
res.state_changed = (state.selected != orig);
return res;
@@ -5700,7 +5914,7 @@ TIMUI_API TimuiTableResult timui_table_ex(TimuiFrame *f, TimuiId id, TimuiRect r
/* Mouse wheel scrolls the body and drags the selection into the new window. */
wh = timui_mouse_wheel(f);
- if(wh){
+ if(wh && timui_mouse_wheel_over_(ui, r)){
scroll -= wh;
scroll = timui_page_slice(nrows, vis, scroll).first;
if(sel < scroll) sel = scroll;
@@ -5860,6 +6074,12 @@ TIMUI_API TimuiTreeResult timui_tree(TimuiFrame *f, TimuiId id, TimuiRect r,
if(selected < 0) selected = 0;
if(selected >= count) selected = count - 1;
orig = selected; /* post-clamp: a pure clamp is not a change */
+ /* Keyboard nav only when focused — process before drawing so the visible
+ * selection and returned value cannot diverge for a frame. */
+ { TimuiInteractResult ir2 = timui_interact_button(&ui->ia, id, r);
+ res.focused = ir2.focused;
+ if(ir2.focused) selected = timui_updown_nav_(f, selected, count);
+ }
content = timui_scroll_begin(f, r, 0);
for(i = 0; i < count; i++){
int y = content.y + i;
@@ -5869,11 +6089,6 @@ TIMUI_API TimuiTreeResult timui_tree(TimuiFrame *f, TimuiId id, TimuiRect r,
timui_tree_draw_node_(ui, &nodes[i], TIMUI_RECT(content.x, y, r.w, 1), y, st);
}
timui_scroll_end(f);
- /* keyboard nav only when focused */
- { TimuiInteractResult ir2 = timui_interact_button(&ui->ia, id, r);
- res.focused = ir2.focused;
- if(ir2.focused) selected = timui_updown_nav_(f, selected, count);
- }
res.selected = selected;
res.state_changed = (selected != orig);
return res;
@@ -5915,7 +6130,7 @@ TIMUI_API TimuiTreeScrollResult timui_tree_scroll(TimuiFrame *f, TimuiId id, Tim
scroll = state.scroll < 0 ? 0 : state.scroll;
wh = timui_mouse_wheel(f);
- if(wh){
+ if(wh && timui_mouse_wheel_over_(ui, r)){
scroll -= wh;
scroll = timui_page_slice(nvis, vis, scroll).first;
if(sel < scroll) sel = scroll;
@@ -5987,6 +6202,16 @@ static int cmd_matches(TimuiStr cmd, const char *filter){
}
return 0;
}
+static int cmd_filter_(const TimuiStr *commands, int count, const char *filter,
+ int *matched_idx, int max){
+ int i, matched_count = 0;
+ if(!commands || !matched_idx || max <= 0 || count <= 0) return 0;
+ for(i = 0; i < count && matched_count < max; i++){
+ TimuiStr cmd = commands[i].ptr ? commands[i] : (TimuiStr){ "", 0 };
+ if(cmd_matches(cmd, filter)) matched_idx[matched_count++] = i;
+ }
+ return matched_count;
+}
TIMUI_API TimuiCmdPaletteResult timui_command_palette(TimuiFrame *f, TimuiId id, TimuiRect r,
const TimuiStr *commands, int count, TimuiCmdPaletteState state){
TimuiCmdPaletteResult res;
@@ -6003,13 +6228,23 @@ TIMUI_API TimuiCmdPaletteResult timui_command_palette(TimuiFrame *f, TimuiId id,
input_r = TIMUI_RECT(r.x + 1, r.y + 1, r.w - 2, 1);
list_r = TIMUI_RECT(r.x + 1, r.y + 2, r.w - 2, r.h - 3);
timui_input_line_buf(f, id + 1, input_r, state.filter, sizeof state.filter);
- /* filter */
- for(i = 0; i < count && matched_count < 256; i++){
- TimuiStr cmd = commands[i].ptr ? commands[i] : (TimuiStr){ "", 0 };
- if(cmd_matches(cmd, state.filter)) matched_idx[matched_count++] = i;
- }
+ matched_count = cmd_filter_(commands, count, state.filter, matched_idx,
+ (int)(sizeof matched_idx / sizeof matched_idx[0]));
if(state.selected < 0) state.selected = 0;
if(state.selected >= matched_count) state.selected = matched_count > 0 ? matched_count - 1 : 0;
+ /* V18: only steer the palette when its filter input is focused, so drawing
+ * the palette without focusing it doesn't swallow arrow/Enter from siblings.
+ * Process before drawing so selection/activation state and pixels agree. */
+ if(ui->ia.focus == id + 1){
+ state.selected = timui_updown_nav_(f, state.selected, matched_count);
+ if(timui_key_pressed(f, TIMUI_KEY_ENTER) && matched_count > 0){
+ res.activated = matched_idx[state.selected];
+ state.filter[0] = '\0';
+ state.selected = 0;
+ matched_count = cmd_filter_(commands, count, state.filter, matched_idx,
+ (int)(sizeof matched_idx / sizeof matched_idx[0]));
+ }
+ }
/* draw matched commands */
{ TimuiRect content = timui_scroll_begin(f, list_r, 0);
for(i = 0; i < matched_count; i++){
@@ -6021,16 +6256,6 @@ TIMUI_API TimuiCmdPaletteResult timui_command_palette(TimuiFrame *f, TimuiId id,
}
timui_scroll_end(f);
}
- /* V18: only steer the palette when its filter input is focused, so drawing
- * the palette without focusing it doesn't swallow arrow/Enter from siblings. */
- if(ui->ia.focus == id + 1){
- state.selected = timui_updown_nav_(f, state.selected, matched_count);
- if(timui_key_pressed(f, TIMUI_KEY_ENTER) && matched_count > 0){
- res.activated = matched_idx[state.selected];
- state.filter[0] = '\0';
- state.selected = 0;
- }
- }
timui_panel_end(f);
res.state = state;
res.state_changed = (state.selected != sel_in ||
@@ -6135,7 +6360,9 @@ TIMUI_API TimuiComboboxResult timui_combobox(TimuiFrame *f, TimuiId id, TimuiRec
res.focused = 0;
if(!f || !f->ui || !state.query || state.cap == 0 || count < 0) return res;
ui = f->ui;
- if(state.cursor >= state.cap) state.cursor = state.cap - 1;
+ { size_t len = text_len_bounded_(state.query, state.cap);
+ if(len >= state.cap){ len = state.cap - 1; state.query[len] = '\0'; }
+ if(state.cursor > len) state.cursor = len; }
field = TIMUI_RECT(r.x, r.y, r.w, r.h > 0 ? 1 : 0);
popup = TIMUI_RECT(r.x, r.y + 1, r.w, r.h > 1 ? r.h - 1 : 0);
ir = timui_interact_button(&ui->ia, id, r);
@@ -6636,42 +6863,57 @@ static int text_area_insert_newline_(TimuiTextAreaState *st){
st->cursor++;
return 1;
}
-static int text_area_enter_submits_(const Timui *ui, int enter_index, uint32_t flags){
+static int text_area_enter_submits_(uint32_t mods, uint32_t flags){
if(!(flags & TIMUI_TEXT_AREA_ENTER_SUBMITS)) return 0;
- return !(ui->enter_mods[enter_index] & TIMUI_MOD_SHIFT);
+ return !(mods & TIMUI_MOD_SHIFT);
+}
+static void text_area_apply_key_(TimuiTextAreaState *st, unsigned key, TimuiTextAreaResult *res){
+ if(key == TIMUI_KEYIN_LEFT) st->cursor = utf8_drop_last(st->text, st->cursor);
+ if(key == TIMUI_KEYIN_RIGHT) st->cursor = utf8_next_(st->text, st->cursor, strlen(st->text));
+ if(key == TIMUI_KEYIN_HOME) st->cursor = line_start_(st->text, st->cursor);
+ if(key == TIMUI_KEYIN_END) st->cursor = line_end_(st->text, st->cursor);
+ if(key == TIMUI_KEYIN_BACKSPACE && st->cursor > 0){
+ size_t prev = utf8_drop_last(st->text, st->cursor);
+ st->cursor = text_erase_(st->text, prev, st->cursor);
+ res->changed = 1;
+ }
+ if(key == TIMUI_KEYIN_DELETE){
+ size_t nxt = utf8_next_(st->text, st->cursor, strlen(st->text));
+ if(nxt > st->cursor){
+ (void)text_erase_(st->text, st->cursor, nxt);
+ res->changed = 1;
+ }
+ }
}
-static void text_area_defer_after_enter_(Timui *ui, int at, int enter_index){
- int tail = ui->text_in_len - at;
- int k;
- if(tail < 0) tail = 0;
- if(tail > (int)sizeof(ui->pending_in)) tail = (int)sizeof(ui->pending_in);
- memcpy(ui->pending_in, ui->text_in + at, (size_t)tail);
- ui->pending_in_len = tail;
- ui->pending_enter_count = ui->enter_count - enter_index - 1;
- if(ui->pending_enter_count < 0) ui->pending_enter_count = 0;
- for(k = 0; k < ui->pending_enter_count; k++){
- ui->pending_enter_at[k] = ui->enter_at[enter_index + 1 + k] - at;
- ui->pending_enter_mods[k] = ui->enter_mods[enter_index + 1 + k];
+static void text_area_process_edit_ops_(Timui *ui, TimuiTextAreaState *st,
+ uint32_t flags, TimuiTextAreaResult *res){
+ int oi;
+ for(oi = 0; oi < ui->edit_count; oi++){
+ TimuiEditOp *op = &ui->edit_ops[oi];
+ if(op->kind == TIMUI_EDIT_TEXT){
+ if(text_area_insert_span_(st, ui->text_in + op->start, op->len)) res->changed = 1;
+ } else if(op->kind == TIMUI_EDIT_KEY && op->key == TIMUI_EDIT_KEY_ENTER_){
+ if(text_area_enter_submits_(op->mods, flags)){
+ res->submitted = 1;
+ timui_defer_edit_ops_after_(ui, oi + 1);
+ return;
+ }
+ if(text_area_insert_newline_(st)) res->changed = 1;
+ } else if(op->kind == TIMUI_EDIT_KEY){
+ text_area_apply_key_(st, op->key, res);
+ }
}
}
-static void text_area_process_text_(Timui *ui, TimuiTextAreaState *st,
- uint32_t flags, TimuiTextAreaResult *res){
- int j = 0;
- int e;
- for(e = 0; e < ui->enter_count; e++){
- int at = ui->enter_at[e];
- if(at < j) at = j;
- if(at > ui->text_in_len) at = ui->text_in_len;
- if(text_area_insert_span_(st, ui->text_in + j, at - j)) res->changed = 1;
- if(text_area_enter_submits_(ui, e, flags)){
- res->submitted = 1;
- text_area_defer_after_enter_(ui, at, e);
- return;
+static int text_area_cursor_row_(const TimuiTextAreaState *st){
+ int cursor_row = 0;
+ size_t ci;
+ for(ci = 0; ci < st->cursor && ci < st->cap; ci++){
+ if(st->text[ci] == '\n' || st->text[ci] == '\r'){
+ cursor_row++;
+ if(st->text[ci] == '\r' && ci + 1 < st->cap && st->text[ci + 1] == '\n') ci++;
}
- if(text_area_insert_newline_(st)) res->changed = 1;
- j = at;
}
- if(text_area_insert_span_(st, ui->text_in + j, ui->text_in_len - j)) res->changed = 1;
+ return cursor_row;
}
TIMUI_API TimuiTextAreaResult timui_text_area_ex(TimuiFrame *f, TimuiId id, TimuiRect r,
TimuiTextAreaState st, uint32_t flags){
@@ -6693,39 +6935,21 @@ TIMUI_API TimuiTextAreaResult timui_text_area_ex(TimuiFrame *f, TimuiId id, Timu
ir = timui_interact_button(&ui->ia, id, r);
res.focused = ir.focused;
if(ir.focused){
- /* Insert typed codepoints at the cursor. Deletion/movement below is
- * grapheme-aware; insertion remains codepoint-by-codepoint and
- * cap-bounded, so invalid partial UTF-8 is not created.
- * The edit helpers live in the widgets section, in scope via the unity
- * build. */
- text_area_process_text_(ui, &st, flags, &res);
- /* cursor movement (one step per frame — the key_in bitmask can't count
- * repeats; key auto-repeat delivers one per frame). */
- if(ui->key_in & TIMUI_KEYIN_LEFT) st.cursor = utf8_drop_last(st.text, st.cursor);
- if(ui->key_in & TIMUI_KEYIN_RIGHT) st.cursor = utf8_next_(st.text, st.cursor, strlen(st.text));
- if(ui->key_in & TIMUI_KEYIN_HOME) st.cursor = line_start_(st.text, st.cursor);
- if(ui->key_in & TIMUI_KEYIN_END) st.cursor = line_end_(st.text, st.cursor);
- /* deletion: backspace removes the cluster before the cursor, DELETE
- * the one at the cursor. */
- if((ui->key_in & TIMUI_KEYIN_BACKSPACE) && st.cursor > 0){
- size_t prev = utf8_drop_last(st.text, st.cursor);
- st.cursor = text_erase_(st.text, prev, st.cursor);
- res.changed = 1;
- }
- if(ui->key_in & TIMUI_KEYIN_DELETE){
- size_t nxt = utf8_next_(st.text, st.cursor, strlen(st.text));
- if(nxt > st.cursor){
- (void)text_erase_(st.text, st.cursor, nxt);
- res.changed = 1;
- }
- }
+ text_area_process_edit_ops_(ui, &st, flags, &res);
ui->text_in_len = 0;
ui->enter_count = 0;
+ ui->edit_count = 0;
ui->key_in = 0;
}
{ TimuiStyle sst = timui_widget_style_(ui, TIMUI_WIDGET_TEXT_AREA,
ir.focused ? TIMUI_SLOT_INPUT_FOCUSED : TIMUI_SLOT_INPUT,
ir.focused ? TIMUI_STYLE_STATE_FOCUSED : 0);
+ { int cursor_row = text_area_cursor_row_(&st);
+ if(st.scroll_y < 0) st.scroll_y = 0;
+ if(cursor_row < st.scroll_y) st.scroll_y = cursor_row;
+ if(cursor_row >= st.scroll_y + r.h) st.scroll_y = cursor_row - r.h + 1;
+ if(st.scroll_y < 0) st.scroll_y = 0;
+ }
content = timui_scroll_begin(f, r, st.scroll_y);
i = 0;
while(i < st.cap && st.text[i]){
@@ -6742,18 +6966,6 @@ TIMUI_API TimuiTextAreaResult timui_text_area_ex(TimuiFrame *f, TimuiId id, Timu
}
y++;
}
- /* auto-scroll to keep the cursor visible (computed before scroll_begin next frame) */
- { int cursor_row = 0;
- size_t ci;
- for(ci = 0; ci < st.cursor && ci < st.cap; ci++)
- if(st.text[ci] == '\n' || st.text[ci] == '\r'){
- cursor_row++;
- if(st.text[ci] == '\r' && ci + 1 < st.cap && st.text[ci+1] == '\n') ci++;
- }
- if(cursor_row < st.scroll_y) st.scroll_y = cursor_row;
- if(cursor_row >= st.scroll_y + r.h) st.scroll_y = cursor_row - r.h + 1;
- if(st.scroll_y < 0) st.scroll_y = 0;
- }
timui_scroll_end(f);
if(ir.focused){ /* F1.4: request the hardware cursor */
int crow, ccol;
@@ -6978,12 +7190,14 @@ TIMUI_API TimuiResult timui_conpty_open(TimuiTransport *out_transport, int *out_
size.X = 80;
size.Y = 24;
if(FAILED(api.create_pseudo_console(size, in_read, out_write, 0, &ctx->hpc))) goto fail;
- conpty_close_handle_(&in_read);
- conpty_close_handle_(&out_write);
memset(&si, 0, sizeof si);
memset(&pi, 0, sizeof pi);
si.StartupInfo.cb = sizeof(STARTUPINFOEXW);
+ si.StartupInfo.dwFlags |= STARTF_USESTDHANDLES;
+ si.StartupInfo.hStdInput = NULL;
+ si.StartupInfo.hStdOutput = NULL;
+ si.StartupInfo.hStdError = NULL;
InitializeProcThreadAttributeList(NULL, 1, 0, &attr_bytes);
if(attr_bytes == 0) goto fail;
si.lpAttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST)calloc(1, attr_bytes);
@@ -6998,6 +7212,8 @@ TIMUI_API TimuiResult timui_conpty_open(TimuiTransport *out_transport, int *out_
if(!cmd[0]) goto fail_attr;
if(!CreateProcessW(NULL, cmd, NULL, NULL, FALSE, EXTENDED_STARTUPINFO_PRESENT,
NULL, NULL, &si.StartupInfo, &pi)) goto fail_attr;
+ conpty_close_handle_(&in_read);
+ conpty_close_handle_(&out_write);
DeleteProcThreadAttributeList(si.lpAttributeList);
free(si.lpAttributeList);
si.lpAttributeList = NULL;
@@ -15324,9 +15540,16 @@ TIMUI_API TimuiImage *timui_image_from_png_rgba(Timui *ui, const void *png,
TimuiAllocator al;
size_t row = 0, total = 0;
const unsigned char *src;
+#ifndef TIMUI_NO_IMAGES
+ int png_w = 0, png_h = 0;
+#endif
(void)ui;
if(!png || png_size == 0 || !rgba || !image_rgba_size_(w, h, stride, &row, &total))
return NULL;
+#ifndef TIMUI_NO_IMAGES
+ if(!image_png_header_(png, png_size, &png_w, &png_h)) return NULL;
+ if(png_w != w || png_h != h) return NULL;
+#endif
al = timui_default_allocator();
img = (TimuiImage *)al.alloc(al.userdata, sizeof(TimuiImage));
if(!img) return NULL;
@@ -15878,6 +16101,8 @@ static void image_record_(Timui *ui, TimuiImage *img, TimuiRect visible, TimuiRe
ui->img_place[ui->img_place_count].rect = visible;
ui->img_place[ui->img_place_count].full = full;
ui->img_place_count++;
+ } else {
+ image_placeholder_(ui, visible);
}
} else {
image_placeholder_(ui, visible);
@@ -15995,10 +16220,16 @@ TIMUI_API void timui_menu_bar_end(TimuiFrame *f, TimuiMenuBar *bar){
* rather than silently truncated. */
#define TIMUI_LAYOUT_MAX 128
+static int timui_sat_i64_(int64_t v){
+ if(v < INT_MIN) return INT_MIN;
+ if(v > INT_MAX) return INT_MAX;
+ return (int)v;
+}
+
/* Round a * num / den to the nearest integer (den > 0; a, num >= 0). */
-static int timui_round_div_(long a, long num, long den){
+static int64_t timui_round_div_(int64_t a, int64_t num, int64_t den){
if(den <= 0) return 0;
- return (int)((a * num + den / 2) / den);
+ return (a * num + den / 2) / den;
}
/* A flexible constraint shares in the leftover space (FLEX/MIN/MAX). */
@@ -16008,19 +16239,20 @@ static int timui_con_is_flex_(TimuiConstraintKind k){
/* Weight of a flexible constraint: FLEX uses its value (clamped >= 0); MIN/MAX
* are single-weight fill segments. */
-static int timui_con_weight_(const TimuiConstraint *c){
+static int64_t timui_con_weight_(const TimuiConstraint *c){
if(c->kind == TIMUI_CON_FLEX) return c->value > 0 ? c->value : 0;
return 1; /* MIN / MAX */
}
TIMUI_API int timui_split_ex(TimuiRect area, TimuiAxis axis, const TimuiConstraint *cons,
int n, TimuiLayoutOpts opts, TimuiRect *out){
- int size[TIMUI_LAYOUT_MAX];
+ int64_t size[TIMUI_LAYOUT_MAX];
char locked[TIMUI_LAYOUT_MAX];
- int i, gap, margin;
- int axis_start, axis_len, cross_start, cross_len;
- int inner_start, inner_len, cross_inner_start, cross_inner_len;
- int avail, fixed_sum, leftover, remaining, pos, end;
+ int i;
+ int64_t gap, margin;
+ int64_t axis_start, axis_len, cross_start, cross_len;
+ int64_t inner_start, inner_len, cross_inner_start, cross_inner_len;
+ int64_t avail, fixed_sum, leftover, remaining, pos, end;
if(!cons || !out || n <= 0 || n > TIMUI_LAYOUT_MAX) return 0;
@@ -16051,7 +16283,7 @@ TIMUI_API int timui_split_ex(TimuiRect area, TimuiAxis axis, const TimuiConstrai
size[i] = cons[i].value > 0 ? cons[i].value : 0;
fixed_sum += size[i];
} else if(cons[i].kind == TIMUI_CON_PCT){
- int p = cons[i].value < 0 ? 0 : cons[i].value;
+ int64_t p = cons[i].value < 0 ? 0 : cons[i].value;
size[i] = timui_round_div_(avail, p, 100);
fixed_sum += size[i];
} else {
@@ -16065,13 +16297,15 @@ TIMUI_API int timui_split_ex(TimuiRect area, TimuiAxis axis, const TimuiConstrai
* honouring MIN/MAX bounds via freeze-and-redistribute. */
remaining = leftover;
for(;;){
- int free_weight = 0, last_free = -1, assigned = 0, changed = 0;
+ int64_t free_weight = 0, assigned = 0;
+ int last_free = -1, changed = 0;
for(i = 0; i < n; i++)
if(timui_con_is_flex_(cons[i].kind) && !locked[i]){
free_weight += timui_con_weight_(&cons[i]);
last_free = i;
}
if(last_free < 0) break; /* no free flexible children left */
+ if(free_weight <= 0) break; /* all remaining flex children asked for zero */
/* Provisional shares — the last free child gets the exact remainder so
* the free children always sum to `remaining` (no rounding gap). */
@@ -16106,17 +16340,17 @@ TIMUI_API int timui_split_ex(TimuiRect area, TimuiAxis axis, const TimuiConstrai
pos = inner_start;
end = inner_start + inner_len;
for(i = 0; i < n; i++){
- int s = size[i], rem;
+ int64_t s = size[i], rem;
if(i > 0) pos += gap;
rem = end - pos; if(rem < 0) rem = 0;
if(s > rem) s = rem;
if(s < 0) s = 0;
if(axis == TIMUI_AXIS_V){
- out[i].x = cross_inner_start; out[i].w = cross_inner_len;
- out[i].y = pos; out[i].h = s;
+ out[i].x = timui_sat_i64_(cross_inner_start); out[i].w = timui_sat_i64_(cross_inner_len);
+ out[i].y = timui_sat_i64_(pos); out[i].h = timui_sat_i64_(s);
} else {
- out[i].x = pos; out[i].w = s;
- out[i].y = cross_inner_start; out[i].h = cross_inner_len;
+ out[i].x = timui_sat_i64_(pos); out[i].w = timui_sat_i64_(s);
+ out[i].y = timui_sat_i64_(cross_inner_start); out[i].h = timui_sat_i64_(cross_inner_len);
}
pos += s;
}
@@ -16138,7 +16372,8 @@ TIMUI_API int timui_grid_ex(TimuiRect area, const TimuiConstraint *rows, int nr,
const TimuiConstraint *cols, int nc, TimuiLayoutOpts opts, TimuiRect *out){
TimuiRect rowrects[TIMUI_LAYOUT_MAX];
int r;
- if(!rows || !cols || !out || nr <= 0 || nc <= 0 || nr > TIMUI_LAYOUT_MAX) return 0;
+ if(!rows || !cols || !out || nr <= 0 || nc <= 0 ||
+ nr > TIMUI_LAYOUT_MAX || nc > TIMUI_LAYOUT_MAX || nr > INT_MAX / nc) return 0;
/* Rows carve `area` into vertical bands; each band is then split into cells
* by the column constraints. Output is row-major: out[r*nc + c]. */
if(timui_split_ex(area, TIMUI_AXIS_V, rows, nr, opts, rowrects) != nr) return 0;
@@ -16521,10 +16756,6 @@ TIMUI_API void timui_spinner(TimuiFrame *f, int x, int y, int tick, TimuiStyle s
}
/* ---- optional functional runner --------------------------------------- *
* UI-thread message queue (emit during view, recv into update) + the runner. */
-/* timui_run delivers each posted message via a fixed internal buffer. Messages
- * larger than TIMUI_RUN_BUF are truncated (W5) — keep posts small, or call
- * timui_recv directly with your own buffer for large payloads. */
-#define TIMUI_RUN_BUF 4096
TIMUI_API bool timui_emit(TimuiFrame *f, uint32_t type, const void *data, size_t size){
return f && f->ui && timui_mpsc_post(&f->ui->postq, type, data, size) != 0;
}
@@ -16537,28 +16768,32 @@ TIMUI_API bool timui_recv(Timui *ui, uint32_t *out_type, void *out_buf, size_t *
TIMUI_API void timui_frame_quit(TimuiFrame *f){
if(f && f->ui) timui_quit(f->ui);
}
+static void timui_app_drain_updates_(Timui *ui, TimuiApp *app){
+ TimuiMpscNode *n;
+ if(!ui || !app) return;
+ while((n = timui_mpsc_pop_node_(&ui->postq)) != NULL){
+ if(app->update) app->update(app->model, n->type, n->data, n->size);
+ timui_mpsc_free_node_(&ui->postq, n);
+ }
+}
+TIMUI_API int timui_app_frame(Timui *ui, TimuiApp *app){
+ TimuiFrame *f = NULL;
+ if(!ui || !app || !app->view || timui_should_quit(ui)) return 0;
+ if(!timui_begin(ui, &f)) return 0;
+ app->view(f, app->model);
+ timui_end(f);
+ timui_app_drain_updates_(ui, app);
+ return 1;
+}
TIMUI_API int timui_run(const TimuiConfig *cfg, TimuiApp *app){
Timui *ui = NULL;
if(!cfg || !app || !app->view || timui_open(cfg, &ui) != TIMUI_OK) return 1;
while(!timui_should_quit(ui)){
- TimuiFrame *f = NULL;
- uint32_t type = 0;
- unsigned char buf[TIMUI_RUN_BUF];
- size_t sz;
- if(!timui_begin(ui, &f)) break;
- app->view(f, app->model);
- sz = sizeof buf;
- while(timui_recv(ui, &type, buf, &sz)){
- if(sz > sizeof buf) sz = sizeof buf; /* clamp to prevent stack over-read */
- if(app->update) app->update(app->model, type, buf, sz);
- sz = sizeof buf;
- }
- timui_end(f);
+ if(!timui_app_frame(ui, app)) break;
}
timui_close(ui);
return 0;
}
-#undef TIMUI_RUN_BUF /* Z10: impl-only macro must not leak into the consumer TU */
/*
* timui_syntax.c — table-driven syntax highlighter + read-only code viewer (W4).
*