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). *