From 52d0615a1ef3a5c7aa03efd615dab7c5811cdc0d Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:34:54 -0400 Subject: [PATCH 01/90] Plan Icod.DCurses 1.4 interaction routing --- ...rses-1.1.0-to-1.4.0-Development-Roadmap.md | 119 +++-- Icod.DCurses-1.4.0-Development-Roadmap.md | 499 ++++++++++++++++++ Icod.DCurses-Development-Roadmap.md | 156 ++++-- ...-dcurses-1.4-interaction-routing-design.md | 370 +++++++++++++ 4 files changed, 1039 insertions(+), 105 deletions(-) create mode 100644 Icod.DCurses-1.4.0-Development-Roadmap.md create mode 100644 docs/superpowers/specs/2026-09-12-icod-dcurses-1.4-interaction-routing-design.md diff --git a/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md b/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md index b10074393..02bed1145 100644 --- a/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md +++ b/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md @@ -2,11 +2,11 @@ **Project:** `Icod.DCurses` **Scope:** post-1.0 additive core development -**Published compatibility floor:** `1.2.0` -**Current main source package:** `1.3.0` +**Published compatibility floor:** `1.3.0` +**Current published package:** `1.3.0` **Assembly version policy:** retain `1.0.0.0` through compatible additive 1.x releases **Current declared runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` -**Planning status:** 1.3 complete, merged, and Release-qualified; 1.4 remains the approved next development release +**Planning status:** 1.3 is complete/published; 1.4 is the active approved development release --- @@ -14,34 +14,22 @@ ```text 1.1.0 semantic cell metadata + hyperlinks complete/published history -1.2.0 panels/layers + z-order composition complete/published -1.3.0 layout + resize primitives complete/merged; publication pending -1.4.0 focus/interaction/key gestures/hit testing/pointer approved future release +1.2.0 panels/layers + z-order composition complete/published history +1.3.0 layout + resize primitives complete/published +1.4.0 interaction routing/focus/gestures/hit testing active development ``` -The sequence is cumulative: 1.1 adds meaning to retained content; 1.2 composes overlapping retained surfaces; 1.3 makes geometry manageable; 1.4 routes semantic input to logical regions. +The sequence is cumulative: 1.1 adds meaning to retained content; 1.2 composes overlapping retained surfaces; 1.3 makes geometry manageable; 1.4 routes semantic input to logical application regions. ## Ownership boundary DCurses consumes Terminal's live-session/input/lifecycle/semantic-output contracts rather than terminal-family protocol details and does not install private protocol writers or a second input owner. -The 1.3 layout work preserves the same ownership principle internally: geometry values are immutable, `CursesLayout` is pure/stateless, and applications explicitly recompute/apply layout rather than delegating control to a background layout owner. +The 1.3 layout work preserved that ownership principle internally: geometry values are immutable, `CursesLayout` is pure/stateless, and applications explicitly recompute/apply layout rather than delegating control to a background layout owner. -## Published 1.2 floor +The 1.4 interaction work extends the same principle. DCurses may classify and route already-normalized input, but `Icod.Terminal` remains authoritative for terminal input decoding, rich-input protocol ownership, pointer-shape transport/lifetime, session lifecycle, and output serialization. -```text -47 exported types -356 canonical declared contract lines -sha256 4810ebb088764acedbb94aca84b231677886b9c1a1f920d9a30f960cbe1dfce7 -``` - -`CursesPanel` and `CursesPanelTransparency` are the two 1.2 exported additions. The published contract includes retained panel content, deterministic z-order, show/hide, movement/ordering, opaque or blank-transparent composition, clipping, incremental recomposition, semantic-metadata/wide-cell coherence, live refresh/lifecycle integration, and deterministic one-way disposal. - -Panel dimensions are fixed in the published 1.2 package; resize/layout belongs to 1.3. - -## Release 1.3 — layout and resize primitives - -Frozen stable-source contract: +## Published 1.3 floor ```text 51 exported types @@ -49,47 +37,70 @@ Frozen stable-source contract: sha256 a655bd85e3c88f5bf38ad0d43a148e3bd06a9e943bbf3e3aa2e21575ffb07424 ``` -Four exported types are added over 1.2: +Tagged baseline: ```text -CursesRectangle -CursesInsets -CursesDockEdge -CursesLayout +v1.3.0 -> c10ca043a666b85225f2d3b8955a1ac2075b0d31 ``` -Additive members on existing types provide screen/window/panel bounds, atomic rectangle application, and retained panel resizing. +Version 1.3 contributes the reusable `CursesRectangle` coordinate substrate, screen/window/panel bounds, explicit rectangle application, retained panel resizing, and lifecycle-driven application relayout model which 1.4 will consume rather than replace. + +## Release 1.4 — deterministic interaction routing + +Version 1.4 turns the existing semantic input and geometry foundations into an application-facing interaction substrate without introducing widgets or a hidden event loop. + +The release is planned to provide: -The accepted 1.3 design provides: +- bounded screen-bound interaction-region registration; +- screen-relative and panel-associated hit targets; +- deterministic overlap resolution using panel z-order plus explicit region precedence; +- region-local coordinate translation; +- logical focus independent of terminal/window-manager focus; +- explicit focus/clear plus forward/backward traversal; +- deterministic focus repair when regions become ineligible; +- immutable semantic keyboard gestures over the existing `CursesKey`, modifiers, characters, and event phases; +- region-local and router-global command bindings; +- structured routing results rather than callback invocation; +- pointer-shape preferences and a DCurses-shaped wrapper over Terminal-owned pointer leases; +- resize/panel visibility/reorder/disposal coherence; +- an application sample proving keyboard, mouse, panel, pointer, and live-resize composition; +- allocation/performance/adversarial qualification before API freeze. -- immutable terminal-cell rectangles and insets; -- deterministic containment/intersection/inset semantics; -- fixed top/bottom/left/right allocation; -- proportional row/column allocation; -- Top/Right/Bottom/Left docking; -- clipping and explicit empty geometry; -- `CursesScreen.Bounds`; -- parent-relative nested `CursesWindow.Bounds` plus atomic `SetBounds`; -- screen-relative `CursesPanel.Bounds`, retained `Resize`, and atomic `SetBounds`; -- surviving upper-left content/metadata preservation on panel resize; -- width-two footprint repair at shrink boundaries; -- explicit lifecycle-driven recomputation from `session.Screen.Bounds`; -- a dedicated interactive layout sample; -- Unicode/metadata/repeated-resize hardening; -- application/performance/allocation acceptance with no retained layout state; -- fresh NuGet-only package consumption of the full 1.3 surface. +The detailed authority is: -T1310 qualified exact head `c8d6a6313b9f6ca124a255c12b912f8ed89ffda7` in workflow #681 / `34694609178`, all seven jobs green. The API regret audit found no naming, mutability, ambiguity, ownership, or 1.4-reuse correction requiring an API break. +- `Icod.DCurses-1.4.0-Development-Roadmap.md` +- `docs/superpowers/specs/2026-09-12-icod-dcurses-1.4-interaction-routing-design.md` -The unchanged implementation/API was promoted to `1.3.0-rc.1`; exact RC head `2ab949a64f63759ad9cf4e93e45a368c50ab6e49` passed workflow #689 / `34694881296` across all seven jobs. The declared dependencies were then advanced to `Icod.Terminal 1.11.1` and `Icod.TermInfo 1.11.0` without changing the frozen public API. Final stable-source head `b90b444556291434bddb9f56d031f09ac1aabfbf` passed workflow #719 / `34708925529` across all seven jobs. PR #27 was merged as `18255d59136922b9e246f4113dc6fdb6a9ea24a3`, and main Release workflow #17 / `34709142076` passed. +## 1.4 key policy decisions -## Planned 1.4 focus and interaction mechanics +The approved direction deliberately keeps rendering and interaction separate. -Version 1.4 is planned to add focusable regions, focus traversal, keyboard gestures/commands, mouse hit testing, interaction regions, pointer-shape requests, focus repair, resize-aware hit testing, and deterministic overlap precedence. Terminal remains the authoritative input/protocol owner. +Interaction regions are not windows, panels, widgets, event handlers, or callbacks. They identify logical application targets and routing metadata. Ordinary regions are screen-relative. Panel-associated regions derive effective screen position and eligibility from their owning panel while preserving panel z-order as the first overlap discriminator. -The key 1.3-to-1.4 bridge is `CursesRectangle`: 1.4 can reuse the same immutable terminal-cell coordinate substrate for focus and hit-test regions without inventing a second geometry model or changing 1.3 layout ownership. +Logical focus is application focus and is therefore distinct from `CursesFocusEvent`, which represents actual terminal focus reports. Terminal focus loss does not erase which application region is logically focused. -Version 1.4 is not being pulled backward into 1.3. No focus router, gesture registry, hit-test tree, pointer policy, widget framework, raster placement, animation system, or general constraint solver belongs in the 1.3 release. +Mouse routing is hit testing, not automatic application policy. A click may identify a focusable target, but the router does not automatically focus it. Visual panel transparency does not automatically create mouse click-through semantics. + +Keyboard command routing is semantic rather than escape-sequence based. Focused-region bindings take precedence over router-global bindings, and duplicate gestures within one binding scope are rejected instead of silently depending on registration order. + +Pointer-shape preferences are routing data. Synchronous hit testing never performs asynchronous terminal output. The application/session layer explicitly applies the desired shape through DCurses-owned semantics which delegate transport and lifetime to `Icod.Terminal`. + +## 1.4 tranche sequence + +```text +T1401 contract/design/public-surface candidate freeze +T1402 interaction-region registry +T1403 hit testing / clipping / panel precedence +T1404 logical focus / traversal / repair +T1405 keyboard gestures +T1406 command bindings / routing result +T1407 pointer shape integration +T1408 resize / panel / lifecycle coherence +T1409 application acceptance sample +T1410 performance / allocation / hardening +T1411 API / package / docs / licensing regret gate +T1412 RC / stable-source closure +``` ## Cross-release rules @@ -97,6 +108,10 @@ Compatible 1.x releases retain additive API by default, exact compiler-derived f Historical release documents remain historical authorities and are not rewritten simply to reflect newer package/dependency state. +## Deliberate 1.4 exclusions + +Version 1.4 does not add a widget/control library, retained widget hierarchy, event capture/bubbling tree, automatic focus-on-click policy, generalized drag/drop framework, flex/grid/constraint layout, automatic layout ownership, accessibility tree, raster scene graph, animation framework, PTY/process hosting, or raw terminal protocol escape hatches. + ## Immediate next step -Repository-side 1.3 development is complete. Public `v1.3.0` tagging/GitHub Release/NuGet publication remain explicit release actions. The next approved development track is 1.4.0. +T1401 freezes the interaction contract before production implementation begins. Once that contract is accepted, T1402 becomes the first source tranche and may advance the development package identity from the published `1.3.0` floor to the 1.4 prerelease line. diff --git a/Icod.DCurses-1.4.0-Development-Roadmap.md b/Icod.DCurses-1.4.0-Development-Roadmap.md new file mode 100644 index 000000000..dd1eff16e --- /dev/null +++ b/Icod.DCurses-1.4.0-Development-Roadmap.md @@ -0,0 +1,499 @@ +# Icod.DCurses 1.4.0 Development Roadmap + +**Project:** `Icod.DCurses` +**Release line:** `1.4.0` +**Development branch:** `1.4.0-interaction-routing` +**Published baseline:** `1.3.0` +**Baseline tag:** `v1.3.0` +**Baseline commit:** `c10ca043a666b85225f2d3b8955a1ac2075b0d31` +**Planning-stage source identity:** `1.3.0` until the first implementation tranche +**Final target package:** `1.4.0` +**Assembly version policy:** retain `1.0.0.0` for compatible additive 1.x development +**Baseline runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Theme:** deterministic interaction routing over semantic input, immutable geometry, retained panels, and Terminal-owned protocol state +**Status:** architecture approved; T1401 contract freeze is the first gate + +--- + +## Release objective + +`Icod.DCurses 1.4.0` adds deterministic, application-owned interaction routing over the existing curses semantic-input and geometry foundations. + +The release should make it possible to build higher-level controls later without requiring those controls to: + +- parse terminal protocols; +- create a second input reader; +- invent a second geometry system; +- reimplement panel overlap precedence; +- reimplement focus traversal; +- reimplement key-chord matching; +- expose `Icod.Terminal` protocol types through their own public surface. + +The intended application flow is: + +```text +CursesSession.ReadEventAsync(...) + | + v +CursesInputEvent + | + v +application-owned interaction router + | | + | +--> semantic gesture -> command identity + | + +--> mouse coordinate -> target region + -> local coordinate + -> pointer-shape preference + +logical focus + -> current region + -> forward/backward traversal + -> deterministic repair when eligibility changes +``` + +The router never becomes a hidden application event loop. Applications continue to own their event loop and explicitly decide what routed results mean. + +## Published 1.3 compatibility floor + +The 1.4 branch starts from the published 1.3 contract: + +```text +51 exported types +406 canonical declared contract lines +sha256 a655bd85e3c88f5bf38ad0d43a148e3bd06a9e943bbf3e3aa2e21575ffb07424 +``` + +The 1.3 geometry contract is foundational to 1.4: + +```text +CursesRectangle +CursesInsets +CursesLayout +CursesScreen.Bounds +CursesWindow.Bounds / SetBounds +CursesPanel.Bounds / Resize / SetBounds +``` + +Version 1.4 is additive by default. Any breaking correction to the published 1.3 contract requires a concrete regret finding and explicit approval before implementation. + +## Architectural invariants + +The following rules apply to every 1.4 tranche. + +### Terminal ownership + +`Icod.Terminal` remains authoritative for: + +- the single terminal input reader; +- byte decoding and query-response routing; +- rich-input protocol acquisition/restoration; +- terminal/window-manager focus reports; +- terminal pointer-shape transport and lease lifetime; +- session lifecycle and state invalidation; +- output serialization. + +DCurses routes only already-normalized curses events and never adds raw CSI/OSC/DCS/APC parsing or a competing reader. + +### Geometry ownership + +`CursesRectangle` is the only terminal-cell rectangle vocabulary. Interaction routing does not create another rectangle/point hierarchy merely for hit testing. + +Ordinary interaction regions use screen-relative geometry. Panel-associated regions use panel-relative geometry which is translated and clipped against the panel's current screen-relative bounds. + +### Interaction is not rendering + +Interaction regions are not windows, panels, widgets, controls, event handlers, or layout owners. They are bounded logical targets plus routing metadata. + +The interaction subsystem does not draw, mutate cells, own layout, or automatically resize application geometry. + +### Structured results, not callbacks + +Routing returns deterministic data describing target, command, local coordinates, and pointer preference. The core router does not invoke arbitrary application delegates while resolving input. + +This avoids hidden reentrancy, makes routing independently testable, and keeps command execution/application policy outside the library mechanism. + +### Logical focus is distinct from terminal focus + +`CursesFocusEvent` continues to mean an actual terminal focus-in/focus-out report. The new 1.4 logical focus model identifies the application interaction region which should receive focused input. + +A terminal focus-out report does not erase the application's logical focused region. + +### Determinism and bounds + +Registration, traversal, hit-test precedence, gesture resolution, command binding, and focus repair must have deterministic tie-breaking. Registries and binding collections must have documented finite bounds frozen by T1401 before implementation. + +## Working public API concepts + +The following names describe the approved architecture but are **working names until T1401 freezes the public contract**: + +```text +CursesInteractionRouter +CursesInteractionRegion +CursesInteractionRegionOptions +CursesInteractionResult +CursesInteractionTarget +CursesFocusDirection +CursesKeyGesture +CursesCommand / CursesCommandId +CursesPointerShape +CursesPointerShapeLease +``` + +T1401 may rename or consolidate these concepts, but it may not weaken the architectural invariants above without explicit design reapproval. + +## T1401 — interaction contract and public-surface candidate freeze + +### Objective + +Freeze the semantic contract before production implementation starts. + +### Deliverables + +- define the ownership relationship between `CursesScreen`, the application, and one interaction router; +- define region identity and one-way disposal semantics; +- freeze screen-relative versus panel-relative coordinate rules; +- freeze effective clipping against screen/panel bounds; +- freeze panel z-order precedence and same-scope region tie-breaking; +- freeze visual-transparency versus input-transparency semantics; +- define enabled/focusable/visible/eligible state; +- define logical focus, traversal ordering, wrap behavior, and deterministic repair; +- define which `CursesInputEventKind` values are routable and how each is represented in routing results; +- define semantic key-gesture normalization across traditional text delivery and modern Character key reports; +- define gesture conflict rules and local-versus-global command precedence; +- define pointer-shape preference semantics without terminal I/O in hit testing; +- freeze concrete maximum region/binding counts and overflow behavior; +- freeze single-writer/concurrency expectations; +- freeze exception/result behavior for invalid/disposed/foreign objects; +- produce an initial public API candidate and dependency-boundary impact audit; +- record explicit exclusions for widgets, event bubbling/capture, auto-focus policy, and pointer capture. + +### Acceptance gate + +No production API implementation begins until the design/spec and 1.4 roadmap agree on all items above and contain no unresolved semantic placeholders. + +## T1402 — bounded interaction-region registry + +### Objective + +Implement the deterministic region/lifetime substrate without input routing or Terminal I/O. + +### Deliverables + +- create the frozen router/region public contracts from T1401; +- register ordinary screen-relative regions; +- register panel-associated regions with panel-relative bounds; +- expose immutable region identity/configuration observations required by callers; +- support explicit enable/disable, focusability changes, geometry changes, and one-way disposal according to the frozen contract; +- assign deterministic registration ordinals internally; +- enforce T1401 registry bounds before mutation; +- reject foreign-screen panel association atomically; +- keep the router application-owned and free of background work. + +### Tests + +- argument validation before mutation; +- bound exhaustion and recovery after disposal; +- registration ordinal stability; +- disposed-region behavior; +- foreign panel rejection; +- ordinary and panel-relative geometry snapshots; +- no terminal I/O and no event-loop ownership. + +### Version gate + +T1402 is the first source tranche. At its start, promote the development package to `1.4.0-alpha.1` while retaining `AssemblyVersion 1.0.0.0`. + +## T1403 — deterministic hit testing and panel precedence + +### Objective + +Map terminal-cell coordinates to exactly one interaction target under explicit overlap rules. + +### Deliverables + +- hit test zero-based screen row/column coordinates; +- translate successful hits to region-local row/column coordinates; +- clip ordinary regions to current `CursesScreen.Bounds`; +- translate and clip panel-associated regions to current panel bounds; +- make hidden or disposed panel associations ineligible; +- use current panel z-order as the first discriminator among overlapping panel-associated targets; +- use T1401-frozen region priority/order rules within the same panel/screen scope; +- place ordinary screen regions below panel-associated regions unless T1401 explicitly freezes a reviewed overlay exception; +- keep blank-transparent visual cells input-opaque by default; click-through requires explicit interaction geometry/policy rather than inferred cell transparency. + +### Tests + +- edge/corner containment; +- clipped/empty effective rectangles; +- overlapping ordinary regions; +- overlapping panels and same-panel regions; +- panel move/reorder/hide/dispose behavior without re-registration; +- deterministic repeated results; +- no allocations in the accepted steady-state hot path where practical. + +## T1404 — logical focus, traversal, and repair + +### Objective + +Provide application logical focus independent of terminal focus reporting. + +### Deliverables + +- query current logical focus; +- explicitly focus an eligible region; +- explicitly clear focus; +- move focus forward/backward through eligible regions; +- use explicit traversal order followed by registration ordinal as the deterministic order; +- define and implement wrap behavior exactly as frozen in T1401; +- repair focus when the current region becomes disabled, non-focusable, disposed, fully clipped, or associated with a hidden/disposed panel; +- preserve logical focus across terminal `Focused`/`Unfocused` reports; +- do not automatically focus a region merely because a mouse event hits it. + +### Tests + +- traversal order and ties; +- forward/backward wrap; +- no-eligible-region behavior; +- region state transitions; +- panel hide/dispose/resize repair; +- screen clipping repair; +- terminal-focus independence. + +## T1405 — semantic keyboard gesture model + +### Objective + +Represent and match application gestures without protocol/backend branching. + +### Deliverables + +- implement the T1401-frozen immutable gesture contract; +- match semantic named keys, function keys, character identity, modifiers, and selected key phases; +- default ordinary command gestures to press semantics while allowing explicitly supported repeat/release matching; +- define exact handling of CapsLock/NumLock state according to T1401; +- normalize traditional text input and modern Character-key reports so common character commands do not require terminal-family branching; +- reject impossible/ambiguous gesture constructions at creation time; +- keep gesture equality/hash behavior deterministic and culture-independent. + +### Tests + +- named/navigation/function keys; +- modified character gestures; +- traditional text versus modern Character parity; +- Press/Repeat/Release distinctions; +- modifier validation; +- culture invariance; +- equality/hash stability. + +## T1406 — command bindings and structured interaction routing + +### Objective + +Compose focus, gestures, mouse hit testing, and command identity into one deterministic routing mechanism. + +### Deliverables + +- bind semantic gestures to command identities within an interaction region; +- bind router-global gestures; +- reject duplicate gestures within one binding scope; +- resolve focused-region bindings before router-global bindings; +- route ordinary text/key/paste input to the current logical target according to the frozen T1401 contract; +- route mouse events through T1403 hit testing and carry region-local coordinates; +- represent unmatched-but-targeted input distinctly from matched commands; +- preserve the original normalized input payload in the structured result where the contract requires it; +- do not invoke application callbacks or automatically mutate focus. + +### Tests + +- local-versus-global binding precedence; +- duplicate detection; +- unmatched focused input; +- command match result identity; +- mouse target/local-coordinate result; +- no-focus and no-hit outcomes; +- deterministic replay of equivalent input/state. + +## T1407 — pointer-shape abstraction and Terminal-owned lease integration + +### Objective + +Expose curses-shaped semantic pointer preferences without leaking Terminal protocol types or performing I/O during routing. + +### Deliverables + +- add the frozen DCurses pointer-shape semantic enum/value set; +- map it exhaustively to `Icod.Terminal` pointer-shape semantics internally; +- add a DCurses-owned asynchronous pointer-shape lease wrapper on `CursesSession`; +- preserve Terminal's nesting, cleanup, lifecycle, and failure ownership rather than recreating it; +- allow an interaction region to advertise an optional preferred pointer shape; +- include the desired pointer shape in hit-test/routing observations; +- require explicit application/session action to apply a changed shape; +- keep synchronous hit testing/routing free of terminal output/query operations; +- retain the existing public Terminal/TermInfo dependency allow-list unless a separately justified review changes it. + +### Tests + +- full mapping coverage; +- nested lease behavior through the DCurses wrapper; +- cancellation/failure propagation; +- pointer preference from hit results; +- no I/O during hit testing; +- public dependency-boundary audit. + +## T1408 — resize, panel, disposal, and lifecycle coherence + +### Objective + +Prove that interaction state remains deterministic while geometry and retained surfaces change. + +### Deliverables + +- preserve application-owned ordinary region geometry rather than silently relayout it on screen resize; +- recompute effective clipping against current screen bounds on interaction operations; +- follow current panel move/resize/reorder/visibility/disposal state for panel-associated regions; +- repair logical focus at deterministic interaction/focus boundaries when eligibility has changed; +- define behavior across Terminal suspend/resume without creating a second lifecycle owner; +- preserve region/command registrations across ordinary session lifecycle invalidation unless the region itself becomes ineligible; +- keep pointer-shape restoration authoritative in Terminal-owned leases. + +### Tests + +- repeated screen grow/shrink with explicit application relayout; +- panel movement/resizing/order churn; +- hidden/disposed panel targets; +- focused region becoming clipped/ineligible; +- suspend/resume while regions and pointer leases exist; +- disposal races permitted by the single-writer contract; +- repeated lifecycle cycles with deterministic route results. + +## T1409 — application acceptance sample + +### Objective + +Prove the 1.4 mechanisms compose into a realistic TUI without a widget framework. + +### Sample scenario + +Create an interactive sample containing: + +- header/status regions; +- two ordinary layout-driven panes; +- a retained popup/dialog panel above the panes; +- Tab and Shift+Tab focus traversal; +- local shortcuts and at least one global command; +- mouse targeting with region-local coordinates; +- a panel-overlap case proving topmost precedence; +- pointer-shape preference changes over different targets; +- live terminal resize followed by explicit application relayout; +- terminal focus events demonstrating they do not erase logical focus. + +The sample must consume only public 1.4 APIs and must not use internal router state or Terminal protocol details. + +## T1410 — performance, allocation, bounds, and adversarial hardening + +### Objective + +Qualify the interaction core as a hot-path mechanism rather than a demo-only abstraction. + +### Deliverables + +- benchmark/accept repeated hit testing, focus traversal, and gesture routing with representative region counts; +- freeze steady-state allocation expectations for the common routing path; +- stress registration/disposal churn up to documented bounds; +- stress overlapping panel/region topologies; +- stress repeated geometry/focus eligibility changes; +- verify invalid input and disposed objects fail before partial mutation; +- verify deterministic results across repeated equivalent runs; +- audit for unbounded collections, accidental callback/reentrancy paths, and hidden Terminal I/O. + +### Acceptance gate + +Performance thresholds and allocation gates must be documented with a repeatable measurement protocol, following the same evidence-first approach used by 1.3 T1309. + +## T1411 — public API, package, documentation, licensing, and regret gate + +### Objective + +Decide whether the complete 1.4 surface is suitable for stable 1.x publication. + +### Deliverables + +- generate the compiler-derived 1.4 public API fingerprint on all target frameworks; +- compare every additive public type/member against the published 1.3 baseline; +- conduct naming/mutability/ownership/ambiguity/nullability/exception regret review; +- verify the Terminal/TermInfo public dependency boundary; +- add fresh NuGet-only consumer coverage for regions, focus, gestures, routing, hit testing, and pointer-shape wrapper APIs; +- update XML documentation and README examples; +- audit samples and source headers/licensing; +- update package release notes; +- ensure documentation clearly distinguishes application logical focus from terminal focus and routing data from callback execution. + +No RC promotion occurs while an identified API regret remains unresolved. + +## T1412 — release candidate and stable-source closure + +### Objective + +Promote the unchanged qualified implementation through RC and stable identity. + +### Sequence + +1. promote the T1411-qualified source/API to `1.4.0-rc.1`; +2. run the complete Staging/package/runtime matrix on the exact RC head; +3. correct only demonstrated release-blocking defects, with full requalification after any correction; +4. if the RC is green and API/behavior remain accepted, promote the same implementation/API to stable-source `1.4.0`; +5. run the final exact-head package candidate plus Windows/Linux/macOS x64/ARM64 matrix; +6. merge only after explicit approval; +7. qualify the resulting `main` Release build before tagging/publishing. + +Tagging, GitHub Release creation, and NuGet publication remain separate explicit release actions. + +## Cross-cutting qualification policy + +Every tranche follows repository conventions: + +- C# 13 and existing 1TBS formatting conventions; +- braces on every `if`/`else` block; +- multiline call closing parentheses on their own line; +- public/protected/internal parameter validation at method entry; +- tests do not write stdout/stderr except process communication; +- warnings-as-errors under PR Staging qualification; +- no private/raw Terminal protocol implementation in DCurses; +- no new public Terminal/TermInfo types without explicit dependency-boundary review; +- package-only consumer validation before stable promotion; +- Windows, Linux, and macOS runtime qualification including x64/ARM64 where the repository matrix applies; +- public API fingerprint equality across `net8.0`, `net9.0`, and `net10.0`. + +## Deliberate non-goals + +Version 1.4 does not provide: + +- widgets or controls; +- widget parent/child ownership; +- capture/bubble event propagation; +- automatic focus-on-click; +- spatial directional focus navigation; +- generalized mouse/pointer capture unless separately approved after a concrete deficiency is demonstrated; +- drag/drop framework semantics; +- flexbox/grid/constraint layout; +- retained automatic layout; +- accessibility-tree ownership; +- dependency injection or command-handler registration; +- application navigation/routing frameworks; +- raster placement/scene graphs; +- animation; +- PTY/process hosting. + +## Definition of done + +`Icod.DCurses 1.4.0` is repository-side complete only when: + +- T1401-T1411 are complete with no unresolved contract/API regrets; +- the exact RC passes the full package/runtime matrix; +- the unchanged accepted implementation/API is promoted to stable source; +- the stable-source exact head passes the full package/runtime matrix; +- all supported target frameworks share one frozen public API fingerprint; +- package-only consumption proves the interaction surface from the packed artifact; +- the published 1.3 compatibility floor remains supported; +- no widget framework, hidden input loop, raw Terminal protocol path, or unbounded interaction registry has entered the package. diff --git a/Icod.DCurses-Development-Roadmap.md b/Icod.DCurses-Development-Roadmap.md index e2768c53f..5c41e7789 100644 --- a/Icod.DCurses-Development-Roadmap.md +++ b/Icod.DCurses-Development-Roadmap.md @@ -2,28 +2,26 @@ **Project:** `Icod.DCurses` **Repository:** `https://github.com/uniblab/Icod.DCurses` -**Accepted stable compatibility floor:** published `1.2.0` -**Current main package identity:** `1.3.0` +**Published compatibility floor:** `1.3.0` +**Current published package:** `1.3.0` **Assembly version:** `1.0.0.0` **Current declared runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` **Target frameworks:** `net8.0`; `net9.0`; `net10.0` **Configurations:** `Debug`; `Staging`; `Release` -**Next approved development target:** `1.4.0` — focus, interaction, gestures, hit testing, and pointer semantics -**Status:** 1.3.0 development complete; merged to `main` and Release-qualified; public tag/release/NuGet publication pending +**Active development target:** `1.4.0` — deterministic interaction routing, focus, gestures, hit testing, and pointer semantics +**Status:** `1.3.0` is published; `1.4.0` planning is approved and T1401 contract freeze is the next development gate --- ## Current authorities - `Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md` -- `Icod.DCurses-1.3.0-Development-Roadmap.md` +- `Icod.DCurses-1.4.0-Development-Roadmap.md` +- `docs/superpowers/specs/2026-09-12-icod-dcurses-1.4-interaction-routing-design.md` - `docs/Public-API-Fingerprint-1.3.json` - `docs/Public-API-Baseline-1.3.md` -- `docs/T1309-Layout-Application-Performance-and-Allocation-Acceptance.md` -- `docs/T1310-Public-API-Package-Documentation-and-Regret-Gate.md` -- `docs/T1311-RC-and-Stable-Closure.md` -Historical 1.0-1.2 tranche records remain historical compatibility authorities and are not rewritten to simulate current development state. +The 1.0-1.3 tranche and closure documents remain historical compatibility/release authorities and are not rewritten to simulate current development state. ## Release train @@ -31,21 +29,20 @@ Historical 1.0-1.2 tranche records remain historical compatibility authorities a |---|---|---| | `1.0.0` | Stable core contract | Historical stable baseline | | `1.1.0` | Semantic metadata and hyperlinks | Historical stable baseline | -| `1.2.0` | Panels/layers/z-order composition | Current published stable release | -| `1.3.0` | Layout and resize primitives | Source complete; merged and Release-qualified; publication pending | -| `1.4.0` | Focus/interaction/gestures/hit testing/pointer semantics | Approved future release | +| `1.2.0` | Panels/layers/z-order composition | Historical stable baseline | +| `1.3.0` | Layout and resize primitives | Current published stable release | +| `1.4.0` | Interaction routing/focus/gestures/hit testing/pointer semantics | Active approved development release | -## API policy - -Published 1.2 contract: +The progression is intentionally cumulative: ```text -47 exported types -356 canonical declared contract lines -sha256 4810ebb088764acedbb94aca84b231677886b9c1a1f920d9a30f960cbe1dfce7 +1.1 cells carry semantic meaning +1.2 retained surfaces overlap deterministically +1.3 surfaces have explicit immutable geometry and resize policy +1.4 normalized input can target logical application regions deterministically ``` -Frozen 1.3 contract: +## Published 1.3 API floor ```text 51 exported types @@ -53,53 +50,106 @@ Frozen 1.3 contract: sha256 a655bd85e3c88f5bf38ad0d43a148e3bd06a9e943bbf3e3aa2e21575ffb07424 ``` -Four exported types are added over 1.2: `CursesRectangle`, `CursesInsets`, `CursesDockEdge`, and `CursesLayout`. Existing screen/window/panel types receive additive bounds/resize application members. `AssemblyVersion` remains `1.0.0.0`. +The tagged compatibility baseline is `v1.3.0`, which resolves to commit: -The final dependency refresh to `Icod.Terminal 1.11.1` and `Icod.TermInfo 1.11.0` changed the package dependency graph only. It did not change the frozen DCurses public API fingerprint above. +```text +c10ca043a666b85225f2d3b8955a1ac2075b0d31 +``` -## 1.3 accepted architecture +Version 1.4 remains additive by default. Any proposed break to the published 1.3 surface requires an explicit regret-gate finding, migration justification, and user approval before implementation. -Geometry is immutable and terminal-independent. `CursesLayout` performs pure fixed/proportional/docking/clipping calculations and retains no application state. +## 1.4 release objective -`CursesWindow` remains a shared logical view. `CursesPanel` remains an independent retained screen-owned surface. Version 1.3 adds retained panel resizing plus explicit rectangle application without changing those ownership models. +`Icod.DCurses 1.4.0` will add deterministic, application-owned interaction routing over the existing semantic input, geometry, panel, and Terminal ownership foundations. -Live terminal resize remains explicit application policy: +The intended flow is: ```text -session.Screen.Bounds - -> application layout calculation - -> window/panel SetBounds or panel Resize - -> retained RefreshAsync +Terminal-owned input decoding + | + v +CursesInputEvent + | + v +DCurses interaction router + | | + | +--> semantic key gesture -> command identity + | + +--> mouse coordinate -> deterministic hit-test target + -> region-local coordinates + -> pointer-shape preference + +logical focus + -> focused region + -> forward/backward traversal + -> deterministic repair when eligibility changes ``` -No retained layout tree or automatic layout owner exists. +The router is mechanism, not an application event loop. It does not read terminal bytes, create a second input owner, invoke arbitrary application callbacks, render widgets, retain layout rules, or perform hidden asynchronous terminal I/O. -## Qualified 1.3 checkpoints +## 1.4 architectural boundaries -| Tranche | Exact head | Workflow | Result | -|---|---|---|---| -| T1301 | `fb77341ac844ae9345917a36f4e4d33febe46125` | #633 / `34598939553` | seven jobs green | -| T1302 | `acb3f5beee8003cc952f6f5062eefd8ed7f70eb1` | #637 / `34603146171` | seven jobs green | -| T1303 | `a10a58bb37e17a86401a1ccabb863458b516cb21` | #643 / `34613814570` | seven jobs green | -| T1304 | `2c80b1dceca475b81690baa6c2c00f30167b24a6` | #647 / `34614953520` | seven jobs green | -| T1305 | `89409d7e323657bd8f86cfd80bedd882486fba4a` | #652 / `34621558815` | seven jobs green | -| T1306 | `aa29fd378ba96e879cfb28fee05fefa37cdf3774` | #659 / `34622698523` | seven jobs green | -| T1307 | `1c8ad631fd68fba4e56a9afd4cbc2bca832769d4` | #665 / `34623947573` | seven jobs green | -| T1308 | `976f677a24a07cacfdde21c2bb8aed61b6b7be89` | #666 / `34624445542` | seven jobs green | -| T1309 | `c209780cf4a0afbf71991e34a1d8912373426922` | #671 / `34625614322` | seven jobs green | -| T1310 | `c8d6a6313b9f6ca124a255c12b912f8ed89ffda7` | #681 / `34694609178` | seven jobs green | -| T1311 RC | `2ab949a64f63759ad9cf4e93e45a368c50ab6e49` | #689 / `34694881296` | seven jobs green | -| T1311 stable source | `b90b444556291434bddb9f56d031f09ac1aabfbf` | #719 / `34708925529` | seven jobs green | -| `main` Release after merge | `18255d59136922b9e246f4113dc6fdb6a9ea24a3` | #17 / `34709142076` | success | +The 1.4 track is governed by these rules: -## Current sequence +- `Icod.Terminal` remains the single live terminal/input/protocol authority. +- DCurses routes already-normalized `CursesInputEvent` values; it does not parse escape sequences or terminal-family protocols. +- `CursesRectangle` remains the coordinate substrate; no second geometry model is introduced. +- Interaction regions are application interaction objects, not widgets and not rendering surfaces. +- Logical application focus is distinct from terminal/window-manager focus reports represented by `CursesFocusEvent`. +- Screen-relative and panel-associated interaction must use deterministic coordinate conversion and overlap precedence. +- Panel z-order remains the authoritative precedence source for panel-associated hit targets. +- Visual blank-cell transparency does not automatically imply input transparency. +- Focus traversal in 1.4 is forward/backward deterministic traversal only; spatial focus navigation is deferred. +- Gesture matching is semantic and protocol-independent. +- Region-local and router-global command bindings produce command identities/results, not callback execution. +- Hit testing and routing remain synchronous and perform no terminal I/O. +- Pointer-shape protocol ownership stays inside `Icod.Terminal`; DCurses exposes its own curses-shaped semantic abstraction and lease. +- Public Terminal/TermInfo dependency exposure remains tightly allow-listed. +- Interaction registries, command bindings, and internal bookkeeping must be bounded and deterministic. +- Existing single-writer expectations remain unless a tranche explicitly proves a safe additive concurrency contract. + +## Planned 1.4 sequence ```text -T1301-T1310 implementation + acceptance + regret gate complete -T1311 RC 1.3.0-rc.1 exact-head qualification complete -T1311 stable Terminal 1.11.1 + TermInfo 1.11.0 refresh complete -merge/main merge commit + Release qualification complete -publication v1.3.0 tag / GitHub Release / NuGet pending +T1401 interaction architecture / terminology / contract freeze +T1402 bounded interaction-region registry +T1403 deterministic hit testing and panel precedence +T1404 logical focus and focus repair +T1405 semantic keyboard gesture model +T1406 command bindings and structured interaction routing +T1407 pointer-shape abstraction and Terminal-owned lease integration +T1408 resize / panel / lifecycle coherence +T1409 application acceptance sample +T1410 hardening / performance / allocation / adversarial acceptance +T1411 public API / package / docs / licensing regret gate +T1412 RC and stable-source closure ``` -Repository-side 1.3 development is complete. The next code-development track is 1.4.0; publication of 1.3.0 remains a separate release action. +Every implementation tranche must receive exact-head Staging qualification before being called complete. The final release must retain the existing package-only consumer gate, compiler-derived public API fingerprinting, Windows/Linux/macOS x64/ARM64 coverage, and `net8.0`/`net9.0`/`net10.0` validation. + +## Deliberate 1.4 non-goals + +Version 1.4 will not add: + +- a widget framework; +- buttons, text boxes, menus, controls, or application navigation; +- a retained widget/event tree; +- event capture/bubbling phases; +- automatic focus-on-click policy; +- generalized pointer/mouse capture unless a later explicit requirement proves it necessary; +- drag/drop framework semantics; +- flexbox/grid/general constraint layout; +- automatic layout ownership; +- accessibility-tree ownership; +- command callback/dependency-injection machinery; +- raster placement/scene-graph ownership; +- animation; +- PTY/process hosting. + +A future widget package should be able to build on the 1.4 mechanisms without bypassing Terminal ownership or reimplementing focus/hit-test/gesture routing. + +## Immediate next step + +T1401 is the active gate. It freezes terminology, ownership, coordinate spaces, eligibility/focus rules, deterministic overlap semantics, command/gesture normalization, pointer-shape boundaries, concrete registry bounds, failure behavior, and the candidate public API before T1402 begins implementation. + +No 1.4 production API should be treated as frozen until T1401 exits green and the written design authority is approved. diff --git a/docs/superpowers/specs/2026-09-12-icod-dcurses-1.4-interaction-routing-design.md b/docs/superpowers/specs/2026-09-12-icod-dcurses-1.4-interaction-routing-design.md new file mode 100644 index 000000000..971085fdb --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-icod-dcurses-1.4-interaction-routing-design.md @@ -0,0 +1,370 @@ +# Icod.DCurses 1.4 Interaction Routing Design + +**Date:** 2026-09-12 +**Status:** approved architectural direction; T1401 freezes exact public naming and numeric bounds before implementation +**Release:** `Icod.DCurses 1.4.0` +**Baseline:** published `Icod.DCurses 1.3.0` at `c10ca043a666b85225f2d3b8955a1ac2075b0d31` + +## Purpose + +Version 1.4 adds an interaction-routing substrate above the semantic input already provided by DCurses and the live-terminal ownership already provided by `Icod.Terminal`. + +The feature exists so applications and future widget libraries can answer four questions deterministically: + +1. Which logical region is currently focused? +2. Which logical region is under a mouse coordinate? +3. Does a semantic key gesture map to an application command in the current context? +4. What pointer shape does the target prefer? + +The feature does not execute application commands, draw controls, own application layout, read terminal bytes, or create a second event loop. + +## Architectural ownership + +### Application + +The application owns: + +- its event loop; +- creation/disposal of the interaction router and regions; +- ordinary region geometry and relayout policy; +- whether a mouse click changes logical focus; +- execution of command identities returned by routing; +- whether/when a returned pointer preference is applied; +- all higher-level widget/application behavior. + +### Icod.DCurses + +DCurses owns: + +- bounded region registration; +- deterministic region identity/order; +- hit testing and local-coordinate translation; +- panel-aware overlap precedence; +- logical focus/traversal/repair; +- semantic gesture normalization and binding lookup; +- structured routing results; +- curses-shaped pointer semantics and the wrapper over Terminal pointer leases. + +### Icod.Terminal + +Terminal remains authoritative for: + +- the one terminal input reader; +- decoding bytes into semantic input; +- query/response correlation; +- mouse/focus/keyboard/paste reporting protocol ownership; +- pointer-shape wire protocol, lifetime, nesting, cleanup, and lifecycle behavior; +- terminal lifecycle and output serialization. + +No 1.4 DCurses type parses raw terminal escape/protocol frames. + +## Router model + +The design uses one application-owned router bound to one `CursesScreen`. + +Binding a router to a screen gives hit testing a stable coordinate authority and lets panel-associated regions consult the screen's existing panel ownership/z-order without creating another scene graph. + +The router does not consume `CursesSession.ReadEventAsync()` itself. The application passes already-decoded `CursesInputEvent` values to routing operations explicitly. + +A router is single-writer under the same broad application ownership expectations as logical screens/windows/panels. T1401 freezes the exact documented concurrency contract before source implementation. + +## Interaction regions + +A region represents a logical interaction target. It is not a rendering object. + +Each active region has: + +- a stable router-local identity; +- deterministic registration ordinal; +- geometry; +- enabled state; +- focusable state; +- traversal-order metadata; +- hit-test precedence metadata where required by the frozen contract; +- optional pointer-shape preference; +- local command bindings. + +Disposal is one-way. A disposed region cannot be reattached or routed. + +### Ordinary regions + +An ordinary region stores screen-relative `CursesRectangle` geometry supplied by the application. + +Screen resize does not silently rewrite that geometry. Effective hit geometry is clipped against current `CursesScreen.Bounds`. Applications remain responsible for explicit relayout, consistent with the 1.3 ownership model. + +### Panel-associated regions + +A panel-associated region stores panel-relative geometry and a reference to one active panel owned by the router's screen. + +Effective hit geometry is: + +```text +panel-relative region + -> translated through current panel origin + -> clipped to current panel bounds + -> clipped to current screen bounds +``` + +Panel movement, resize, z-order changes, show/hide, and disposal therefore affect routing without requiring the application to destroy/re-register the region. + +Association with a panel owned by another screen is rejected before mutation. + +A hidden or disposed panel makes associated regions ineligible. + +## Hit-test precedence + +Hit testing returns at most one target. + +The precedence model is: + +```text +1. eligible panel-associated regions + topmost panel first + then frozen same-panel region precedence + +2. eligible ordinary screen regions + frozen screen-region precedence +``` + +Within equal precedence, deterministic registration order is the final tie-break. + +The exact direction of the registration tie-break and any explicit region-priority property are frozen by T1401 and then tested as public behavior. + +Panel visual transparency is not input transparency. `BlankCellsTransparent` affects composition, not hit-test ownership. Click-through behavior must be expressed by interaction geometry/policy rather than inferred from rendered blank cells. + +A successful hit includes the region identity and region-local row/column coordinates. + +## Eligibility + +A region is eligible for hit testing only when all required state is active under the frozen contract. At minimum this includes: + +- not disposed; +- enabled; +- non-empty effective geometry; +- for panel-associated regions, an active visible owning panel. + +A region is focus-eligible only when it is hit-test/application eligible as defined by T1401 and is additionally marked focusable. + +The contract must keep enabled, focusable, visible/effective, and disposed states distinct rather than folding them into one ambiguous Boolean. + +## Logical focus + +Logical focus belongs to the interaction router and is independent of `CursesFocusEvent`. + +The public model supports: + +- query current focus; +- explicitly focus a region; +- explicitly clear focus; +- move focus forward; +- move focus backward. + +Version 1.4 intentionally omits spatial directional focus navigation. + +Traversal order is deterministic: + +```text +explicit traversal order + -> registration ordinal tie-break +``` + +T1401 freezes wrap behavior. The approved default direction is to wrap once when moving forward/backward, because that matches conventional terminal Tab/Shift+Tab traversal and avoids requiring applications to implement an extra wrap layer. + +### Focus repair + +When the current focused region becomes ineligible, focus repair selects the next eligible region in deterministic forward traversal order beginning after the previous focused position, wrapping at most once. If no region is eligible, focus becomes empty. + +Repair occurs at explicit interaction/focus boundaries rather than through a hidden background worker. + +Terminal focus loss does not clear logical focus. The application may choose to react to terminal focus events separately. + +Mouse hit testing does not automatically mutate logical focus. + +## Keyboard gestures + +Gestures are immutable semantic values. They do not contain terminal escape sequences. + +The gesture model is built over existing DCurses semantics: + +- `CursesKey`; +- character identity where applicable; +- `CursesKeyModifiers`; +- function-key number where applicable; +- `CursesKeyEventPhase`. + +Normal command gestures default to press behavior. Repeat/release matching is opt-in only where the frozen gesture contract permits it. + +CapsLock/NumLock are reported state bits and should not accidentally prevent ordinary command matching. T1401 freezes whether they are ignored by default with an explicit exact-state option, or represented separately in gesture matching. The implementation must choose one documented rule before T1405. + +### Traditional text and modern Character parity + +Terminal environments may report a printable character as ordinary `Text` or as a semantic Character key event under negotiated modern keyboard reporting. + +A common command such as unmodified `q` must not require application code to branch on the terminal protocol family. T1401/T1405 freeze one normalization rule which maps equivalent traditional and modern semantic character input to the same gesture match where appropriate. + +This normalization must not turn arbitrary pasted text into command gestures. + +## Commands and bindings + +Commands are identities, not delegates. + +A region may bind gestures to command identities. The router may also contain global bindings. + +Resolution order is: + +```text +focused-region binding + -> router-global binding + -> unmatched input +``` + +Duplicate gestures inside one binding scope are rejected before mutation. The router does not silently choose between duplicates by registration order. + +Routing a matched command returns the command identity and target context. Application code decides what that command does. + +## Routed input + +The router accepts already-normalized `CursesInputEvent` values. + +The intended behavior is: + +- `Key`: evaluate semantic command gestures, then target focused region according to the frozen result model; +- `Text`: target the focused region and participate in character-command normalization only under the narrow rule frozen by T1401; +- `Paste`: target the focused region; paste payload is never reinterpreted as a sequence of command gestures; +- `Mouse`: hit test screen coordinates and return target/local coordinates plus pointer preference; +- terminal `Focus`: preserve as terminal focus information and do not convert it into logical region focus; +- `EndOfInput`: no interaction target. + +The router never consumes lifecycle events directly; lifecycle/resize effects are observed through current screen/panel state when routing or focus operations occur. + +## Structured routing result + +Routing returns data sufficient for the application to act without callbacks. + +The exact public shape is frozen by T1401, but the semantic result needs to distinguish: + +- no target/no command; +- targeted unmatched input; +- targeted command match; +- global command match; +- mouse target and local coordinates; +- desired pointer-shape preference where one exists. + +The original normalized input should remain available where needed so a future widget layer can consume text/key/paste payload without reconstructing it. + +## Pointer shapes + +DCurses exposes its own semantic pointer vocabulary rather than adding `TerminalPointerShape` to the public dependency boundary. + +The DCurses vocabulary maps exhaustively to the Terminal vocabulary accepted for the release. + +The router only reports a desired pointer shape as data. Hit testing and routing are synchronous and emit no terminal output. + +An explicit `CursesSession` pointer operation acquires a DCurses-owned lease which delegates to `TerminalSession.AcquirePointerShapeAsync(...)` internally. Terminal remains responsible for nesting, restoration/reset, failure, cancellation, and lifecycle behavior. + +DCurses must not duplicate OSC 22 framing or state recovery. + +## Resize and lifecycle behavior + +Screen resize follows the 1.3 explicit-layout model: + +```text +terminal resize + -> CursesSession synchronizes Screen dimensions + -> application recomputes ordinary layout + -> application updates ordinary region geometry +``` + +Before application relayout, interaction operations clip ordinary regions to current screen bounds. They do not mutate stored application geometry implicitly. + +Panel-associated regions follow current panel geometry and z-order automatically because those are properties of the referenced retained panel. + +Suspend/resume does not destroy region registrations or logical focus merely because Terminal invalidates live protocol state. Eligibility/focus repair still applies if current geometry/panel state makes a region unavailable. + +Pointer leases continue to follow Terminal lifecycle semantics. + +## Bounds and resource policy + +The interaction system must be bounded. T1401 freezes concrete maximum counts for: + +- live regions per router; +- local command bindings per region; +- global command bindings per router; +- any auxiliary index/cache whose size scales with registrations. + +Bound exhaustion must fail before mutation. Disposal/removal must release local capacity deterministically. + +No unbounded event queue, callback queue, command queue, or hidden background task is added. + +## Error model + +Public/protected/internal methods validate parameters at entry under repository conventions. + +Invalid enum/geometry/options values use argument exceptions. Foreign-screen association and incompatible ownership use argument/invalid-operation failures according to the frozen T1401 API contract. Operations on disposed router/region/lease objects follow the library's established disposed-object conventions. + +Routing ordinary no-target/no-command input is not exceptional; it is a normal structured result. + +## Public dependency boundary + +The published 1.3 allow-list intentionally exposes only a small set of Terminal/TermInfo types. + +Version 1.4 should preserve that allow-list. Pointer shape and interaction types are DCurses-owned public abstractions. Any proposed new public Terminal/TermInfo type requires an explicit dependency-boundary review and user approval. + +## Performance model + +Hit testing, focus traversal, and gesture matching are expected hot-path operations. + +The design favors: + +- deterministic bounded collections; +- immutable value inputs where practical; +- no callback dispatch inside the core router; +- no terminal I/O inside routing; +- no culture-sensitive comparisons; +- steady-state allocation avoidance for repeated common routing paths where the chosen public model permits it. + +T1410 freezes and measures concrete allocation/performance gates using a repeatable multi-sample protocol rather than relying on one noisy allocation sample. + +## Explicit non-goals + +Version 1.4 does not define: + +- widgets/controls; +- widget parent/child hierarchy; +- event capture/bubbling; +- automatic focus-on-click; +- directional/spatial focus; +- generalized pointer capture; +- drag/drop framework; +- automatic layout/flex/grid/constraints; +- accessibility tree; +- command callbacks/DI container; +- application navigation framework; +- raster scene graph; +- animation; +- PTY/process hosting. + +If a future widget package can build on 1.4 without bypassing it, while applications which do not want widgets can use the router directly, the abstraction boundary is successful. + +## Acceptance scenarios + +The final 1.4 design must support these scenarios through public APIs: + +1. two ordinary panes receive explicit layout rectangles; Tab/Shift+Tab traverses their focus regions deterministically; +2. a popup `CursesPanel` overlaps both panes and its interaction region wins mouse hit testing while visible/topmost; +3. hiding or disposing the popup removes it from hit-test eligibility and repairs focus deterministically if it owned focus; +4. a local `Ctrl+S` binding wins over a global `Ctrl+S` binding while its region is focused; +5. an unmodified character command behaves consistently whether the terminal produces traditional text or modern Character-key semantics under the frozen normalization rule; +6. pasted data reaches the focused target without being exploded into shortcut commands; +7. mouse routing reports region-local coordinates; +8. a target advertises a pointer preference without hit testing performing I/O; an explicit DCurses session pointer lease applies the preference through Terminal; +9. terminal focus-out/focus-in events do not erase the current logical focus; +10. terminal resize plus explicit application relayout updates ordinary region geometry without any hidden layout owner; +11. panel movement/reorder/resize changes effective panel-region routing without re-registration; +12. repeated equivalent state/input produces equivalent routing results across supported platforms. + +## T1401 exit rule + +T1401 is complete only when exact public names/signatures, concrete collection bounds, tie-break direction, focus wrap behavior, modifier-lock matching rule, text/Character gesture normalization, error behavior, and public dependency-boundary effects are all written down and accepted with no unresolved placeholders. + +Only then may T1402 begin production implementation. From d70b6a9892812a624c810ae115daaafd636d2192 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:47:53 -0400 Subject: [PATCH 02/90] Freeze T1401 interaction routing contract --- ...ction-Contract-and-Public-API-Candidate.md | 772 ++++++++++++++++++ 1 file changed, 772 insertions(+) create mode 100644 docs/T1401-Interaction-Contract-and-Public-API-Candidate.md diff --git a/docs/T1401-Interaction-Contract-and-Public-API-Candidate.md b/docs/T1401-Interaction-Contract-and-Public-API-Candidate.md new file mode 100644 index 000000000..e4505024c --- /dev/null +++ b/docs/T1401-Interaction-Contract-and-Public-API-Candidate.md @@ -0,0 +1,772 @@ +# T1401 — Interaction Contract and Public API Candidate + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1401 +**Published compatibility floor:** `1.3.0` +**Baseline tag:** `v1.3.0` +**Baseline commit:** `c10ca043a666b85225f2d3b8955a1ac2075b0d31` +**Baseline public API:** 51 exported types / 406 canonical contract lines +**Baseline API SHA-256:** `a655bd85e3c88f5bf38ad0d43a148e3bd06a9e943bbf3e3aa2e21575ffb07424` +**Runtime dependencies at tranche entry:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Source/package identity during T1401:** `1.3.0` +**AssemblyVersion:** `1.0.0.0` +**Status:** contract/public-surface candidate frozen; implementation begins only after exact-head qualification + +--- + +## Purpose + +T1401 freezes the semantic and public-contract decisions that the remaining 1.4 tranches must implement. It deliberately contains no production implementation and does not change the package identity. + +The 1.4 release remains an **interaction-routing foundation**, not a widget framework. Applications continue to own their event loop and application policy. DCurses provides deterministic mechanisms for interaction regions, hit testing, logical focus, semantic key gestures, command identities, structured route results, and pointer-shape requests. + +Where the initial 1.4 design document used working names or deferred a concrete bound/tie-break rule to T1401, this document is the T1401 freeze authority. + +## Ownership model + +### Router ownership + +`CursesInteractionRouter` is application-owned and is associated with exactly one `CursesScreen` for its lifetime. + +The normal application model is one router for one logical interaction domain. DCurses does **not** enforce a singleton router per `CursesScreen`; two independently created routers may reference the same screen, but they do not coordinate focus, registrations, commands, or routing state. + +A router: + +- does not own or dispose its `CursesScreen`; +- does not own or dispose associated `CursesPanel` instances; +- does not read terminal input; +- does not subscribe a hidden application event loop; +- does not perform layout; +- does not perform terminal I/O; +- owns its registered interaction regions and gesture-binding registries; +- uses the current screen and panel state only when resolving effective geometry and eligibility. + +Router disposal is one-way and idempotent. It disposes/removes all of its regions and clears all gesture bindings, but it does not dispose the screen or panels. + +### Region ownership + +A `CursesInteractionRegion` belongs to exactly one router and cannot move to another router. + +Region disposal is one-way and idempotent. Disposal: + +- removes the region from future hit testing, focus traversal, and routing; +- releases that region's gesture bindings from the router's total binding budget; +- repairs focus immediately when the disposed region is currently focused; +- does not dispose an associated panel; +- leaves immutable/observational region properties readable so previously returned routing results remain diagnosable. + +Any region mutation or binding operation after disposal throws `ObjectDisposedException`. + +## Geometry and coordinate spaces + +`CursesRectangle` remains the only terminal-cell rectangle vocabulary. + +### Ordinary regions + +When `CursesInteractionRegionOptions.Panel` is `null`, `Bounds` is screen-relative. + +The declared rectangle does not need to fit inside the current screen and may be empty. Effective geometry is: + +```text +region.Bounds ∩ router.Screen.Bounds +``` + +A completely clipped or empty region is not visible for interaction purposes. + +### Panel-associated regions + +When `Panel` is non-null, `Bounds` is relative to the panel's retained content coordinate space. + +The declared rectangle may extend outside the panel and may be empty. Its screen-space origin is: + +```text +panel.Bounds origin + region.Bounds origin +``` + +The effective hit/focus rectangle is clipped by both the current panel bounds and current screen bounds. + +Registration rejects a panel owned by another screen atomically. Registration of an already-disposed panel throws `ObjectDisposedException`. + +A region associated with a panel follows the panel's current move, resize, z-order, visibility, and disposal state without re-registration. Panel disposal does not dispose the region; it makes that region permanently ineligible until the region itself is disposed. + +### Local coordinates + +A successful hit reports `LocalRow` and `LocalColumn` relative to the **declared region origin**, not relative to the clipped effective rectangle. + +This preserves stable application-local coordinates when only clipping changes. + +## Visibility, enablement, and eligibility + +Version 1.4 does not add a separate mutable region visibility flag. + +A region is **effectively visible** when: + +- it is not disposed; +- its effective clipped rectangle is non-empty; and +- when panel-associated, the panel is visible and not disposed. + +`IsEnabled` is independent application interaction policy. A disabled region: + +- is ignored by hit testing; +- cannot receive logical focus; +- does not receive focused input; +- cannot supply a local command binding result. + +`IsFocusable` affects logical focus only. A non-focusable enabled region may still be hit by the mouse. + +A region is **focus eligible** when it is enabled, focusable, effectively visible, and not disposed. + +### Visual transparency is not input transparency + +`CursesPanelTransparency.BlankCellsTransparent` remains a rendering/composition rule only. Blank-transparent cells do not automatically click through an interaction region. + +In 1.4, click-through is expressed through explicit interaction geometry or by disabling/removing the covering interaction region. No separate per-cell or region `InputTransparent` policy is added. + +## Hit-test precedence + +Hit testing accepts zero-based screen `row` and `column` coordinates. + +Negative coordinates throw `ArgumentOutOfRangeException`. Coordinates outside the current screen return no hit rather than throwing; this permits stale-but-valid terminal mouse coordinates around resize boundaries to be handled conservatively. + +A hit resolves to at most one region. + +Precedence is frozen as follows: + +1. eligible panel-associated regions are considered before ordinary screen regions; +2. among panel-associated candidates on different panels, the current topmost panel wins regardless of region priority; +3. within the same panel, the region with the larger `HitTestPriority` wins; +4. if same-panel priorities tie, the later registration ordinal wins; +5. among ordinary screen-region candidates, the larger `HitTestPriority` wins; +6. if ordinary-region priorities tie, the later registration ordinal wins. + +The panel's current z-order is consulted on every hit test; moving a panel in z-order changes hit precedence without re-registration. + +Registration ordinal is an internal monotonically increasing signed 64-bit value. It starts at zero, is never reused during one router lifetime, and is not exposed as application identity. If the ordinal domain is ever exhausted, a new registration fails with `InvalidOperationException` before mutation. + +Hit testing never changes logical focus and performs no terminal I/O. + +## Logical focus + +Logical focus belongs to the interaction router and remains distinct from terminal/window-manager focus represented by `CursesFocusEvent`. + +Terminal `Focused`/`Unfocused` reports do not clear or replace logical focus. + +### Explicit focus + +`Focus(region)`: + +- validates ownership first; +- throws for a null, foreign, or disposed region; +- returns `false` without changing existing focus when the supplied region is currently ineligible; +- returns `true` and makes the supplied eligible region current otherwise. + +`ClearFocus()` is idempotent. + +### Traversal order + +Eligible regions are ordered by: + +1. ascending `TraversalOrder`; +2. ascending registration ordinal. + +`MoveFocus(Forward)` and `MoveFocus(Backward)` always wrap. + +When there is no current focus: + +- `Forward` selects the first eligible region; +- `Backward` selects the last eligible region. + +When there are no eligible regions, traversal clears focus and returns `null`. + +### Focus repair + +Focus repair is deterministic and uses the **forward** direction regardless of the last user traversal direction. + +When the current region becomes ineligible, repair chooses the first eligible region after the former region's traversal slot and wraps to the first eligible region when necessary. If no eligible region exists, focus becomes `null`. + +Region-owned changes that can invalidate current focus—disabling, clearing focusability, changing bounds to a fully clipped rectangle, or disposing the region—repair immediately. + +External screen/panel changes are intentionally not observed through a background subscription graph. They are repaired lazily at the start of focus-sensitive operations: + +- reading `FocusedRegion`; +- `Focus(...)`; +- `MoveFocus(...)`; +- routing text, key, or paste input. + +Pure mouse `HitTest(...)` does not repair or mutate logical focus. + +## Routable input kinds + +`CursesInteractionRouter.Route(CursesInputEvent)` accepts every existing `CursesInputEventKind`, but only some kinds participate in target/command routing. + +### Text + +`Text` input: + +- is eligible for character-gesture matching; +- resolves a focused-region binding first; +- then resolves a router-global binding; +- if no command matches and focus exists, returns a targeted result for the focused region; +- otherwise returns an unrouted result. + +### Key + +`Key` input follows the same local-command, global-command, then focused-target sequence as `Text` input. + +### Paste + +`Paste` input is routed to the current eligible focused region as a targeted result. Version 1.4 does not add paste gestures/commands. + +### Mouse + +`Mouse` input is routed exclusively through hit testing. A successful hit returns a targeted result carrying region-local coordinates and the region's optional pointer preference. Mouse routing does not automatically change focus and does not run key-gesture bindings. + +### Focus + +Terminal `Focus` input returns an unrouted interaction result and does not change logical focus. + +### EndOfInput + +`EndOfInput` returns an unrouted interaction result. + +The original `CursesInputEvent` is retained in every `CursesInteractionResult`, including unrouted results. + +## Semantic key gestures + +`CursesKeyGesture` is an immutable value contract. Construction occurs through explicit factory methods so impossible key/function/character combinations are rejected at creation time. + +### Factories + +The candidate surface is: + +```csharp +public static CursesKeyGesture ForKey( + CursesKey key, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press +); + +public static CursesKeyGesture ForCharacter( + Rune character, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press +); + +public static CursesKeyGesture ForFunctionKey( + int functionKeyNumber, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press +); +``` + +`ForKey(...)` rejects: + +- `CursesKey.None`; +- `CursesKey.Character`; +- `CursesKey.Function`; +- `CursesKey.Space`; +- `CursesKey.Unrecognized`; +- undefined enum values. + +Space is deliberately expressed through `ForCharacter(new Rune(' '), ...)` so traditional text-space input and modern semantic Space/Character reports can share one application gesture. + +`ForFunctionKey(...)` accepts only the existing DCurses function-key range `0..63`. + +### Gesture properties + +The frozen candidate exposes: + +```text +Key +Character +Modifiers +Phase +FunctionKeyNumber +``` + +Exactly one identity form is active: ordinary named key, character, or numbered function key. + +### Modifier normalization + +Gesture identity permits these modifier flags: + +```text +Shift +Control +Alt +Super +Hyper +Meta +``` + +`CapsLock` and `NumLock` are keyboard state, not 1.4 gesture identity. Gesture factories reject those flags, and matching masks them from event modifier state. + +The existing `CursesKey.CapsLock` and `CursesKey.NumLock` key identities remain bindable as ordinary named keys; only their lock-state modifier flags are excluded from gesture identity. + +### Character normalization + +A `ForCharacter(rune, ..., Press)` gesture may match: + +- ordinary `CursesInputEventKind.Text` carrying that rune; +- modern `CursesKey.Character` Press carrying that rune; +- semantic `CursesKey.Space` Press when the rune is U+0020 SPACE. + +Ordinary Text input is normalized as **Press** because the traditional text event does not carry Repeat/Release information. + +Character Repeat/Release gestures match only key events which explicitly report those phases. DCurses does not fabricate repeat/release phase for traditional text input. + +Matching uses the event's primary `Character` identity. `ShiftedCharacter`, `BaseLayoutCharacter`, and `AssociatedText` remain observable input metadata but are not alternate 1.4 gesture identities. + +If an older/traditional input path cannot report a modifier required by a gesture, DCurses does not infer that modifier. + +### Equality + +Gesture equality/hash behavior is ordinal, culture-independent, and consists only of the frozen semantic identity fields above. + +A default-zero `CursesKeyGesture` value is not a valid bindable gesture; binding APIs validate the value before registry mutation. + +## Command identity + +Commands are identifiers only; DCurses never invokes application command delegates. + +The candidate type is a sealed immutable record: + +```csharp +public sealed record CursesCommand { + public const int MaximumNameLength = 128; + + public CursesCommand( string name ); + + public string Name { get; } + + public override string ToString(); +} +``` + +Command names: + +- must be non-null and non-whitespace; +- must contain at most 128 UTF-16 code units; +- are preserved exactly without trimming or Unicode normalization; +- compare by ordinal case-sensitive record equality. + +Different gestures may map to the same command. + +## Binding precedence and conflicts + +Bindings exist in two scopes: + +- region-local; +- router-global. + +A binding scope may contain at most one command for a gesture. Attempting to bind a duplicate gesture in the same scope throws `InvalidOperationException` without replacing the existing command. + +Changing a binding therefore uses explicit unbind then bind semantics. + +For Text/Key routing: + +1. repair/query current focus; +2. if an eligible focused region has a matching local binding, return that command with the focused region as the result region; +3. otherwise, if a global binding matches, return that command with `Region == null` to identify global scope; +4. otherwise, if focus exists, return a targeted result for the focused region; +5. otherwise return unrouted. + +A global command does not implicitly capture or change logical focus. + +## Registry and binding bounds + +The following limits are frozen for 1.4: + +```text +Maximum live regions per router 4096 +Maximum gesture bindings per live region 256 +Maximum router-global gesture bindings 1024 +Maximum total live gesture bindings per router 16384 +``` + +The public constants are defined on `CursesInteractionRouter`: + +```csharp +public const int MaximumRegions = 4096; +public const int MaximumRegionGestureBindings = 256; +public const int MaximumGlobalGestureBindings = 1024; +public const int MaximumGestureBindings = 16384; +``` + +Every registration/bind operation checks all applicable limits before mutation. + +Exhaustion returns `InvalidOperationException`. Region disposal/unbinding immediately releases the corresponding live-budget count. + +No hidden unbounded side registry, event callback list, or deferred command queue is part of the router. + +## Pointer-shape contract + +DCurses exposes its own semantic `CursesPointerShape` enum and `CursesPointerShapeLease`; no `Icod.Terminal.TerminalPointerShape` or `TerminalPointerShapeLease` type is added to the public dependency boundary. + +`CursesPointerShape` mirrors the reviewed Terminal semantic vocabulary with explicit stable numeric values: + +```text +0 Alias +1 Cell +2 Copy +3 Crosshair +4 Default +5 EastResize +6 EastWestResize +7 Grab +8 Grabbing +9 Help +10 Move +11 NorthResize +12 NorthEastResize +13 NorthEastSouthWestResize +14 NoDrop +15 NotAllowed +16 NorthSouthResize +17 NorthWestResize +18 NorthWestSouthEastResize +19 Pointer +20 Progress +21 SouthResize +22 SouthEastResize +23 SouthWestResize +24 Text +25 VerticalText +26 WestResize +27 Wait +28 ZoomIn +29 ZoomOut +``` + +Mapping to Terminal is exhaustive by semantic name; implementation must not rely on equal numeric values. + +The session-level candidate surface is: + +```csharp +public ValueTask AcquirePointerShapeAsync( + CursesPointerShape shape, + CancellationToken cancellationToken = default +); +``` + +`CursesPointerShapeLease` exposes the requested `Shape` and forwards disposal ownership to Terminal's scoped pointer-shape lease. + +Region `PointerShape` is only a nullable **preference** reported by hit testing/routing. Hit testing never performs output or support queries. Applications explicitly decide whether and when to acquire/apply a pointer-shape lease. + +## Structured hit and route results + +### Hit result + +`CursesInteractionHit` is immutable and exposes: + +```text +Region +LocalRow +LocalColumn +PointerShape +``` + +`PointerShape` is the region preference at hit-resolution time and may be null. + +### Route result + +`CursesInteractionResultKind` is frozen as: + +```text +Unrouted +Targeted +Command +``` + +`CursesInteractionResult` is immutable and exposes: + +```text +Kind +Input +Region +Command +Hit +``` + +Invariants: + +- `Unrouted`: `Region`, `Command`, and `Hit` are null; +- keyboard/text/paste `Targeted`: `Region` is non-null, `Command` and `Hit` are null; +- mouse `Targeted`: `Region` and `Hit` are non-null and identify the same region; `Command` is null; +- region-local `Command`: `Command` and `Region` are non-null; `Hit` is null; +- router-global `Command`: `Command` is non-null and `Region`/`Hit` are null. + +The result is a snapshot. Later region disposal or geometry mutation does not mutate an already-returned result object. + +## Frozen public API candidate + +T1401 freezes the following 1.4 candidate names. Later tranches may identify a concrete regret, but changing this candidate requires explicit recorded justification and requalification. + +```csharp +public enum CursesFocusDirection { + Forward = 0, + Backward = 1 +} + +public enum CursesInteractionResultKind { + Unrouted = 0, + Targeted = 1, + Command = 2 +} + +public enum CursesPointerShape { + Alias = 0, + Cell = 1, + Copy = 2, + Crosshair = 3, + Default = 4, + EastResize = 5, + EastWestResize = 6, + Grab = 7, + Grabbing = 8, + Help = 9, + Move = 10, + NorthResize = 11, + NorthEastResize = 12, + NorthEastSouthWestResize = 13, + NoDrop = 14, + NotAllowed = 15, + NorthSouthResize = 16, + NorthWestResize = 17, + NorthWestSouthEastResize = 18, + Pointer = 19, + Progress = 20, + SouthResize = 21, + SouthEastResize = 22, + SouthWestResize = 23, + Text = 24, + VerticalText = 25, + WestResize = 26, + Wait = 27, + ZoomIn = 28, + ZoomOut = 29 +} + +public readonly record struct CursesKeyGesture { + public CursesKey Key { get; } + public Rune? Character { get; } + public CursesKeyModifiers Modifiers { get; } + public CursesKeyEventPhase Phase { get; } + public int? FunctionKeyNumber { get; } + + public static CursesKeyGesture ForKey( + CursesKey key, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ); + + public static CursesKeyGesture ForCharacter( + Rune character, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ); + + public static CursesKeyGesture ForFunctionKey( + int functionKeyNumber, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ); +} + +public sealed record CursesCommand { + public const int MaximumNameLength = 128; + public CursesCommand( string name ); + public string Name { get; } + public override string ToString(); +} + +public sealed class CursesInteractionRegionOptions { + public CursesInteractionRegionOptions( CursesRectangle bounds ); + public CursesRectangle Bounds { get; init; } + public CursesPanel? Panel { get; init; } + public bool IsEnabled { get; init; } + public bool IsFocusable { get; init; } + public int TraversalOrder { get; init; } + public int HitTestPriority { get; init; } + public CursesPointerShape? PointerShape { get; init; } +} + +public sealed class CursesInteractionRegion : IDisposable { + public CursesRectangle Bounds { get; } + public CursesPanel? Panel { get; } + public bool IsEnabled { get; set; } + public bool IsFocusable { get; set; } + public int TraversalOrder { get; set; } + public int HitTestPriority { get; set; } + public CursesPointerShape? PointerShape { get; set; } + + public void SetBounds( CursesRectangle bounds ); + public void BindGesture( CursesKeyGesture gesture, CursesCommand command ); + public bool UnbindGesture( CursesKeyGesture gesture ); + public void Dispose(); +} + +public sealed class CursesInteractionHit { + public CursesInteractionRegion Region { get; } + public int LocalRow { get; } + public int LocalColumn { get; } + public CursesPointerShape? PointerShape { get; } +} + +public sealed class CursesInteractionResult { + public CursesInteractionResultKind Kind { get; } + public CursesInputEvent Input { get; } + public CursesInteractionRegion? Region { get; } + public CursesCommand? Command { get; } + public CursesInteractionHit? Hit { get; } +} + +public sealed class CursesInteractionRouter : IDisposable { + public const int MaximumRegions = 4096; + public const int MaximumRegionGestureBindings = 256; + public const int MaximumGlobalGestureBindings = 1024; + public const int MaximumGestureBindings = 16384; + + public CursesInteractionRouter( CursesScreen screen ); + + public CursesScreen Screen { get; } + public CursesInteractionRegion? FocusedRegion { get; } + + public CursesInteractionRegion RegisterRegion( + CursesInteractionRegionOptions options + ); + + public CursesInteractionHit? HitTest( + int row, + int column + ); + + public bool Focus( CursesInteractionRegion region ); + public void ClearFocus(); + public CursesInteractionRegion? MoveFocus( CursesFocusDirection direction ); + + public void BindGlobalGesture( + CursesKeyGesture gesture, + CursesCommand command + ); + + public bool UnbindGlobalGesture( CursesKeyGesture gesture ); + + public CursesInteractionResult Route( CursesInputEvent input ); + + public void Dispose(); +} + +public sealed class CursesPointerShapeLease : IAsyncDisposable { + public CursesPointerShape Shape { get; } + public ValueTask DisposeAsync(); +} + +public sealed partial class CursesSession { + public ValueTask AcquirePointerShapeAsync( + CursesPointerShape shape, + CancellationToken cancellationToken = default + ); +} +``` + +Constructors for immutable result/lease types not intended for application construction remain non-public. + +## Failure and validation contract + +Public parameter validation occurs before mutation. + +The frozen error policy is: + +- null required reference argument -> `ArgumentNullException`; +- undefined enum or negative coordinate -> `ArgumentOutOfRangeException`; +- malformed gesture factory combination -> `ArgumentException` or `ArgumentOutOfRangeException` according to the offending value; +- invalid/oversized command name -> `ArgumentException` / `ArgumentOutOfRangeException`; +- foreign region/router ownership -> `ArgumentException`; +- foreign panel/screen ownership -> `ArgumentException`; +- disposed router used for routing/registration/focus/binding -> `ObjectDisposedException`; +- disposed region mutation/binding/focus request -> `ObjectDisposedException`; +- disposed panel supplied during new registration -> `ObjectDisposedException`; +- ineligible but otherwise valid region passed to `Focus(...)` -> `false` without mutation; +- duplicate gesture in one scope -> `InvalidOperationException` before mutation; +- registry/binding bound exhaustion -> `InvalidOperationException` before mutation; +- out-of-current-screen positive hit-test coordinate -> no hit, not an exception. + +Pointer-shape acquisition preserves Terminal's authoritative transport/lifecycle exceptions through the DCurses wrapper rather than converting them into guessed support results. + +## Concurrency contract + +Interaction routers and regions follow the existing DCurses narrow ownership model: they are **single-writer application state** unless a later API explicitly documents otherwise. + +T1401 does not add per-region locks or claim concurrent mutation/routing safety. + +Applications must serialize router/region mutation with their own interaction-routing loop. Immutable input events, gestures, commands, hits, and route-result snapshots may be read after creation according to ordinary .NET object/value semantics. + +The pointer-shape session wrapper participates in the existing `CursesSession`/Terminal serialized terminal-activity domain rather than creating a second output lock. + +## Dependency-boundary audit + +The T1401 public API candidate introduces no new exported `Icod.Terminal` or `Icod.TermInfo` types. + +The existing approved dependency exposure remains exactly: + +```text +Icod.Terminal.TerminalControlResult +Icod.Terminal.TerminalEndpoint +Icod.Terminal.TerminalSession +Icod.TermInfo.TerminalDescription +Icod.TermInfo.TerminalSize +``` + +`PublicDependencyBoundaryTests` therefore remains unchanged at T1401. T1407 must prove that the pointer-shape wrapper preserves this allow-list. + +## Explicit 1.4 exclusions + +T1401 freezes these exclusions for 1.4: + +- widget/control framework; +- automatic layout ownership; +- hidden event loop or background input consumer; +- arbitrary application callbacks invoked by the router; +- capture/bubble event tree; +- automatic focus-on-click; +- directional/spatial focus navigation beyond Forward/Backward; +- generalized mouse/pointer capture; +- drag/drop framework; +- per-cell input-transparency masks; +- gesture sequences/chords longer than one semantic key event; +- paste-command gestures; +- mouse-command gestures; +- accessibility tree; +- animation system; +- raster placement/scene graph; +- raw Terminal pointer/protocol type exposure; +- automatic pointer-shape application during hit testing; +- terminal-brand, `TERM`, protocol-family, or backend branching in application-facing routing. + +## T1401 acceptance checklist + +T1401 is ready for exact-head qualification when all of the following are true: + +- [x] router/screen/application ownership relationship frozen; +- [x] region identity/lifetime/disposal frozen; +- [x] screen-relative and panel-relative geometry frozen; +- [x] clipping/local-coordinate semantics frozen; +- [x] panel z-order and same-scope hit precedence frozen; +- [x] visual transparency versus interaction opacity frozen; +- [x] enabled/focusable/visible/eligible semantics frozen; +- [x] focus traversal, wrapping, and repair frozen; +- [x] routable input kinds and result semantics frozen; +- [x] character/text/modern-key gesture normalization frozen; +- [x] lock-state modifier treatment frozen; +- [x] gesture conflict and local/global precedence frozen; +- [x] pointer preference/application split frozen; +- [x] concrete registry/binding limits frozen; +- [x] validation/failure semantics frozen; +- [x] concurrency expectations frozen; +- [x] public API candidate names/signatures frozen; +- [x] dependency-boundary impact audited; +- [x] explicit exclusions frozen; +- [x] package/source identity intentionally remains 1.3.0 until T1402. + +No production source change is part of T1401. The next tranche, T1402, begins only after this exact contract head passes the normal pull-request qualification matrix. \ No newline at end of file From 7ee0a8e2c5763ad759c1451ca8dc84b30333e86a Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:51:20 -0400 Subject: [PATCH 03/90] Add failing T1402 interaction registry tests --- .../CursesInteractionRegionRegistryTests.cs | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs new file mode 100644 index 000000000..bad256fd1 --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs @@ -0,0 +1,255 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using Xunit; + +namespace Icod.DCurses.Tests; + +public sealed class CursesInteractionRegionRegistryTests { + [Fact] + public void RouterRejectsNullScreenAndNullRegistrationOptions() { + Assert.Throws( + () => new CursesInteractionRouter( null! ) + ); + + CursesScreen screen = new( 80, 24 ); + using CursesInteractionRouter router = new( screen ); + Assert.Throws( + () => router.RegisterRegion( null! ) + ); + } + + [Fact] + public void RouterRegistersOrdinaryRegionAndSnapshotsInitialState() { + CursesScreen screen = new( 80, 24 ); + using CursesInteractionRouter router = new( screen ); + CursesRectangle bounds = new( 2, 3, 4, 5 ); + CursesInteractionRegionOptions options = new( bounds ) { + IsEnabled = false, + IsFocusable = true, + TraversalOrder = -4, + HitTestPriority = 7 + }; + + using CursesInteractionRegion region = router.RegisterRegion( options ); + + Assert.Same( screen, router.Screen ); + Assert.Equal( bounds, region.Bounds ); + Assert.Null( region.Panel ); + Assert.False( region.IsEnabled ); + Assert.True( region.IsFocusable ); + Assert.Equal( -4, region.TraversalOrder ); + Assert.Equal( 7, region.HitTestPriority ); + } + + [Fact] + public void RegionAllowsEmptyAndOutOfScreenDeclaredGeometry() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion empty = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 50, 70, 0, 0 ) + ) + ); + + Assert.Equal( + new CursesRectangle( 50, 70, 0, 0 ), + empty.Bounds + ); + } + + [Fact] + public void RouterRegistersPanelRelativeRegionOnOwningScreen() { + CursesScreen screen = new( 80, 24 ); + using CursesPanel panel = screen.CreatePanel( 3, 4, 8, 12 ); + using CursesInteractionRouter router = new( screen ); + CursesRectangle relativeBounds = new( 1, 2, 3, 4 ); + + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( relativeBounds ) { + Panel = panel, + IsFocusable = true + } + ); + + Assert.Same( panel, region.Panel ); + Assert.Equal( relativeBounds, region.Bounds ); + Assert.True( region.IsEnabled ); + Assert.True( region.IsFocusable ); + } + + [Fact] + public void RouterRejectsForeignAndDisposedPanelAssociation() { + CursesScreen first = new( 80, 24 ); + CursesScreen second = new( 80, 24 ); + using CursesInteractionRouter router = new( first ); + using CursesPanel foreign = second.CreatePanel( 0, 0, 2, 2 ); + + Assert.Throws( + () => router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + Panel = foreign + } + ) + ); + + CursesPanel disposed = first.CreatePanel( 0, 0, 2, 2 ); + disposed.Dispose(); + Assert.Throws( + () => router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + Panel = disposed + } + ) + ); + } + + [Fact] + public void RegionMutationsUpdateStateAndBounds() { + CursesScreen screen = new( 80, 24 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 2, 3, 4 ) + ) + ); + + region.IsEnabled = false; + region.IsFocusable = true; + region.TraversalOrder = 11; + region.HitTestPriority = -9; + region.SetBounds( new CursesRectangle( 30, 40, 0, 5 ) ); + + Assert.False( region.IsEnabled ); + Assert.True( region.IsFocusable ); + Assert.Equal( 11, region.TraversalOrder ); + Assert.Equal( -9, region.HitTestPriority ); + Assert.Equal( + new CursesRectangle( 30, 40, 0, 5 ), + region.Bounds + ); + } + + [Fact] + public void RouterEnforcesMaximumRegionCountAndDisposalReleasesSlot() { + CursesScreen screen = new( 1, 1 ); + using CursesInteractionRouter router = new( screen ); + CursesInteractionRegion[] regions = + new CursesInteractionRegion[ CursesInteractionRouter.MaximumRegions ]; + + for ( int index = 0; index < regions.Length; index++ ) { + regions[ index ] = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 0, 0 ) + ) + ); + } + + Assert.Throws( + () => router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 0, 0 ) + ) + ) + ); + + regions[ 0 ].Dispose(); + using CursesInteractionRegion replacement = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 0, 0 ) + ) + ); + + for ( int index = 1; index < regions.Length; index++ ) { + regions[ index ].Dispose(); + } + } + + [Fact] + public void RegionDisposalIsIdempotentAndObservationsRemainReadable() { + CursesScreen screen = new( 80, 24 ); + using CursesInteractionRouter router = new( screen ); + CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 2, 3, 4 ) + ) { + IsFocusable = true, + TraversalOrder = 5, + HitTestPriority = 6 + } + ); + + region.Dispose(); + region.Dispose(); + + Assert.Equal( new CursesRectangle( 1, 2, 3, 4 ), region.Bounds ); + Assert.Null( region.Panel ); + Assert.True( region.IsEnabled ); + Assert.True( region.IsFocusable ); + Assert.Equal( 5, region.TraversalOrder ); + Assert.Equal( 6, region.HitTestPriority ); + Assert.Throws( + () => region.IsEnabled = false + ); + Assert.Throws( + () => region.IsFocusable = false + ); + Assert.Throws( + () => region.TraversalOrder = 0 + ); + Assert.Throws( + () => region.HitTestPriority = 0 + ); + Assert.Throws( + () => region.SetBounds( new CursesRectangle( 0, 0, 1, 1 ) ) + ); + } + + [Fact] + public void RouterDisposalIsIdempotentAndInvalidatesRegionMutation() { + CursesScreen screen = new( 80, 24 ); + CursesInteractionRouter router = new( screen ); + CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 2, 3, 4 ) + ) + ); + + router.Dispose(); + router.Dispose(); + + Assert.Same( screen, router.Screen ); + Assert.Throws( + () => router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) + ) + ); + Assert.Throws( + () => region.IsEnabled = false + ); + } +} From aa76c4c72af55522ea54b1b36e5b127d4b70a026 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:53:47 -0400 Subject: [PATCH 04/90] Clean T1402 RED test lambdas --- .../CursesInteractionRegionRegistryTests.cs | 88 ++++++++++++------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs index bad256fd1..c62fabaff 100644 --- a/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs @@ -27,13 +27,17 @@ public sealed class CursesInteractionRegionRegistryTests { [Fact] public void RouterRejectsNullScreenAndNullRegistrationOptions() { Assert.Throws( - () => new CursesInteractionRouter( null! ) + () => { + _ = new CursesInteractionRouter( null! ); + } ); CursesScreen screen = new( 80, 24 ); using CursesInteractionRouter router = new( screen ); Assert.Throws( - () => router.RegisterRegion( null! ) + () => { + _ = router.RegisterRegion( null! ); + } ); } @@ -104,25 +108,29 @@ public void RouterRejectsForeignAndDisposedPanelAssociation() { using CursesPanel foreign = second.CreatePanel( 0, 0, 2, 2 ); Assert.Throws( - () => router.RegisterRegion( - new CursesInteractionRegionOptions( - new CursesRectangle( 0, 0, 1, 1 ) - ) { - Panel = foreign - } - ) + () => { + _ = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + Panel = foreign + } + ); + } ); CursesPanel disposed = first.CreatePanel( 0, 0, 2, 2 ); disposed.Dispose(); Assert.Throws( - () => router.RegisterRegion( - new CursesInteractionRegionOptions( - new CursesRectangle( 0, 0, 1, 1 ) - ) { - Panel = disposed - } - ) + () => { + _ = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + Panel = disposed + } + ); + } ); } @@ -168,11 +176,13 @@ public void RouterEnforcesMaximumRegionCountAndDisposalReleasesSlot() { } Assert.Throws( - () => router.RegisterRegion( - new CursesInteractionRegionOptions( - new CursesRectangle( 0, 0, 0, 0 ) - ) - ) + () => { + _ = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 0, 0 ) + ) + ); + } ); regions[ 0 ].Dispose(); @@ -211,19 +221,29 @@ public void RegionDisposalIsIdempotentAndObservationsRemainReadable() { Assert.Equal( 5, region.TraversalOrder ); Assert.Equal( 6, region.HitTestPriority ); Assert.Throws( - () => region.IsEnabled = false + () => { + region.IsEnabled = false; + } ); Assert.Throws( - () => region.IsFocusable = false + () => { + region.IsFocusable = false; + } ); Assert.Throws( - () => region.TraversalOrder = 0 + () => { + region.TraversalOrder = 0; + } ); Assert.Throws( - () => region.HitTestPriority = 0 + () => { + region.HitTestPriority = 0; + } ); Assert.Throws( - () => region.SetBounds( new CursesRectangle( 0, 0, 1, 1 ) ) + () => { + region.SetBounds( new CursesRectangle( 0, 0, 1, 1 ) ); + } ); } @@ -242,14 +262,18 @@ public void RouterDisposalIsIdempotentAndInvalidatesRegionMutation() { Assert.Same( screen, router.Screen ); Assert.Throws( - () => router.RegisterRegion( - new CursesInteractionRegionOptions( - new CursesRectangle( 0, 0, 1, 1 ) - ) - ) + () => { + _ = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) + ); + } ); Assert.Throws( - () => region.IsEnabled = false + () => { + region.IsEnabled = false; + } ); } } From f580bc4f521022d0760c183203a03096eaa30a72 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:56:11 -0400 Subject: [PATCH 05/90] Implement T1402 interaction region options --- src/CursesInteractionRegionOptions.cs | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/CursesInteractionRegionOptions.cs diff --git a/src/CursesInteractionRegionOptions.cs b/src/CursesInteractionRegionOptions.cs new file mode 100644 index 000000000..034fc65a2 --- /dev/null +++ b/src/CursesInteractionRegionOptions.cs @@ -0,0 +1,72 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// Describes one interaction region before it is registered with a router. +public sealed class CursesInteractionRegionOptions { + /// Initializes interaction-region options with the declared rectangle. + /// + /// The region rectangle. It is screen-relative when is null and + /// panel-relative otherwise. + /// + public CursesInteractionRegionOptions( + CursesRectangle bounds + ) { + this.Bounds = bounds; + } + + /// Gets the declared region rectangle. + public CursesRectangle Bounds { + get; + init; + } + + /// Gets the optional panel whose retained coordinate space owns . + public CursesPanel? Panel { + get; + init; + } + + /// Gets whether the region initially participates in interaction routing. + public bool IsEnabled { + get; + init; + } = true; + + /// Gets whether the region initially participates in logical focus traversal. + public bool IsFocusable { + get; + init; + } + + /// Gets the initial logical-focus traversal order. + public int TraversalOrder { + get; + init; + } + + /// Gets the initial same-surface hit-test priority. + public int HitTestPriority { + get; + init; + } +} From 4f5f2147210ef46da7bba0996248f8acb189f502 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:56:26 -0400 Subject: [PATCH 06/90] Implement T1402 interaction region lifetime --- src/CursesInteractionRegion.cs | 134 +++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/CursesInteractionRegion.cs diff --git a/src/CursesInteractionRegion.cs b/src/CursesInteractionRegion.cs new file mode 100644 index 000000000..8d8bda507 --- /dev/null +++ b/src/CursesInteractionRegion.cs @@ -0,0 +1,134 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// +/// Represents one application-owned interaction region registered with a +/// . +/// +public sealed class CursesInteractionRegion : IDisposable { + private readonly CursesInteractionRouter owner; + private CursesRectangle bounds; + private bool isEnabled; + private bool isFocusable; + private int traversalOrder; + private int hitTestPriority; + private bool disposed; + + internal CursesInteractionRegion( + CursesInteractionRouter owner, + CursesInteractionRegionOptions options, + long registrationOrdinal + ) { + ArgumentNullException.ThrowIfNull( owner ); + ArgumentNullException.ThrowIfNull( options ); + if ( 0 > registrationOrdinal ) { + throw new ArgumentOutOfRangeException( nameof( registrationOrdinal ) ); + } + + this.owner = owner; + this.bounds = options.Bounds; + this.Panel = options.Panel; + this.isEnabled = options.IsEnabled; + this.isFocusable = options.IsFocusable; + this.traversalOrder = options.TraversalOrder; + this.hitTestPriority = options.HitTestPriority; + this.RegistrationOrdinal = registrationOrdinal; + } + + /// Gets the declared region rectangle. + public CursesRectangle Bounds => this.bounds; + + /// Gets the optional panel whose coordinate space owns . + public CursesPanel? Panel { + get; + } + + /// Gets or sets whether this region participates in interaction routing. + public bool IsEnabled { + get => this.isEnabled; + set { + this.ThrowIfDisposed(); + this.isEnabled = value; + } + } + + /// Gets or sets whether this region participates in logical focus traversal. + public bool IsFocusable { + get => this.isFocusable; + set { + this.ThrowIfDisposed(); + this.isFocusable = value; + } + } + + /// Gets or sets the logical-focus traversal order. + public int TraversalOrder { + get => this.traversalOrder; + set { + this.ThrowIfDisposed(); + this.traversalOrder = value; + } + } + + /// Gets or sets the same-surface hit-test priority. + public int HitTestPriority { + get => this.hitTestPriority; + set { + this.ThrowIfDisposed(); + this.hitTestPriority = value; + } + } + + /// Replaces the declared region rectangle. + /// The new region rectangle. + public void SetBounds( + CursesRectangle bounds + ) { + this.ThrowIfDisposed(); + this.bounds = bounds; + } + + /// Permanently removes this region from its owning interaction router. + public void Dispose() { + if ( this.disposed ) { + return; + } + + this.owner.RemoveRegion( this ); + this.disposed = true; + } + + internal CursesInteractionRouter Owner => this.owner; + + internal long RegistrationOrdinal { + get; + } + + internal bool IsDisposed => this.disposed; + + private void ThrowIfDisposed() { + if ( this.disposed ) { + throw new ObjectDisposedException( nameof( CursesInteractionRegion ) ); + } + } +} From b0d2271ff18ec3772eb89bdc618ce8ade1c5f2e7 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:56:44 -0400 Subject: [PATCH 07/90] Implement T1402 interaction region registry --- src/CursesInteractionRouter.cs | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/CursesInteractionRouter.cs diff --git a/src/CursesInteractionRouter.cs b/src/CursesInteractionRouter.cs new file mode 100644 index 000000000..cc99c59a4 --- /dev/null +++ b/src/CursesInteractionRouter.cs @@ -0,0 +1,152 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// +/// Owns a bounded set of application interaction regions associated with one logical screen. +/// +public sealed class CursesInteractionRouter : IDisposable { + /// Gets the maximum number of live interaction regions owned by one router. + public const int MaximumRegions = 4096; + + /// Gets the maximum number of gesture bindings owned by one region. + public const int MaximumRegionGestureBindings = 256; + + /// Gets the maximum number of router-global gesture bindings. + public const int MaximumGlobalGestureBindings = 1024; + + /// Gets the maximum total number of live gesture bindings owned by one router. + public const int MaximumGestureBindings = 16384; + + private readonly List regions = []; + private long nextRegistrationOrdinal; + private bool registrationOrdinalExhausted; + private bool disposed; + + /// Initializes an interaction router for one logical curses screen. + /// The logical screen whose coordinate space is routed. + public CursesInteractionRouter( + CursesScreen screen + ) { + ArgumentNullException.ThrowIfNull( screen ); + this.Screen = screen; + } + + /// Gets the logical screen associated with this router. + public CursesScreen Screen { + get; + } + + /// Registers one interaction region. + /// The region geometry and initial interaction state. + /// The registered region. + public CursesInteractionRegion RegisterRegion( + CursesInteractionRegionOptions options + ) { + ArgumentNullException.ThrowIfNull( options ); + this.ThrowIfDisposed(); + + CursesPanel? panel = options.Panel; + if ( panel is not null ) { + if ( panel.IsDisposed ) { + throw new ObjectDisposedException( nameof( options.Panel ) ); + } + if ( !ReferenceEquals( + panel.Owner, + this.Screen + ) ) { + throw new ArgumentException( + "The interaction region panel belongs to another screen.", + nameof( options ) + ); + } + } + + if ( MaximumRegions <= this.regions.Count ) { + throw new InvalidOperationException( + $"An interaction router cannot own more than {MaximumRegions} live regions." + ); + } + if ( this.registrationOrdinalExhausted ) { + throw new InvalidOperationException( + "The interaction router registration ordinal domain is exhausted." + ); + } + + long registrationOrdinal = this.nextRegistrationOrdinal; + CursesInteractionRegion region = new( + this, + options, + registrationOrdinal + ); + this.regions.Add( region ); + + if ( long.MaxValue == registrationOrdinal ) { + this.registrationOrdinalExhausted = true; + } else { + this.nextRegistrationOrdinal = registrationOrdinal + 1; + } + + return region; + } + + /// Disposes all live regions and closes this router to further mutation. + public void Dispose() { + if ( this.disposed ) { + return; + } + + this.disposed = true; + CursesInteractionRegion[] snapshot = this.regions.ToArray(); + foreach ( CursesInteractionRegion region in snapshot ) { + region.Dispose(); + } + this.regions.Clear(); + } + + internal void RemoveRegion( + CursesInteractionRegion region + ) { + ArgumentNullException.ThrowIfNull( region ); + if ( !ReferenceEquals( + region.Owner, + this + ) ) { + throw new ArgumentException( + "The interaction region belongs to another router.", + nameof( region ) + ); + } + + if ( !this.regions.Remove( region ) ) { + throw new InvalidOperationException( + "The interaction region is not registered with this router." + ); + } + } + + private void ThrowIfDisposed() { + if ( this.disposed ) { + throw new ObjectDisposedException( nameof( CursesInteractionRouter ) ); + } + } +} From 6a6648dd72586bc42c79c401fe59b0dfe013fa31 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:57:00 -0400 Subject: [PATCH 08/90] Begin Icod.DCurses 1.4.0 alpha line --- Icod.DCurses.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Icod.DCurses.csproj b/Icod.DCurses.csproj index 6d38e60aa..08f3e538e 100644 --- a/Icod.DCurses.csproj +++ b/Icod.DCurses.csproj @@ -34,16 +34,16 @@ Icod.DCurses Icod.DCurses Debug;Staging;Release - 1.3.0 + 1.4.0-alpha.1 1.0.0.0 - 1.3.0 + 1.4.0-alpha.1 Icod.DCurses Icod.DCurses Timothy J. Bruce Managed, cross-platform curses-like terminal UI library for .NET, built on Icod.TermInfo and Icod.Terminal. - 1.3.0 adds immutable CursesRectangle/CursesInsets geometry, pure fixed/proportional/docking/clipping layout helpers, screen/window/panel bounds application, retained panel resizing, and explicit live resize recomputation over the published 1.2 panel contract. Runtime dependencies are Icod.Terminal 1.11.1 and Icod.TermInfo 1.11.0; AssemblyVersion remains 1.0.0.0. + 1.4.0-alpha.1 begins the interaction-routing release with the bounded application-owned CursesInteractionRouter/CursesInteractionRegion registry and one-way region lifetime semantics over the published 1.3 geometry/panel foundation. Hit testing, focus, gestures, command routing, and pointer-shape integration remain later 1.4 tranches. Runtime dependencies remain Icod.Terminal 1.11.1 and Icod.TermInfo 1.11.0; AssemblyVersion remains 1.0.0.0. README.md icon.png https://github.com/uniblab/Icod.DCurses From 84d93c62013b21f5d82e585ec700ba8271a3e278 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:58:48 -0400 Subject: [PATCH 09/90] Record T1402 alpha public API fingerprint --- docs/Public-API-Fingerprint-1.4.json | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/Public-API-Fingerprint-1.4.json diff --git a/docs/Public-API-Fingerprint-1.4.json b/docs/Public-API-Fingerprint-1.4.json new file mode 100644 index 000000000..190b41386 --- /dev/null +++ b/docs/Public-API-Fingerprint-1.4.json @@ -0,0 +1,64 @@ +{ + "schema": 1, + "release": "1.4.0-alpha.1", + "status": "alpha", + "sha256": "64a202117a0de57cd5ad85a8b800fd411f0940bdd7c29561a3eaa8b3f3eabe22", + "exportedTypeCount": 54, + "contractLineCount": 432, + "exportedTypes": [ + "Icod.DCurses.CursesAlertKind", + "Icod.DCurses.CursesAmbiguousWidthPolicy", + "Icod.DCurses.CursesCell", + "Icod.DCurses.CursesCellMetadata", + "Icod.DCurses.CursesColor", + "Icod.DCurses.CursesColorKind", + "Icod.DCurses.CursesCursorVisibility", + "Icod.DCurses.CursesDockEdge", + "Icod.DCurses.CursesEvent", + "Icod.DCurses.CursesEventKind", + "Icod.DCurses.CursesFocusEvent", + "Icod.DCurses.CursesFocusState", + "Icod.DCurses.CursesHyperlink", + "Icod.DCurses.CursesInputEvent", + "Icod.DCurses.CursesInputEventKind", + "Icod.DCurses.CursesInputMode", + "Icod.DCurses.CursesInputProtocolLease", + "Icod.DCurses.CursesInputProtocolOptions", + "Icod.DCurses.CursesInsets", + "Icod.DCurses.CursesInteractionRegion", + "Icod.DCurses.CursesInteractionRegionOptions", + "Icod.DCurses.CursesInteractionRouter", + "Icod.DCurses.CursesKey", + "Icod.DCurses.CursesKeyEventPhase", + "Icod.DCurses.CursesKeyModifiers", + "Icod.DCurses.CursesKeyboardReportingMode", + "Icod.DCurses.CursesLayout", + "Icod.DCurses.CursesLifecycleEvent", + "Icod.DCurses.CursesLifecycleEventKind", + "Icod.DCurses.CursesLineGlyph", + "Icod.DCurses.CursesMouseAction", + "Icod.DCurses.CursesMouseButton", + "Icod.DCurses.CursesMouseEvent", + "Icod.DCurses.CursesMouseTrackingMode", + "Icod.DCurses.CursesPad", + "Icod.DCurses.CursesPadViewport", + "Icod.DCurses.CursesPanel", + "Icod.DCurses.CursesPanelTransparency", + "Icod.DCurses.CursesPasteEvent", + "Icod.DCurses.CursesPastePhase", + "Icod.DCurses.CursesPresentationCapabilities", + "Icod.DCurses.CursesRectangle", + "Icod.DCurses.CursesScreen", + "Icod.DCurses.CursesScreenResizedEventArgs", + "Icod.DCurses.CursesSession", + "Icod.DCurses.CursesSessionOptions", + "Icod.DCurses.CursesStyle", + "Icod.DCurses.CursesText", + "Icod.DCurses.CursesTextAttributes", + "Icod.DCurses.CursesVirtualScreen", + "Icod.DCurses.CursesWindow", + "Icod.DCurses.CursesWrapMode", + "Icod.DCurses.ICursesTextWidthProvider", + "Icod.DCurses.UnicodeCursesTextWidthProvider" + ] +} From d705f4d8582278ea4df1b42f9d1a67559bc4d774 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 14:59:01 -0400 Subject: [PATCH 10/90] Advance current API fingerprint gate to 1.4 --- tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj b/tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj index 6e5170a69..7c8c140a6 100644 --- a/tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj +++ b/tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj @@ -63,8 +63,11 @@ - + + From 3da87891c88be225bbc4ac71312ecf66cdbee5ff Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 15:03:01 -0400 Subject: [PATCH 11/90] Record T1402 interaction region registry --- ...402-Bounded-Interaction-Region-Registry.md | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 docs/T1402-Bounded-Interaction-Region-Registry.md diff --git a/docs/T1402-Bounded-Interaction-Region-Registry.md b/docs/T1402-Bounded-Interaction-Region-Registry.md new file mode 100644 index 000000000..f5ecf593f --- /dev/null +++ b/docs/T1402-Bounded-Interaction-Region-Registry.md @@ -0,0 +1,237 @@ +# T1402 — Bounded Interaction-Region Registry + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1402 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Qualified T1401 head:** `d70b6a9892812a624c810ae115daaafd636d2192` +**T1401 workflow:** #722 / `34712277620` +**Implementation checkpoint:** `d705f4d8582278ea4df1b42f9d1a67559bc4d774` +**Implementation workflow:** #730 / `34712804283` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** implementation checkpoint qualified; final documentation head requires normal exact-head qualification + +--- + +## Objective + +T1402 implements the bounded application-owned interaction-region registry frozen by T1401 without pulling forward hit testing, logical focus, semantic gestures, command routing, or pointer-shape terminal I/O. + +The tranche establishes the first production substrate for the 1.4 interaction-routing release: + +```text +CursesScreen + -> application-owned CursesInteractionRouter + -> bounded CursesInteractionRegion registrations + -> ordinary screen-relative geometry + -> optional panel-relative geometry + -> mutable enabled/focusable/order/priority metadata + -> deterministic one-way disposal +``` + +## Test-first checkpoint + +T1402 began with `CursesInteractionRegionRegistryTests` before production implementation. + +The initial RED test head exposed an xUnit assertion-overload warning caused by expression-bodied `Assert.Throws` lambdas. Those test lambdas were corrected without adding production code. + +The clean RED head was: + +```text +aa76c4c72af55522ea54b1b36e5b127d4b70a026 +``` + +Workflow #724 / `34712560648` then failed in build with zero warnings and only the expected missing-type errors for: + +```text +CursesInteractionRouter +CursesInteractionRegionOptions +CursesInteractionRegion +``` + +This established the intended RED condition before implementation. + +## Implemented public surface + +T1402 adds exactly three exported types: + +```text +CursesInteractionRouter +CursesInteractionRegionOptions +CursesInteractionRegion +``` + +### `CursesInteractionRegionOptions` + +The registration options contain: + +```text +Bounds +Panel +IsEnabled +IsFocusable +TraversalOrder +HitTestPriority +``` + +`IsEnabled` defaults to `true`; the remaining Boolean/integer metadata defaults follow ordinary CLR defaults. + +`Bounds` may be empty or extend outside the current screen/panel. Effective clipping belongs to T1403 hit testing rather than T1402 registration. + +### `CursesInteractionRouter` + +The router: + +- requires a non-null `CursesScreen`; +- exposes that screen through `Screen`; +- registers regions through `RegisterRegion(...)`; +- validates a panel association against the router's screen; +- rejects an already-disposed panel; +- enforces `MaximumRegions = 4096` before mutation; +- allocates a private monotonically increasing signed 64-bit registration ordinal beginning at zero; +- never reuses an ordinal within one router lifetime; +- rejects future registration if the ordinal domain is exhausted; +- releases the live-region count immediately when a region is disposed; +- owns no terminal session, input reader, output path, or layout policy; +- disposes all live regions when the router is disposed; +- makes router disposal idempotent. + +The T1401 binding constants are also exposed now for future tranches: + +```text +MaximumRegionGestureBindings = 256 +MaximumGlobalGestureBindings = 1024 +MaximumGestureBindings = 16384 +``` + +T1402 does not yet create binding registries. + +### `CursesInteractionRegion` + +A registered region snapshots its initial options and exposes: + +```text +Bounds +Panel +IsEnabled +IsFocusable +TraversalOrder +HitTestPriority +SetBounds(...) +Dispose() +``` + +The mutable properties and `SetBounds(...)` reject post-disposal mutation with `ObjectDisposedException`. + +Disposal is idempotent and permanently removes the region from the owning router. Read-only observations remain readable after disposal so previously captured application state and future immutable route-result snapshots can remain diagnosable. + +Panel disposal remains independent: a region does not own or dispose its associated panel. + +## Registry bound acceptance + +The T1402 tests allocate exactly `CursesInteractionRouter.MaximumRegions` live regions and prove: + +1. all 4,096 registrations are accepted; +2. registration 4,097 fails with `InvalidOperationException` before mutation; +3. disposing one region releases one live slot; +4. one replacement region can then be registered successfully. + +This is a live-count bound, not a lifetime-registration bound. Registration ordinals remain monotonic even when slots are reused. + +## Version and compatibility + +T1402 starts the 1.4 source/package line: + +```text +Version 1.4.0-alpha.1 +PackageVersion 1.4.0-alpha.1 +AssemblyVersion 1.0.0.0 +``` + +The dependency graph is unchanged: + +```text +Icod.Terminal 1.11.1 +Icod.TermInfo 1.11.0 +``` + +The published 1.3 API remains an immutable compatibility floor and is retained in `docs/Public-API-Fingerprint-1.3.json`. + +## Current 1.4 alpha API fingerprint + +The compiler-derived T1402 surface is: + +```text +54 exported types +432 canonical declared contract lines +sha256 64a202117a0de57cd5ad85a8b800fd411f0940bdd7c29561a3eaa8b3f3eabe22 +``` + +Machine authority: + +```text +docs/Public-API-Fingerprint-1.4.json +``` + +The three additions over published 1.3 are exactly: + +```text +Icod.DCurses.CursesInteractionRegion +Icod.DCurses.CursesInteractionRegionOptions +Icod.DCurses.CursesInteractionRouter +``` + +The public Terminal/TermInfo dependency allow-list remains unchanged. + +## GREEN qualification checkpoint + +After implementation, the first GREEN matrix exposed only the intentional public-API fingerprint gate. The assembly built successfully and all ordinary tests passed; the gate reported the new 54-type / 432-line contract consistently on all target frameworks. + +After advancing the **current-development** fingerprint to the 1.4 alpha authority while retaining the published 1.3 fingerprint unchanged, exact head: + +```text +d705f4d8582278ea4df1b42f9d1a67559bc4d774 +``` + +passed workflow: + +```text +#730 / 34712804283 +``` + +with all seven jobs green: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +Package validation also accepted the `1.4.0-alpha.1` package identity and unchanged sibling dependency graph. + +## Deliberate exclusions + +T1402 does not implement: + +- hit testing; +- effective clipping; +- panel z-order routing; +- logical focus; +- focus repair; +- key gestures; +- command identity or binding registries; +- structured routing results; +- pointer-shape vocabulary or leases; +- terminal I/O; +- hidden input consumption; +- widgets or callbacks. + +Those remain assigned to T1403 and later tranches. + +## Completion rule + +The implementation checkpoint is fully qualified. This documentation commit intentionally changes the exact head, so T1402 closes only when the resulting documentation-complete head passes the same seven-job pull-request qualification matrix. + +The final exact T1402 head/workflow should be recorded externally in PR #29 after qualification rather than creating another self-referential source commit merely to record its own SHA. From e4795747fa382aaefd48b70a6c958a52f6cc09aa Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 15:07:10 -0400 Subject: [PATCH 12/90] Add failing T1403 hit testing tests --- .../src/CursesInteractionHitTestingTests.cs | 357 ++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesInteractionHitTestingTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionHitTestingTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionHitTestingTests.cs new file mode 100644 index 000000000..2b63ae80f --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionHitTestingTests.cs @@ -0,0 +1,357 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using Xunit; + +namespace Icod.DCurses.Tests; + +public sealed class CursesInteractionHitTestingTests { + [Fact] + public void HitTestRejectsNegativeCoordinatesAndReturnsNullOutsideScreen() { + CursesScreen screen = new( 10, 5 ); + using CursesInteractionRouter router = new( screen ); + + Assert.Throws( + () => { + _ = router.HitTest( -1, 0 ); + } + ); + Assert.Throws( + () => { + _ = router.HitTest( 0, -1 ); + } + ); + Assert.Null( router.HitTest( 5, 0 ) ); + Assert.Null( router.HitTest( 0, 10 ) ); + } + + [Fact] + public void OrdinaryRegionHitUsesDeclaredOriginForLocalCoordinates() { + CursesScreen screen = new( 10, 5 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 2, 2, 3 ) + ) + ); + + CursesInteractionHit topLeft = Assert.IsType( + router.HitTest( 1, 2 ) + ); + CursesInteractionHit bottomRight = Assert.IsType( + router.HitTest( 2, 4 ) + ); + + Assert.Same( region, topLeft.Region ); + Assert.Equal( 0, topLeft.LocalRow ); + Assert.Equal( 0, topLeft.LocalColumn ); + Assert.Same( region, bottomRight.Region ); + Assert.Equal( 1, bottomRight.LocalRow ); + Assert.Equal( 2, bottomRight.LocalColumn ); + Assert.Null( router.HitTest( 0, 2 ) ); + Assert.Null( router.HitTest( 3, 2 ) ); + Assert.Null( router.HitTest( 1, 5 ) ); + } + + [Fact] + public void OrdinaryRegionIsClippedByCurrentScreenAndEmptyRegionDoesNotHit() { + CursesScreen screen = new( 10, 5 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion clipped = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 4, 8, 4, 4 ) + ) + ); + using CursesInteractionRegion empty = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 0, 5 ) + ) { + HitTestPriority = int.MaxValue + } + ); + + CursesInteractionHit hit = Assert.IsType( + router.HitTest( 4, 9 ) + ); + + Assert.Same( clipped, hit.Region ); + Assert.Equal( 0, hit.LocalRow ); + Assert.Equal( 1, hit.LocalColumn ); + Assert.Null( router.HitTest( 1, 1 ) ); + Assert.Null( router.HitTest( 5, 9 ) ); + } + + [Fact] + public void OrdinaryOverlapUsesPriorityThenLaterRegistration() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion first = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 5, 5 ) + ) { + HitTestPriority = 10 + } + ); + using CursesInteractionRegion second = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 5, 5 ) + ) { + HitTestPriority = 9 + } + ); + + Assert.Same( first, router.HitTest( 2, 2 )!.Region ); + + second.HitTestPriority = 10; + Assert.Same( second, router.HitTest( 2, 2 )!.Region ); + + second.HitTestPriority = 11; + Assert.Same( second, router.HitTest( 2, 2 )!.Region ); + } + + [Fact] + public void DisabledRegionIsIgnoredByHitTesting() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion lower = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 5, 5 ) + ) + ); + using CursesInteractionRegion upper = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 5, 5 ) + ) { + HitTestPriority = 100 + } + ); + + upper.IsEnabled = false; + + Assert.Same( lower, router.HitTest( 2, 2 )!.Region ); + } + + [Fact] + public void PanelRegionUsesPanelRelativeCoordinatesAndCurrentGeometry() { + CursesScreen screen = new( 30, 15 ); + using CursesPanel panel = screen.CreatePanel( 3, 4, 4, 5 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 2, 3, 4 ) + ) { + Panel = panel + } + ); + + CursesInteractionHit initial = Assert.IsType( + router.HitTest( 4, 6 ) + ); + Assert.Same( region, initial.Region ); + Assert.Equal( 0, initial.LocalRow ); + Assert.Equal( 0, initial.LocalColumn ); + + Assert.Null( router.HitTest( 6, 9 ) ); + + panel.MoveTo( 5, 6 ); + Assert.Null( router.HitTest( 4, 6 ) ); + CursesInteractionHit moved = Assert.IsType( + router.HitTest( 6, 8 ) + ); + Assert.Equal( 0, moved.LocalRow ); + Assert.Equal( 0, moved.LocalColumn ); + + panel.Resize( 2, 3 ); + Assert.NotNull( router.HitTest( 6, 8 ) ); + Assert.Null( router.HitTest( 7, 8 ) ); + } + + [Fact] + public void PanelRegionsAlwaysPrecedeOrdinaryRegions() { + CursesScreen screen = new( 30, 15 ); + using CursesPanel panel = screen.CreatePanel( 2, 2, 5, 5 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion ordinary = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 15, 30 ) + ) { + HitTestPriority = int.MaxValue + } + ); + using CursesInteractionRegion panelRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 5, 5 ) + ) { + Panel = panel, + HitTestPriority = int.MinValue + } + ); + + Assert.Same( panelRegion, router.HitTest( 3, 3 )!.Region ); + Assert.Same( ordinary, router.HitTest( 10, 10 )!.Region ); + } + + [Fact] + public void CurrentPanelZOrderPrecedesCrossPanelRegionPriority() { + CursesScreen screen = new( 30, 15 ); + using CursesPanel firstPanel = screen.CreatePanel( 2, 2, 5, 5 ); + using CursesPanel secondPanel = screen.CreatePanel( 2, 2, 5, 5 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion first = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 5, 5 ) + ) { + Panel = firstPanel, + HitTestPriority = int.MaxValue + } + ); + using CursesInteractionRegion second = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 5, 5 ) + ) { + Panel = secondPanel, + HitTestPriority = int.MinValue + } + ); + + Assert.Same( second, router.HitTest( 3, 3 )!.Region ); + + firstPanel.MoveToTop(); + Assert.Same( first, router.HitTest( 3, 3 )!.Region ); + } + + [Fact] + public void SamePanelOverlapUsesPriorityThenLaterRegistration() { + CursesScreen screen = new( 30, 15 ); + using CursesPanel panel = screen.CreatePanel( 2, 2, 5, 5 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion first = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 5, 5 ) + ) { + Panel = panel, + HitTestPriority = 5 + } + ); + using CursesInteractionRegion second = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 5, 5 ) + ) { + Panel = panel, + HitTestPriority = 4 + } + ); + + Assert.Same( first, router.HitTest( 3, 3 )!.Region ); + + second.HitTestPriority = 5; + Assert.Same( second, router.HitTest( 3, 3 )!.Region ); + } + + [Fact] + public void PanelVisibilityAndDisposalChangeEligibilityWithoutReregistration() { + CursesScreen screen = new( 30, 15 ); + CursesPanel panel = screen.CreatePanel( 2, 2, 5, 5 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion ordinary = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 15, 30 ) + ) + ); + using CursesInteractionRegion panelRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 5, 5 ) + ) { + Panel = panel + } + ); + + Assert.Same( panelRegion, router.HitTest( 3, 3 )!.Region ); + + panel.Hide(); + Assert.Same( ordinary, router.HitTest( 3, 3 )!.Region ); + + panel.Show(); + Assert.Same( panelRegion, router.HitTest( 3, 3 )!.Region ); + + panel.Dispose(); + Assert.Same( ordinary, router.HitTest( 3, 3 )!.Region ); + } + + [Fact] + public void BlankTransparentPanelStillOwnsInteractionRegion() { + CursesScreen screen = new( 30, 15 ); + using CursesPanel panel = screen.CreatePanel( 2, 2, 5, 5 ); + panel.Transparency = CursesPanelTransparency.BlankCellsTransparent; + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion ordinary = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 15, 30 ) + ) + ); + using CursesInteractionRegion panelRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 5, 5 ) + ) { + Panel = panel + } + ); + + Assert.Same( panelRegion, router.HitTest( 3, 3 )!.Region ); + } + + [Fact] + public void EquivalentRepeatedHitTestsAreDeterministic() { + CursesScreen screen = new( 30, 15 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion first = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 10, 10 ) + ) { + HitTestPriority = 3 + } + ); + using CursesInteractionRegion second = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 10, 10 ) + ) { + HitTestPriority = 3 + } + ); + + for ( int iteration = 0; iteration < 1000; iteration++ ) { + Assert.Same( second, router.HitTest( 5, 5 )!.Region ); + } + } + + [Fact] + public void HitTestAfterRouterDisposalThrows() { + CursesScreen screen = new( 10, 5 ); + CursesInteractionRouter router = new( screen ); + router.Dispose(); + + Assert.Throws( + () => { + _ = router.HitTest( 0, 0 ); + } + ); + } +} From 783869b33df6baca78bcab5b46f1707c61d835f3 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 15:08:39 -0400 Subject: [PATCH 13/90] Implement T1403 interaction hit result --- src/CursesInteractionHit.cs | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/CursesInteractionHit.cs diff --git a/src/CursesInteractionHit.cs b/src/CursesInteractionHit.cs new file mode 100644 index 000000000..7a96ff142 --- /dev/null +++ b/src/CursesInteractionHit.cs @@ -0,0 +1,58 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// Represents one resolved interaction-region hit in region-local coordinates. +public sealed class CursesInteractionHit { + internal CursesInteractionHit( + CursesInteractionRegion region, + int localRow, + int localColumn + ) { + ArgumentNullException.ThrowIfNull( region ); + if ( 0 > localRow ) { + throw new ArgumentOutOfRangeException( nameof( localRow ) ); + } + if ( 0 > localColumn ) { + throw new ArgumentOutOfRangeException( nameof( localColumn ) ); + } + + this.Region = region; + this.LocalRow = localRow; + this.LocalColumn = localColumn; + } + + /// Gets the interaction region selected by the hit test. + public CursesInteractionRegion Region { + get; + } + + /// Gets the zero-based row relative to the declared region origin. + public int LocalRow { + get; + } + + /// Gets the zero-based column relative to the declared region origin. + public int LocalColumn { + get; + } +} From aa4da17058232e4f3d8bb64b190a5a4c9a897d8e Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 16:38:36 -0400 Subject: [PATCH 14/90] Implement T1403 deterministic hit testing --- src/CursesInteractionRouter.cs | 149 +++++++++++++++++++++++++++++++ src/CursesScreen.cs | 18 ++++ src/Internal/CursesPanelOrder.cs | 8 ++ 3 files changed, 175 insertions(+) diff --git a/src/CursesInteractionRouter.cs b/src/CursesInteractionRouter.cs index cc99c59a4..4042997a9 100644 --- a/src/CursesInteractionRouter.cs +++ b/src/CursesInteractionRouter.cs @@ -109,6 +109,66 @@ CursesInteractionRegionOptions options return region; } + /// Resolves the highest-precedence enabled interaction region at one screen coordinate. + /// The non-negative zero-based screen row. + /// The non-negative zero-based screen column. + /// The resolved hit, or when no region is eligible at the coordinate. + public CursesInteractionHit? HitTest( + int row, + int column + ) { + if ( 0 > row ) { + throw new ArgumentOutOfRangeException( nameof( row ) ); + } + if ( 0 > column ) { + throw new ArgumentOutOfRangeException( nameof( column ) ); + } + this.ThrowIfDisposed(); + + if ( row >= this.Screen.Rows + || column >= this.Screen.Columns ) { + return null; + } + + CursesInteractionRegion? selected = null; + int selectedLocalRow = 0; + int selectedLocalColumn = 0; + + foreach ( CursesInteractionRegion region in this.regions ) { + if ( !region.IsEnabled ) { + continue; + } + if ( !TryGetLocalCoordinates( + region, + row, + column, + out int localRow, + out int localColumn + ) ) { + continue; + } + + if ( selected is null + || this.IsPreferredHitCandidate( + region, + selected + ) ) { + selected = region; + selectedLocalRow = localRow; + selectedLocalColumn = localColumn; + } + } + + return selected is null + ? null + : new CursesInteractionHit( + selected, + selectedLocalRow, + selectedLocalColumn + ) + ; + } + /// Disposes all live regions and closes this router to further mutation. public void Dispose() { if ( this.disposed ) { @@ -144,6 +204,95 @@ CursesInteractionRegion region } } + private bool IsPreferredHitCandidate( + CursesInteractionRegion candidate, + CursesInteractionRegion selected + ) { + CursesPanel? candidatePanel = candidate.Panel; + CursesPanel? selectedPanel = selected.Panel; + + if ( candidatePanel is not null && selectedPanel is null ) { + return true; + } + if ( candidatePanel is null && selectedPanel is not null ) { + return false; + } + + if ( candidatePanel is not null + && selectedPanel is not null + && !ReferenceEquals( + candidatePanel, + selectedPanel + ) ) { + int candidatePanelIndex = this.Screen.GetPanelOrderIndex( candidatePanel ); + int selectedPanelIndex = this.Screen.GetPanelOrderIndex( selectedPanel ); + if ( candidatePanelIndex != selectedPanelIndex ) { + return candidatePanelIndex > selectedPanelIndex; + } + } + + if ( candidate.HitTestPriority != selected.HitTestPriority ) { + return candidate.HitTestPriority > selected.HitTestPriority; + } + + return candidate.RegistrationOrdinal > selected.RegistrationOrdinal; + } + + private static bool TryGetLocalCoordinates( + CursesInteractionRegion region, + int row, + int column, + out int localRow, + out int localColumn + ) { + CursesRectangle bounds = region.Bounds; + CursesPanel? panel = region.Panel; + + if ( panel is null ) { + if ( !bounds.Contains( + row, + column + ) ) { + localRow = 0; + localColumn = 0; + return false; + } + + localRow = row - bounds.Row; + localColumn = column - bounds.Column; + return true; + } + + if ( panel.IsDisposed || !panel.IsVisible ) { + localRow = 0; + localColumn = 0; + return false; + } + if ( !panel.Bounds.Contains( + row, + column + ) ) { + localRow = 0; + localColumn = 0; + return false; + } + + int panelLocalRow = row - panel.Row; + int panelLocalColumn = column - panel.Column; + if ( !bounds.Contains( + panelLocalRow, + panelLocalColumn + ) ) { + localRow = 0; + localColumn = 0; + return false; + } + + localRow = panelLocalRow - bounds.Row; + localColumn = panelLocalColumn - bounds.Column; + return true; + } + private void ThrowIfDisposed() { if ( this.disposed ) { throw new ObjectDisposedException( nameof( CursesInteractionRouter ) ); diff --git a/src/CursesScreen.cs b/src/CursesScreen.cs index d3fea4db6..2231a6b80 100644 --- a/src/CursesScreen.cs +++ b/src/CursesScreen.cs @@ -151,6 +151,24 @@ internal CursesPanel[] SnapshotPanelsBottomToTop() { return panelOrder.SnapshotBottomToTop(); } + /// Gets the current bottom-to-top order index of one owned active panel. + /// The panel whose current order index is requested. + /// The zero-based order index, or -1 when the panel is no longer attached. + internal int GetPanelOrderIndex( CursesPanel panel ) { + ArgumentNullException.ThrowIfNull( panel ); + if ( !ReferenceEquals( + panel.Owner, + this + ) ) { + throw new ArgumentException( + "The panel belongs to another screen.", + nameof( panel ) + ); + } + + return panelOrder.GetIndex( panel ); + } + /// Permanently removes one owned panel from this screen's composition order. /// The owned panel to remove. internal void RemovePanel( CursesPanel panel ) { diff --git a/src/Internal/CursesPanelOrder.cs b/src/Internal/CursesPanelOrder.cs index c97ef921d..3a72b319b 100644 --- a/src/Internal/CursesPanelOrder.cs +++ b/src/Internal/CursesPanelOrder.cs @@ -132,6 +132,14 @@ T sibling ); } + /// Gets the current bottom-to-top index for one panel identity. + /// The panel identity to locate. + /// The zero-based order index, or -1 when the identity is not present. + internal int GetIndex( T panel ) { + ArgumentNullException.ThrowIfNull( panel ); + return IndexOfReference( panel ); + } + /// Creates a stable bottom-to-top snapshot of the current order. /// A new array containing the current ordered identities. internal T[] SnapshotBottomToTop() { From f97c116e84c46aed1153f7f3a90ef1ca0f41da3e Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 16:42:44 -0400 Subject: [PATCH 15/90] Advance 1.4 API fingerprint for T1403 --- docs/Public-API-Fingerprint-1.4.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/Public-API-Fingerprint-1.4.json b/docs/Public-API-Fingerprint-1.4.json index 190b41386..aeaa1bffb 100644 --- a/docs/Public-API-Fingerprint-1.4.json +++ b/docs/Public-API-Fingerprint-1.4.json @@ -2,9 +2,9 @@ "schema": 1, "release": "1.4.0-alpha.1", "status": "alpha", - "sha256": "64a202117a0de57cd5ad85a8b800fd411f0940bdd7c29561a3eaa8b3f3eabe22", - "exportedTypeCount": 54, - "contractLineCount": 432, + "sha256": "8a54fa1b78ac9b4eea2b4ad60ac671dce2c36fa8a8ccc45ad953c814cb7386d5", + "exportedTypeCount": 55, + "contractLineCount": 437, "exportedTypes": [ "Icod.DCurses.CursesAlertKind", "Icod.DCurses.CursesAmbiguousWidthPolicy", @@ -25,6 +25,7 @@ "Icod.DCurses.CursesInputProtocolLease", "Icod.DCurses.CursesInputProtocolOptions", "Icod.DCurses.CursesInsets", + "Icod.DCurses.CursesInteractionHit", "Icod.DCurses.CursesInteractionRegion", "Icod.DCurses.CursesInteractionRegionOptions", "Icod.DCurses.CursesInteractionRouter", From 137261e9417077734c0e4006cd3ea28920022f2b Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 16:46:02 -0400 Subject: [PATCH 16/90] Record T1403 deterministic hit testing --- ...nistic-Hit-Testing-and-Panel-Precedence.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/T1403-Deterministic-Hit-Testing-and-Panel-Precedence.md diff --git a/docs/T1403-Deterministic-Hit-Testing-and-Panel-Precedence.md b/docs/T1403-Deterministic-Hit-Testing-and-Panel-Precedence.md new file mode 100644 index 000000000..18b3f1d42 --- /dev/null +++ b/docs/T1403-Deterministic-Hit-Testing-and-Panel-Precedence.md @@ -0,0 +1,148 @@ +# T1403 — Deterministic Hit Testing and Panel Precedence + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1403 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** implementation complete and qualified; documentation-complete head requires its own exact-head matrix + +--- + +## Objective + +T1403 maps one zero-based screen coordinate to at most one interaction region using the precedence and geometry semantics frozen by T1401. It adds no focus mutation, input loop, terminal protocol work, or terminal I/O. + +## RED checkpoint + +The T1403 tests were committed first at: + +`e4795747fa382aaefd48b70a6c958a52f6cc09aa` + +Workflow #732 / `34713216968` failed as intended because the frozen `CursesInteractionRouter.HitTest(...)` method and `CursesInteractionHit` type did not yet exist. The failure contained no unrelated warning or production regression. + +The RED suite covers: + +- negative coordinate validation and stale coordinates outside the current screen; +- ordinary screen-relative region containment and local coordinates; +- empty and screen-clipped effective geometry; +- enabled-state eligibility; +- ordinary-region priority and later-registration tie breaking; +- panel-relative geometry, local coordinates, movement, and resizing; +- panel-associated precedence over ordinary regions; +- current cross-panel z-order precedence independent of region priority; +- same-panel priority and later-registration tie breaking; +- panel hide/show/disposal without re-registration; +- blank-transparent rendering remaining input-opaque; +- deterministic repeated hit results; +- disposed-router behavior. + +## Implementation + +The public additive surface is: + +```csharp +public sealed class CursesInteractionHit { + public CursesInteractionRegion Region { get; } + public int LocalRow { get; } + public int LocalColumn { get; } +} + +public sealed partial-contract CursesInteractionRouter { + public CursesInteractionHit? HitTest( + int row, + int column + ); +} +``` + +`CursesInteractionHit` first landed at: + +`783869b33df6baca78bcab5b46f1707c61d835f3` + +The complete hit-testing implementation landed at: + +`aa4da17058232e4f3d8bb64b190a5a4c9a897d8e` + +### Geometry + +Ordinary regions interpret `Bounds` in screen coordinates. A point must lie inside both the current screen and the declared region. + +Panel-associated regions interpret `Bounds` in panel-local coordinates. A point must lie inside the panel's current screen-space bounds and the declared panel-local region. Hidden or disposed panels are ineligible. + +Successful `LocalRow` and `LocalColumn` values are measured from the declared region origin, not from a clipped effective rectangle. + +### Precedence + +T1403 implements the exact T1401 precedence order: + +1. panel-associated candidates precede ordinary candidates; +2. among different panels, the currently topmost panel wins regardless of region priority; +3. within the same panel, larger `HitTestPriority` wins; +4. same-panel priority ties choose the later registration ordinal; +5. among ordinary regions, larger `HitTestPriority` wins; +6. ordinary priority ties choose the later registration ordinal. + +Panel position, dimensions, visibility, disposal, and z-order are read from current state on every hit test, so applications never need to re-register a region after those panel changes. + +### Allocation-conscious panel precedence + +The pre-existing panel-composition API exposes snapshot ordering for composition work. T1403 does not allocate that array merely to compare two panel candidates during a hit test. + +An internal reference-identity order-index query was added to `CursesPanelOrder` and exposed internally through `CursesScreen.GetPanelOrderIndex(...)`. This keeps current z-order authoritative while avoiding an intermediate panel-order snapshot in the normal hit-test path. + +No new internal side registry or cached duplicate panel geometry was introduced. + +## API fingerprint + +The first implementation run correctly reached the public-API fingerprint guard after all behavioral tests compiled and ran. The compiler-derived contract was identical on `net8.0`, `net9.0`, and `net10.0`: + +```text +55 exported types +437 canonical declared contract lines +sha256 8a54fa1b78ac9b4eea2b4ad60ac671dce2c36fa8a8ccc45ad953c814cb7386d5 +``` + +`docs/Public-API-Fingerprint-1.4.json` was advanced to that exact alpha contract at: + +`f97c116e84c46aed1153f7f3a90ef1ca0f41da3e` + +The published 1.3 fingerprint remains unchanged historical authority. + +## Qualification + +Exact implementation/fingerprint head: + +`f97c116e84c46aed1153f7f3a90ef1ca0f41da3e` + +Workflow #735 / `34717891570` passed all seven PR jobs: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +The package candidate therefore also exercised the repository's packed-artifact validation path at the current alpha identity. + +## Scope audit + +T1403 deliberately does **not** add: + +- logical focus or focus traversal; +- focus-on-click behavior; +- semantic key gestures; +- command bindings or callbacks; +- pointer-shape I/O; +- input transparency inferred from visual transparency; +- a hidden event loop; +- raw terminal protocol parsing. + +Those remain assigned to later tranches exactly as frozen by T1401. + +## Exit gate + +T1403 is complete when this documentation-complete source head passes the full PR package/runtime matrix. After that exact-head qualification, T1404 may begin logical focus, traversal, and deterministic repair work. From 3785c26610cfc25bb9b2de485e0b9670e3a2ebd9 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 16:49:50 -0400 Subject: [PATCH 17/90] Add failing T1404 logical focus tests --- .../src/CursesInteractionFocusTests.cs | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesInteractionFocusTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionFocusTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionFocusTests.cs new file mode 100644 index 000000000..2ef301717 --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionFocusTests.cs @@ -0,0 +1,366 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using Xunit; + +namespace Icod.DCurses.Tests; + +public sealed class CursesInteractionFocusTests { + [Fact] + public void ExplicitFocusAndClearAreDeterministic() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( + router, + new CursesRectangle( 1, 2, 3, 4 ) + ); + + Assert.Null( router.FocusedRegion ); + Assert.True( router.Focus( region ) ); + Assert.Same( region, router.FocusedRegion ); + + router.ClearFocus(); + Assert.Null( router.FocusedRegion ); + router.ClearFocus(); + Assert.Null( router.FocusedRegion ); + } + + [Fact] + public void FocusValidatesOwnershipDisposalAndEligibilityBeforeMutation() { + CursesScreen firstScreen = new( 20, 10 ); + CursesScreen secondScreen = new( 20, 10 ); + using CursesInteractionRouter first = new( firstScreen ); + using CursesInteractionRouter second = new( secondScreen ); + using CursesInteractionRegion current = RegisterFocusable( + first, + new CursesRectangle( 0, 0, 1, 1 ) + ); + using CursesInteractionRegion foreign = RegisterFocusable( + second, + new CursesRectangle( 0, 0, 1, 1 ) + ); + using CursesInteractionRegion disabled = first.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 0, 1, 1 ) + ) { + IsEnabled = false, + IsFocusable = true + } + ); + using CursesInteractionRegion nonFocusable = first.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 2, 0, 1, 1 ) + ) + ); + using CursesInteractionRegion clipped = RegisterFocusable( + first, + new CursesRectangle( 50, 50, 1, 1 ) + ); + + Assert.True( first.Focus( current ) ); + + Assert.Throws( + () => { + _ = first.Focus( null! ); + } + ); + Assert.Throws( + () => { + _ = first.Focus( foreign ); + } + ); + + CursesInteractionRegion disposed = RegisterFocusable( + first, + new CursesRectangle( 3, 0, 1, 1 ) + ); + disposed.Dispose(); + Assert.Throws( + () => { + _ = first.Focus( disposed ); + } + ); + + Assert.False( first.Focus( disabled ) ); + Assert.Same( current, first.FocusedRegion ); + Assert.False( first.Focus( nonFocusable ) ); + Assert.Same( current, first.FocusedRegion ); + Assert.False( first.Focus( clipped ) ); + Assert.Same( current, first.FocusedRegion ); + } + + [Fact] + public void MoveFocusUsesTraversalOrderThenRegistrationAndAlwaysWraps() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion last = RegisterFocusable( + router, + new CursesRectangle( 0, 0, 1, 1 ), + traversalOrder: 10 + ); + using CursesInteractionRegion first = RegisterFocusable( + router, + new CursesRectangle( 0, 1, 1, 1 ), + traversalOrder: 0 + ); + using CursesInteractionRegion second = RegisterFocusable( + router, + new CursesRectangle( 0, 2, 1, 1 ), + traversalOrder: 0 + ); + + Assert.Same( + first, + router.MoveFocus( CursesFocusDirection.Forward ) + ); + Assert.Same( + second, + router.MoveFocus( CursesFocusDirection.Forward ) + ); + Assert.Same( + last, + router.MoveFocus( CursesFocusDirection.Forward ) + ); + Assert.Same( + first, + router.MoveFocus( CursesFocusDirection.Forward ) + ); + Assert.Same( + last, + router.MoveFocus( CursesFocusDirection.Backward ) + ); + + router.ClearFocus(); + Assert.Same( + last, + router.MoveFocus( CursesFocusDirection.Backward ) + ); + } + + [Fact] + public void MoveFocusRejectsUndefinedDirectionAndReturnsNullWithoutEligibleRegions() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion disabled = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + IsEnabled = false, + IsFocusable = true + } + ); + + Assert.Throws( + () => { + _ = router.MoveFocus( (CursesFocusDirection)99 ); + } + ); + Assert.Null( router.MoveFocus( CursesFocusDirection.Forward ) ); + Assert.Null( router.FocusedRegion ); + Assert.Null( router.MoveFocus( CursesFocusDirection.Backward ) ); + } + + [Fact] + public void RegionOwnedEligibilityChangesRepairFocusedRegionForward() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion first = RegisterFocusable( + router, + new CursesRectangle( 0, 0, 1, 1 ), + traversalOrder: 0 + ); + using CursesInteractionRegion second = RegisterFocusable( + router, + new CursesRectangle( 0, 1, 1, 1 ), + traversalOrder: 1 + ); + using CursesInteractionRegion third = RegisterFocusable( + router, + new CursesRectangle( 0, 2, 1, 1 ), + traversalOrder: 2 + ); + + Assert.True( router.Focus( second ) ); + second.IsEnabled = false; + Assert.Same( third, router.FocusedRegion ); + + third.IsFocusable = false; + Assert.Same( first, router.FocusedRegion ); + + first.SetBounds( new CursesRectangle( 40, 40, 1, 1 ) ); + Assert.Null( router.FocusedRegion ); + } + + [Fact] + public void DisposingFocusedRegionRepairsFromItsFormerTraversalSlot() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion first = RegisterFocusable( + router, + new CursesRectangle( 0, 0, 1, 1 ), + traversalOrder: 0 + ); + CursesInteractionRegion second = RegisterFocusable( + router, + new CursesRectangle( 0, 1, 1, 1 ), + traversalOrder: 1 + ); + using CursesInteractionRegion third = RegisterFocusable( + router, + new CursesRectangle( 0, 2, 1, 1 ), + traversalOrder: 2 + ); + + Assert.True( router.Focus( second ) ); + second.Dispose(); + Assert.Same( third, router.FocusedRegion ); + + third.Dispose(); + Assert.Same( first, router.FocusedRegion ); + } + + [Fact] + public void FocusedRegionLazilyRepairsAfterScreenClipping() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion fallback = RegisterFocusable( + router, + new CursesRectangle( 0, 0, 1, 1 ), + traversalOrder: 0 + ); + using CursesInteractionRegion clipped = RegisterFocusable( + router, + new CursesRectangle( 8, 18, 2, 2 ), + traversalOrder: 1 + ); + + Assert.True( router.Focus( clipped ) ); + screen.Resize( 10, 5 ); + + Assert.Same( fallback, router.FocusedRegion ); + } + + [Fact] + public void FocusedRegionLazilyRepairsAfterPanelStateChanges() { + CursesScreen screen = new( 20, 10 ); + using CursesPanel panel = screen.CreatePanel( 1, 1, 4, 4 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion fallback = RegisterFocusable( + router, + new CursesRectangle( 0, 0, 1, 1 ), + traversalOrder: 0 + ); + using CursesInteractionRegion panelRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 2, 2, 2, 2 ) + ) { + Panel = panel, + IsFocusable = true, + TraversalOrder = 1 + } + ); + + Assert.True( router.Focus( panelRegion ) ); + panel.Hide(); + Assert.Same( fallback, router.FocusedRegion ); + + panel.Show(); + Assert.True( router.Focus( panelRegion ) ); + panel.Resize( 2, 2 ); + Assert.Same( fallback, router.FocusedRegion ); + } + + [Fact] + public void HitTestDoesNotRepairLogicalFocus() { + CursesScreen screen = new( 20, 10 ); + using CursesPanel panel = screen.CreatePanel( 1, 1, 3, 3 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion ordinary = RegisterFocusable( + router, + new CursesRectangle( 1, 1, 3, 3 ), + traversalOrder: 0 + ); + using CursesInteractionRegion panelRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 3, 3 ) + ) { + Panel = panel, + IsFocusable = true, + TraversalOrder = 1 + } + ); + + Assert.True( router.Focus( panelRegion ) ); + panel.Hide(); + CursesInteractionHit? hit = router.HitTest( 1, 1 ); + Assert.NotNull( hit ); + Assert.Same( ordinary, hit.Region ); + + panel.Show(); + Assert.Same( panelRegion, router.FocusedRegion ); + } + + [Fact] + public void FocusOperationsRejectDisposedRouter() { + CursesScreen screen = new( 20, 10 ); + CursesInteractionRouter router = new( screen ); + CursesInteractionRegion region = RegisterFocusable( + router, + new CursesRectangle( 0, 0, 1, 1 ) + ); + router.Dispose(); + + Assert.Throws( + () => { + _ = router.FocusedRegion; + } + ); + Assert.Throws( + () => { + _ = router.Focus( region ); + } + ); + Assert.Throws( + () => { + router.ClearFocus(); + } + ); + Assert.Throws( + () => { + _ = router.MoveFocus( CursesFocusDirection.Forward ); + } + ); + } + + private static CursesInteractionRegion RegisterFocusable( + CursesInteractionRouter router, + CursesRectangle bounds, + int traversalOrder = 0 + ) { + ArgumentNullException.ThrowIfNull( router ); + return router.RegisterRegion( + new CursesInteractionRegionOptions( bounds ) { + IsFocusable = true, + TraversalOrder = traversalOrder + } + ); + } +} From 5ef19026b3842eb71fbee95a1bba3509d1f39338 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 16:54:33 -0400 Subject: [PATCH 18/90] Implement T1404 logical focus and traversal --- src/CursesFocusDirection.cs | 30 ++++ src/CursesInteractionRegion.cs | 15 ++ src/CursesInteractionRouter.cs | 306 +++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 src/CursesFocusDirection.cs diff --git a/src/CursesFocusDirection.cs b/src/CursesFocusDirection.cs new file mode 100644 index 000000000..045304d6d --- /dev/null +++ b/src/CursesFocusDirection.cs @@ -0,0 +1,30 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// Identifies one logical-focus traversal direction. +public enum CursesFocusDirection { + /// Moves to the next eligible interaction region. + Forward = 0, + /// Moves to the previous eligible interaction region. + Backward = 1 +} diff --git a/src/CursesInteractionRegion.cs b/src/CursesInteractionRegion.cs index 8d8bda507..1622869e8 100644 --- a/src/CursesInteractionRegion.cs +++ b/src/CursesInteractionRegion.cs @@ -68,7 +68,12 @@ public bool IsEnabled { get => this.isEnabled; set { this.ThrowIfDisposed(); + if ( this.isEnabled == value ) { + return; + } + this.isEnabled = value; + this.owner.HandleRegionEligibilityChanged( this ); } } @@ -77,7 +82,12 @@ public bool IsFocusable { get => this.isFocusable; set { this.ThrowIfDisposed(); + if ( this.isFocusable == value ) { + return; + } + this.isFocusable = value; + this.owner.HandleRegionEligibilityChanged( this ); } } @@ -105,7 +115,12 @@ public void SetBounds( CursesRectangle bounds ) { this.ThrowIfDisposed(); + if ( this.bounds == bounds ) { + return; + } + this.bounds = bounds; + this.owner.HandleRegionEligibilityChanged( this ); } /// Permanently removes this region from its owning interaction router. diff --git a/src/CursesInteractionRouter.cs b/src/CursesInteractionRouter.cs index 4042997a9..b29b9d955 100644 --- a/src/CursesInteractionRouter.cs +++ b/src/CursesInteractionRouter.cs @@ -38,6 +38,7 @@ public sealed class CursesInteractionRouter : IDisposable { public const int MaximumGestureBindings = 16384; private readonly List regions = []; + private CursesInteractionRegion? focusedRegion; private long nextRegistrationOrdinal; private bool registrationOrdinalExhausted; private bool disposed; @@ -56,6 +57,15 @@ public CursesScreen Screen { get; } + /// Gets the current logical-focus region after deterministic lazy repair. + public CursesInteractionRegion? FocusedRegion { + get { + this.ThrowIfDisposed(); + this.RepairFocusIfNeeded(); + return this.focusedRegion; + } + } + /// Registers one interaction region. /// The region geometry and initial interaction state. /// The registered region. @@ -109,6 +119,77 @@ CursesInteractionRegionOptions options return region; } + /// Explicitly gives logical focus to one eligible owned region. + /// The owned region to focus. + /// when the region is eligible and was focused; otherwise . + public bool Focus( + CursesInteractionRegion region + ) { + ArgumentNullException.ThrowIfNull( region ); + this.ThrowIfDisposed(); + if ( !ReferenceEquals( + region.Owner, + this + ) ) { + throw new ArgumentException( + "The interaction region belongs to another router.", + nameof( region ) + ); + } + if ( region.IsDisposed ) { + throw new ObjectDisposedException( nameof( region ) ); + } + + this.RepairFocusIfNeeded(); + if ( !this.IsRegionEligible( region ) ) { + return false; + } + + this.focusedRegion = region; + return true; + } + + /// Clears application logical focus without changing terminal focus state. + public void ClearFocus() { + this.ThrowIfDisposed(); + this.focusedRegion = null; + } + + /// Moves logical focus through eligible regions using deterministic traversal order. + /// The traversal direction. + /// The newly focused region, or when no eligible region exists. + public CursesInteractionRegion? MoveFocus( + CursesFocusDirection direction + ) { + if ( CursesFocusDirection.Forward != direction + && CursesFocusDirection.Backward != direction ) { + throw new ArgumentOutOfRangeException( nameof( direction ) ); + } + this.ThrowIfDisposed(); + this.RepairFocusIfNeeded(); + + CursesInteractionRegion? next; + if ( this.focusedRegion is null ) { + next = CursesFocusDirection.Forward == direction + ? this.FindFirstEligibleRegion() + : this.FindLastEligibleRegion() + ; + } else if ( CursesFocusDirection.Forward == direction ) { + next = this.FindNextEligibleRegion( + this.focusedRegion.TraversalOrder, + this.focusedRegion.RegistrationOrdinal + ); + } else { + next = this.FindPreviousEligibleRegion( + this.focusedRegion.TraversalOrder, + this.focusedRegion.RegistrationOrdinal + ); + } + + this.focusedRegion = next; + return next; + } + /// Resolves the highest-precedence enabled interaction region at one screen coordinate. /// The non-negative zero-based screen row. /// The non-negative zero-based screen column. @@ -176,6 +257,7 @@ public void Dispose() { } this.disposed = true; + this.focusedRegion = null; CursesInteractionRegion[] snapshot = this.regions.ToArray(); foreach ( CursesInteractionRegion region in snapshot ) { region.Dispose(); @@ -183,6 +265,31 @@ public void Dispose() { this.regions.Clear(); } + internal void HandleRegionEligibilityChanged( + CursesInteractionRegion region + ) { + ArgumentNullException.ThrowIfNull( region ); + if ( !ReferenceEquals( + region.Owner, + this + ) ) { + throw new ArgumentException( + "The interaction region belongs to another router.", + nameof( region ) + ); + } + + if ( ReferenceEquals( + this.focusedRegion, + region + ) && !this.IsRegionEligible( region ) ) { + this.focusedRegion = this.FindNextEligibleRegion( + region.TraversalOrder, + region.RegistrationOrdinal + ); + } + } + internal void RemoveRegion( CursesInteractionRegion region ) { @@ -197,11 +304,110 @@ CursesInteractionRegion region ); } + bool wasFocused = ReferenceEquals( + this.focusedRegion, + region + ); + int traversalOrder = region.TraversalOrder; + long registrationOrdinal = region.RegistrationOrdinal; if ( !this.regions.Remove( region ) ) { throw new InvalidOperationException( "The interaction region is not registered with this router." ); } + + if ( wasFocused ) { + this.focusedRegion = this.FindNextEligibleRegion( + traversalOrder, + registrationOrdinal + ); + } + } + + private CursesInteractionRegion? FindFirstEligibleRegion() { + CursesInteractionRegion? selected = null; + foreach ( CursesInteractionRegion region in this.regions ) { + if ( !this.IsRegionEligible( region ) ) { + continue; + } + if ( selected is null + || IsTraversalBefore( + region, + selected + ) ) { + selected = region; + } + } + return selected; + } + + private CursesInteractionRegion? FindLastEligibleRegion() { + CursesInteractionRegion? selected = null; + foreach ( CursesInteractionRegion region in this.regions ) { + if ( !this.IsRegionEligible( region ) ) { + continue; + } + if ( selected is null + || IsTraversalAfter( + region, + selected + ) ) { + selected = region; + } + } + return selected; + } + + private CursesInteractionRegion? FindNextEligibleRegion( + int traversalOrder, + long registrationOrdinal + ) { + CursesInteractionRegion? selected = null; + foreach ( CursesInteractionRegion region in this.regions ) { + if ( !this.IsRegionEligible( region ) + || !IsTraversalAfter( + region, + traversalOrder, + registrationOrdinal + ) ) { + continue; + } + if ( selected is null + || IsTraversalBefore( + region, + selected + ) ) { + selected = region; + } + } + + return selected ?? this.FindFirstEligibleRegion(); + } + + private CursesInteractionRegion? FindPreviousEligibleRegion( + int traversalOrder, + long registrationOrdinal + ) { + CursesInteractionRegion? selected = null; + foreach ( CursesInteractionRegion region in this.regions ) { + if ( !this.IsRegionEligible( region ) + || !IsTraversalBefore( + region, + traversalOrder, + registrationOrdinal + ) ) { + continue; + } + if ( selected is null + || IsTraversalAfter( + region, + selected + ) ) { + selected = region; + } + } + + return selected ?? this.FindLastEligibleRegion(); } private bool IsPreferredHitCandidate( @@ -238,6 +444,106 @@ CursesInteractionRegion selected return candidate.RegistrationOrdinal > selected.RegistrationOrdinal; } + private bool IsRegionEligible( + CursesInteractionRegion region + ) { + if ( region.IsDisposed + || !region.IsEnabled + || !region.IsFocusable ) { + return false; + } + + CursesRectangle bounds = region.Bounds; + if ( bounds.IsEmpty ) { + return false; + } + + CursesPanel? panel = region.Panel; + if ( panel is null ) { + return bounds.Row < this.Screen.Rows + && bounds.Column < this.Screen.Columns; + } + + if ( panel.IsDisposed || !panel.IsVisible ) { + return false; + } + + long top = (long)panel.Row + bounds.Row; + long left = (long)panel.Column + bounds.Column; + long bottom = Math.Min( + (long)this.Screen.Rows, + Math.Min( + (long)panel.Row + panel.Rows, + top + bounds.Rows + ) + ); + long right = Math.Min( + (long)this.Screen.Columns, + Math.Min( + (long)panel.Column + panel.Columns, + left + bounds.Columns + ) + ); + + return top < bottom && left < right; + } + + private void RepairFocusIfNeeded() { + if ( this.focusedRegion is null + || this.IsRegionEligible( this.focusedRegion ) ) { + return; + } + + int traversalOrder = this.focusedRegion.TraversalOrder; + long registrationOrdinal = this.focusedRegion.RegistrationOrdinal; + this.focusedRegion = this.FindNextEligibleRegion( + traversalOrder, + registrationOrdinal + ); + } + + private static bool IsTraversalAfter( + CursesInteractionRegion candidate, + CursesInteractionRegion reference + ) { + return IsTraversalAfter( + candidate, + reference.TraversalOrder, + reference.RegistrationOrdinal + ); + } + + private static bool IsTraversalAfter( + CursesInteractionRegion candidate, + int traversalOrder, + long registrationOrdinal + ) { + return candidate.TraversalOrder > traversalOrder + || ( candidate.TraversalOrder == traversalOrder + && candidate.RegistrationOrdinal > registrationOrdinal ); + } + + private static bool IsTraversalBefore( + CursesInteractionRegion candidate, + CursesInteractionRegion reference + ) { + return IsTraversalBefore( + candidate, + reference.TraversalOrder, + reference.RegistrationOrdinal + ); + } + + private static bool IsTraversalBefore( + CursesInteractionRegion candidate, + int traversalOrder, + long registrationOrdinal + ) { + return candidate.TraversalOrder < traversalOrder + || ( candidate.TraversalOrder == traversalOrder + && candidate.RegistrationOrdinal < registrationOrdinal ); + } + private static bool TryGetLocalCoordinates( CursesInteractionRegion region, int row, From d380fa0033f7d3c6d0932167f1c31cc7594be5af Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 16:56:51 -0400 Subject: [PATCH 19/90] Advance 1.4 API fingerprint for T1404 --- docs/Public-API-Fingerprint-1.4.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/Public-API-Fingerprint-1.4.json b/docs/Public-API-Fingerprint-1.4.json index aeaa1bffb..19b446a5d 100644 --- a/docs/Public-API-Fingerprint-1.4.json +++ b/docs/Public-API-Fingerprint-1.4.json @@ -2,9 +2,9 @@ "schema": 1, "release": "1.4.0-alpha.1", "status": "alpha", - "sha256": "8a54fa1b78ac9b4eea2b4ad60ac671dce2c36fa8a8ccc45ad953c814cb7386d5", - "exportedTypeCount": 55, - "contractLineCount": 437, + "sha256": "2fa4edb0c303c3cfb88c9c5aa0c3dd2386dc1022e27aa506d369fddc622ae3a6", + "exportedTypeCount": 56, + "contractLineCount": 443, "exportedTypes": [ "Icod.DCurses.CursesAlertKind", "Icod.DCurses.CursesAmbiguousWidthPolicy", @@ -16,6 +16,7 @@ "Icod.DCurses.CursesDockEdge", "Icod.DCurses.CursesEvent", "Icod.DCurses.CursesEventKind", + "Icod.DCurses.CursesFocusDirection", "Icod.DCurses.CursesFocusEvent", "Icod.DCurses.CursesFocusState", "Icod.DCurses.CursesHyperlink", From 7ab6ab0ba20ac5e8e6107bccb13a7ce0cab5be02 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:29:00 -0400 Subject: [PATCH 20/90] Record T1404 logical focus and traversal --- ...1404-Logical-Focus-Traversal-and-Repair.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/T1404-Logical-Focus-Traversal-and-Repair.md diff --git a/docs/T1404-Logical-Focus-Traversal-and-Repair.md b/docs/T1404-Logical-Focus-Traversal-and-Repair.md new file mode 100644 index 000000000..a043602c3 --- /dev/null +++ b/docs/T1404-Logical-Focus-Traversal-and-Repair.md @@ -0,0 +1,230 @@ +# T1404 — Logical Focus, Traversal, and Repair + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1404 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** implementation/fingerprint head qualified; documentation-complete head requires its own exact-head matrix + +--- + +## Objective + +T1404 adds router-owned application logical focus which remains completely distinct from terminal/window-manager focus reports. It implements explicit focus, deterministic forward/backward traversal, wrapping, and repair when the focused interaction region becomes ineligible. + +The tranche does not add input routing, command dispatch, callbacks, mouse focus policy, terminal protocol work, or terminal I/O. + +## RED checkpoint + +The T1404 focus tests were committed first at: + +`3785c26610cfc25bb9b2de485e0b9670e3a2ebd9` + +Workflow #737 / `34718226058` failed as intended with zero warnings because the frozen T1404 public surface did not yet exist: + +- `CursesFocusDirection`; +- `CursesInteractionRouter.FocusedRegion`; +- `CursesInteractionRouter.Focus(...)`; +- `CursesInteractionRouter.ClearFocus()`; +- `CursesInteractionRouter.MoveFocus(...)`. + +No unrelated production regression was present in the RED run. + +The RED suite covers: + +- explicit focus and idempotent clear; +- null/foreign/disposed region validation; +- ineligible focus attempts preserving existing focus; +- traversal ordering by `TraversalOrder` then registration ordinal; +- forward and backward wrap; +- no-current-focus direction semantics; +- no-eligible-region behavior; +- undefined direction validation; +- immediate repair when the current region becomes disabled, non-focusable, fully clipped by a region-owned bounds change, or disposed; +- repair from a disposed region's former traversal slot; +- lazy repair after screen clipping; +- lazy repair after panel hide/resize changes; +- proof that pure `HitTest(...)` does not repair logical focus; +- disposed-router behavior for all focus operations. + +## Public surface + +T1404 adds exactly one exported type and four public router members: + +```csharp +public enum CursesFocusDirection { + Forward = 0, + Backward = 1 +} + +public sealed partial-contract CursesInteractionRouter { + public CursesInteractionRegion? FocusedRegion { get; } + + public bool Focus( CursesInteractionRegion region ); + public void ClearFocus(); + public CursesInteractionRegion? MoveFocus( CursesFocusDirection direction ); +} +``` + +The implementation landed at: + +`5ef19026b3842eb71fbee95a1bba3509d1f39338` + +## Logical-focus ownership + +Logical focus is state owned by `CursesInteractionRouter` only. It does not represent terminal focus and is not driven by `CursesFocusEvent`. + +A terminal `Focused` or `Unfocused` input event therefore cannot implicitly replace or clear the router's logical focused region. + +The router does not subscribe to hidden screen/panel lifecycle callbacks merely to maintain focus. This preserves the T1401 application-owned/single-writer architecture. + +## Explicit focus + +`Focus(region)`: + +1. validates null, router ownership, router disposal, and region disposal; +2. repairs any stale existing focus before applying the request; +3. returns `false` without changing the repaired current focus if the requested region is not currently focus eligible; +4. otherwise stores the supplied region and returns `true`. + +`ClearFocus()` is idempotent and performs no traversal. + +Reading `FocusedRegion` is a focus-sensitive operation and therefore performs lazy repair before returning the current value. + +## Eligibility + +T1404 uses the T1401 frozen focus-eligibility contract. A region is focus eligible only when all of the following hold: + +- it is not disposed; +- `IsEnabled` is true; +- `IsFocusable` is true; +- its effective clipped geometry is non-empty; +- when panel-associated, the current panel is visible and not disposed. + +Ordinary region eligibility is clipped against the current screen. Panel-associated eligibility is clipped against current region bounds, panel bounds, and screen bounds. + +No duplicate geometry cache or background observer graph is introduced. + +## Traversal + +Eligible regions are ordered by the exact frozen tuple: + +```text +(ascending TraversalOrder, ascending registration ordinal) +``` + +The implementation uses repeated bounded scans of the router's existing region registry rather than sorting or allocating a temporary traversal collection. + +`MoveFocus(Forward)` always selects the next eligible traversal slot and wraps to the first eligible region. + +`MoveFocus(Backward)` always selects the previous eligible traversal slot and wraps to the last eligible region. + +With no current focus: + +- `Forward` chooses the first eligible region; +- `Backward` chooses the last eligible region. + +With no eligible regions, traversal stores and returns `null`. + +Undefined `CursesFocusDirection` values fail with `ArgumentOutOfRangeException` before focus mutation. + +## Repair + +Repair direction is always forward, independent of the user's most recent traversal direction. + +When the current region becomes ineligible, repair selects the first eligible region after the former traversal slot and wraps to the first eligible region when needed. If no eligible region remains, focus becomes `null`. + +### Immediate repair + +Region-owned changes can notify the owning router directly and therefore repair immediately when they invalidate the current focused region: + +- `IsEnabled = false`; +- `IsFocusable = false`; +- `SetBounds(...)` producing no effective visible area; +- region disposal. + +Region disposal captures the former traversal tuple before removing the region, then repairs from that former slot. Registration ordinals remain monotonic and unreused. + +### Lazy repair + +External geometry/state changes are not subscribed through a background graph. They repair at focus-sensitive boundaries instead: + +- reading `FocusedRegion`; +- `Focus(...)`; +- `MoveFocus(...)`. + +This covers current screen size and current panel visibility, size, position, and disposal state. + +Later T1406 routing will add the other T1401-frozen focus-sensitive boundary: routing text/key/paste input. + +Pure `HitTest(...)` intentionally remains focus-neutral and does not repair or mutate focus. + +## Implementation properties + +The focus/traversal core: + +- owns no background work; +- performs no terminal input or output; +- invokes no application callbacks; +- uses the existing bounded region registry; +- creates no unbounded focus history; +- performs traversal/repair with nonallocating scans in the normal path; +- leaves mouse focus policy entirely application-owned. + +Router disposal clears focus before disposing/removing owned regions so teardown does not perform unnecessary repair churn. + +## First GREEN evidence and API guard + +Workflow #738 / `34718456495` built the complete solution cleanly with zero warnings. + +Across `net8.0`, `net9.0`, and `net10.0`, all **655 behavioral tests passed**. The only failure was the expected current-development public API fingerprint guard. + +The compiler-derived T1404 contract was identical on all three TFMs: + +```text +56 exported types +443 canonical declared contract lines +sha256 2fa4edb0c303c3cfb88c9c5aa0c3dd2386dc1022e27aa506d369fddc622ae3a6 +``` + +No behavioral implementation correction was required after this run. + +## Fingerprint qualification + +`docs/Public-API-Fingerprint-1.4.json` was advanced to the compiler-derived contract at: + +`d380fa0033f7d3c6d0932167f1c31cc7594be5af` + +Workflow #739 / `34718551272` then passed all seven PR jobs on that exact head: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +This qualifies the T1404 implementation and the current alpha API fingerprint together. + +## Scope audit + +T1404 deliberately does **not** add: + +- automatic focus on mouse hit/click; +- terminal focus coupling; +- semantic key gestures; +- command identities/bindings; +- structured input routing; +- pointer-shape behavior; +- event bubbling/capture; +- callback dispatch; +- terminal protocol parsing or I/O. + +Those remain assigned to later tranches exactly as frozen by T1401. + +## Exit gate + +T1404 is complete when this documentation-complete head passes the full PR package/runtime matrix. After that exact-head qualification, T1405 may begin the semantic keyboard gesture model with a fresh RED checkpoint. From 2b6688203fc932dca2ed18b73631620595fe722a Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:35:11 -0400 Subject: [PATCH 21/90] Add failing T1405 semantic gesture tests --- .../src/CursesKeyGestureTests.cs | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesKeyGestureTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesKeyGestureTests.cs b/tests/Icod.DCurses.Tests/src/CursesKeyGestureTests.cs new file mode 100644 index 000000000..c39e1daae --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesKeyGestureTests.cs @@ -0,0 +1,417 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using System.Text; +using Xunit; + +namespace Icod.DCurses.Tests; + +public sealed class CursesKeyGestureTests { + [Fact] + public void NamedKeyFactoryProducesFrozenIdentity() { + CursesKeyGesture gesture = CursesKeyGesture.ForKey( + CursesKey.Tab, + CursesKeyModifiers.Control | CursesKeyModifiers.Alt, + CursesKeyEventPhase.Release + ); + + Assert.Equal( CursesKey.Tab, gesture.Key ); + Assert.Null( gesture.Character ); + Assert.Equal( + CursesKeyModifiers.Control | CursesKeyModifiers.Alt, + gesture.Modifiers + ); + Assert.Equal( CursesKeyEventPhase.Release, gesture.Phase ); + Assert.Null( gesture.FunctionKeyNumber ); + } + + [Fact] + public void CharacterFactoryProducesFrozenIdentity() { + Rune character = new( 0x03bb ); + CursesKeyGesture gesture = CursesKeyGesture.ForCharacter( + character, + CursesKeyModifiers.Shift | CursesKeyModifiers.Meta, + CursesKeyEventPhase.Repeat + ); + + Assert.Equal( CursesKey.Character, gesture.Key ); + Assert.Equal( character, gesture.Character ); + Assert.Equal( + CursesKeyModifiers.Shift | CursesKeyModifiers.Meta, + gesture.Modifiers + ); + Assert.Equal( CursesKeyEventPhase.Repeat, gesture.Phase ); + Assert.Null( gesture.FunctionKeyNumber ); + } + + [Fact] + public void FunctionFactoryProducesFrozenIdentity() { + CursesKeyGesture gesture = CursesKeyGesture.ForFunctionKey( + 12, + CursesKeyModifiers.Super, + CursesKeyEventPhase.Press + ); + + Assert.Equal( CursesKey.Function, gesture.Key ); + Assert.Null( gesture.Character ); + Assert.Equal( CursesKeyModifiers.Super, gesture.Modifiers ); + Assert.Equal( CursesKeyEventPhase.Press, gesture.Phase ); + Assert.Equal( 12, gesture.FunctionKeyNumber ); + } + + [Fact] + public void FactoriesDefaultToPressWithoutModifiers() { + CursesKeyGesture named = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesKeyGesture character = CursesKeyGesture.ForCharacter( new Rune( 'x' ) ); + CursesKeyGesture function = CursesKeyGesture.ForFunctionKey( 1 ); + + Assert.Equal( CursesKeyEventPhase.Press, named.Phase ); + Assert.Equal( CursesKeyModifiers.None, named.Modifiers ); + Assert.Equal( CursesKeyEventPhase.Press, character.Phase ); + Assert.Equal( CursesKeyModifiers.None, character.Modifiers ); + Assert.Equal( CursesKeyEventPhase.Press, function.Phase ); + Assert.Equal( CursesKeyModifiers.None, function.Modifiers ); + } + + [Theory] + [InlineData( CursesKey.None )] + [InlineData( CursesKey.Character )] + [InlineData( CursesKey.Function )] + [InlineData( CursesKey.Space )] + [InlineData( CursesKey.Unrecognized )] + public void NamedKeyFactoryRejectsDedicatedOrNonBindableKeyForms( + CursesKey key + ) { + Assert.Throws( + () => { + _ = CursesKeyGesture.ForKey( key ); + } + ); + } + + [Fact] + public void NamedKeyFactoryRejectsUndefinedKey() { + Assert.Throws( + () => { + _ = CursesKeyGesture.ForKey( (CursesKey)999 ); + } + ); + } + + [Theory] + [InlineData( -1 )] + [InlineData( 64 )] + public void FunctionFactoryRejectsNumbersOutsideExistingRange( + int functionKeyNumber + ) { + Assert.Throws( + () => { + _ = CursesKeyGesture.ForFunctionKey( functionKeyNumber ); + } + ); + } + + [Theory] + [InlineData( CursesKeyModifiers.CapsLock )] + [InlineData( CursesKeyModifiers.NumLock )] + [InlineData( CursesKeyModifiers.Control | CursesKeyModifiers.CapsLock )] + [InlineData( (CursesKeyModifiers)256 )] + public void FactoriesRejectKeyboardStateAndUnknownModifierFlags( + CursesKeyModifiers modifiers + ) { + Assert.Throws( + () => { + _ = CursesKeyGesture.ForKey( + CursesKey.Enter, + modifiers + ); + } + ); + Assert.Throws( + () => { + _ = CursesKeyGesture.ForCharacter( + new Rune( 'x' ), + modifiers + ); + } + ); + Assert.Throws( + () => { + _ = CursesKeyGesture.ForFunctionKey( + 1, + modifiers + ); + } + ); + } + + [Fact] + public void FactoriesRejectUndefinedPhase() { + CursesKeyEventPhase invalid = (CursesKeyEventPhase)99; + + Assert.Throws( + () => { + _ = CursesKeyGesture.ForKey( + CursesKey.Enter, + phase: invalid + ); + } + ); + Assert.Throws( + () => { + _ = CursesKeyGesture.ForCharacter( + new Rune( 'x' ), + phase: invalid + ); + } + ); + Assert.Throws( + () => { + _ = CursesKeyGesture.ForFunctionKey( + 1, + phase: invalid + ); + } + ); + } + + [Fact] + public void EqualityAndHashCodeUseOnlyFrozenSemanticIdentity() { + CursesKeyGesture first = CursesKeyGesture.ForCharacter( + new Rune( 'i' ), + CursesKeyModifiers.Control, + CursesKeyEventPhase.Release + ); + CursesKeyGesture equal = CursesKeyGesture.ForCharacter( + new Rune( 'i' ), + CursesKeyModifiers.Control, + CursesKeyEventPhase.Release + ); + CursesKeyGesture differentCase = CursesKeyGesture.ForCharacter( + new Rune( 'I' ), + CursesKeyModifiers.Control, + CursesKeyEventPhase.Release + ); + + Assert.Equal( first, equal ); + Assert.Equal( first.GetHashCode(), equal.GetHashCode() ); + Assert.NotEqual( first, differentCase ); + } + + [Fact] + public void NamedKeyMatchingMasksLockStateButRequiresBindingModifiersAndPhase() { + CursesKeyGesture gesture = CursesKeyGesture.ForKey( + CursesKey.Tab, + CursesKeyModifiers.Control, + CursesKeyEventPhase.Repeat + ); + CursesInputEvent matching = CursesInputEvent.FromKey( + CursesKey.Tab, + CursesKeyModifiers.Control + | CursesKeyModifiers.CapsLock + | CursesKeyModifiers.NumLock, + keyPhase: CursesKeyEventPhase.Repeat + ); + CursesInputEvent wrongModifier = CursesInputEvent.FromKey( + CursesKey.Tab, + CursesKeyModifiers.Alt, + keyPhase: CursesKeyEventPhase.Repeat + ); + CursesInputEvent wrongPhase = CursesInputEvent.FromKey( + CursesKey.Tab, + CursesKeyModifiers.Control, + keyPhase: CursesKeyEventPhase.Press + ); + + Assert.True( gesture.Matches( matching ) ); + Assert.False( gesture.Matches( wrongModifier ) ); + Assert.False( gesture.Matches( wrongPhase ) ); + } + + [Fact] + public void LockKeysRemainBindableNamedKeys() { + CursesKeyGesture capsLock = CursesKeyGesture.ForKey( CursesKey.CapsLock ); + CursesKeyGesture numLock = CursesKeyGesture.ForKey( CursesKey.NumLock ); + + Assert.True( + capsLock.Matches( + CursesInputEvent.FromKey( + CursesKey.CapsLock, + CursesKeyModifiers.CapsLock + ) + ) + ); + Assert.True( + numLock.Matches( + CursesInputEvent.FromKey( + CursesKey.NumLock, + CursesKeyModifiers.NumLock + ) + ) + ); + } + + [Fact] + public void FunctionKeyMatchingRequiresExactFunctionNumber() { + CursesKeyGesture gesture = CursesKeyGesture.ForFunctionKey( + 7, + CursesKeyModifiers.Alt + ); + + Assert.True( + gesture.Matches( + CursesInputEvent.FromKey( + CursesKey.Function, + CursesKeyModifiers.Alt, + functionKeyNumber: 7 + ) + ) + ); + Assert.False( + gesture.Matches( + CursesInputEvent.FromKey( + CursesKey.Function, + CursesKeyModifiers.Alt, + functionKeyNumber: 8 + ) + ) + ); + } + + [Fact] + public void CharacterPressMatchesTraditionalTextAndModernCharacterKey() { + CursesKeyGesture gesture = CursesKeyGesture.ForCharacter( new Rune( 'x' ) ); + CursesInputEvent text = CursesInputEvent.FromText( new Rune( 'x' ) ); + CursesInputEvent modern = CursesInputEvent.FromKey( + CursesKey.Character, + character: new Rune( 'x' ) + ); + + Assert.True( gesture.Matches( text ) ); + Assert.True( gesture.Matches( modern ) ); + } + + [Fact] + public void CharacterSpacePressAlsoMatchesSemanticSpaceKey() { + CursesKeyGesture gesture = CursesKeyGesture.ForCharacter( new Rune( ' ' ) ); + + Assert.True( + gesture.Matches( + CursesInputEvent.FromText( new Rune( ' ' ) ) + ) + ); + Assert.True( + gesture.Matches( + CursesInputEvent.FromKey( CursesKey.Space ) + ) + ); + } + + [Fact] + public void TraditionalTextDoesNotFabricateModifiersOrRepeatReleasePhase() { + CursesInputEvent text = CursesInputEvent.FromText( new Rune( 'x' ) ); + + Assert.False( + CursesKeyGesture.ForCharacter( + new Rune( 'x' ), + CursesKeyModifiers.Control + ).Matches( text ) + ); + Assert.False( + CursesKeyGesture.ForCharacter( + new Rune( 'x' ), + phase: CursesKeyEventPhase.Repeat + ).Matches( text ) + ); + Assert.False( + CursesKeyGesture.ForCharacter( + new Rune( 'x' ), + phase: CursesKeyEventPhase.Release + ).Matches( text ) + ); + } + + [Fact] + public void CharacterMatchingUsesOnlyPrimaryCharacterIdentity() { + CursesInputEvent input = CursesInputEvent.FromKey( + CursesKey.Character, + CursesKeyModifiers.None, + new Rune( 'a' ), + functionKeyNumber: null, + CursesKeyEventPhase.Press, + new Rune( 'A' ), + new Rune( 'q' ), + "x" + ); + + Assert.True( + CursesKeyGesture.ForCharacter( new Rune( 'a' ) ).Matches( input ) + ); + Assert.False( + CursesKeyGesture.ForCharacter( new Rune( 'A' ) ).Matches( input ) + ); + Assert.False( + CursesKeyGesture.ForCharacter( new Rune( 'q' ) ).Matches( input ) + ); + Assert.False( + CursesKeyGesture.ForCharacter( new Rune( 'x' ) ).Matches( input ) + ); + } + + [Fact] + public void CharacterRepeatAndReleaseRequireExplicitModernKeyPhases() { + CursesKeyGesture repeat = CursesKeyGesture.ForCharacter( + new Rune( 'x' ), + phase: CursesKeyEventPhase.Repeat + ); + CursesKeyGesture release = CursesKeyGesture.ForCharacter( + new Rune( 'x' ), + phase: CursesKeyEventPhase.Release + ); + + Assert.True( + repeat.Matches( + CursesInputEvent.FromKey( + CursesKey.Character, + character: new Rune( 'x' ), + keyPhase: CursesKeyEventPhase.Repeat + ) + ) + ); + Assert.True( + release.Matches( + CursesInputEvent.FromKey( + CursesKey.Character, + character: new Rune( 'x' ), + keyPhase: CursesKeyEventPhase.Release + ) + ) + ); + } + + [Fact] + public void GestureDoesNotMatchNonKeyboardInput() { + CursesKeyGesture gesture = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesInputEvent endOfInput = CursesInputEvent.EndOfInput(); + + Assert.False( gesture.Matches( endOfInput ) ); + } +} From ef234ff8da74b71eb5f7846fc9a17ec0245061c5 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:36:51 -0400 Subject: [PATCH 22/90] Implement T1405 semantic key gestures --- src/CursesKeyGesture.cs | 218 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/CursesKeyGesture.cs diff --git a/src/CursesKeyGesture.cs b/src/CursesKeyGesture.cs new file mode 100644 index 000000000..4ba60f5d8 --- /dev/null +++ b/src/CursesKeyGesture.cs @@ -0,0 +1,218 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +using System.Text; + +/// +/// Represents one immutable terminal-independent semantic keyboard gesture. +/// +public readonly record struct CursesKeyGesture { + private const CursesKeyModifiers BindingModifiers = + CursesKeyModifiers.Shift + | CursesKeyModifiers.Control + | CursesKeyModifiers.Alt + | CursesKeyModifiers.Super + | CursesKeyModifiers.Hyper + | CursesKeyModifiers.Meta; + + private CursesKeyGesture( + CursesKey key, + Rune? character, + CursesKeyModifiers modifiers, + CursesKeyEventPhase phase, + int? functionKeyNumber + ) { + this.Key = key; + this.Character = character; + this.Modifiers = modifiers; + this.Phase = phase; + this.FunctionKeyNumber = functionKeyNumber; + } + + /// Gets the semantic key identity. + public CursesKey Key { + get; + } + + /// Gets the Unicode scalar identity for a character gesture. + public Rune? Character { + get; + } + + /// Gets the normalized binding modifiers. + public CursesKeyModifiers Modifiers { + get; + } + + /// Gets the semantic key-event phase. + public CursesKeyEventPhase Phase { + get; + } + + /// Gets the function-key number for a function-key gesture. + public int? FunctionKeyNumber { + get; + } + + /// Creates a gesture for one named semantic key. + /// The named semantic key. + /// The binding modifiers. + /// The key-event phase. + /// The immutable gesture. + public static CursesKeyGesture ForKey( + CursesKey key, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ) { + if ( !Enum.IsDefined( key ) ) { + throw new ArgumentOutOfRangeException( nameof( key ) ); + } + ValidateModifiers( modifiers ); + ValidatePhase( phase ); + if ( CursesKey.None == key + || CursesKey.Character == key + || CursesKey.Function == key + || CursesKey.Space == key + || CursesKey.Unrecognized == key ) { + throw new ArgumentException( + "The key must be a bindable named-key identity.", + nameof( key ) + ); + } + + return new CursesKeyGesture( + key, + character: null, + modifiers, + phase, + functionKeyNumber: null + ); + } + + /// Creates a gesture for one Unicode character identity. + /// The Unicode scalar identity. + /// The binding modifiers. + /// The key-event phase. + /// The immutable gesture. + public static CursesKeyGesture ForCharacter( + Rune character, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ) { + ValidateModifiers( modifiers ); + ValidatePhase( phase ); + + return new CursesKeyGesture( + CursesKey.Character, + character, + modifiers, + phase, + functionKeyNumber: null + ); + } + + /// Creates a gesture for one numbered function key. + /// The function-key number in the existing DCurses range 0 through 63. + /// The binding modifiers. + /// The key-event phase. + /// The immutable gesture. + public static CursesKeyGesture ForFunctionKey( + int functionKeyNumber, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ) { + if ( functionKeyNumber is < 0 or > 63 ) { + throw new ArgumentOutOfRangeException( nameof( functionKeyNumber ) ); + } + ValidateModifiers( modifiers ); + ValidatePhase( phase ); + + return new CursesKeyGesture( + CursesKey.Function, + character: null, + modifiers, + phase, + functionKeyNumber + ); + } + + /// Determines whether one normalized input event matches this gesture. + /// The normalized DCurses input event. + /// when the event has this semantic gesture identity. + internal bool Matches( + CursesInputEvent input + ) { + ArgumentNullException.ThrowIfNull( input ); + + if ( CursesInputEventKind.Text == input.Kind ) { + return CursesKey.Character == this.Key + && CursesKeyEventPhase.Press == this.Phase + && CursesKeyModifiers.None == this.Modifiers + && input.Character == this.Character; + } + if ( CursesInputEventKind.Key != input.Kind ) { + return false; + } + + CursesKeyModifiers modifiers = input.Modifiers & BindingModifiers; + if ( modifiers != this.Modifiers + || input.KeyPhase != this.Phase ) { + return false; + } + + if ( CursesKey.Character == this.Key ) { + if ( CursesKey.Character == input.Key ) { + return input.Character == this.Character; + } + + return CursesKey.Space == input.Key + && this.Character == new Rune( ' ' ); + } + + if ( CursesKey.Function == this.Key ) { + return CursesKey.Function == input.Key + && input.FunctionKeyNumber == this.FunctionKeyNumber; + } + + return input.Key == this.Key; + } + + /// Gets whether this value was created as a valid bindable gesture. + internal bool IsBindable => CursesKey.None != this.Key; + + private static void ValidateModifiers( + CursesKeyModifiers modifiers + ) { + if ( CursesKeyModifiers.None != ( modifiers & ~BindingModifiers ) ) { + throw new ArgumentOutOfRangeException( nameof( modifiers ) ); + } + } + + private static void ValidatePhase( + CursesKeyEventPhase phase + ) { + if ( !Enum.IsDefined( phase ) ) { + throw new ArgumentOutOfRangeException( nameof( phase ) ); + } + } +} From e755b29951ba7446e1f7adcca45d9cb08cc3160c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:38:36 -0400 Subject: [PATCH 23/90] Advance 1.4 API fingerprint for T1405 --- docs/Public-API-Fingerprint-1.4.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/Public-API-Fingerprint-1.4.json b/docs/Public-API-Fingerprint-1.4.json index 19b446a5d..6dd252a7c 100644 --- a/docs/Public-API-Fingerprint-1.4.json +++ b/docs/Public-API-Fingerprint-1.4.json @@ -2,9 +2,9 @@ "schema": 1, "release": "1.4.0-alpha.1", "status": "alpha", - "sha256": "2fa4edb0c303c3cfb88c9c5aa0c3dd2386dc1022e27aa506d369fddc622ae3a6", - "exportedTypeCount": 56, - "contractLineCount": 443, + "sha256": "638288580b66974106913742dcb52268a2826bf9e30c08350334a8fb5af148b4", + "exportedTypeCount": 57, + "contractLineCount": 458, "exportedTypes": [ "Icod.DCurses.CursesAlertKind", "Icod.DCurses.CursesAmbiguousWidthPolicy", @@ -32,6 +32,7 @@ "Icod.DCurses.CursesInteractionRouter", "Icod.DCurses.CursesKey", "Icod.DCurses.CursesKeyEventPhase", + "Icod.DCurses.CursesKeyGesture", "Icod.DCurses.CursesKeyModifiers", "Icod.DCurses.CursesKeyboardReportingMode", "Icod.DCurses.CursesLayout", From bf57ed9b87a40d762b969c09c88d44a0bbd0678b Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:41:55 -0400 Subject: [PATCH 24/90] Record T1405 semantic keyboard gestures --- docs/T1405-Semantic-Keyboard-Gestures.md | 215 +++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/T1405-Semantic-Keyboard-Gestures.md diff --git a/docs/T1405-Semantic-Keyboard-Gestures.md b/docs/T1405-Semantic-Keyboard-Gestures.md new file mode 100644 index 000000000..17b7b1e40 --- /dev/null +++ b/docs/T1405-Semantic-Keyboard-Gestures.md @@ -0,0 +1,215 @@ +# T1405 — Semantic Keyboard Gestures + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1405 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** implementation/fingerprint head qualified; documentation-complete head requires its own exact-head matrix + +--- + +## Objective + +T1405 adds the immutable semantic key-gesture value frozen by T1401. It gives later command routing a terminal-independent identity for named keys, Unicode characters, function keys, modifiers, and key-event phase without exposing protocol families or terminal-brand heuristics. + +This tranche does not add command identities, binding registries, input routing, callbacks, pointer behavior, or terminal I/O. + +## RED checkpoint + +The T1405 gesture tests were committed first at: + +`2b6688203fc932dca2ed18b73631620595fe722a` + +Workflow #741 / `34720375290` failed as intended. The Linux ARM64 build recorded zero warnings and failed only because `CursesKeyGesture` did not yet exist (`CS0246` / `CS0103`). There was no unrelated source regression. + +The RED suite covers: + +- named-key, character, and function-key factory identity; +- default Press phase and no modifiers; +- rejection of dedicated/non-bindable named-key forms; +- undefined key/phase validation; +- function-key range `0..63`; +- rejection of CapsLock/NumLock state flags in binding identity; +- rejection of unknown modifier bits; +- exact ordinal/culture-independent value equality and hash behavior; +- masking CapsLock/NumLock state from incoming-event modifier comparison; +- CapsLock and NumLock actual key identities remaining bindable named keys; +- exact function-key-number matching; +- traditional text / modern Character-key Press parity; +- Space normalization through a character gesture; +- no fabricated modifiers, Repeat, or Release for traditional text; +- primary `Character` as the only character gesture identity; +- explicit modern Repeat and Release matching; +- non-keyboard input rejection. + +## Public surface + +T1405 adds exactly the frozen T1401 value type: + +```csharp +public readonly record struct CursesKeyGesture { + public CursesKey Key { get; } + public Rune? Character { get; } + public CursesKeyModifiers Modifiers { get; } + public CursesKeyEventPhase Phase { get; } + public int? FunctionKeyNumber { get; } + + public static CursesKeyGesture ForKey( + CursesKey key, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ); + + public static CursesKeyGesture ForCharacter( + Rune character, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ); + + public static CursesKeyGesture ForFunctionKey( + int functionKeyNumber, + CursesKeyModifiers modifiers = CursesKeyModifiers.None, + CursesKeyEventPhase phase = CursesKeyEventPhase.Press + ); +} +``` + +The implementation landed at: + +`ef234ff8da74b71eb5f7846fc9a17ec0245061c5` + +Matching remains internal in T1405 so T1406 can consume the semantic mechanism without adding an extra public matching surface that T1401 did not freeze. + +## Gesture identity + +A gesture's equality/hash identity consists only of: + +```text +Key +Character +Modifiers +Phase +FunctionKeyNumber +``` + +The record-struct value semantics therefore remain ordinal, deterministic, culture independent, and free of terminal/protocol metadata. + +A default-zero `CursesKeyGesture` is not a valid bindable gesture. T1406 binding APIs validate this before registry mutation. + +## Named keys + +`ForKey(...)` accepts concrete bindable named key identities and rejects: + +- `CursesKey.None`; +- `CursesKey.Character`; +- `CursesKey.Function`; +- `CursesKey.Space`; +- `CursesKey.Unrecognized`; +- undefined enum values. + +`Character` and `Function` have dedicated factories. `Space` intentionally uses the character path so traditional text-space and a modern semantic Space key resolve to one application gesture identity. + +Actual lock-key identities such as `CursesKey.CapsLock` and `CursesKey.NumLock` remain ordinary bindable named keys. + +## Character gestures + +`ForCharacter(...)` stores a Unicode `Rune` as the primary semantic identity and uses `CursesKey.Character`. + +For Press gestures, matching intentionally unifies: + +- `CursesInputEventKind.Text` carrying the same primary rune; +- modern `CursesKey.Character` key input carrying the same primary rune; +- `CursesKey.Space` for U+0020. + +Traditional text has no reported binding modifiers or key phase. It therefore matches only an unmodified Press character gesture. DCurses does not infer Control/Alt/etc. and does not fabricate Repeat/Release. + +Modern Character-key Repeat/Release can match only when the normalized key event explicitly carries the corresponding phase. + +`ShiftedCharacter`, `BaseLayoutCharacter`, and `AssociatedText` remain useful input metadata but are deliberately not alternate command-gesture identities. Only the primary `Character` participates in character gesture matching. + +## Function keys + +`ForFunctionKey(...)` accepts the existing DCurses function-key range `0..63` and stores `CursesKey.Function` plus the exact function number. + +Matching requires the same normalized function-key number in addition to modifiers and phase. + +## Modifiers + +Binding identity accepts only: + +```text +Shift +Control +Alt +Super +Hyper +Meta +``` + +`CapsLock` and `NumLock` modifier flags describe keyboard state and are rejected by gesture factories. When matching a normalized key event, those state bits are masked before comparing binding modifiers. + +Unknown modifier bits are rejected by factories rather than silently normalized. + +## Phase + +The default gesture phase is `Press`. + +`Press`, `Repeat`, and `Release` remain distinct semantic identities. Undefined enum values fail before gesture creation. + +The gesture layer does not collapse Repeat into Press and does not synthesize phases unavailable from a traditional input path. + +## First GREEN evidence and API guard + +Workflow #742 / `34720454349` built the implementation cleanly. + +Across `net8.0`, `net9.0`, and `net10.0`, all **682 behavioral/non-fingerprint tests passed**. The only failing test was the expected current-development API fingerprint guard. + +The compiler-derived T1405 contract was identical on all three TFMs: + +```text +57 exported types +458 canonical declared contract lines +sha256 638288580b66974106913742dcb52268a2826bf9e30c08350334a8fb5af148b4 +``` + +The only newly exported type is `Icod.DCurses.CursesKeyGesture`. No behavioral implementation correction was required after this run. + +## Fingerprint qualification + +`docs/Public-API-Fingerprint-1.4.json` was advanced to the compiler-derived T1405 contract at: + +`e755b29951ba7446e1f7adcca45d9cb08cc3160c` + +Workflow #743 / `34720537710` passed all seven PR jobs on that exact head: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +This qualifies the semantic gesture implementation and the current 1.4 alpha public API fingerprint together. + +## Scope audit + +T1405 deliberately does **not** add: + +- `CursesCommand`; +- local/global binding registries; +- binding precedence or duplicate handling; +- `CursesInteractionResult` or `Route(...)`; +- callbacks or automatic command execution; +- focus mutation while matching; +- mouse routing; +- pointer-shape state; +- terminal protocol parsing, queries, or output. + +Those remain assigned to T1406/T1407 exactly as frozen by T1401. + +## Exit gate + +T1405 is complete when this documentation-complete head passes the full PR package/runtime matrix. After that exact-head qualification, T1406 may begin command identities, bounded local/global bindings, and structured routing with a fresh RED checkpoint. From 0567ff1f085fa057a72026f062df14bd2a37071c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:47:14 -0400 Subject: [PATCH 25/90] Add failing T1406 command routing tests --- .../src/CursesInteractionRoutingTests.cs | 576 ++++++++++++++++++ 1 file changed, 576 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesInteractionRoutingTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionRoutingTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionRoutingTests.cs new file mode 100644 index 000000000..25feb124c --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionRoutingTests.cs @@ -0,0 +1,576 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using System.Text; +using Xunit; + +namespace Icod.DCurses.Tests; + +public sealed class CursesInteractionRoutingTests { + [Fact] + public void CommandPreservesExactOrdinalName() { + CursesCommand command = new( " Open.File " ); + CursesCommand equal = new( " Open.File " ); + CursesCommand differentCase = new( " open.file " ); + + Assert.Equal( " Open.File ", command.Name ); + Assert.Equal( " Open.File ", command.ToString() ); + Assert.Equal( command, equal ); + Assert.NotEqual( command, differentCase ); + } + + [Fact] + public void CommandRejectsNullWhitespaceAndOversizedNames() { + Assert.Throws( + () => { + _ = new CursesCommand( null! ); + } + ); + Assert.Throws( + () => { + _ = new CursesCommand( " \t\r\n" ); + } + ); + Assert.Throws( + () => { + _ = new CursesCommand( + new string( 'x', CursesCommand.MaximumNameLength + 1 ) + ); + } + ); + + CursesCommand maximum = new( + new string( 'x', CursesCommand.MaximumNameLength ) + ); + Assert.Equal( CursesCommand.MaximumNameLength, maximum.Name.Length ); + } + + [Fact] + public void RegionBindingRejectsInvalidArgumentsAndDuplicatesBeforeMutation() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( router ); + CursesKeyGesture gesture = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand first = new( "First" ); + CursesCommand second = new( "Second" ); + + Assert.Throws( + () => { + region.BindGesture( default, first ); + } + ); + Assert.Throws( + () => { + region.BindGesture( gesture, null! ); + } + ); + + region.BindGesture( gesture, first ); + Assert.Throws( + () => { + region.BindGesture( gesture, second ); + } + ); + + Assert.True( router.Focus( region ) ); + CursesInteractionResult result = router.Route( + CursesInputEvent.FromKey( CursesKey.Enter ) + ); + Assert.Equal( first, result.Command ); + + Assert.True( region.UnbindGesture( gesture ) ); + Assert.False( region.UnbindGesture( gesture ) ); + region.BindGesture( gesture, second ); + Assert.Equal( + second, + router.Route( + CursesInputEvent.FromKey( CursesKey.Enter ) + ).Command + ); + } + + [Fact] + public void GlobalBindingRejectsInvalidArgumentsAndDuplicatesBeforeMutation() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + CursesKeyGesture gesture = CursesKeyGesture.ForKey( CursesKey.Escape ); + CursesCommand first = new( "First" ); + CursesCommand second = new( "Second" ); + + Assert.Throws( + () => { + router.BindGlobalGesture( default, first ); + } + ); + Assert.Throws( + () => { + router.BindGlobalGesture( gesture, null! ); + } + ); + + router.BindGlobalGesture( gesture, first ); + Assert.Throws( + () => { + router.BindGlobalGesture( gesture, second ); + } + ); + + Assert.Equal( + first, + router.Route( + CursesInputEvent.FromKey( CursesKey.Escape ) + ).Command + ); + Assert.True( router.UnbindGlobalGesture( gesture ) ); + Assert.False( router.UnbindGlobalGesture( gesture ) ); + router.BindGlobalGesture( gesture, second ); + Assert.Equal( + second, + router.Route( + CursesInputEvent.FromKey( CursesKey.Escape ) + ).Command + ); + } + + [Fact] + public void DisposedRegionRejectsBindingOperations() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + CursesInteractionRegion region = RegisterFocusable( router ); + CursesKeyGesture gesture = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand command = new( "Enter" ); + region.Dispose(); + + Assert.Throws( + () => { + region.BindGesture( gesture, command ); + } + ); + Assert.Throws( + () => { + _ = region.UnbindGesture( gesture ); + } + ); + } + + [Fact] + public void PerRegionBindingLimitIsEnforcedAndUnbindReleasesCapacity() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( router ); + CursesCommand command = new( "Command" ); + + for ( int index = 0; index < CursesInteractionRouter.MaximumRegionGestureBindings; index++ ) { + region.BindGesture( + CharacterGesture( index ), + command + ); + } + + Assert.Throws( + () => { + region.BindGesture( + CharacterGesture( CursesInteractionRouter.MaximumRegionGestureBindings ), + command + ); + } + ); + + Assert.True( region.UnbindGesture( CharacterGesture( 0 ) ) ); + region.BindGesture( + CharacterGesture( CursesInteractionRouter.MaximumRegionGestureBindings ), + command + ); + } + + [Fact] + public void GlobalBindingLimitIsEnforcedAndUnbindReleasesCapacity() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + CursesCommand command = new( "Command" ); + + for ( int index = 0; index < CursesInteractionRouter.MaximumGlobalGestureBindings; index++ ) { + router.BindGlobalGesture( + CharacterGesture( index ), + command + ); + } + + Assert.Throws( + () => { + router.BindGlobalGesture( + CharacterGesture( CursesInteractionRouter.MaximumGlobalGestureBindings ), + command + ); + } + ); + + Assert.True( router.UnbindGlobalGesture( CharacterGesture( 0 ) ) ); + router.BindGlobalGesture( + CharacterGesture( CursesInteractionRouter.MaximumGlobalGestureBindings ), + command + ); + } + + [Fact] + public void TotalBindingLimitIsEnforcedAndRegionDisposalReleasesCapacity() { + CursesScreen screen = new( 100, 100 ); + using CursesInteractionRouter router = new( screen ); + CursesCommand command = new( "Command" ); + List regions = []; + + try { + int regionCount = CursesInteractionRouter.MaximumGestureBindings + / CursesInteractionRouter.MaximumRegionGestureBindings; + for ( int regionIndex = 0; regionIndex < regionCount; regionIndex++ ) { + CursesInteractionRegion region = RegisterFocusable( + router, + row: regionIndex % 100, + column: regionIndex / 100 + ); + regions.Add( region ); + for ( int gestureIndex = 0; gestureIndex < CursesInteractionRouter.MaximumRegionGestureBindings; gestureIndex++ ) { + region.BindGesture( + CharacterGesture( gestureIndex ), + command + ); + } + } + + using CursesInteractionRegion overflow = RegisterFocusable( + router, + row: 99, + column: 99 + ); + Assert.Throws( + () => { + overflow.BindGesture( + CharacterGesture( 300 ), + command + ); + } + ); + + regions[ 0 ].Dispose(); + overflow.BindGesture( + CharacterGesture( 300 ), + command + ); + } finally { + foreach ( CursesInteractionRegion region in regions ) { + region.Dispose(); + } + } + } + + [Fact] + public void FocusedLocalCommandPrecedesMatchingGlobalCommand() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( router ); + CursesKeyGesture gesture = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand local = new( "Local" ); + CursesCommand global = new( "Global" ); + region.BindGesture( gesture, local ); + router.BindGlobalGesture( gesture, global ); + Assert.True( router.Focus( region ) ); + + CursesInputEvent input = CursesInputEvent.FromKey( CursesKey.Enter ); + CursesInteractionResult result = router.Route( input ); + + Assert.Equal( CursesInteractionResultKind.Command, result.Kind ); + Assert.Same( input, result.Input ); + Assert.Same( region, result.Region ); + Assert.Equal( local, result.Command ); + Assert.Null( result.Hit ); + } + + [Fact] + public void GlobalCommandHasNoRegionAndDoesNotCaptureFocus() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( router ); + CursesKeyGesture gesture = CursesKeyGesture.ForKey( CursesKey.Escape ); + CursesCommand global = new( "Global" ); + router.BindGlobalGesture( gesture, global ); + Assert.True( router.Focus( region ) ); + + CursesInteractionResult result = router.Route( + CursesInputEvent.FromKey( CursesKey.Escape ) + ); + + Assert.Equal( CursesInteractionResultKind.Command, result.Kind ); + Assert.Null( result.Region ); + Assert.Equal( global, result.Command ); + Assert.Null( result.Hit ); + Assert.Same( region, router.FocusedRegion ); + } + + [Fact] + public void TextGestureRoutesThroughLocalThenGlobalBindings() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( router ); + CursesKeyGesture gesture = CursesKeyGesture.ForCharacter( new Rune( 'x' ) ); + CursesCommand local = new( "LocalX" ); + CursesCommand global = new( "GlobalX" ); + region.BindGesture( gesture, local ); + router.BindGlobalGesture( gesture, global ); + Assert.True( router.Focus( region ) ); + + CursesInputEvent input = CursesInputEvent.FromText( new Rune( 'x' ) ); + Assert.Equal( local, router.Route( input ).Command ); + + Assert.True( region.UnbindGesture( gesture ) ); + CursesInteractionResult globalResult = router.Route( input ); + Assert.Equal( CursesInteractionResultKind.Command, globalResult.Kind ); + Assert.Equal( global, globalResult.Command ); + Assert.Null( globalResult.Region ); + } + + [Fact] + public void UnmatchedKeyboardInputTargetsEligibleFocusOrIsUnrouted() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( router ); + CursesInputEvent input = CursesInputEvent.FromKey( CursesKey.Tab ); + + Assert.True( router.Focus( region ) ); + CursesInteractionResult targeted = router.Route( input ); + Assert.Equal( CursesInteractionResultKind.Targeted, targeted.Kind ); + Assert.Same( region, targeted.Region ); + Assert.Null( targeted.Command ); + Assert.Null( targeted.Hit ); + + router.ClearFocus(); + CursesInteractionResult unrouted = router.Route( input ); + Assert.Equal( CursesInteractionResultKind.Unrouted, unrouted.Kind ); + Assert.Null( unrouted.Region ); + Assert.Null( unrouted.Command ); + Assert.Null( unrouted.Hit ); + Assert.Same( input, unrouted.Input ); + } + + [Fact] + public void PasteRepairsFocusThenTargetsCurrentEligibleRegion() { + CursesScreen screen = new( 20, 10 ); + using CursesPanel panel = screen.CreatePanel( 1, 1, 3, 3 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion fallback = RegisterFocusable( + router, + traversalOrder: 0 + ); + using CursesInteractionRegion panelRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 3, 3 ) + ) { + Panel = panel, + IsFocusable = true, + TraversalOrder = 1 + } + ); + Assert.True( router.Focus( panelRegion ) ); + panel.Hide(); + CursesInputEvent input = CursesInputEvent.FromPaste( + new CursesPasteEvent( CursesPastePhase.Data, "hello" ) + ); + + CursesInteractionResult result = router.Route( input ); + + Assert.Equal( CursesInteractionResultKind.Targeted, result.Kind ); + Assert.Same( fallback, result.Region ); + Assert.Same( fallback, router.FocusedRegion ); + } + + [Fact] + public void MouseRoutesOnlyThroughHitTestingAndDoesNotChangeFocus() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion focused = RegisterFocusable( + router, + row: 0, + column: 0 + ); + using CursesInteractionRegion hitRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 4, 5, 3, 4 ) + ) + ); + Assert.True( router.Focus( focused ) ); + CursesInputEvent input = CursesInputEvent.FromMouse( + new CursesMouseEvent( + CursesMouseAction.Press, + CursesMouseButton.Primary, + column: 7, + row: 5 + ) + ); + + CursesInteractionResult result = router.Route( input ); + + Assert.Equal( CursesInteractionResultKind.Targeted, result.Kind ); + Assert.Same( input, result.Input ); + Assert.Same( hitRegion, result.Region ); + Assert.Null( result.Command ); + Assert.NotNull( result.Hit ); + Assert.Same( hitRegion, result.Hit.Region ); + Assert.Equal( 1, result.Hit.LocalRow ); + Assert.Equal( 2, result.Hit.LocalColumn ); + Assert.Same( focused, router.FocusedRegion ); + } + + [Fact] + public void MouseWithoutHitIsUnroutedAndDoesNotRepairFocus() { + CursesScreen screen = new( 20, 10 ); + using CursesPanel panel = screen.CreatePanel( 1, 1, 2, 2 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion focused = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 2, 2 ) + ) { + Panel = panel, + IsFocusable = true + } + ); + Assert.True( router.Focus( focused ) ); + panel.Hide(); + CursesInputEvent input = CursesInputEvent.FromMouse( + new CursesMouseEvent( + CursesMouseAction.Move, + CursesMouseButton.None, + column: 19, + row: 9 + ) + ); + + CursesInteractionResult result = router.Route( input ); + Assert.Equal( CursesInteractionResultKind.Unrouted, result.Kind ); + + panel.Show(); + Assert.Same( focused, router.FocusedRegion ); + } + + [Fact] + public void FocusAndEndOfInputAreUnroutedAndDoNotChangeLogicalFocus() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( router ); + Assert.True( router.Focus( region ) ); + CursesInputEvent focus = CursesInputEvent.FromFocus( + new CursesFocusEvent( CursesFocusState.Unfocused ) + ); + CursesInputEvent end = CursesInputEvent.EndOfInput(); + + Assert.Equal( + CursesInteractionResultKind.Unrouted, + router.Route( focus ).Kind + ); + Assert.Same( region, router.FocusedRegion ); + Assert.Equal( + CursesInteractionResultKind.Unrouted, + router.Route( end ).Kind + ); + Assert.Same( region, router.FocusedRegion ); + } + + [Fact] + public void RouteResultRemainsSnapshotAfterRegionDisposal() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + CursesInteractionRegion region = RegisterFocusable( router ); + Assert.True( router.Focus( region ) ); + CursesInputEvent input = CursesInputEvent.FromKey( CursesKey.Tab ); + CursesInteractionResult result = router.Route( input ); + + region.Dispose(); + + Assert.Equal( CursesInteractionResultKind.Targeted, result.Kind ); + Assert.Same( input, result.Input ); + Assert.Same( region, result.Region ); + Assert.Null( result.Command ); + Assert.Null( result.Hit ); + } + + [Fact] + public void RouteAndGlobalBindingOperationsRejectDisposedRouter() { + CursesScreen screen = new( 20, 10 ); + CursesInteractionRouter router = new( screen ); + CursesKeyGesture gesture = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand command = new( "Command" ); + router.Dispose(); + + Assert.Throws( + () => { + router.BindGlobalGesture( gesture, command ); + } + ); + Assert.Throws( + () => { + _ = router.UnbindGlobalGesture( gesture ); + } + ); + Assert.Throws( + () => { + _ = router.Route( CursesInputEvent.FromKey( CursesKey.Enter ) ); + } + ); + } + + [Fact] + public void RouteRejectsNullInputBeforeMutation() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = RegisterFocusable( router ); + Assert.True( router.Focus( region ) ); + + Assert.Throws( + () => { + _ = router.Route( null! ); + } + ); + Assert.Same( region, router.FocusedRegion ); + } + + private static CursesKeyGesture CharacterGesture( + int index + ) { + return CursesKeyGesture.ForCharacter( + new Rune( 0x1000 + index ) + ); + } + + private static CursesInteractionRegion RegisterFocusable( + CursesInteractionRouter router, + int row = 0, + int column = 0, + int traversalOrder = 0 + ) { + ArgumentNullException.ThrowIfNull( router ); + return router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( row, column, 1, 1 ) + ) { + IsFocusable = true, + TraversalOrder = traversalOrder + } + ); + } +} From 3475d09b6aa4703ccbbcc5c40b17b82dcf82a341 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:51:20 -0400 Subject: [PATCH 26/90] Prepare interaction region for T1406 bindings --- src/CursesInteractionRegion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CursesInteractionRegion.cs b/src/CursesInteractionRegion.cs index 1622869e8..9d6849385 100644 --- a/src/CursesInteractionRegion.cs +++ b/src/CursesInteractionRegion.cs @@ -25,7 +25,7 @@ namespace Icod.DCurses; /// Represents one application-owned interaction region registered with a /// . /// -public sealed class CursesInteractionRegion : IDisposable { +public sealed partial class CursesInteractionRegion : IDisposable { private readonly CursesInteractionRouter owner; private CursesRectangle bounds; private bool isEnabled; From b77c3ef5c3f591c6f140541e40d0b67265370b9c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:52:00 -0400 Subject: [PATCH 27/90] Prepare interaction router for T1406 routing --- src/CursesInteractionRouter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CursesInteractionRouter.cs b/src/CursesInteractionRouter.cs index b29b9d955..f7ec96525 100644 --- a/src/CursesInteractionRouter.cs +++ b/src/CursesInteractionRouter.cs @@ -24,7 +24,7 @@ namespace Icod.DCurses; /// /// Owns a bounded set of application interaction regions associated with one logical screen. /// -public sealed class CursesInteractionRouter : IDisposable { +public sealed partial class CursesInteractionRouter : IDisposable { /// Gets the maximum number of live interaction regions owned by one router. public const int MaximumRegions = 4096; From 1f5c413577d464a116fb662922e504d91a552b7c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:52:11 -0400 Subject: [PATCH 28/90] Add T1406 command identity --- src/CursesCommand.cs | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/CursesCommand.cs diff --git a/src/CursesCommand.cs b/src/CursesCommand.cs new file mode 100644 index 000000000..d23f2bfab --- /dev/null +++ b/src/CursesCommand.cs @@ -0,0 +1,58 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// Identifies an application command without carrying executable behavior. +public sealed record CursesCommand { + /// Gets the maximum command-name length in UTF-16 code units. + public const int MaximumNameLength = 128; + + /// Initializes a command identity with its exact ordinal name. + /// The non-whitespace command name. + public CursesCommand( + string name + ) { + ArgumentNullException.ThrowIfNull( name ); + if ( MaximumNameLength < name.Length ) { + throw new ArgumentOutOfRangeException( nameof( name ) ); + } + if ( string.IsNullOrWhiteSpace( name ) ) { + throw new ArgumentException( + "The command name must contain at least one non-whitespace character.", + nameof( name ) + ); + } + + this.Name = name; + } + + /// Gets the exact ordinal command name. + public string Name { + get; + } + + /// Returns the exact command name. + /// The exact command name. + public override string ToString() { + return this.Name; + } +} From 9f3cfff7c05319404980991fe7e09efdf6897fb1 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:52:20 -0400 Subject: [PATCH 29/90] Add T1406 interaction result kind --- src/CursesInteractionResultKind.cs | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/CursesInteractionResultKind.cs diff --git a/src/CursesInteractionResultKind.cs b/src/CursesInteractionResultKind.cs new file mode 100644 index 000000000..ccffe1c11 --- /dev/null +++ b/src/CursesInteractionResultKind.cs @@ -0,0 +1,32 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// Identifies the semantic outcome of one interaction-routing operation. +public enum CursesInteractionResultKind { + /// The input was not routed to a region or command. + Unrouted = 0, + /// The input was routed to an interaction region without matching a command. + Targeted = 1, + /// The input matched a local or router-global command identity. + Command = 2 +} From 40dbf4279368364d4d6127e0be3c7cc2e028352a Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:52:32 -0400 Subject: [PATCH 30/90] Add T1406 structured interaction result --- src/CursesInteractionResult.cs | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/CursesInteractionResult.cs diff --git a/src/CursesInteractionResult.cs b/src/CursesInteractionResult.cs new file mode 100644 index 000000000..e0a6c3264 --- /dev/null +++ b/src/CursesInteractionResult.cs @@ -0,0 +1,110 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// Represents one immutable snapshot of an interaction-routing outcome. +public sealed class CursesInteractionResult { + private CursesInteractionResult( + CursesInteractionResultKind kind, + CursesInputEvent input, + CursesInteractionRegion? region, + CursesCommand? command, + CursesInteractionHit? hit + ) { + ArgumentNullException.ThrowIfNull( input ); + this.Kind = kind; + this.Input = input; + this.Region = region; + this.Command = command; + this.Hit = hit; + } + + /// Gets the semantic routing outcome. + public CursesInteractionResultKind Kind { + get; + } + + /// Gets the exact normalized input object which was routed. + public CursesInputEvent Input { + get; + } + + /// Gets the targeted region for targeted or local-command outcomes. + public CursesInteractionRegion? Region { + get; + } + + /// Gets the matched command identity for command outcomes. + public CursesCommand? Command { + get; + } + + /// Gets the mouse-hit snapshot for mouse-targeted outcomes. + public CursesInteractionHit? Hit { + get; + } + + internal static CursesInteractionResult Unrouted( + CursesInputEvent input + ) { + ArgumentNullException.ThrowIfNull( input ); + return new CursesInteractionResult( + CursesInteractionResultKind.Unrouted, + input, + region: null, + command: null, + hit: null + ); + } + + internal static CursesInteractionResult Targeted( + CursesInputEvent input, + CursesInteractionRegion region, + CursesInteractionHit? hit = null + ) { + ArgumentNullException.ThrowIfNull( input ); + ArgumentNullException.ThrowIfNull( region ); + return new CursesInteractionResult( + CursesInteractionResultKind.Targeted, + input, + region, + command: null, + hit + ); + } + + internal static CursesInteractionResult CommandMatch( + CursesInputEvent input, + CursesInteractionRegion? region, + CursesCommand command + ) { + ArgumentNullException.ThrowIfNull( input ); + ArgumentNullException.ThrowIfNull( command ); + return new CursesInteractionResult( + CursesInteractionResultKind.Command, + input, + region, + command, + hit: null + ); + } +} From bf499fd3a0ddfd2a3bd165b41fae69ed4a29f26c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:52:53 -0400 Subject: [PATCH 31/90] Implement T1406 region gesture bindings --- src/CursesInteractionRegion.Bindings.cs | 103 ++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/CursesInteractionRegion.Bindings.cs diff --git a/src/CursesInteractionRegion.Bindings.cs b/src/CursesInteractionRegion.Bindings.cs new file mode 100644 index 000000000..19e81ed33 --- /dev/null +++ b/src/CursesInteractionRegion.Bindings.cs @@ -0,0 +1,103 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +public sealed partial class CursesInteractionRegion { + private readonly Dictionary gestureBindings = []; + + /// Binds one semantic gesture to an application command in this region. + /// The bindable semantic gesture. + /// The application command identity. + public void BindGesture( + CursesKeyGesture gesture, + CursesCommand command + ) { + ValidateBindableGesture( + gesture, + nameof( gesture ) + ); + ArgumentNullException.ThrowIfNull( command ); + this.ThrowIfDisposed(); + + if ( this.gestureBindings.ContainsKey( gesture ) ) { + throw new InvalidOperationException( + "The interaction region already has a binding for this gesture." + ); + } + if ( CursesInteractionRouter.MaximumRegionGestureBindings <= this.gestureBindings.Count ) { + throw new InvalidOperationException( + $"An interaction region cannot own more than {CursesInteractionRouter.MaximumRegionGestureBindings} gesture bindings." + ); + } + + this.owner.EnsureGestureBindingCapacity(); + this.gestureBindings.Add( + gesture, + command + ); + } + + /// Removes one gesture binding from this region when present. + /// The bindable semantic gesture. + /// when a binding was removed; otherwise . + public bool UnbindGesture( + CursesKeyGesture gesture + ) { + ValidateBindableGesture( + gesture, + nameof( gesture ) + ); + this.ThrowIfDisposed(); + return this.gestureBindings.Remove( gesture ); + } + + internal int GestureBindingCount => this.gestureBindings.Count; + + internal bool TryGetCommand( + CursesInputEvent input, + out CursesCommand? command + ) { + ArgumentNullException.ThrowIfNull( input ); + foreach ( KeyValuePair binding in this.gestureBindings ) { + if ( binding.Key.Matches( input ) ) { + command = binding.Value; + return true; + } + } + + command = null; + return false; + } + + private static void ValidateBindableGesture( + CursesKeyGesture gesture, + string parameterName + ) { + ArgumentException.ThrowIfNullOrEmpty( parameterName ); + if ( !gesture.IsBindable ) { + throw new ArgumentException( + "The gesture must be created by a CursesKeyGesture factory.", + parameterName + ); + } + } +} From 6ae45c52b976c85c6be79374b7ea783c046ebe6b Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 17:53:12 -0400 Subject: [PATCH 32/90] Implement T1406 structured interaction routing --- src/CursesInteractionRouter.Routing.cs | 215 +++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/CursesInteractionRouter.Routing.cs diff --git a/src/CursesInteractionRouter.Routing.cs b/src/CursesInteractionRouter.Routing.cs new file mode 100644 index 000000000..d304bbcc3 --- /dev/null +++ b/src/CursesInteractionRouter.Routing.cs @@ -0,0 +1,215 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +public sealed partial class CursesInteractionRouter { + private readonly Dictionary globalGestureBindings = []; + + /// Binds one semantic gesture to a router-global application command. + /// The bindable semantic gesture. + /// The application command identity. + public void BindGlobalGesture( + CursesKeyGesture gesture, + CursesCommand command + ) { + ValidateBindableGesture( + gesture, + nameof( gesture ) + ); + ArgumentNullException.ThrowIfNull( command ); + this.ThrowIfDisposed(); + + if ( this.globalGestureBindings.ContainsKey( gesture ) ) { + throw new InvalidOperationException( + "The interaction router already has a global binding for this gesture." + ); + } + if ( MaximumGlobalGestureBindings <= this.globalGestureBindings.Count ) { + throw new InvalidOperationException( + $"An interaction router cannot own more than {MaximumGlobalGestureBindings} global gesture bindings." + ); + } + + this.EnsureGestureBindingCapacity(); + this.globalGestureBindings.Add( + gesture, + command + ); + } + + /// Removes one router-global gesture binding when present. + /// The bindable semantic gesture. + /// when a binding was removed; otherwise . + public bool UnbindGlobalGesture( + CursesKeyGesture gesture + ) { + ValidateBindableGesture( + gesture, + nameof( gesture ) + ); + this.ThrowIfDisposed(); + return this.globalGestureBindings.Remove( gesture ); + } + + /// Routes one normalized input event without invoking application callbacks. + /// The normalized DCurses input event. + /// An immutable structured routing result. + public CursesInteractionResult Route( + CursesInputEvent input + ) { + ArgumentNullException.ThrowIfNull( input ); + this.ThrowIfDisposed(); + + return input.Kind switch { + CursesInputEventKind.Text => this.RouteKeyboardInput( input ), + CursesInputEventKind.Key => this.RouteKeyboardInput( input ), + CursesInputEventKind.Paste => this.RoutePasteInput( input ), + CursesInputEventKind.Mouse => this.RouteMouseInput( input ), + CursesInputEventKind.Focus => CursesInteractionResult.Unrouted( input ), + CursesInputEventKind.EndOfInput => CursesInteractionResult.Unrouted( input ), + _ => throw new InvalidOperationException( + $"Unsupported interaction input kind: {input.Kind}." + ) + }; + } + + internal void EnsureGestureBindingCapacity() { + int count = this.globalGestureBindings.Count; + foreach ( CursesInteractionRegion region in this.regions ) { + count += region.GestureBindingCount; + if ( MaximumGestureBindings <= count ) { + throw new InvalidOperationException( + $"An interaction router cannot own more than {MaximumGestureBindings} total gesture bindings." + ); + } + } + + if ( MaximumGestureBindings <= count ) { + throw new InvalidOperationException( + $"An interaction router cannot own more than {MaximumGestureBindings} total gesture bindings." + ); + } + } + + private CursesInteractionResult RouteKeyboardInput( + CursesInputEvent input + ) { + ArgumentNullException.ThrowIfNull( input ); + this.RepairFocusIfNeeded(); + + if ( this.focusedRegion is not null + && this.focusedRegion.TryGetCommand( + input, + out CursesCommand? localCommand + ) ) { + return CursesInteractionResult.CommandMatch( + input, + this.focusedRegion, + localCommand! + ); + } + + if ( this.TryGetGlobalCommand( + input, + out CursesCommand? globalCommand + ) ) { + return CursesInteractionResult.CommandMatch( + input, + region: null, + globalCommand! + ); + } + + return this.focusedRegion is null + ? CursesInteractionResult.Unrouted( input ) + : CursesInteractionResult.Targeted( + input, + this.focusedRegion + ) + ; + } + + private CursesInteractionResult RoutePasteInput( + CursesInputEvent input + ) { + ArgumentNullException.ThrowIfNull( input ); + this.RepairFocusIfNeeded(); + return this.focusedRegion is null + ? CursesInteractionResult.Unrouted( input ) + : CursesInteractionResult.Targeted( + input, + this.focusedRegion + ) + ; + } + + private CursesInteractionResult RouteMouseInput( + CursesInputEvent input + ) { + ArgumentNullException.ThrowIfNull( input ); + CursesMouseEvent mouse = input.Mouse + ?? throw new InvalidOperationException( + "A mouse input event must carry a mouse payload." + ); + CursesInteractionHit? hit = this.HitTest( + mouse.Row, + mouse.Column + ); + return hit is null + ? CursesInteractionResult.Unrouted( input ) + : CursesInteractionResult.Targeted( + input, + hit.Region, + hit + ) + ; + } + + private bool TryGetGlobalCommand( + CursesInputEvent input, + out CursesCommand? command + ) { + ArgumentNullException.ThrowIfNull( input ); + foreach ( KeyValuePair binding in this.globalGestureBindings ) { + if ( binding.Key.Matches( input ) ) { + command = binding.Value; + return true; + } + } + + command = null; + return false; + } + + private static void ValidateBindableGesture( + CursesKeyGesture gesture, + string parameterName + ) { + ArgumentException.ThrowIfNullOrEmpty( parameterName ); + if ( !gesture.IsBindable ) { + throw new ArgumentException( + "The gesture must be created by a CursesKeyGesture factory.", + parameterName + ); + } + } +} From e01605803c62708f28e5a5516d8fd51982a9e75a Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 21:08:25 -0400 Subject: [PATCH 33/90] Advance 1.4 API fingerprint through T1406 --- docs/Public-API-Fingerprint-1.4.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/Public-API-Fingerprint-1.4.json b/docs/Public-API-Fingerprint-1.4.json index 6dd252a7c..8da96e671 100644 --- a/docs/Public-API-Fingerprint-1.4.json +++ b/docs/Public-API-Fingerprint-1.4.json @@ -2,9 +2,9 @@ "schema": 1, "release": "1.4.0-alpha.1", "status": "alpha", - "sha256": "638288580b66974106913742dcb52268a2826bf9e30c08350334a8fb5af148b4", - "exportedTypeCount": 57, - "contractLineCount": 458, + "sha256": "fdfa06a44c4d2c3f50f52e911584e8ecd8fcd9499765be2673fe4070e2faa025", + "exportedTypeCount": 60, + "contractLineCount": 482, "exportedTypes": [ "Icod.DCurses.CursesAlertKind", "Icod.DCurses.CursesAmbiguousWidthPolicy", @@ -12,6 +12,7 @@ "Icod.DCurses.CursesCellMetadata", "Icod.DCurses.CursesColor", "Icod.DCurses.CursesColorKind", + "Icod.DCurses.CursesCommand", "Icod.DCurses.CursesCursorVisibility", "Icod.DCurses.CursesDockEdge", "Icod.DCurses.CursesEvent", @@ -29,6 +30,8 @@ "Icod.DCurses.CursesInteractionHit", "Icod.DCurses.CursesInteractionRegion", "Icod.DCurses.CursesInteractionRegionOptions", + "Icod.DCurses.CursesInteractionResult", + "Icod.DCurses.CursesInteractionResultKind", "Icod.DCurses.CursesInteractionRouter", "Icod.DCurses.CursesKey", "Icod.DCurses.CursesKeyEventPhase", From 6652e211934256def436c032d0117aa24f803197 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 21:12:06 -0400 Subject: [PATCH 34/90] Document T1406 command bindings and structured routing --- ...Command-Bindings-and-Structured-Routing.md | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/T1406-Command-Bindings-and-Structured-Routing.md diff --git a/docs/T1406-Command-Bindings-and-Structured-Routing.md b/docs/T1406-Command-Bindings-and-Structured-Routing.md new file mode 100644 index 000000000..419016a20 --- /dev/null +++ b/docs/T1406-Command-Bindings-and-Structured-Routing.md @@ -0,0 +1,241 @@ +# T1406 — Command Bindings and Structured Routing + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1406 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** implementation/fingerprint head qualified; documentation-complete head requires its own exact-head matrix + +--- + +## Objective + +T1406 turns the T1405 semantic gesture identity into bounded application command bindings and deterministic structured input routing. DCurses resolves input to command identities or interaction targets; it does not invoke application delegates, create a hidden event loop, infer terminal protocols, or take ownership of application behavior. + +The tranche implements exactly the T1401-frozen local/global binding scopes, precedence, capacity limits, routing result shapes, and event-kind behavior. + +## RED checkpoint + +The T1406 routing tests were committed first at: + +`0567ff1f085fa057a72026f062df14bd2a37071c` + +Workflow #745 / `34720941297` failed as intended across the package job and all six runtime jobs. The representative Linux ARM64 build recorded zero warnings and failed only because the frozen T1406 surface did not yet exist: `CursesCommand`, `CursesInteractionResult`, `CursesInteractionResultKind`, local/global binding APIs, and `CursesInteractionRouter.Route(...)`. + +The RED suite covers: + +- command-name validation, equality, and text representation; +- local and router-global gesture binding and explicit unbinding; +- rejection of default/non-bindable gestures before registry mutation; +- duplicate binding rejection without replacement; +- per-region, global, and total-router binding limits; +- focused-region command precedence over router-global commands; +- Text and Key local/global/targeted/unrouted routing; +- Paste targeted-only behavior; +- Mouse routing exclusively through hit testing; +- no focus mutation from mouse routing; +- Focus and EndOfInput remaining unrouted; +- immutable result-shape invariants for unrouted, targeted, local-command, global-command, and mouse-hit results; +- routing-result snapshot stability after later region changes or disposal; +- cleanup of region-owned bindings on region disposal; +- router lifecycle validation. + +## Implementation chain + +The implementation was deliberately split into small commits after the RED checkpoint: + +- `3475d09b6aa4703ccbbcc5c40b17b82dcf82a341` — prepare `CursesInteractionRegion` for binding implementation; +- `b77c3ef5c3f591c6f140541e40d0b67265370b9c` — prepare `CursesInteractionRouter` for routing implementation; +- `1f5c413577d464a116fb662922e504d91a552b7c` — add `CursesCommand` identity; +- `9f3cfff7c05319404980991fe7e09efdf6897fb1` — add `CursesInteractionResultKind`; +- `40dbf4279368364d4d6127e0be3c7cc2e028352a` — add immutable `CursesInteractionResult`; +- `bf499fd3a0ddfd2a3bd165b41fae69ed4a29f26c` — implement bounded region-local gesture bindings; +- `6ae45c52b976c85c6be79374b7ea783c046ebe6b` — implement router-global bindings and structured routing. + +## Command identity + +`CursesCommand` is an immutable identifier only. It does not contain or invoke a delegate. + +The frozen public surface is: + +```csharp +public sealed record CursesCommand { + public const int MaximumNameLength = 128; + + public CursesCommand( string name ); + + public string Name { get; } + + public override string ToString(); +} +``` + +Names are required, non-whitespace, and at most 128 UTF-16 code units. Record equality is exact ordinal value equality over `Name`, and `ToString()` returns the exact command name. + +## Binding scopes and conflicts + +Bindings exist in two scopes: + +- region-local bindings owned by `CursesInteractionRegion`; +- router-global bindings owned by `CursesInteractionRouter`. + +The public operations are: + +```csharp +region.BindGesture( gesture, command ); +region.UnbindGesture( gesture ); + +router.BindGlobalGesture( gesture, command ); +router.UnbindGlobalGesture( gesture ); +``` + +A scope may contain at most one command for a gesture. Binding a duplicate gesture in the same scope throws `InvalidOperationException` and leaves the original binding intact. Changing a binding therefore uses explicit unbind-then-bind semantics. + +A default-zero or otherwise non-bindable `CursesKeyGesture` is rejected before mutation. + +## Frozen capacity limits + +T1406 enforces the T1401 limits: + +```text +Maximum live regions per router 4096 +Maximum gesture bindings per live region 256 +Maximum router-global gesture bindings 1024 +Maximum total live gesture bindings per router 16384 +``` + +The public constants remain on `CursesInteractionRouter`: + +```csharp +MaximumRegions +MaximumRegionGestureBindings +MaximumGlobalGestureBindings +MaximumGestureBindings +``` + +Capacity checks fail closed with `InvalidOperationException`; no existing binding is evicted or replaced. + +Disposed regions no longer contribute live local bindings to router capacity. + +## Structured routing + +`CursesInteractionRouter.Route(CursesInputEvent)` returns an immutable `CursesInteractionResult` and never invokes application callbacks. + +### Text and Key + +Text and Key input use the same deterministic sequence: + +1. repair logical focus if needed; +2. resolve a matching binding on the eligible focused region; +3. otherwise resolve a matching router-global binding; +4. otherwise, if an eligible focused region exists, return a targeted result for that region; +5. otherwise return an unrouted result. + +This makes focused-region bindings strictly higher precedence than router-global bindings while preserving global commands when there is no local match. + +### Paste + +Paste input is delivered as a targeted result to the current eligible focused region. If no eligible focused region exists, it is unrouted. + +Version 1.4 does not add paste gestures or paste commands. + +### Mouse + +Mouse input is routed exclusively through the existing deterministic `HitTest(...)` contract. A successful hit returns a targeted result with the selected region and immutable hit snapshot, including local coordinates. + +Mouse routing does not run key-gesture bindings and does not automatically change logical focus. + +### Terminal Focus and EndOfInput + +Terminal Focus input and EndOfInput remain unrouted. Terminal focus state is not application logical focus and does not mutate `FocusedRegion`. + +## Result contract + +T1406 adds: + +```csharp +public enum CursesInteractionResultKind { + Unrouted = 0, + Targeted = 1, + Command = 2 +} + +public sealed class CursesInteractionResult { + public CursesInteractionResultKind Kind { get; } + public CursesInputEvent Input { get; } + public CursesInteractionRegion? Region { get; } + public CursesCommand? Command { get; } + public CursesInteractionHit? Hit { get; } +} +``` + +The frozen invariants are: + +- `Unrouted`: `Region`, `Command`, and `Hit` are null; +- keyboard/text/paste `Targeted`: `Region` is non-null; `Command` and `Hit` are null; +- mouse `Targeted`: `Region` and `Hit` are non-null and identify the same region; `Command` is null; +- region-local `Command`: `Command` and `Region` are non-null; `Hit` is null; +- router-global `Command`: `Command` is non-null; `Region` and `Hit` are null. + +Every result retains the original `CursesInputEvent`. Results are snapshots: later focus changes, region geometry changes, binding changes, or region disposal do not rewrite an already-returned result object. + +## First GREEN evidence and API guard + +The complete behavioral implementation head is: + +`6ae45c52b976c85c6be79374b7ea783c046ebe6b` + +Workflow #752 / `34721214333` built the implementation with zero warnings and zero errors. Across `net8.0`, `net9.0`, and `net10.0`, all **701 behavioral/non-fingerprint tests passed**; the only failing test was the expected current-development public API fingerprint guard. + +The compiler-derived T1406 contract was identical across all target frameworks: + +```text +60 exported types +482 canonical declared contract lines +sha256 fdfa06a44c4d2c3f50f52e911584e8ecd8fcd9499765be2673fe4070e2faa025 +``` + +The new exported types over T1405 are: + +- `Icod.DCurses.CursesCommand`; +- `Icod.DCurses.CursesInteractionResult`; +- `Icod.DCurses.CursesInteractionResultKind`. + +No behavioral implementation correction was required after that run. + +## Fingerprint qualification + +`docs/Public-API-Fingerprint-1.4.json` was advanced to the compiler-derived T1406 contract at: + +`e01605803c62708f28e5a5516d8fd51982a9e75a` + +Workflow #753 / `34729776109` passed all seven PR jobs on that exact head: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +This jointly qualifies the T1406 implementation and the current 1.4 alpha public API fingerprint. + +## Ownership and architecture audit + +T1406 preserves the established ownership boundary: + +- DCurses owns logical regions, focus, semantic gestures, command identifiers, binding registries, hit testing, and structured routing results; +- applications own command behavior and decide what to do with returned command identities/targets; +- `Icod.Terminal` remains authoritative for terminal input/protocol lifecycle; +- `Icod.TermInfo` remains authoritative for terminal capability/description facts already consumed by DCurses; +- no new public Terminal or TermInfo types cross the DCurses public API boundary; +- runtime dependency versions remain `Icod.Terminal 1.11.1` and `Icod.TermInfo 1.11.0`. + +T1406 deliberately does **not** add callbacks, delegates, widgets, hidden event loops, automatic focus-on-click, pointer-shape protocol output, retained raster ownership, or raw terminal protocol parsing. + +## Exit gate + +T1406 is complete when this documentation-complete head passes the full PR package/runtime matrix. After that exact-head qualification, T1407 may begin pointer-shape integration with a fresh RED checkpoint. From 97fd5eb06c57780689d53bdb4a1d422e1ec483d0 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 21:18:18 -0400 Subject: [PATCH 35/90] Add T1407 pointer-shape integration red tests --- .../src/CursesPointerShapeTests.cs | 466 ++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesPointerShapeTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesPointerShapeTests.cs b/tests/Icod.DCurses.Tests/src/CursesPointerShapeTests.cs new file mode 100644 index 000000000..808b8c05f --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesPointerShapeTests.cs @@ -0,0 +1,466 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using System.Text; +using Icod.Terminal; +using Icod.TermInfo; +using Xunit; + +namespace Icod.DCurses.Tests; + +/// Contract and integration coverage for the T1407 semantic pointer-shape surface. +public sealed class CursesPointerShapeTests { + private const string ResetFrame = "\u001b]22;\u001b\\"; + + [Fact] + public void PointerShapeVocabularyHasFrozenNamesAndNumericValues() { + string[] expectedNames = [ + "Alias", + "Cell", + "Copy", + "Crosshair", + "Default", + "EastResize", + "EastWestResize", + "Grab", + "Grabbing", + "Help", + "Move", + "NorthResize", + "NorthEastResize", + "NorthEastSouthWestResize", + "NoDrop", + "NotAllowed", + "NorthSouthResize", + "NorthWestResize", + "NorthWestSouthEastResize", + "Pointer", + "Progress", + "SouthResize", + "SouthEastResize", + "SouthWestResize", + "Text", + "VerticalText", + "WestResize", + "Wait", + "ZoomIn", + "ZoomOut" + ]; + CursesPointerShape[] values = Enum.GetValues(); + + Assert.Equal( expectedNames.Length, values.Length ); + for ( int index = 0; index < expectedNames.Length; index++ ) { + Assert.Equal( index, (int)values[ index ] ); + Assert.Equal( expectedNames[ index ], values[ index ].ToString() ); + } + } + + [Fact] + public void RegionPointerPreferenceIsMutableAndHitIsSnapshot() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 2, 3, 4, 5 ) + ) { + PointerShape = CursesPointerShape.Pointer + } + ); + + Assert.Equal( CursesPointerShape.Pointer, region.PointerShape ); + CursesInteractionHit first = Assert.IsType( + router.HitTest( 3, 4 ) + ); + Assert.Equal( CursesPointerShape.Pointer, first.PointerShape ); + + region.PointerShape = CursesPointerShape.Wait; + Assert.Equal( CursesPointerShape.Wait, region.PointerShape ); + Assert.Equal( CursesPointerShape.Pointer, first.PointerShape ); + + CursesInteractionHit second = Assert.IsType( + router.HitTest( 3, 4 ) + ); + Assert.Equal( CursesPointerShape.Wait, second.PointerShape ); + + region.PointerShape = null; + Assert.Null( router.HitTest( 3, 4 )!.PointerShape ); + } + + [Fact] + public void RegionRejectsUndefinedPointerPreferenceAndMutationAfterDisposal() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + + Assert.Throws( + () => { + _ = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + PointerShape = (CursesPointerShape)30 + } + ); + } + ); + + CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) + ); + region.Dispose(); + + Assert.Throws( + () => { + region.PointerShape = CursesPointerShape.Help; + } + ); + } + + [Fact] + public void MouseRouteCarriesPointerPreferenceWithoutChangingFocus() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion focused = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + IsFocusable = true + } + ); + using CursesInteractionRegion hitRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 4, 5, 3, 4 ) + ) { + PointerShape = CursesPointerShape.Crosshair + } + ); + Assert.True( router.Focus( focused ) ); + CursesInputEvent input = CursesInputEvent.FromMouse( + new CursesMouseEvent( + CursesMouseAction.Press, + CursesMouseButton.Primary, + column: 7, + row: 5 + ) + ); + + CursesInteractionResult result = router.Route( input ); + + Assert.Equal( CursesInteractionResultKind.Targeted, result.Kind ); + Assert.Same( hitRegion, result.Region ); + Assert.NotNull( result.Hit ); + Assert.Equal( CursesPointerShape.Crosshair, result.Hit.PointerShape ); + Assert.Same( focused, router.FocusedRegion ); + + hitRegion.PointerShape = CursesPointerShape.Move; + Assert.Equal( CursesPointerShape.Crosshair, result.Hit.PointerShape ); + } + + [Fact] + public async Task EveryPointerShapeMapsToCanonicalTerminalWireName() { + string[] wireNames = [ + "alias", + "cell", + "copy", + "crosshair", + "default", + "e-resize", + "ew-resize", + "grab", + "grabbing", + "help", + "move", + "n-resize", + "ne-resize", + "nesw-resize", + "no-drop", + "not-allowed", + "ns-resize", + "nw-resize", + "nwse-resize", + "pointer", + "progress", + "s-resize", + "se-resize", + "sw-resize", + "text", + "vertical-text", + "w-resize", + "wait", + "zoom-in", + "zoom-out" + ]; + RecordingOutput output = new(); + await using CursesSession session = await OpenSessionAsync( output ); + CursesPointerShape[] shapes = Enum.GetValues(); + + for ( int index = 0; index < shapes.Length; index++ ) { + output.Clear(); + CursesPointerShapeLease lease = await session.AcquirePointerShapeAsync( + shapes[ index ] + ); + + Assert.Equal( shapes[ index ], lease.Shape ); + Assert.Equal( + $"\u001b]22;{wireNames[ index ]}\u001b\\", + output.Text + ); + + await lease.DisposeAsync(); + Assert.Equal( + $"\u001b]22;{wireNames[ index ]}\u001b\\{ResetFrame}", + output.Text + ); + } + } + + [Fact] + public async Task NestedCursesPointerLeasesDelegateRestorationToTerminal() { + RecordingOutput output = new(); + await using CursesSession session = await OpenSessionAsync( output ); + output.Clear(); + + CursesPointerShapeLease outer = await session.AcquirePointerShapeAsync( + CursesPointerShape.Pointer + ); + CursesPointerShapeLease inner = await session.AcquirePointerShapeAsync( + CursesPointerShape.Wait + ); + await inner.DisposeAsync(); + await outer.DisposeAsync(); + + Assert.Equal( + "\u001b]22;pointer\u001b\\" + + "\u001b]22;wait\u001b\\" + + "\u001b]22;pointer\u001b\\" + + ResetFrame, + output.Text + ); + } + + [Fact] + public async Task CursesPointerLeaseComposesWithOuterTerminalPointerLease() { + RecordingOutput output = new(); + TerminalSession terminalSession = await OpenTerminalSessionAsync( output ); + await using CursesSession session = await CursesSession.OpenAsync( + terminalSession, + NoPresentationOptions() + ); + output.Clear(); + TerminalPointerShapeLease outer = await terminalSession.AcquirePointerShapeAsync( + TerminalPointerShape.Pointer + ); + CursesPointerShapeLease inner = await session.AcquirePointerShapeAsync( + CursesPointerShape.Wait + ); + + await inner.DisposeAsync(); + await outer.DisposeAsync(); + + Assert.Equal( + "\u001b]22;pointer\u001b\\" + + "\u001b]22;wait\u001b\\" + + "\u001b]22;pointer\u001b\\" + + ResetFrame, + output.Text + ); + } + + [Fact] + public async Task InvalidAndCanceledAcquisitionsFailBeforeTerminalOutput() { + RecordingOutput output = new(); + await using CursesSession session = await OpenSessionAsync( output ); + output.Clear(); + + await Assert.ThrowsAsync( + async () => { + _ = await session.AcquirePointerShapeAsync( + (CursesPointerShape)30 + ); + } + ); + Assert.Equal( string.Empty, output.Text ); + + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync( + async () => { + _ = await session.AcquirePointerShapeAsync( + CursesPointerShape.Text, + cancellation.Token + ); + } + ); + Assert.Equal( string.Empty, output.Text ); + } + + private static async ValueTask OpenSessionAsync( + RecordingOutput output + ) { + ArgumentNullException.ThrowIfNull( output ); + TerminalSession terminalSession = await OpenTerminalSessionAsync( output ); + return await CursesSession.OpenAsync( + terminalSession, + NoPresentationOptions() + ); + } + + private static ValueTask OpenTerminalSessionAsync( + RecordingOutput output + ) { + ArgumentNullException.ThrowIfNull( output ); + return TerminalSession.OpenAsync( + new RecordingTerminalControlProvider(), + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + new EmptyInput(), + output, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + ObserveLifecycleEvents = false + } + ); + } + + private static CursesSessionOptions NoPresentationOptions() { + return new CursesSessionOptions { + UseAlternateScreen = false, + EnableKeypad = false, + HideCursor = false + }; + } + + private sealed class EmptyInput : ITerminalInput { + public async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + _ = buffer; + await Task.Delay( + Timeout.InfiniteTimeSpan, + cancellationToken + ).ConfigureAwait( false ); + return 0; + } + } + + private sealed class RecordingOutput : ITerminalOutput { + private readonly object sync = new(); + private readonly List bytes = []; + + internal string Text { + get { + lock ( this.sync ) { + return Encoding.ASCII.GetString( this.bytes.ToArray() ); + } + } + } + + internal void Clear() { + lock ( this.sync ) { + this.bytes.Clear(); + } + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + lock ( this.sync ) { + this.bytes.AddRange( buffer.ToArray() ); + } + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + } + + private sealed class RecordingTerminalControlProvider : ITerminalControlProvider { + private readonly TerminalModeSnapshot baseline = TerminalModeSnapshot.CreatePosix( + 0, + 0, + 0, + 0x0002UL, + new byte[ 32 ], + 0, + 32, + 0, + new TerminalSpeed( 13, 9600 ), + new TerminalSpeed( 13, 9600 ) + ); + + public TerminalControlResult Observe( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + new TerminalEndpointObservation( + true, + null, + TerminalPlatformKind.PosixTermios, + TerminalControlCapabilities.Attachment + | TerminalControlCapabilities.LiveSize + | TerminalControlCapabilities.ModeRead + | TerminalControlCapabilities.ModeWrite + ) + ); + } + + public TerminalControlResult GetSize( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + new TerminalSize( 80, 24 ) + ); + } + + public TerminalControlResult GetMode( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + this.baseline + ); + } + + public TerminalControlMutationResult SetMode( + TerminalEndpoint endpoint, + TerminalModeSnapshot mode, + TerminalModeApplyTiming timing + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + ArgumentNullException.ThrowIfNull( mode ); + if ( !Enum.IsDefined( timing ) ) { + throw new ArgumentOutOfRangeException( nameof( timing ) ); + } + return TerminalControlMutationResult.Success(); + } + } +} From ddf654463f0bb6310fe0bab8d12a438c11b2a162 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 21:21:02 -0400 Subject: [PATCH 36/90] Add T1407 pointer-shape vocabulary --- src/CursesPointerShape.cs | 115 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/CursesPointerShape.cs diff --git a/src/CursesPointerShape.cs b/src/CursesPointerShape.cs new file mode 100644 index 000000000..7133398a5 --- /dev/null +++ b/src/CursesPointerShape.cs @@ -0,0 +1,115 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +/// Identifies one semantic terminal mouse-pointer shape. +public enum CursesPointerShape { + /// The CSS alias pointer shape. + Alias = 0, + + /// The CSS cell pointer shape. + Cell = 1, + + /// The CSS copy pointer shape. + Copy = 2, + + /// The CSS crosshair pointer shape. + Crosshair = 3, + + /// The CSS default pointer shape. + Default = 4, + + /// The CSS east-resize pointer shape. + EastResize = 5, + + /// The CSS east-west-resize pointer shape. + EastWestResize = 6, + + /// The CSS grab pointer shape. + Grab = 7, + + /// The CSS grabbing pointer shape. + Grabbing = 8, + + /// The CSS help pointer shape. + Help = 9, + + /// The CSS move pointer shape. + Move = 10, + + /// The CSS north-resize pointer shape. + NorthResize = 11, + + /// The CSS north-east-resize pointer shape. + NorthEastResize = 12, + + /// The CSS north-east/south-west-resize pointer shape. + NorthEastSouthWestResize = 13, + + /// The CSS no-drop pointer shape. + NoDrop = 14, + + /// The CSS not-allowed pointer shape. + NotAllowed = 15, + + /// The CSS north-south-resize pointer shape. + NorthSouthResize = 16, + + /// The CSS north-west-resize pointer shape. + NorthWestResize = 17, + + /// The CSS north-west/south-east-resize pointer shape. + NorthWestSouthEastResize = 18, + + /// The CSS pointer pointer shape. + Pointer = 19, + + /// The CSS progress pointer shape. + Progress = 20, + + /// The CSS south-resize pointer shape. + SouthResize = 21, + + /// The CSS south-east-resize pointer shape. + SouthEastResize = 22, + + /// The CSS south-west-resize pointer shape. + SouthWestResize = 23, + + /// The CSS text pointer shape. + Text = 24, + + /// The CSS vertical-text pointer shape. + VerticalText = 25, + + /// The CSS west-resize pointer shape. + WestResize = 26, + + /// The CSS wait pointer shape. + Wait = 27, + + /// The CSS zoom-in pointer shape. + ZoomIn = 28, + + /// The CSS zoom-out pointer shape. + ZoomOut = 29 +} From 435bce714ccc41f3437950169e4a138fbf5b24f7 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 21:21:17 -0400 Subject: [PATCH 37/90] Add T1407 region pointer preference --- src/CursesInteractionRegionOptions.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/CursesInteractionRegionOptions.cs b/src/CursesInteractionRegionOptions.cs index 034fc65a2..eba63ec3f 100644 --- a/src/CursesInteractionRegionOptions.cs +++ b/src/CursesInteractionRegionOptions.cs @@ -69,4 +69,10 @@ public int HitTestPriority { get; init; } + + /// Gets the optional semantic pointer-shape preference reported by hit testing. + public CursesPointerShape? PointerShape { + get; + init; + } } From 2d2d705060f8f291ffda710e3bba01c1a527e5d9 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 21:21:32 -0400 Subject: [PATCH 38/90] Implement T1407 region pointer preference --- src/CursesInteractionRegion.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/CursesInteractionRegion.cs b/src/CursesInteractionRegion.cs index 9d6849385..9e6df01db 100644 --- a/src/CursesInteractionRegion.cs +++ b/src/CursesInteractionRegion.cs @@ -32,6 +32,7 @@ public sealed partial class CursesInteractionRegion : IDisposable { private bool isFocusable; private int traversalOrder; private int hitTestPriority; + private CursesPointerShape? pointerShape; private bool disposed; internal CursesInteractionRegion( @@ -44,6 +45,10 @@ long registrationOrdinal if ( 0 > registrationOrdinal ) { throw new ArgumentOutOfRangeException( nameof( registrationOrdinal ) ); } + if ( options.PointerShape.HasValue + && !Enum.IsDefined( options.PointerShape.Value ) ) { + throw new ArgumentOutOfRangeException( nameof( options.PointerShape ) ); + } this.owner = owner; this.bounds = options.Bounds; @@ -52,6 +57,7 @@ long registrationOrdinal this.isFocusable = options.IsFocusable; this.traversalOrder = options.TraversalOrder; this.hitTestPriority = options.HitTestPriority; + this.pointerShape = options.PointerShape; this.RegistrationOrdinal = registrationOrdinal; } @@ -109,6 +115,19 @@ public int HitTestPriority { } } + /// Gets or sets the optional semantic pointer-shape preference reported by hit testing. + public CursesPointerShape? PointerShape { + get => this.pointerShape; + set { + this.ThrowIfDisposed(); + if ( value.HasValue + && !Enum.IsDefined( value.Value ) ) { + throw new ArgumentOutOfRangeException( nameof( value ) ); + } + this.pointerShape = value; + } + } + /// Replaces the declared region rectangle. /// The new region rectangle. public void SetBounds( From 523577b70ef8cefae7b433c93034c9cfa0c36fbf Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 21:59:41 -0400 Subject: [PATCH 39/90] Add T1407 pointer preference to interaction hits --- src/CursesInteractionHit.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/CursesInteractionHit.cs b/src/CursesInteractionHit.cs index 7a96ff142..94ecc5dc1 100644 --- a/src/CursesInteractionHit.cs +++ b/src/CursesInteractionHit.cs @@ -39,6 +39,7 @@ int localColumn this.Region = region; this.LocalRow = localRow; this.LocalColumn = localColumn; + this.PointerShape = region.PointerShape; } /// Gets the interaction region selected by the hit test. @@ -55,4 +56,9 @@ public int LocalRow { public int LocalColumn { get; } + + /// Gets the semantic pointer-shape preference captured when this hit was resolved. + public CursesPointerShape? PointerShape { + get; + } } From 178ee57da1ce379f01cd5d30182c854aec1f0fba Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 21:59:59 -0400 Subject: [PATCH 40/90] Add T1407 DCurses pointer-shape lease --- src/CursesPointerShapeLease.cs | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/CursesPointerShapeLease.cs diff --git a/src/CursesPointerShapeLease.cs b/src/CursesPointerShapeLease.cs new file mode 100644 index 000000000..681050cd6 --- /dev/null +++ b/src/CursesPointerShapeLease.cs @@ -0,0 +1,53 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +using Icod.Terminal; + +/// Owns one session-managed terminal mouse-pointer shape request. +public sealed class CursesPointerShapeLease : IAsyncDisposable { + private readonly TerminalPointerShapeLease terminalLease; + + internal CursesPointerShapeLease( + CursesPointerShape shape, + TerminalPointerShapeLease terminalLease + ) { + if ( !Enum.IsDefined( shape ) ) { + throw new ArgumentOutOfRangeException( nameof( shape ) ); + } + ArgumentNullException.ThrowIfNull( terminalLease ); + + this.Shape = shape; + this.terminalLease = terminalLease; + } + + /// Gets the semantic pointer shape owned by this lease. + public CursesPointerShape Shape { + get; + } + + /// Releases this pointer-shape request through the canonical Terminal owner. + /// A value task representing asynchronous restoration or final reset. + public ValueTask DisposeAsync() { + return this.terminalLease.DisposeAsync(); + } +} From 75dc454cd55983f99e5fca96583c608a1e4c9f69 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 22:00:16 -0400 Subject: [PATCH 41/90] Bridge T1407 pointer shapes to Terminal leases --- .../CursesSession.PointerShape.Terminal.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/Integration/CursesSession.PointerShape.Terminal.cs diff --git a/src/Integration/CursesSession.PointerShape.Terminal.cs b/src/Integration/CursesSession.PointerShape.Terminal.cs new file mode 100644 index 000000000..d94200592 --- /dev/null +++ b/src/Integration/CursesSession.PointerShape.Terminal.cs @@ -0,0 +1,88 @@ +/* + Icod.DCurses + Managed, cross-platform curses-style terminal UI library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses; + +using Icod.Terminal; + +public sealed partial class CursesSession { + /// Acquires a scoped semantic terminal mouse-pointer shape. + /// The DCurses semantic pointer shape to request. + /// Cancellation for acquisition only. + /// A DCurses lease forwarding ownership to the canonical Terminal session. + public async ValueTask AcquirePointerShapeAsync( + CursesPointerShape shape, + CancellationToken cancellationToken = default + ) { + TerminalPointerShape terminalShape = ToTerminalPointerShape( shape ); + cancellationToken.ThrowIfCancellationRequested(); + + using IDisposable activity = await this.AcquireTerminalActivityAsync( + cancellationToken + ).ConfigureAwait( false ); + TerminalPointerShapeLease terminalLease = await this.terminalSession.AcquirePointerShapeAsync( + terminalShape, + cancellationToken + ).ConfigureAwait( false ); + return new CursesPointerShapeLease( + shape, + terminalLease + ); + } + + private static TerminalPointerShape ToTerminalPointerShape( + CursesPointerShape shape + ) { + return shape switch { + CursesPointerShape.Alias => TerminalPointerShape.Alias, + CursesPointerShape.Cell => TerminalPointerShape.Cell, + CursesPointerShape.Copy => TerminalPointerShape.Copy, + CursesPointerShape.Crosshair => TerminalPointerShape.Crosshair, + CursesPointerShape.Default => TerminalPointerShape.Default, + CursesPointerShape.EastResize => TerminalPointerShape.EastResize, + CursesPointerShape.EastWestResize => TerminalPointerShape.EastWestResize, + CursesPointerShape.Grab => TerminalPointerShape.Grab, + CursesPointerShape.Grabbing => TerminalPointerShape.Grabbing, + CursesPointerShape.Help => TerminalPointerShape.Help, + CursesPointerShape.Move => TerminalPointerShape.Move, + CursesPointerShape.NorthResize => TerminalPointerShape.NorthResize, + CursesPointerShape.NorthEastResize => TerminalPointerShape.NorthEastResize, + CursesPointerShape.NorthEastSouthWestResize => TerminalPointerShape.NorthEastSouthWestResize, + CursesPointerShape.NoDrop => TerminalPointerShape.NoDrop, + CursesPointerShape.NotAllowed => TerminalPointerShape.NotAllowed, + CursesPointerShape.NorthSouthResize => TerminalPointerShape.NorthSouthResize, + CursesPointerShape.NorthWestResize => TerminalPointerShape.NorthWestResize, + CursesPointerShape.NorthWestSouthEastResize => TerminalPointerShape.NorthWestSouthEastResize, + CursesPointerShape.Pointer => TerminalPointerShape.Pointer, + CursesPointerShape.Progress => TerminalPointerShape.Progress, + CursesPointerShape.SouthResize => TerminalPointerShape.SouthResize, + CursesPointerShape.SouthEastResize => TerminalPointerShape.SouthEastResize, + CursesPointerShape.SouthWestResize => TerminalPointerShape.SouthWestResize, + CursesPointerShape.Text => TerminalPointerShape.Text, + CursesPointerShape.VerticalText => TerminalPointerShape.VerticalText, + CursesPointerShape.WestResize => TerminalPointerShape.WestResize, + CursesPointerShape.Wait => TerminalPointerShape.Wait, + CursesPointerShape.ZoomIn => TerminalPointerShape.ZoomIn, + CursesPointerShape.ZoomOut => TerminalPointerShape.ZoomOut, + _ => throw new ArgumentOutOfRangeException( nameof( shape ) ) + }; + } +} From 32ff70710a0be3b3d22b1751c0b5ec8723460fa5 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 22:02:21 -0400 Subject: [PATCH 42/90] Advance 1.4 API fingerprint for T1407 --- docs/Public-API-Fingerprint-1.4.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/Public-API-Fingerprint-1.4.json b/docs/Public-API-Fingerprint-1.4.json index 8da96e671..deae0bcd8 100644 --- a/docs/Public-API-Fingerprint-1.4.json +++ b/docs/Public-API-Fingerprint-1.4.json @@ -2,9 +2,9 @@ "schema": 1, "release": "1.4.0-alpha.1", "status": "alpha", - "sha256": "fdfa06a44c4d2c3f50f52e911584e8ecd8fcd9499765be2673fe4070e2faa025", - "exportedTypeCount": 60, - "contractLineCount": 482, + "sha256": "8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147", + "exportedTypeCount": 62, + "contractLineCount": 491, "exportedTypes": [ "Icod.DCurses.CursesAlertKind", "Icod.DCurses.CursesAmbiguousWidthPolicy", @@ -52,6 +52,8 @@ "Icod.DCurses.CursesPanelTransparency", "Icod.DCurses.CursesPasteEvent", "Icod.DCurses.CursesPastePhase", + "Icod.DCurses.CursesPointerShape", + "Icod.DCurses.CursesPointerShapeLease", "Icod.DCurses.CursesPresentationCapabilities", "Icod.DCurses.CursesRectangle", "Icod.DCurses.CursesScreen", From 83a636cf8b4192394cb6c25bf0b6322eb06003e4 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 22:05:43 -0400 Subject: [PATCH 43/90] Document T1407 pointer-shape integration --- docs/T1407-Pointer-Shape-Integration.md | 253 ++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/T1407-Pointer-Shape-Integration.md diff --git a/docs/T1407-Pointer-Shape-Integration.md b/docs/T1407-Pointer-Shape-Integration.md new file mode 100644 index 000000000..f3754baec --- /dev/null +++ b/docs/T1407-Pointer-Shape-Integration.md @@ -0,0 +1,253 @@ +# T1407 — Pointer-Shape Integration + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1407 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** implementation/fingerprint head qualified; documentation-complete head requires its own exact-head matrix + +--- + +## Objective + +T1407 implements the pointer-shape contract frozen by T1401 without widening the DCurses public dependency boundary to Terminal pointer types. + +The tranche adds: + +- the stable DCurses semantic `CursesPointerShape` vocabulary; +- optional mutable pointer-shape preferences on interaction regions; +- immutable pointer-shape snapshots on resolved hits and mouse-route results; +- `CursesPointerShapeLease`; +- `CursesSession.AcquirePointerShapeAsync(...)`; +- exhaustive semantic-name mapping to the canonical `Icod.Terminal 1.11.1` pointer-shape lease API. + +It deliberately does **not** perform pointer-shape output during hit testing or routing, query terminal support automatically, infer support from terminal identity, or expose `TerminalPointerShape` / `TerminalPointerShapeLease` through the DCurses public contract. + +## RED checkpoint + +The T1407 tests were committed first at: + +`97fd5eb06c57780689d53bdb4a1d422e1ec483d0` + +Workflow #755 / `34730200422` failed as intended. Linux x64/ARM64 and macOS ARM64 recorded **0 warnings** and failed compilation only because the frozen T1407 production surface did not yet exist: + +- `CursesPointerShape`; +- `CursesPointerShapeLease`; +- `CursesInteractionRegionOptions.PointerShape`; +- `CursesInteractionRegion.PointerShape`; +- `CursesInteractionHit.PointerShape`; +- `CursesSession.AcquirePointerShapeAsync(...)`. + +The RED suite covers: + +- all 30 frozen semantic names and explicit numeric values; +- initialization and mutation of nullable region pointer preferences; +- rejection of undefined pointer-shape values; +- mutation rejection after region disposal; +- immutable hit snapshots across later region preference changes; +- mouse routing carrying the hit snapshot without changing logical focus; +- canonical OSC 22 wire-name mapping for every DCurses semantic value; +- nested DCurses pointer leases restoring the previous active shape; +- composition with an outer direct Terminal pointer-shape lease; +- final reset to terminal policy after the last owner is released; +- cancellation before terminal output; +- invalid-shape failure before terminal output. + +## Public semantic vocabulary + +T1407 adds exactly the 30-value semantic vocabulary frozen by T1401: + +```text +0 Alias +1 Cell +2 Copy +3 Crosshair +4 Default +5 EastResize +6 EastWestResize +7 Grab +8 Grabbing +9 Help +10 Move +11 NorthResize +12 NorthEastResize +13 NorthEastSouthWestResize +14 NoDrop +15 NotAllowed +16 NorthSouthResize +17 NorthWestResize +18 NorthWestSouthEastResize +19 Pointer +20 Progress +21 SouthResize +22 SouthEastResize +23 SouthWestResize +24 Text +25 VerticalText +26 WestResize +27 Wait +28 ZoomIn +29 ZoomOut +``` + +The numeric values are explicitly assigned in DCurses and form part of the 1.4 public contract. + +## Region preference and hit snapshots + +`CursesInteractionRegionOptions.PointerShape` is nullable and initializes the registered region preference. + +`CursesInteractionRegion.PointerShape` is mutable while the region is live. Undefined enum values are rejected with `ArgumentOutOfRangeException`; mutation after disposal throws `ObjectDisposedException`. + +The preference is advisory only. It does not affect region eligibility, focus, hit precedence, or panel precedence. + +`CursesInteractionHit.PointerShape` captures the selected region's preference at hit-resolution time. The hit remains diagnosable and immutable even if the region preference subsequently changes or is cleared. + +Because mouse routing carries the resolved `CursesInteractionHit`, `CursesInteractionResult.Hit.PointerShape` is the same immutable snapshot. Routing does not automatically apply it and does not change logical focus. + +## Terminal ownership bridge + +The session surface is: + +```csharp +public ValueTask AcquirePointerShapeAsync( + CursesPointerShape shape, + CancellationToken cancellationToken = default +); +``` + +`CursesPointerShapeLease` exposes only: + +```csharp +public CursesPointerShape Shape { get; } +public ValueTask DisposeAsync(); +``` + +Internally, acquisition delegates to the canonical `TerminalSession.AcquirePointerShapeAsync(...)` API from `Icod.Terminal 1.11.1`. + +The DCurses-to-Terminal mapping is an exhaustive semantic-name switch. The implementation does **not** cast between enum numeric values, so a future Terminal numeric change cannot silently alter DCurses semantics. + +The mapping is: + +```text +Curses Alias -> Terminal Alias +Curses Cell -> Terminal Cell +Curses Copy -> Terminal Copy +Curses Crosshair -> Terminal Crosshair +Curses Default -> Terminal Default +Curses EastResize -> Terminal EastResize +Curses EastWestResize -> Terminal EastWestResize +Curses Grab -> Terminal Grab +Curses Grabbing -> Terminal Grabbing +Curses Help -> Terminal Help +Curses Move -> Terminal Move +Curses NorthResize -> Terminal NorthResize +Curses NorthEastResize -> Terminal NorthEastResize +Curses NorthEastSouthWestResize -> Terminal NorthEastSouthWestResize +Curses NoDrop -> Terminal NoDrop +Curses NotAllowed -> Terminal NotAllowed +Curses NorthSouthResize -> Terminal NorthSouthResize +Curses NorthWestResize -> Terminal NorthWestResize +Curses NorthWestSouthEastResize -> Terminal NorthWestSouthEastResize +Curses Pointer -> Terminal Pointer +Curses Progress -> Terminal Progress +Curses SouthResize -> Terminal SouthResize +Curses SouthEastResize -> Terminal SouthEastResize +Curses SouthWestResize -> Terminal SouthWestResize +Curses Text -> Terminal Text +Curses VerticalText -> Terminal VerticalText +Curses WestResize -> Terminal WestResize +Curses Wait -> Terminal Wait +Curses ZoomIn -> Terminal ZoomIn +Curses ZoomOut -> Terminal ZoomOut +``` + +## Lease and lifecycle semantics + +DCurses does not implement a second pointer-state manager. `CursesPointerShapeLease` forwards disposal to Terminal's identity-aware scoped lease. + +Therefore the physical ownership semantics remain Terminal-authoritative: + +- acquisition immediately emits the requested semantic pointer shape; +- nested owners may be disposed out of order; +- the newest active owner controls physical pointer shape; +- releasing an inner owner restores the newest remaining owner; +- releasing the final owner emits the OSC 22 empty-payload terminal-policy reset; +- repeated successful disposal is idempotent; +- if Terminal restoration/reset fails, the underlying owner remains retryable according to Terminal's contract. + +Acquisition is serialized through the existing DCurses terminal-activity gate so it cannot begin after session disposal has started and participates in the same Terminal activity boundary as other DCurses terminal operations. + +DCurses does not automatically query pointer-shape support. Successful acquisition proves only that Terminal emitted the complete request according to its existing contract. + +## First GREEN evidence and API guard + +The complete behavioral implementation landed at: + +`75dc454cd55983f99e5fca96583c608a1e4c9f69` + +Workflow #761 / `34731938672` built the implementation with **0 warnings / 0 errors**. + +Across `net8.0`, `net9.0`, and `net10.0`, all **709 behavioral/non-fingerprint tests passed**. The only failing test was the expected current-development public API fingerprint guard. + +The compiler-derived T1407 contract was identical on all three TFMs: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + +The two newly exported types are: + +- `Icod.DCurses.CursesPointerShape`; +- `Icod.DCurses.CursesPointerShapeLease`. + +The remaining additive public changes are the frozen pointer-preference properties and session acquisition method. + +## Fingerprint qualification + +`docs/Public-API-Fingerprint-1.4.json` was advanced to the compiler-derived T1407 contract at: + +`32ff70710a0be3b3d22b1751c0b5ec8723460fa5` + +Workflow #762 / `34732031825` passed all seven PR jobs on that exact head: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +This qualifies the pointer-shape implementation and the current 1.4 alpha public API fingerprint together. + +## Public dependency boundary + +T1407 intentionally keeps Terminal pointer types internal to DCurses implementation. + +No `Icod.Terminal.TerminalPointerShape` or `Icod.Terminal.TerminalPointerShapeLease` type appears in a public DCurses member signature. Applications use only the DCurses semantic enum and lease while Terminal remains the physical protocol/state owner. + +No `Icod.TermInfo.Inspection` dependency is introduced. + +## Scope audit + +T1407 deliberately does **not** add: + +- automatic application of a region pointer preference; +- pointer changes from `HitTest(...)` or `Route(...)`; +- pointer-shape capability/support queries; +- terminal-brand or protocol heuristics; +- callbacks or hidden input loops; +- focus changes on mouse routing; +- new raw OSC APIs; +- raster resources or placements; +- new layout ownership. + +Those exclusions preserve the T1401 architecture: DCurses reports application interaction intent while Terminal owns reversible terminal state and protocol emission. + +## Exit gate + +T1407 is complete when this documentation-complete head passes the full PR package/runtime matrix. After that exact-head qualification, T1408 may begin resize, panel, and lifecycle coherence hardening with a fresh RED checkpoint. From 44ea46a735303f853ec3c7f44e855f630a9ae4f9 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 22:10:56 -0400 Subject: [PATCH 44/90] Add T1408 resize and lifecycle coherence acceptance --- .../src/CursesInteractionCoherenceTests.cs | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesInteractionCoherenceTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionCoherenceTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionCoherenceTests.cs new file mode 100644 index 000000000..eb86fc9cc --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionCoherenceTests.cs @@ -0,0 +1,407 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using System.Text; +using Icod.Terminal; +using Icod.TermInfo; +using Xunit; + +namespace Icod.DCurses.Tests; + +/// Proves T1408 resize, retained-surface, disposal, and lifecycle coherence. +public sealed class CursesInteractionCoherenceTests { + [Fact] + public void ScreenShrinkAndRegrowPreserveDeclaredRegionAndBindingsWithoutRestoringOldFocus() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion fallback = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 2, 2 ) + ) { + IsFocusable = true, + TraversalOrder = 0 + } + ); + CursesRectangle edgeBounds = new( 8, 18, 2, 2 ); + using CursesInteractionRegion edge = router.RegisterRegion( + new CursesInteractionRegionOptions( edgeBounds ) { + IsFocusable = true, + TraversalOrder = 1 + } + ); + CursesKeyGesture enter = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand command = new( "Edge.Enter" ); + edge.BindGesture( enter, command ); + Assert.True( router.Focus( edge ) ); + Assert.Equal( + command, + router.Route( CursesInputEvent.FromKey( CursesKey.Enter ) ).Command + ); + + screen.Resize( 10, 5 ); + + Assert.Equal( edgeBounds, edge.Bounds ); + Assert.Same( fallback, router.FocusedRegion ); + + screen.Resize( 20, 10 ); + + Assert.Equal( edgeBounds, edge.Bounds ); + Assert.Same( fallback, router.FocusedRegion ); + Assert.True( router.Focus( edge ) ); + Assert.Equal( + command, + router.Route( CursesInputEvent.FromKey( CursesKey.Enter ) ).Command + ); + } + + [Fact] + public void PanelMoveResizeHideAndShowUseCurrentGeometryWithoutLosingInteractionState() { + CursesScreen screen = new( 30, 15 ); + using CursesPanel panel = screen.CreatePanel( 2, 2, 5, 5 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 5, 5 ) + ) { + Panel = panel, + IsFocusable = true, + PointerShape = CursesPointerShape.Pointer + } + ); + CursesKeyGesture enter = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand command = new( "Panel.Enter" ); + region.BindGesture( enter, command ); + Assert.True( router.Focus( region ) ); + + Assert.Same( region, router.HitTest( 3, 3 )!.Region ); + Assert.Equal( CursesPointerShape.Pointer, router.HitTest( 3, 3 )!.PointerShape ); + + panel.MoveTo( 5, 6 ); + panel.Resize( 3, 3 ); + + Assert.Null( router.HitTest( 3, 3 ) ); + Assert.Same( region, router.HitTest( 6, 7 )!.Region ); + + panel.Hide(); + Assert.Null( router.HitTest( 6, 7 ) ); + Assert.Null( router.FocusedRegion ); + + panel.Show(); + Assert.Same( region, router.HitTest( 6, 7 )!.Region ); + Assert.True( router.Focus( region ) ); + Assert.Equal( + command, + router.Route( CursesInputEvent.FromKey( CursesKey.Enter ) ).Command + ); + Assert.Equal( CursesPointerShape.Pointer, region.PointerShape ); + } + + [Fact] + public void DisposedPanelMakesRegionPermanentlyIneligibleWithoutDisposingItsApplicationState() { + CursesScreen screen = new( 20, 10 ); + CursesPanel panel = screen.CreatePanel( 1, 1, 4, 4 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 4, 4 ) + ) { + Panel = panel, + IsFocusable = true, + PointerShape = CursesPointerShape.Text + } + ); + CursesKeyGesture enter = CursesKeyGesture.ForKey( CursesKey.Enter ); + region.BindGesture( + enter, + new CursesCommand( "Panel.Enter" ) + ); + Assert.True( router.Focus( region ) ); + + panel.Dispose(); + + Assert.Null( router.HitTest( 2, 2 ) ); + Assert.Null( router.FocusedRegion ); + Assert.False( router.Focus( region ) ); + Assert.Equal( CursesPointerShape.Text, region.PointerShape ); + Assert.True( region.UnbindGesture( enter ) ); + } + + [Fact] + public async Task DimensionSynchronizationReclipsApplicationRouterWithoutRelayoutOrRegistrationLoss() { + RecordingOutput output = new(); + RecordingTerminalControlProvider provider = new() { + Size = new TerminalSize( 20, 10 ) + }; + await using CursesSession session = await OpenSessionAsync( + provider, + output + ); + CursesScreen screen = session.Screen; + using CursesInteractionRouter router = new( screen ); + CursesRectangle bounds = new( 8, 18, 2, 2 ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( bounds ) { + IsFocusable = true + } + ); + CursesKeyGesture enter = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand command = new( "Edge.Enter" ); + region.BindGesture( enter, command ); + Assert.True( router.Focus( region ) ); + + provider.Size = new TerminalSize( 10, 5 ); + TerminalControlResult smaller = session.SynchronizeDimensions(); + + Assert.True( smaller.IsAvailable ); + Assert.Equal( 10, screen.Columns ); + Assert.Equal( 5, screen.Rows ); + Assert.Equal( bounds, region.Bounds ); + Assert.Null( router.FocusedRegion ); + + provider.Size = new TerminalSize( 20, 10 ); + TerminalControlResult restored = session.SynchronizeDimensions(); + + Assert.True( restored.IsAvailable ); + Assert.Equal( bounds, region.Bounds ); + Assert.Null( router.FocusedRegion ); + Assert.True( router.Focus( region ) ); + Assert.Equal( + command, + router.Route( CursesInputEvent.FromKey( CursesKey.Enter ) ).Command + ); + } + + [Fact] + public async Task RepeatedDCursesLifecycleCallbacksDoNotMutateApplicationOwnedRouterState() { + RecordingOutput output = new(); + RecordingTerminalControlProvider provider = new() { + Size = new TerminalSize( 20, 10 ) + }; + await using CursesSession session = await OpenSessionAsync( + provider, + output + ); + using CursesInteractionRouter router = new( session.Screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 4, 4 ) + ) { + IsFocusable = true, + PointerShape = CursesPointerShape.Crosshair + } + ); + CursesKeyGesture enter = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand command = new( "Region.Enter" ); + region.BindGesture( enter, command ); + Assert.True( router.Focus( region ) ); + + for ( int cycle = 0; cycle < 3; cycle++ ) { + await session.LifecycleParticipant.PrepareForTerminalSuspendAsync(); + Assert.Same( region, router.FocusedRegion ); + Assert.Equal( + command, + router.Route( CursesInputEvent.FromKey( CursesKey.Enter ) ).Command + ); + await session.LifecycleParticipant.ResumeAfterTerminalSuspendAsync(); + Assert.Same( region, router.FocusedRegion ); + Assert.Equal( CursesPointerShape.Crosshair, region.PointerShape ); + } + } + + [Fact] + public async Task SessionDisposalLetsTerminalClosePointerStateWithoutInvalidatingApplicationRouter() { + RecordingOutput output = new(); + RecordingTerminalControlProvider provider = new() { + Size = new TerminalSize( 20, 10 ) + }; + CursesSession session = await OpenSessionAsync( + provider, + output + ); + CursesScreen screen = session.Screen; + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 1, 4, 4 ) + ) { + IsFocusable = true + } + ); + CursesKeyGesture enter = CursesKeyGesture.ForKey( CursesKey.Enter ); + CursesCommand command = new( "Region.Enter" ); + region.BindGesture( enter, command ); + Assert.True( router.Focus( region ) ); + CursesPointerShapeLease pointer = await session.AcquirePointerShapeAsync( + CursesPointerShape.Pointer + ); + Assert.Contains( "\u001b]22;pointer\u001b\\", output.Text ); + + await session.DisposeAsync(); + + Assert.Contains( "\u001b]22;\u001b\\", output.Text ); + await pointer.DisposeAsync(); + await pointer.DisposeAsync(); + Assert.Same( region, router.FocusedRegion ); + Assert.Equal( + command, + router.Route( CursesInputEvent.FromKey( CursesKey.Enter ) ).Command + ); + } + + private static async ValueTask OpenSessionAsync( + RecordingTerminalControlProvider provider, + RecordingOutput output + ) { + ArgumentNullException.ThrowIfNull( provider ); + ArgumentNullException.ThrowIfNull( output ); + TerminalSession terminalSession = await TerminalSession.OpenAsync( + provider, + TerminalEndpoint.StandardInput, + TerminalEndpoint.StandardOutput, + new EmptyInput(), + output, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ConfigureOutput = false, + ObserveLifecycleEvents = false + } + ); + return await CursesSession.OpenAsync( + terminalSession, + new CursesSessionOptions { + UseAlternateScreen = false, + EnableKeypad = false, + HideCursor = false + } + ); + } + + private sealed class EmptyInput : ITerminalInput { + public async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) { + _ = buffer; + await Task.Delay( + Timeout.InfiniteTimeSpan, + cancellationToken + ).ConfigureAwait( false ); + return 0; + } + } + + private sealed class RecordingOutput : ITerminalOutput { + private readonly object sync = new(); + private readonly List bytes = []; + + internal string Text { + get { + lock ( this.sync ) { + return Encoding.ASCII.GetString( this.bytes.ToArray() ); + } + } + } + + public ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + lock ( this.sync ) { + this.bytes.AddRange( buffer.ToArray() ); + } + return ValueTask.CompletedTask; + } + + public ValueTask FlushAsync( + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.CompletedTask; + } + } + + private sealed class RecordingTerminalControlProvider : ITerminalControlProvider { + private readonly TerminalModeSnapshot baseline = TerminalModeSnapshot.CreatePosix( + 0, + 0, + 0, + 0x0002UL, + new byte[ 32 ], + 0, + 32, + 0, + new TerminalSpeed( 13, 9600 ), + new TerminalSpeed( 13, 9600 ) + ); + + internal TerminalSize Size { + get; + set; + } = new( 80, 24 ); + + public TerminalControlResult Observe( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + new TerminalEndpointObservation( + true, + null, + TerminalPlatformKind.PosixTermios, + TerminalControlCapabilities.Attachment + | TerminalControlCapabilities.LiveSize + | TerminalControlCapabilities.ModeRead + | TerminalControlCapabilities.ModeWrite + ) + ); + } + + public TerminalControlResult GetSize( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( this.Size ); + } + + public TerminalControlResult GetMode( + TerminalEndpoint endpoint + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + return TerminalControlResult.Available( + this.baseline + ); + } + + public TerminalControlMutationResult SetMode( + TerminalEndpoint endpoint, + TerminalModeSnapshot mode, + TerminalModeApplyTiming timing + ) { + ArgumentNullException.ThrowIfNull( endpoint ); + ArgumentNullException.ThrowIfNull( mode ); + if ( !Enum.IsDefined( timing ) ) { + throw new ArgumentOutOfRangeException( nameof( timing ) ); + } + return TerminalControlMutationResult.Success(); + } + } +} From 1fca652d9a7a6ba32ccf01274a7e4dff1135f785 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 22:14:06 -0400 Subject: [PATCH 45/90] Document T1408 interaction coherence acceptance --- ...-Panel-Disposal-and-Lifecycle-Coherence.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/T1408-Resize-Panel-Disposal-and-Lifecycle-Coherence.md diff --git a/docs/T1408-Resize-Panel-Disposal-and-Lifecycle-Coherence.md b/docs/T1408-Resize-Panel-Disposal-and-Lifecycle-Coherence.md new file mode 100644 index 000000000..ef28f44ba --- /dev/null +++ b/docs/T1408-Resize-Panel-Disposal-and-Lifecycle-Coherence.md @@ -0,0 +1,197 @@ +# T1408 — Resize, Panel, Disposal, and Lifecycle Coherence + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1408 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** coherence acceptance head qualified; documentation-complete head requires its own exact-head matrix + +--- + +## Objective + +T1408 proves that the interaction mechanisms implemented by T1402–T1407 remain deterministic while logical-screen geometry, retained panels, session dimensions, lifecycle coordination, and Terminal-owned pointer state change. + +This is intentionally an **acceptance/hardening tranche**. The T1401 architecture specified that interaction geometry remains application-owned and is resolved against current screen/panel state rather than maintained by a hidden subscription or layout graph. T1408 therefore begins by testing the already-composed behavior rather than presuming a new production mechanism is required. + +The acceptance suite passed without any production correction. T1408 adds tests and this evidence record only; it does not change the public API fingerprint. + +## Acceptance checkpoint + +The T1408 coherence suite was added at: + +`44ea46a735303f853ec3c7f44e855f630a9ae4f9` + +Workflow #764 / `34732410658` passed all seven PR jobs on that exact head: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +Linux x64 qualification recorded: + +```text +Build succeeded +0 warnings +0 errors +716 / 716 tests passed on net8.0 +716 / 716 tests passed on net9.0 +716 / 716 tests passed on net10.0 +``` + +The six T1408 tests all passed on their first execution against the already-qualified T1407 production implementation. No source correction was necessary. + +## Screen shrink/regrow coherence + +T1408 proves that ordinary interaction-region geometry remains application-owned across logical-screen resize. + +A region near the old lower-right edge is focused and owns a local command binding. Shrinking the screen until the region is fully clipped: + +- does not mutate the region's declared `Bounds`; +- makes the region ineligible through current-screen clipping; +- repairs focus deterministically to the next eligible region when focus is observed; +- leaves the clipped region and its gesture binding registered. + +Growing the screen again: + +- preserves the same declared region rectangle; +- does not silently restore the former focus; +- makes the region eligible again under current geometry; +- allows explicit refocus; +- preserves the original local command binding. + +This confirms that screen resize changes **effective geometry**, not application-owned declared geometry or registration identity. + +## Panel churn coherence + +T1408 exercises a panel-associated region while its retained panel is moved, resized, hidden, and shown. + +The router follows current panel state without re-registration: + +- old screen coordinates stop hitting after panel movement; +- new screen coordinates resolve against the moved panel; +- panel resize clips the effective interaction rectangle; +- hiding the panel removes it from hit/focus eligibility; +- logical focus repairs when the hidden panel made the focused region ineligible; +- showing the panel makes the still-registered region eligible again; +- explicit refocus restores local-command routing; +- the region's pointer-shape preference remains intact throughout. + +No interaction geometry cache or panel-change subscription is required. + +## Panel disposal coherence + +Panel disposal does not dispose its interaction region. + +T1408 proves that after the associated panel is disposed: + +- the region no longer hits; +- the region is no longer focus eligible; +- current logical focus repairs away from it; +- `Focus(region)` returns `false` rather than reviving an invalid panel association; +- immutable/observational region state remains readable; +- the region's pointer preference remains diagnosable; +- its local gesture binding remains application-owned and can still be explicitly unbound. + +The region itself remains live until the application disposes it, exactly as frozen by T1401. + +## Live-dimension synchronization + +T1408 uses a mutable Terminal control provider to exercise `CursesSession.SynchronizeDimensions()` against an already-created application router. + +When Terminal dimensions shrink and later regrow: + +- the materialized `CursesScreen` follows the current Terminal size; +- ordinary interaction region declarations remain unchanged; +- the router immediately resolves eligibility against the current `CursesScreen`; +- losing focus due to clipping does not erase registrations or command bindings; +- regrow does not automatically restore a previously lost logical focus; +- explicit refocus after regrow restores command routing without re-registration. + +This preserves the 1.3/1.4 division of responsibility: DCurses synchronizes the logical screen, while applications remain responsible for any desired relayout of their own interaction geometry. + +## Lifecycle-participant coherence + +Terminal remains the lifecycle owner. DCurses contributes only its registered higher-layer lifecycle participant for curses-specific rendition/activity coordination. + +T1408 repeatedly calls the DCurses participant's suspend preparation and resume callback while an application-owned router has focused regions, bindings, and pointer preferences. + +Across repeated cycles: + +- router registrations remain intact; +- logical focus remains intact when geometry/eligibility did not change; +- command routing remains deterministic; +- pointer preference metadata remains intact; +- no router lifecycle owner or background subscription is introduced. + +The Terminal 1.11.1 lifecycle ordering was reviewed as part of this tranche. Terminal prepares higher-layer participants before core pointer-state suspension and resumes core state before completing higher-layer resume. Its `TerminalPointerShapeManager` is itself a core lifecycle participant which resets physical pointer state for suspension and re-enters the newest active owner after resume. DCurses therefore must not duplicate that state machine. + +## Session disposal and pointer ownership + +T1408 also proves closure behavior with a live DCurses pointer-shape lease. + +The test: + +1. opens a DCurses session and application-owned router; +2. registers/focuses a region and binds a command; +3. acquires `CursesPointerShape.Pointer` through the DCurses wrapper; +4. confirms Terminal emits the OSC 22 pointer request; +5. disposes the `CursesSession` while the lease remains live; +6. confirms Terminal closes pointer ownership with its terminal-policy reset; +7. disposes the wrapper lease repeatedly after session closure; +8. confirms the independent application-owned router still routes deterministically over its retained `CursesScreen` object. + +This confirms that Terminal remains authoritative for physical pointer cleanup and that DCurses does not make router lifetime dependent on Terminal pointer-lease lifetime. + +## No production delta + +T1408 discovered no coherence defect requiring source modification. + +That absence of a production delta is intentional evidence, not a skipped implementation step. The following already-qualified mechanisms compose successfully: + +- T1402 region ownership and disposal; +- T1403 current-state hit testing and panel precedence; +- T1404 lazy deterministic focus repair; +- T1405 semantic gestures; +- T1406 binding/route preservation; +- T1407 Terminal-owned pointer leases; +- 1.3 logical-screen and panel resize behavior; +- existing Terminal lifecycle participation. + +Adding another watcher, geometry cache, layout owner, pointer manager, or lifecycle owner would duplicate responsibilities and weaken the frozen architecture. + +## Public API fingerprint + +T1408 adds no production member or exported type. The current 1.4 alpha public API therefore remains: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + +`docs/Public-API-Fingerprint-1.4.json` is unchanged from the T1407-qualified value. + +## Scope audit + +T1408 deliberately does **not** add: + +- automatic region relayout on screen resize; +- automatic restoration of a previously clipped logical focus; +- panel-change subscriptions; +- hidden router background work; +- a second Terminal lifecycle owner; +- a second pointer-state manager; +- automatic pointer application during routing; +- new Terminal/TermInfo public dependencies; +- public API changes. + +## Exit gate + +T1408 is complete when this documentation-complete head passes the full PR package/runtime matrix. After that exact-head qualification, T1409 may begin the public-API-only interaction acceptance sample. From beb731644218e4820a1440a3da041fdd5b27b93c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 22:42:38 -0400 Subject: [PATCH 46/90] Specify T1409 interaction acceptance sample --- ...dcurses-t1409-interaction-sample-design.md | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md diff --git a/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md b/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md new file mode 100644 index 000000000..3edfd0d47 --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md @@ -0,0 +1,380 @@ +# T1409 Interaction Acceptance Sample Design + +**Project:** `Icod.DCurses` +**Release:** `1.4.0` +**Tranche:** T1409 +**Branch:** `1.4.0-interaction-routing` +**Sample project:** `samples/Icod.DCurses.Interaction.Sample` +**Status:** approved design; implementation pending plan approval + +--- + +## Purpose + +T1409 proves that the 1.4 interaction primitives compose into a realistic application without introducing a widget framework, hidden event loop, automatic focus policy, or raw terminal-protocol dependency. + +The sample is a teaching and acceptance artifact. It should show an application author how to combine: + +- explicit layout recomputation; +- ordinary and panel-associated interaction regions; +- logical focus and deterministic forward/backward traversal; +- region-local and global semantic command bindings; +- mouse hit testing with region-local coordinates; +- retained panel precedence; +- pointer-shape preferences plus explicit lease application; +- terminal resize and repaint handling; +- terminal focus reporting without conflating it with logical application focus. + +The sample must consume only public `Icod.DCurses` APIs. + +## Why this is a new sample + +`Icod.DCurses.Layout.Sample` remains intentionally narrow: it teaches explicit 1.3 layout recomputation after resize. Expanding it into a 1.4 interaction demonstration would blur that purpose and make the simpler layout story harder to follow. + +T1409 therefore introduces a new project: + +```text +samples/Icod.DCurses.Interaction.Sample/ + Icod.DCurses.Interaction.Sample.csproj + Program.cs +``` + +The project is added to `Icod.DCurses.sln` and documented in `samples/README.md`. + +## Project contract + +The sample project follows repository conventions: + +- executable sample; +- GPL-3.0-or-later source/header convention used by existing executable samples; +- `TargetFrameworks` = `net8.0;net9.0;net10.0`; +- `Configurations` = `Debug;Staging;Release`; +- `IsPackable` = `false`; +- project reference only to the repository `Icod.DCurses.csproj`; +- no direct `Icod.Terminal` or `Icod.TermInfo` references; +- no internal APIs, reflection, or friend-assembly access. + +The sample must build under the normal solution Staging matrix, which provides compile-time proof that the public DCurses surface is sufficient on all three target frameworks. + +## User-visible layout + +The application presents four logical areas: + +```text ++------------------------------------------------------------+ +| header / status | ++----------------------------+-------------------------------+ +| | | +| left pane | right pane | +| | | +| | | ++----------------------------+-------------------------------+ +| footer / help | ++------------------------------------------------------------+ +``` + +A retained popup panel can be shown over the body panes: + +```text ++------------------------------------------------------------+ +| header / status | ++----------------------------+-------------------------------+ +| | | +| left pane +----------------------+ | +| | retained popup | | +| | | | +| +----------------------+ | ++----------------------------+-------------------------------+ +| footer / help | ++------------------------------------------------------------+ +``` + +The popup exists specifically to demonstrate panel-over-ordinary hit precedence and panel-relative interaction coordinates. + +## Minimum terminal size + +The sample defines a fixed minimum terminal geometry sufficient to render all four ordinary regions and the popup without degenerate windows. The implementation plan must choose one concrete minimum and use it consistently in startup validation and resize handling. + +When the terminal is below that minimum, the application must: + +1. hide the popup panel if shown; +2. render a simple resize-required message on the standard screen; +3. avoid constructing or applying invalid window/panel bounds; +4. continue receiving lifecycle/input events so the application can recover after the terminal grows; +5. preserve application command/interaction registrations rather than rebuilding the router merely because the terminal became small. + +## Geometry model + +The application owns layout explicitly. + +One `ComputeLayout(...)` routine derives immutable `CursesRectangle` values from current `CursesScreen.Bounds`. The body is split into left and right panes using `CursesLayout`; header and footer areas are docked explicitly. + +On every accepted resize/repaint boundary: + +1. call `session.SynchronizeDimensions()`; +2. evaluate the minimum-size rule; +3. recompute rectangles from the current `screen.Bounds`; +4. apply new window bounds with `SetBounds(...)`; +5. apply the popup panel bounds with `SetBounds(...)` when it can be shown; +6. update the application-owned ordinary interaction-region bounds with `SetBounds(...)`; +7. update the popup region bounds in panel-local coordinates if its internal target geometry changes; +8. invalidate/repaint explicitly. + +The sample does not introduce a retained layout tree and does not expect the router to relayout regions automatically. + +## Interaction router ownership + +The application creates exactly one `CursesInteractionRouter` for the session's materialized `CursesScreen` and owns it for the lifetime of the sample. + +The router registers at least these interaction regions: + +- header/status region; +- left pane region; +- right pane region; +- footer/help region; +- one popup panel-associated region. + +The left and right panes are focusable. The popup region is focusable while shown. Header/footer regions may remain non-focusable if their only purpose is pointer/mouse demonstration. + +All router and region lifetimes are explicit through normal `using`/`Dispose` ownership. + +## Focus model + +Logical focus is application state routed through DCurses; terminal focus reports remain a separate concept. + +The sample demonstrates: + +- explicit initial logical focus on the left pane; +- Tab -> `MoveFocus(Forward)`; +- Shift+Tab -> `MoveFocus(Backward)`; +- popup focus when explicitly requested by an application command; +- deterministic repair when the focused popup becomes hidden or otherwise ineligible; +- terminal Focused/Unfocused reports update status text only and do not clear or replace `router.FocusedRegion`. + +Mouse hits do not automatically mutate logical focus. If the sample chooses to focus a clicked region for one demonstrated command, it must do so explicitly in application code after inspecting the routing result; no implicit focus-on-click helper is introduced. + +## Command vocabulary + +The sample uses semantic `CursesCommand` identities rather than callbacks registered in the router. + +The implementation defines a small, stable local vocabulary inside the sample, including commands equivalent to: + +- next focus; +- previous focus; +- toggle popup; +- close popup; +- left-pane action; +- right-pane action; +- quit. + +At least one binding must be router-global and at least one binding must be region-local so the sample visibly demonstrates local-before-global resolution. + +Recommended bindings: + +- `Tab` -> next focus; +- `Shift+Tab` -> previous focus; +- `F2` -> global popup toggle; +- `Escape` -> close popup when open, otherwise quit; +- one character shortcut local to the left pane; +- the same character gesture bound globally to a different command, proving the focused local binding wins; +- `q` or `Q` -> quit when not shadowed by a deliberately demonstrated local command. + +Exact command names are sample-private implementation details and do not become library API. + +## Event loop + +The application owns one explicit event loop: + +```text +while running + draw current application state + RefreshAsync + ReadEventAsync + if input: + router.Route(input) + application interprets result + application mutates its own state + application explicitly applies focus/pointer changes when desired + if lifecycle: + update lifecycle/status state + on resize/resume repaint boundary: + synchronize dimensions + recompute layout + repeat +``` + +The sample never starts a second terminal reader and never consumes raw escape sequences. + +## Routing behavior demonstrated + +### Keyboard/text + +The sample passes normalized `CursesInputEvent` values to `router.Route(...)` and handles `CursesInteractionResult` as data. + +For a matched command, the application switches on command identity and performs the action itself. For targeted-but-unmatched input, the status line may display the target region and normalized input kind. Unhandled input remains inert. + +### Mouse + +Mouse events are routed through `router.Route(...)`. + +The status area displays: + +- the selected region name; +- screen row/column from the original mouse event; +- region-local row/column from `CursesInteractionHit`; +- the hit's pointer-shape preference, if any. + +When the popup overlaps a body pane, a mouse event inside the overlap must resolve to the popup region, proving T1403 panel precedence in a real application. + +## Pointer-shape policy + +The sample assigns distinct `PointerShape` preferences to at least three regions, for example: + +- ordinary body pane -> `Text`; +- actionable/help region -> `Pointer`; +- popup -> `Crosshair` or `Move`. + +Routing/hit testing only reports the preferred shape. The application owns physical application of that preference. + +The sample maintains at most one active `CursesPointerShapeLease` for the shape it most recently chose to apply. + +When a routed mouse hit requests a shape different from the currently applied shape: + +1. acquire the new shape with `session.AcquirePointerShapeAsync(...)`; +2. only after successful acquisition, dispose the prior DCurses lease; +3. remember the new lease and shape. + +When a mouse event has no preferred shape, the application disposes its current DCurses pointer lease and leaves terminal policy authoritative. + +On sample exit, any remaining pointer lease is disposed before session disposal through normal structured ownership. + +The sample never calls `Icod.Terminal` pointer APIs directly. + +## Popup behavior + +The popup is a retained `CursesPanel` created once after startup and reused. + +Application state controls whether it is visible. + +Showing the popup: + +- makes the panel visible; +- enables its panel-associated interaction region if needed; +- may explicitly focus the popup region as part of the application command handling; +- places it above ordinary body regions by normal panel precedence. + +Hiding the popup: + +- hides the panel; +- makes its associated region ineligible through existing router semantics; +- relies on normal logical-focus repair if the popup held focus; +- does not destroy/recreate command registrations. + +The sample does not implement a modal event loop. The popup is retained application state inside the single normal event loop. + +## Rendering/state model + +The sample keeps a small application state record/object containing only values needed for presentation and demonstration, such as: + +- running flag; +- popup-visible flag; +- latest status message; +- latest terminal focus state; +- latest routed target/local coordinate description; +- current applied pointer shape observation. + +Rendering is deterministic from current state. Drawing code may be split into small helpers for header, panes, popup, and footer, but the sample remains intentionally lightweight and does not introduce widget abstractions. + +## Resize and lifecycle behavior + +The sample responds to DCurses lifecycle events, not Terminal types. + +For `Resize` and `Resumed` events: + +- call `SynchronizeDimensions()`; +- recompute application layout explicitly; +- apply window/panel/region bounds; +- call `session.Invalidate()` before the next refresh when necessary. + +For `Focused`/`Unfocused` input reports, if present through normalized input, update status only; logical focus remains controlled by the router. + +For interrupt/termination lifecycle events, exit the event loop through the normal application shutdown path. + +The sample contains no custom suspend/resume protocol handling. T1408 already proves DCurses/Terminal lifecycle ownership; T1409 consumes that public behavior only. + +## Error handling + +The sample should remain pedagogical rather than wrap every call in broad exception handling. + +Expected policy: + +- startup/open failures propagate normally; +- pointer-shape acquisition failures are caught narrowly, reported in status text, and leave the prior successfully-owned pointer lease in place; +- cancellation/termination exits through the normal session lifecycle path; +- invalid geometry is prevented by minimum-size checks and deterministic layout calculations rather than caught after the fact; +- no exception is swallowed silently. + +## Files touched by T1409 implementation + +Expected implementation scope: + +```text +Icod.DCurses.sln +samples/README.md +samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj +samples/Icod.DCurses.Interaction.Sample/Program.cs +docs/T1409-Interaction-Acceptance-Sample.md +``` + +If implementation reveals that a helper source file is necessary to keep `Program.cs` comprehensible, one additional sample-local `.cs` file is permitted, provided it remains sample-only and introduces no framework-style abstraction. + +No library production source file should change during T1409 unless the sample exposes a concrete public-API defect. Any such defect upgrades the work from sample implementation to a separately documented correction and must be requalified before T1409 can close. + +## Automated qualification + +T1409 qualification requires: + +1. the sample project is part of `Icod.DCurses.sln`; +2. the sample builds successfully for `net8.0`, `net9.0`, and `net10.0` under Staging; +3. the existing full test suite remains green; +4. package candidate remains green; +5. Windows/Linux/macOS x64/ARM64 PR jobs remain green; +6. the 1.4 public API fingerprint remains exactly unchanged from T1408 unless a separately justified production correction was required; +7. `samples/README.md` documents how to run the sample and what 1.4 contracts it demonstrates. + +## Manual acceptance checklist + +A human run of the sample should be able to demonstrate all of the following in one session: + +- left/right logical focus is visibly distinguishable; +- Tab and Shift+Tab traverse focus deterministically; +- a local binding wins over an equivalent global gesture while its region is focused; +- a global command works regardless of focused ordinary pane; +- the popup can be shown and hidden without a second event loop; +- the popup wins mouse routing when overlapping a body pane; +- mouse status reports region-local coordinates; +- pointer-shape preference changes are applied explicitly by the application; +- terminal resize triggers explicit recomputation of window, panel, and interaction geometry; +- shrinking below minimum size does not destroy router/binding state; +- growing back restores the normal application layout; +- terminal focus reporting does not erase logical focus; +- quit/termination cleans up the pointer lease and session normally. + +## Non-goals + +T1409 must not introduce: + +- a widget/control hierarchy; +- callback registration for commands; +- automatic focus-on-click policy in the library; +- automatic layout ownership; +- modal nested event loops; +- raw `Icod.Terminal` protocol APIs in the sample; +- pointer capture or drag/drop semantics; +- accessibility framework ownership; +- generalized application navigation; +- raster graphics. + +## Exit criterion + +T1409 is complete when the approved sample exists, uses only public DCurses APIs, is documented, demonstrates the listed behaviors, and its exact documentation-complete head passes the repository's full PR package/runtime matrix without changing the accepted 1.4 public API contract. From 85d69b6bc57730d2f5e3cbefa21990dc5fb412aa Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 22:43:31 -0400 Subject: [PATCH 47/90] Tighten T1409 interaction sample design --- ...dcurses-t1409-interaction-sample-design.md | 153 ++++++++++-------- 1 file changed, 85 insertions(+), 68 deletions(-) diff --git a/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md b/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md index 3edfd0d47..c9a496071 100644 --- a/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md +++ b/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md @@ -13,7 +13,7 @@ T1409 proves that the 1.4 interaction primitives compose into a realistic application without introducing a widget framework, hidden event loop, automatic focus policy, or raw terminal-protocol dependency. -The sample is a teaching and acceptance artifact. It should show an application author how to combine: +The sample is a teaching and acceptance artifact. It shows an application author how to combine: - explicit layout recomputation; - ordinary and panel-associated interaction regions; @@ -93,7 +93,9 @@ The popup exists specifically to demonstrate panel-over-ordinary hit precedence ## Minimum terminal size -The sample defines a fixed minimum terminal geometry sufficient to render all four ordinary regions and the popup without degenerate windows. The implementation plan must choose one concrete minimum and use it consistently in startup validation and resize handling. +The minimum supported geometry is **64 columns x 16 rows**. + +This leaves one header row, two footer/help rows, at least thirteen body rows before footer allocation is applied, two usable ordinary panes, and room for a retained popup without degenerate rectangles. When the terminal is below that minimum, the application must: @@ -107,17 +109,24 @@ When the terminal is below that minimum, the application must: The application owns layout explicitly. -One `ComputeLayout(...)` routine derives immutable `CursesRectangle` values from current `CursesScreen.Bounds`. The body is split into left and right panes using `CursesLayout`; header and footer areas are docked explicitly. +One `ComputeLayout(...)` routine derives immutable `CursesRectangle` values from current `CursesScreen.Bounds`. + +The layout is frozen as follows: + +- dock one row from the top for the header/status area; +- dock two rows from the bottom for the footer/help area; +- split the remaining body into left/right panes using `CursesLayout.SplitColumns(...)` with equal weights; +- center a popup rectangle of **28 columns x 8 rows** within the body, clipping only through the minimum-size fallback rather than creating an undersized popup. On every accepted resize/repaint boundary: 1. call `session.SynchronizeDimensions()`; -2. evaluate the minimum-size rule; +2. evaluate the 64x16 minimum-size rule; 3. recompute rectangles from the current `screen.Bounds`; 4. apply new window bounds with `SetBounds(...)`; 5. apply the popup panel bounds with `SetBounds(...)` when it can be shown; 6. update the application-owned ordinary interaction-region bounds with `SetBounds(...)`; -7. update the popup region bounds in panel-local coordinates if its internal target geometry changes; +7. keep the popup interaction region panel-local and set to the full popup content rectangle; 8. invalidate/repaint explicitly. The sample does not introduce a retained layout tree and does not expect the router to relayout regions automatically. @@ -126,15 +135,15 @@ The sample does not introduce a retained layout tree and does not expect the rou The application creates exactly one `CursesInteractionRouter` for the session's materialized `CursesScreen` and owns it for the lifetime of the sample. -The router registers at least these interaction regions: +The router registers exactly these interaction regions: -- header/status region; -- left pane region; -- right pane region; -- footer/help region; -- one popup panel-associated region. +- header/status region — non-focusable; +- left pane region — focusable; +- right pane region — focusable; +- footer/help region — non-focusable; +- popup panel-associated region — focusable. -The left and right panes are focusable. The popup region is focusable while shown. Header/footer regions may remain non-focusable if their only purpose is pointer/mouse demonstration. +The popup region remains registered for the entire sample lifetime. Panel visibility controls whether it is currently eligible. All router and region lifetimes are explicit through normal `using`/`Dispose` ownership. @@ -147,39 +156,43 @@ The sample demonstrates: - explicit initial logical focus on the left pane; - Tab -> `MoveFocus(Forward)`; - Shift+Tab -> `MoveFocus(Backward)`; -- popup focus when explicitly requested by an application command; -- deterministic repair when the focused popup becomes hidden or otherwise ineligible; +- popup focus when F2 opens the popup; +- deterministic focus repair when Escape hides a focused popup; - terminal Focused/Unfocused reports update status text only and do not clear or replace `router.FocusedRegion`. -Mouse hits do not automatically mutate logical focus. If the sample chooses to focus a clicked region for one demonstrated command, it must do so explicitly in application code after inspecting the routing result; no implicit focus-on-click helper is introduced. +Mouse hits do not mutate logical focus. The sample intentionally leaves click-to-focus absent so the distinction between target routing and focus policy remains visible. -## Command vocabulary +## Command vocabulary and bindings The sample uses semantic `CursesCommand` identities rather than callbacks registered in the router. -The implementation defines a small, stable local vocabulary inside the sample, including commands equivalent to: +The sample-private command names are frozen as: -- next focus; -- previous focus; -- toggle popup; -- close popup; -- left-pane action; -- right-pane action; -- quit. +```text +focus.next +focus.previous +popup.toggle +escape +left.action +right.action +global.x +quit +``` -At least one binding must be router-global and at least one binding must be region-local so the sample visibly demonstrates local-before-global resolution. +Bindings are frozen as follows: -Recommended bindings: +- global `Tab` -> `focus.next`; +- global `Shift+Tab` -> `focus.previous`; +- global `F2` -> `popup.toggle`; +- global `Escape` -> `escape`; +- left-pane local character `x` -> `left.action`; +- right-pane local character `r` -> `right.action`; +- global character `x` -> `global.x`; +- global character `q` -> `quit`. -- `Tab` -> next focus; -- `Shift+Tab` -> previous focus; -- `F2` -> global popup toggle; -- `Escape` -> close popup when open, otherwise quit; -- one character shortcut local to the left pane; -- the same character gesture bound globally to a different command, proving the focused local binding wins; -- `q` or `Q` -> quit when not shadowed by a deliberately demonstrated local command. +This deliberately binds `x` both locally and globally. When the left pane has logical focus, `left.action` must win. When another focusable region has focus, the same `x` gesture resolves to `global.x`. -Exact command names are sample-private implementation details and do not become library API. +The `escape` command is interpreted by application state: when the popup is visible it hides the popup; otherwise it exits the application. This keeps one deterministic binding while demonstrating that command execution policy belongs to the application. ## Event loop @@ -211,7 +224,7 @@ The sample never starts a second terminal reader and never consumes raw escape s The sample passes normalized `CursesInputEvent` values to `router.Route(...)` and handles `CursesInteractionResult` as data. -For a matched command, the application switches on command identity and performs the action itself. For targeted-but-unmatched input, the status line may display the target region and normalized input kind. Unhandled input remains inert. +For a matched command, the application switches on command identity and performs the action itself. For targeted-but-unmatched input, the status line displays the target region and normalized input kind. Unhandled input remains inert. ### Mouse @@ -228,11 +241,13 @@ When the popup overlaps a body pane, a mouse event inside the overlap must resol ## Pointer-shape policy -The sample assigns distinct `PointerShape` preferences to at least three regions, for example: +Pointer preferences are frozen as: -- ordinary body pane -> `Text`; -- actionable/help region -> `Pointer`; -- popup -> `Crosshair` or `Move`. +- header/status -> `Default`; +- left pane -> `Text`; +- right pane -> `Crosshair`; +- footer/help -> `Pointer`; +- popup -> `Move`. Routing/hit testing only reports the preferred shape. The application owns physical application of that preference. @@ -244,7 +259,7 @@ When a routed mouse hit requests a shape different from the currently applied sh 2. only after successful acquisition, dispose the prior DCurses lease; 3. remember the new lease and shape. -When a mouse event has no preferred shape, the application disposes its current DCurses pointer lease and leaves terminal policy authoritative. +When a mouse event has no hit/preferred shape, the application disposes its current DCurses pointer lease and leaves terminal policy authoritative. On sample exit, any remaining pointer lease is disposed before session disposal through normal structured ownership. @@ -256,32 +271,30 @@ The popup is a retained `CursesPanel` created once after startup and reused. Application state controls whether it is visible. -Showing the popup: +F2 toggles the popup: + +- when hidden, show the panel and explicitly focus the popup region; +- when visible, hide the panel and allow existing router focus-repair semantics to select the next eligible region. -- makes the panel visible; -- enables its panel-associated interaction region if needed; -- may explicitly focus the popup region as part of the application command handling; -- places it above ordinary body regions by normal panel precedence. +Escape behaves as follows: -Hiding the popup: +- when the popup is visible, hide it and keep the application running; +- when the popup is hidden, exit the application. -- hides the panel; -- makes its associated region ineligible through existing router semantics; -- relies on normal logical-focus repair if the popup held focus; -- does not destroy/recreate command registrations. +Hiding the popup does not destroy/recreate its region or any command registrations. The sample does not implement a modal event loop. The popup is retained application state inside the single normal event loop. ## Rendering/state model -The sample keeps a small application state record/object containing only values needed for presentation and demonstration, such as: +The sample keeps one small sample-local application-state type containing only: -- running flag; -- popup-visible flag; +- `Running`; +- `PopupVisible`; - latest status message; - latest terminal focus state; -- latest routed target/local coordinate description; -- current applied pointer shape observation. +- latest routed target/local-coordinate description; +- current applied pointer-shape observation. Rendering is deterministic from current state. Drawing code may be split into small helpers for header, panes, popup, and footer, but the sample remains intentionally lightweight and does not introduce widget abstractions. @@ -289,14 +302,17 @@ Rendering is deterministic from current state. Drawing code may be split into sm The sample responds to DCurses lifecycle events, not Terminal types. -For `Resize` and `Resumed` events: +For `Resize` and `Resumed` lifecycle events: - call `SynchronizeDimensions()`; - recompute application layout explicitly; - apply window/panel/region bounds; -- call `session.Invalidate()` before the next refresh when necessary. +- call `session.Invalidate()` before the next refresh. + +For normalized terminal Focused/Unfocused input reports: -For `Focused`/`Unfocused` input reports, if present through normalized input, update status only; logical focus remains controlled by the router. +- update the header/status observation only; +- leave logical focus entirely controlled by `CursesInteractionRouter`. For interrupt/termination lifecycle events, exit the event loop through the normal application shutdown path. @@ -304,14 +320,14 @@ The sample contains no custom suspend/resume protocol handling. T1408 already pr ## Error handling -The sample should remain pedagogical rather than wrap every call in broad exception handling. +The sample remains pedagogical rather than wrapping every call in broad exception handling. -Expected policy: +Policy is frozen as: - startup/open failures propagate normally; - pointer-shape acquisition failures are caught narrowly, reported in status text, and leave the prior successfully-owned pointer lease in place; - cancellation/termination exits through the normal session lifecycle path; -- invalid geometry is prevented by minimum-size checks and deterministic layout calculations rather than caught after the fact; +- invalid geometry is prevented by the 64x16 minimum-size check and deterministic layout calculation rather than caught after the fact; - no exception is swallowed silently. ## Files touched by T1409 implementation @@ -326,7 +342,7 @@ samples/Icod.DCurses.Interaction.Sample/Program.cs docs/T1409-Interaction-Acceptance-Sample.md ``` -If implementation reveals that a helper source file is necessary to keep `Program.cs` comprehensible, one additional sample-local `.cs` file is permitted, provided it remains sample-only and introduces no framework-style abstraction. +One additional sample-local `InteractionSampleState.cs` file is permitted if keeping the state type separate materially improves readability; no other helper project or framework layer is permitted. No library production source file should change during T1409 unless the sample exposes a concrete public-API defect. Any such defect upgrades the work from sample implementation to a separately documented correction and must be requalified before T1409 can close. @@ -339,25 +355,26 @@ T1409 qualification requires: 3. the existing full test suite remains green; 4. package candidate remains green; 5. Windows/Linux/macOS x64/ARM64 PR jobs remain green; -6. the 1.4 public API fingerprint remains exactly unchanged from T1408 unless a separately justified production correction was required; +6. the 1.4 public API fingerprint remains exactly **62 exported types / 491 contract lines / SHA-256 `8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147`**; 7. `samples/README.md` documents how to run the sample and what 1.4 contracts it demonstrates. ## Manual acceptance checklist -A human run of the sample should be able to demonstrate all of the following in one session: +A human run of the sample must demonstrate all of the following in one session: - left/right logical focus is visibly distinguishable; - Tab and Shift+Tab traverse focus deterministically; -- a local binding wins over an equivalent global gesture while its region is focused; -- a global command works regardless of focused ordinary pane; -- the popup can be shown and hidden without a second event loop; +- left-pane local `x` wins over global `x` while the left region is focused; +- global `x` works while the right or popup region has focus; +- F2 shows/hides the popup without a second event loop; - the popup wins mouse routing when overlapping a body pane; - mouse status reports region-local coordinates; - pointer-shape preference changes are applied explicitly by the application; - terminal resize triggers explicit recomputation of window, panel, and interaction geometry; -- shrinking below minimum size does not destroy router/binding state; +- shrinking below 64x16 does not destroy router/binding state; - growing back restores the normal application layout; - terminal focus reporting does not erase logical focus; +- Escape closes the popup before it quits the application; - quit/termination cleans up the pointer lease and session normally. ## Non-goals From 9d167356a072c97908a6969234e69a2cd39aee84 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sat, 12 Sep 2026 22:44:28 -0400 Subject: [PATCH 48/90] Correct T1409 layout API name --- ...2026-09-12-icod-dcurses-t1409-interaction-sample-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md b/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md index c9a496071..b4af72a62 100644 --- a/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md +++ b/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md @@ -95,7 +95,7 @@ The popup exists specifically to demonstrate panel-over-ordinary hit precedence The minimum supported geometry is **64 columns x 16 rows**. -This leaves one header row, two footer/help rows, at least thirteen body rows before footer allocation is applied, two usable ordinary panes, and room for a retained popup without degenerate rectangles. +After allocating one header row and two footer/help rows, this leaves thirteen body rows, two usable ordinary panes, and room for a retained popup without degenerate rectangles. When the terminal is below that minimum, the application must: @@ -115,7 +115,7 @@ The layout is frozen as follows: - dock one row from the top for the header/status area; - dock two rows from the bottom for the footer/help area; -- split the remaining body into left/right panes using `CursesLayout.SplitColumns(...)` with equal weights; +- split the remaining body into left/right panes using `CursesLayout.SplitColumnsProportional(...)` with equal weights; - center a popup rectangle of **28 columns x 8 rows** within the body, clipping only through the minimum-size fallback rather than creating an undersized popup. On every accepted resize/repaint boundary: From c99862ad9ebd8b79ed757395643c3db934a4ca18 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:04:28 -0400 Subject: [PATCH 49/90] Plan T1409 interaction acceptance sample --- ...3-icod-dcurses-t1409-interaction-sample.md | 597 ++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md diff --git a/docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md b/docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md new file mode 100644 index 000000000..a9fdcdd79 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md @@ -0,0 +1,597 @@ +# T1409 Interaction Acceptance Sample Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a public-DCurses-only interactive 1.4 acceptance sample that demonstrates focus traversal, local/global command routing, mouse hit testing, panel precedence, explicit pointer-shape leases, resize relayout, and terminal-focus/logical-focus independence. + +**Architecture:** Introduce one new executable sample project, `Icod.DCurses.Interaction.Sample`, under the existing `samples` solution folder. The sample owns one `CursesSession`, one `CursesInteractionRouter`, five retained interaction regions, one retained popup panel, three DCurses input-protocol leases, and at most one active DCurses pointer-shape lease. All command execution, focus changes, layout recomputation, pointer application, and rendering stay in application code; no production library source changes are expected. + +**Tech Stack:** C# 13; .NET `net8.0;net9.0;net10.0`; `Icod.DCurses 1.4.0-alpha.1` source project; xUnit; Visual Studio solution format 12.00; GitHub Actions Staging matrix. + +**Spec:** `docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md` + +## Global Constraints + +- Work only on branch `1.4.0-interaction-routing` / PR #29. +- Preserve package identity `1.4.0-alpha.1` and `AssemblyVersion 1.0.0.0`. +- Preserve public API fingerprint exactly: 62 exported types / 491 canonical contract lines / SHA-256 `8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147`. +- Do not modify production library source unless the sample proves a concrete public-API defect; any such defect requires a separate documented correction before T1409 may close. +- The new sample targets `net8.0;net9.0;net10.0`, configurations `Debug;Staging;Release`, `IsPackable=false`, and references only `../../Icod.DCurses.csproj`. +- The sample must not reference or import `Icod.Terminal` or `Icod.TermInfo` directly. +- Use the repository GPL-3.0-or-later header for executable sample/test source and project files. +- Maintain existing 1TBS C# formatting: braces on every `if`/`else`, multiline call closing parenthesis on its own line, parameter validation at method entry. +- Minimum terminal geometry is exactly 64 columns x 16 rows; popup geometry is exactly 28 columns x 8 rows. +- Use `CursesLayout.Dock(...)` and `CursesLayout.SplitColumnsProportional(...)`; do not introduce retained/automatic layout ownership. +- Exactly five interaction regions: header, left, right, footer, popup. Left/right/popup are focusable; header/footer are not. +- Frozen command names: `focus.next`, `focus.previous`, `popup.toggle`, `escape`, `left.action`, `right.action`, `global.x`, `quit`. +- Frozen bindings: global Tab, global Shift+Tab, global F2, global Escape, left-local `x`, right-local `r`, global `x`, global `q`. +- Frozen pointer preferences: header `Default`, left `Text`, right `Crosshair`, footer `Pointer`, popup `Move`. +- Acquire rich input only through public DCurses input-protocol leases: keyboard event types, focus reporting, mouse button events. +- The sample owns exactly one event loop; never create a competing reader or parse raw escape sequences. + +--- + +### Task 1: Project and solution wiring acceptance gate + +**Files:** +- Create: `tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs` +- Create: `samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj` +- Create: `samples/Icod.DCurses.Interaction.Sample/Program.cs` (minimal compile placeholder only for this task) +- Modify: `Icod.DCurses.sln` + +**Interfaces:** +- Consumes: existing root `Icod.DCurses.csproj`; existing `samples` solution folder `{4A1B4462-96F7-4E95-95A4-059D416B73E7}`. +- Produces: one solution-buildable executable project named `Icod.DCurses.Interaction.Sample`, with project GUID `{B427973D-98EF-4F7E-BB0D-6AE640753A21}`. + +- [ ] **Step 1: Write the failing project contract test** + +Create `tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs` with the GPL test header and this first test/helper: + +```csharp +using System.Xml.Linq; +using Xunit; + +namespace Icod.DCurses.Tests; + +public sealed class InteractionSampleProjectContractTests { + [Fact] + public void InteractionSampleProjectIsWiredIntoTheSolution() { + string root = FindRepositoryRoot(); + string projectPath = Path.Combine( + root, + "samples", + "Icod.DCurses.Interaction.Sample", + "Icod.DCurses.Interaction.Sample.csproj" + ); + Assert.True( File.Exists( projectPath ) ); + + XDocument project = XDocument.Load( projectPath ); + Assert.Equal( + "net8.0;net9.0;net10.0", + project.Descendants( "TargetFrameworks" ).Single().Value + ); + Assert.Equal( + "Debug;Staging;Release", + project.Descendants( "Configurations" ).Single().Value + ); + Assert.Equal( + "false", + project.Descendants( "IsPackable" ).Single().Value + ); + + XElement reference = project.Descendants( "ProjectReference" ).Single(); + Assert.Equal( + @"..\..\Icod.DCurses.csproj", + reference.Attribute( "Include" )!.Value + ); + + string solution = File.ReadAllText( + Path.Combine( + root, + "Icod.DCurses.sln" + ) + ); + Assert.Contains( + @"samples\Icod.DCurses.Interaction.Sample\Icod.DCurses.Interaction.Sample.csproj", + solution, + StringComparison.Ordinal + ); + } + + private static string FindRepositoryRoot() { + DirectoryInfo? current = new( AppContext.BaseDirectory ); + while ( current is not null ) { + if ( File.Exists( + Path.Combine( + current.FullName, + "Icod.DCurses.sln" + ) + ) ) { + return current.FullName; + } + current = current.Parent; + } + + throw new InvalidOperationException( "Repository root not found." ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```text +dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~InteractionSampleProjectIsWiredIntoTheSolution +``` + +Expected: FAIL because `samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj` does not yet exist. + +- [ ] **Step 3: Create the sample project** + +Create `samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj` matching the existing executable-sample convention: + +```xml + + + + + Exe + net8.0;net9.0;net10.0 + Icod.DCurses.Interaction.Sample + Icod.DCurses.Interaction.Sample + false + Debug;Staging;Release + + + + + + +``` + +Create a temporary GPL-headed `Program.cs` containing only: + +```csharp +using Icod.DCurses; + +await using CursesSession session = await CursesSession.OpenAsync(); +return 0; +``` + +- [ ] **Step 4: Add the project to `Icod.DCurses.sln`** + +Add: + +```text +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.DCurses.Interaction.Sample", "samples\Icod.DCurses.Interaction.Sample\Icod.DCurses.Interaction.Sample.csproj", "{B427973D-98EF-4F7E-BB0D-6AE640753A21}" +EndProject +``` + +Add Debug/Staging/Release `ActiveCfg` + `Build.0` entries for `{B427973D-98EF-4F7E-BB0D-6AE640753A21}` and nest it under solution folder `{4A1B4462-96F7-4E95-95A4-059D416B73E7}`. + +- [ ] **Step 5: Run the focused contract test and solution build** + +Run: + +```text +dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~InteractionSampleProjectIsWiredIntoTheSolution +dotnet build Icod.DCurses.sln -c Staging +``` + +Expected: PASS; solution builds all three sample TFMs with zero warnings/errors. + +- [ ] **Step 6: Commit the wiring gate** + +```text +git add tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs samples/Icod.DCurses.Interaction.Sample Icod.DCurses.sln +git commit -m "Add T1409 interaction sample project" +``` + +--- + +### Task 2: Interactive sample behavior and public-only source contract + +**Files:** +- Modify: `tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs` +- Replace: `samples/Icod.DCurses.Interaction.Sample/Program.cs` +- Create: `samples/Icod.DCurses.Interaction.Sample/InteractionSampleState.cs` + +**Interfaces:** +- Consumes public DCurses signatures: + - `new CursesInteractionRouter(CursesScreen)` + - `router.RegisterRegion(CursesInteractionRegionOptions)` + - `router.BindGlobalGesture(CursesKeyGesture, CursesCommand)` + - `region.BindGesture(CursesKeyGesture, CursesCommand)` + - `router.Route(CursesInputEvent)` + - `router.Focus(CursesInteractionRegion)` / `MoveFocus(CursesFocusDirection)` + - `CursesKeyGesture.ForKey(...)`, `ForCharacter(...)`, `ForFunctionKey(...)` + - `session.AcquireInputProtocolsAsync(CursesInputProtocolOptions)` + - `session.AcquirePointerShapeAsync(CursesPointerShape)` + - `session.SynchronizeDimensions()` / `session.Invalidate()` / `session.RefreshAsync()` / `session.ReadEventAsync()` +- Produces: one public-API-only interactive sample implementing every manual acceptance behavior frozen in the spec. + +- [ ] **Step 1: Add a failing source-contract test** + +Append this test to `InteractionSampleProjectContractTests`: + +```csharp +[Fact] +public void InteractionSampleUsesTheFrozenPublicInteractionContract() { + string root = FindRepositoryRoot(); + string programPath = Path.Combine( + root, + "samples", + "Icod.DCurses.Interaction.Sample", + "Program.cs" + ); + string source = File.ReadAllText( programPath ); + + Assert.DoesNotContain( "Icod.Terminal", source, StringComparison.Ordinal ); + Assert.DoesNotContain( "Icod.TermInfo", source, StringComparison.Ordinal ); + + string[] requiredMarkers = [ + "CursesInteractionRouter", + "CursesInteractionRegionOptions", + "BindGlobalGesture", + "BindGesture", + "CursesKeyGesture.ForKey", + "CursesKeyGesture.ForCharacter", + "CursesKeyGesture.ForFunctionKey", + "AcquireInputProtocolsAsync", + "CursesKeyboardReportingMode.EventTypes", + "FocusReporting = true", + "CursesMouseTrackingMode.ButtonEvents", + "router.Route", + "MoveFocus", + "AcquirePointerShapeAsync", + "CursesPointerShapeLease", + "SynchronizeDimensions", + "CursesLayout.SplitColumnsProportional", + "focus.next", + "focus.previous", + "popup.toggle", + "left.action", + "right.action", + "global.x", + "quit" + ]; + + foreach ( string marker in requiredMarkers ) { + Assert.Contains( marker, source, StringComparison.Ordinal ); + } +} +``` + +- [ ] **Step 2: Run the focused source-contract test and verify RED** + +Run: + +```text +dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~InteractionSampleUsesTheFrozenPublicInteractionContract +``` + +Expected: FAIL against the temporary two-line sample. + +- [ ] **Step 3: Add sample-local state** + +Create GPL-headed `InteractionSampleState.cs`: + +```csharp +namespace Icod.DCurses.Interaction.Sample; + +using Icod.DCurses; + +internal sealed class InteractionSampleState { + internal bool Running { get; set; } = true; + internal bool PopupVisible { get; set; } + internal string Status { get; set; } = "Ready"; + internal CursesFocusState? TerminalFocus { get; set; } + internal string RoutedTarget { get; set; } = "-"; + internal CursesPointerShape? AppliedPointerShape { get; set; } +} +``` + +- [ ] **Step 4: Replace `Program.cs` with the full session/protocol/router setup** + +Use only `using Icod.DCurses;` plus BCL namespaces. Open the session and materialize `screen` / `standard`; set `standard.WrapMode = CursesWrapMode.Clip`. + +Acquire three DCurses input-protocol leases via a helper equivalent to the existing Input.Showcase pattern: + +```csharp +await TryAcquireInputProtocolAsync( + session, + new CursesInputProtocolOptions { + KeyboardReportingMode = CursesKeyboardReportingMode.EventTypes + }, + protocolLeases, + state +); +await TryAcquireInputProtocolAsync( + session, + new CursesInputProtocolOptions { + FocusReporting = true + }, + protocolLeases, + state +); +await TryAcquireInputProtocolAsync( + session, + new CursesInputProtocolOptions { + MouseTrackingMode = CursesMouseTrackingMode.ButtonEvents + }, + protocolLeases, + state +); +``` + +Create one router and eight command identities with the exact frozen names. Bind global gestures: + +```csharp +router.BindGlobalGesture( + CursesKeyGesture.ForKey( CursesKey.Tab ), + FocusNext +); +router.BindGlobalGesture( + CursesKeyGesture.ForKey( + CursesKey.Tab, + CursesKeyModifiers.Shift + ), + FocusPrevious +); +router.BindGlobalGesture( + CursesKeyGesture.ForFunctionKey( 2 ), + PopupToggle +); +router.BindGlobalGesture( + CursesKeyGesture.ForKey( CursesKey.Escape ), + Escape +); +router.BindGlobalGesture( + CursesKeyGesture.ForCharacter( new Rune( 'x' ) ), + GlobalX +); +router.BindGlobalGesture( + CursesKeyGesture.ForCharacter( new Rune( 'q' ) ), + Quit +); +``` + +After creating left/right regions, bind `x -> left.action` and `r -> right.action` locally. Explicitly focus the left region at startup. + +- [ ] **Step 5: Implement frozen layout and retained geometry** + +Define constants: + +```csharp +const int MinimumColumns = 64; +const int MinimumRows = 16; +const int PopupColumns = 28; +const int PopupRows = 8; +``` + +Implement `ComputeLayout(...)` with: + +```csharp +CursesLayout.Dock( + bounds, + CursesDockEdge.Top, + 1, + out header, + out CursesRectangle afterHeader +); +CursesLayout.Dock( + afterHeader, + CursesDockEdge.Bottom, + 2, + out footer, + out CursesRectangle body +); +CursesLayout.SplitColumnsProportional( + body, + 1, + 1, + out left, + out right +); +``` + +Center a 28x8 popup within `body`. Create header/left/right/footer windows once while the initial screen is large enough; create the popup `CursesPanel` once and hide it initially. Register exactly five regions with the frozen focusability/pointer-shape values; popup region bounds are panel-local `(0,0,PopupRows,PopupColumns)`. + +`ApplyLayout(...)` must update the four window bounds, popup panel bounds, and four ordinary region bounds. The popup region stays panel-local/full-panel. + +When below 64x16, hide the popup and draw only the resize message on the standard screen without destroying router/region/binding state. + +- [ ] **Step 6: Implement structured command handling** + +For every input event: + +```csharp +CursesInteractionResult routed = router.Route( input ); +``` + +Switch on `routed.Command?.Name`: + +- `focus.next` -> `router.MoveFocus( CursesFocusDirection.Forward )`; +- `focus.previous` -> backward; +- `popup.toggle` -> show/focus popup or hide and rely on repair; +- `escape` -> hide popup if visible, otherwise `state.Running = false`; +- `left.action`, `right.action`, `global.x` -> update status text so the precedence result is visible; +- `quit` -> stop. + +For `Targeted` mouse results, update `state.RoutedTarget` with region name, original screen row/column, local row/column, and `Hit.PointerShape`. + +Do not call `router.Focus(...)` from mouse routing. + +- [ ] **Step 7: Implement explicit pointer lease replacement** + +Keep: + +```csharp +CursesPointerShapeLease? pointerLease = null; +CursesPointerShape? appliedPointerShape = null; +``` + +On a mouse result with a desired shape different from the current shape: + +```csharp +CursesPointerShapeLease replacement = + await session.AcquirePointerShapeAsync( desiredShape ); +CursesPointerShapeLease? prior = pointerLease; +pointerLease = replacement; +appliedPointerShape = desiredShape; +state.AppliedPointerShape = desiredShape; +if ( prior is not null ) { + await prior.DisposeAsync(); +} +``` + +On no hit/no desired shape, dispose the current lease and clear the observation. Catch only acquisition failures, keep the prior successfully owned lease, and place the exception message in `state.Status`. + +- [ ] **Step 8: Implement lifecycle, focus-report, draw, and cleanup behavior** + +Handle normalized `CursesInputEventKind.Focus` by updating `state.TerminalFocus = input.Focus?.State`; never mutate router logical focus from that event. + +Handle `Resize` / `Resumed` lifecycle events by: + +```csharp +_ = session.SynchronizeDimensions(); +ApplyLayoutIfLargeEnough(...); +session.Invalidate(); +``` + +Interrupt/termination stop the loop. Draw visibly distinct focused pane/popup state using public `CursesStyle`/text APIs; header shows terminal-focus observation and status, footer shows controls. + +Use `try/finally` to dispose, in order: + +1. current pointer lease; +2. input-protocol leases in reverse acquisition order; +3. router/regions/panel through normal `using` ownership; +4. session via `await using`. + +- [ ] **Step 9: Run focused source contract and full Staging build/tests** + +Run: + +```text +dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~InteractionSampleProjectContractTests +dotnet build Icod.DCurses.sln -c Staging +dotnet test Icod.DCurses.sln -c Staging --no-build +``` + +Expected: all pass, with the public API fingerprint still 62 / 491 / `8afe72de...`. + +- [ ] **Step 10: Commit the interactive behavior** + +```text +git add samples/Icod.DCurses.Interaction.Sample tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs +git commit -m "Implement T1409 interaction acceptance sample" +``` + +--- + +### Task 3: Sample documentation and T1409 closure evidence + +**Files:** +- Modify: `tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs` +- Modify: `samples/README.md` +- Create: `docs/T1409-Interaction-Acceptance-Sample.md` +- Modify: PR #29 body after exact-head qualification (metadata only, no source SHA change) + +**Interfaces:** +- Consumes: implemented `Icod.DCurses.Interaction.Sample` and exact T1409 CI evidence. +- Produces: repository-visible run instructions, manual acceptance checklist, and exact-head tranche record. + +- [ ] **Step 1: Add a failing documentation-contract test** + +Append: + +```csharp +[Fact] +public void InteractionSampleIsDocumentedForUsers() { + string root = FindRepositoryRoot(); + string readme = File.ReadAllText( + Path.Combine( + root, + "samples", + "README.md" + ) + ); + + Assert.Contains( "## Icod.DCurses.Interaction.Sample", readme, StringComparison.Ordinal ); + Assert.Contains( + "dotnet run --project samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj", + readme, + StringComparison.Ordinal + ); + Assert.Contains( "Tab", readme, StringComparison.Ordinal ); + Assert.Contains( "Shift+Tab", readme, StringComparison.Ordinal ); + Assert.Contains( "F2", readme, StringComparison.Ordinal ); + Assert.Contains( "pointer", readme, StringComparison.OrdinalIgnoreCase ); +} +``` + +- [ ] **Step 2: Verify documentation test RED** + +Run the single test; expected FAIL because `samples/README.md` does not yet contain the Interaction.Sample section. + +- [ ] **Step 3: Update `samples/README.md`** + +Change “eight executable samples” to “nine executable samples”. Add a section after `Layout.Sample` describing: + +- 1.4 interaction routing; +- Tab / Shift+Tab focus traversal; +- F2 popup toggle; +- local-vs-global command precedence; +- mouse/local coordinates and topmost popup precedence; +- explicit pointer-shape application; +- explicit resize relayout; +- terminal focus reports remaining distinct from logical focus; +- the exact `dotnet run --project ...` command. + +- [ ] **Step 4: Run documentation contract and full tests** + +Expected: PASS, public API fingerprint unchanged. + +- [ ] **Step 5: Commit user-facing documentation** + +```text +git add samples/README.md tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs +git commit -m "Document T1409 interaction sample" +``` + +- [ ] **Step 6: Run/observe the exact implementation documentation head through the full PR matrix** + +Required seven jobs: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +Do not write closure evidence until this exact source/documentation head is green. + +- [ ] **Step 7: Create `docs/T1409-Interaction-Acceptance-Sample.md`** + +Record: + +- approved spec path; +- RED project/source/docs checkpoints; +- exact implementation commits; +- public-only dependency audit; +- 64x16 / 28x8 geometry contract; +- command and pointer-shape demonstration matrix; +- exact unchanged 62 / 491 fingerprint; +- exact workflow run and seven-job results; +- manual acceptance checklist and note that CI validates compile/test/package behavior while terminal visual interaction remains a human-run acceptance activity. + +- [ ] **Step 8: Qualify the documentation-complete closure head** + +Run/observe the full seven-job PR matrix again on the closure-doc commit. T1409 is complete only when that exact head is green. + +- [ ] **Step 9: Update PR #29 progress ledger** + +Mark T1409 complete with exact closure SHA/workflow, preserve T1401–T1408 evidence, and mark T1410 as next. This metadata update must not change the qualified source SHA. From e4450ccbf4acf968aa837e6e08eb82b782ac22e1 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:06:07 -0400 Subject: [PATCH 50/90] Refine T1409 interaction sample plan --- ...3-icod-dcurses-t1409-interaction-sample.md | 623 +++--------------- 1 file changed, 88 insertions(+), 535 deletions(-) diff --git a/docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md b/docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md index a9fdcdd79..ea354d62c 100644 --- a/docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md +++ b/docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md @@ -2,155 +2,55 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add a public-DCurses-only interactive 1.4 acceptance sample that demonstrates focus traversal, local/global command routing, mouse hit testing, panel precedence, explicit pointer-shape leases, resize relayout, and terminal-focus/logical-focus independence. +**Goal:** Add a public-DCurses-only interactive 1.4 acceptance sample demonstrating focus traversal, local/global commands, mouse hit testing, panel precedence, explicit pointer-shape leases, resize relayout, and terminal-focus/logical-focus independence. -**Architecture:** Introduce one new executable sample project, `Icod.DCurses.Interaction.Sample`, under the existing `samples` solution folder. The sample owns one `CursesSession`, one `CursesInteractionRouter`, five retained interaction regions, one retained popup panel, three DCurses input-protocol leases, and at most one active DCurses pointer-shape lease. All command execution, focus changes, layout recomputation, pointer application, and rendering stay in application code; no production library source changes are expected. +**Architecture:** Add one executable sample project, `Icod.DCurses.Interaction.Sample`, under the existing `samples` solution folder. The sample owns one `CursesSession`, one `CursesInteractionRouter`, exactly five interaction regions, one retained popup panel, three DCurses input-protocol leases, and at most one active DCurses pointer-shape lease. Application code owns command execution, focus changes, layout recomputation, pointer application, and rendering; no production library change is expected. -**Tech Stack:** C# 13; .NET `net8.0;net9.0;net10.0`; `Icod.DCurses 1.4.0-alpha.1` source project; xUnit; Visual Studio solution format 12.00; GitHub Actions Staging matrix. +**Tech Stack:** C# 13; .NET `net8.0;net9.0;net10.0`; xUnit; existing DCurses public API; Visual Studio solution format 12.00; GitHub Actions Staging matrix. **Spec:** `docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md` ## Global Constraints -- Work only on branch `1.4.0-interaction-routing` / PR #29. -- Preserve package identity `1.4.0-alpha.1` and `AssemblyVersion 1.0.0.0`. -- Preserve public API fingerprint exactly: 62 exported types / 491 canonical contract lines / SHA-256 `8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147`. -- Do not modify production library source unless the sample proves a concrete public-API defect; any such defect requires a separate documented correction before T1409 may close. -- The new sample targets `net8.0;net9.0;net10.0`, configurations `Debug;Staging;Release`, `IsPackable=false`, and references only `../../Icod.DCurses.csproj`. -- The sample must not reference or import `Icod.Terminal` or `Icod.TermInfo` directly. -- Use the repository GPL-3.0-or-later header for executable sample/test source and project files. -- Maintain existing 1TBS C# formatting: braces on every `if`/`else`, multiline call closing parenthesis on its own line, parameter validation at method entry. -- Minimum terminal geometry is exactly 64 columns x 16 rows; popup geometry is exactly 28 columns x 8 rows. -- Use `CursesLayout.Dock(...)` and `CursesLayout.SplitColumnsProportional(...)`; do not introduce retained/automatic layout ownership. -- Exactly five interaction regions: header, left, right, footer, popup. Left/right/popup are focusable; header/footer are not. -- Frozen command names: `focus.next`, `focus.previous`, `popup.toggle`, `escape`, `left.action`, `right.action`, `global.x`, `quit`. -- Frozen bindings: global Tab, global Shift+Tab, global F2, global Escape, left-local `x`, right-local `r`, global `x`, global `q`. -- Frozen pointer preferences: header `Default`, left `Text`, right `Crosshair`, footer `Pointer`, popup `Move`. -- Acquire rich input only through public DCurses input-protocol leases: keyboard event types, focus reporting, mouse button events. -- The sample owns exactly one event loop; never create a competing reader or parse raw escape sequences. +- Branch: `1.4.0-interaction-routing`; PR #29. +- Preserve package `1.4.0-alpha.1`, `AssemblyVersion 1.0.0.0`, and public fingerprint **62 / 491 / `8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147`**. +- No production source change unless the sample proves a real public-API defect. +- Sample project: `net8.0;net9.0;net10.0`; `Debug;Staging;Release`; `IsPackable=false`; only `../../Icod.DCurses.csproj` as a project reference. +- No direct `Icod.Terminal` or `Icod.TermInfo` import/reference. +- GPL-3.0-or-later headers for sample/test artifacts; existing 1TBS formatting conventions. +- Minimum terminal size: **64x16**. Popup: **28x8**. +- Layout: `CursesLayout.Dock(...)` plus `CursesLayout.SplitColumnsProportional(...)` only; no retained layout owner. +- Exactly five regions: header, left, right, footer, popup. Focusable: left/right/popup only. +- Commands: `focus.next`, `focus.previous`, `popup.toggle`, `escape`, `left.action`, `right.action`, `global.x`, `quit`. +- Bindings: global Tab, Shift+Tab, F2, Escape, `x`, `q`; left-local `x`; right-local `r`. +- Pointer preferences: header `Default`, left `Text`, right `Crosshair`, footer `Pointer`, popup `Move`. +- Acquire keyboard event types, focus reporting, and mouse button events only through DCurses input-protocol leases. +- One event loop; no competing reader and no raw escape parsing. --- -### Task 1: Project and solution wiring acceptance gate +### Task 1: Add a RED project/solution contract and wire the sample project **Files:** - Create: `tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs` - Create: `samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj` -- Create: `samples/Icod.DCurses.Interaction.Sample/Program.cs` (minimal compile placeholder only for this task) +- Create: `samples/Icod.DCurses.Interaction.Sample/Program.cs` (temporary compile placeholder) - Modify: `Icod.DCurses.sln` **Interfaces:** -- Consumes: existing root `Icod.DCurses.csproj`; existing `samples` solution folder `{4A1B4462-96F7-4E95-95A4-059D416B73E7}`. -- Produces: one solution-buildable executable project named `Icod.DCurses.Interaction.Sample`, with project GUID `{B427973D-98EF-4F7E-BB0D-6AE640753A21}`. - -- [ ] **Step 1: Write the failing project contract test** - -Create `tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs` with the GPL test header and this first test/helper: - -```csharp -using System.Xml.Linq; -using Xunit; - -namespace Icod.DCurses.Tests; - -public sealed class InteractionSampleProjectContractTests { - [Fact] - public void InteractionSampleProjectIsWiredIntoTheSolution() { - string root = FindRepositoryRoot(); - string projectPath = Path.Combine( - root, - "samples", - "Icod.DCurses.Interaction.Sample", - "Icod.DCurses.Interaction.Sample.csproj" - ); - Assert.True( File.Exists( projectPath ) ); - - XDocument project = XDocument.Load( projectPath ); - Assert.Equal( - "net8.0;net9.0;net10.0", - project.Descendants( "TargetFrameworks" ).Single().Value - ); - Assert.Equal( - "Debug;Staging;Release", - project.Descendants( "Configurations" ).Single().Value - ); - Assert.Equal( - "false", - project.Descendants( "IsPackable" ).Single().Value - ); - - XElement reference = project.Descendants( "ProjectReference" ).Single(); - Assert.Equal( - @"..\..\Icod.DCurses.csproj", - reference.Attribute( "Include" )!.Value - ); - - string solution = File.ReadAllText( - Path.Combine( - root, - "Icod.DCurses.sln" - ) - ); - Assert.Contains( - @"samples\Icod.DCurses.Interaction.Sample\Icod.DCurses.Interaction.Sample.csproj", - solution, - StringComparison.Ordinal - ); - } - - private static string FindRepositoryRoot() { - DirectoryInfo? current = new( AppContext.BaseDirectory ); - while ( current is not null ) { - if ( File.Exists( - Path.Combine( - current.FullName, - "Icod.DCurses.sln" - ) - ) ) { - return current.FullName; - } - current = current.Parent; - } - - throw new InvalidOperationException( "Repository root not found." ); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify RED** - -Run: - -```text -dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~InteractionSampleProjectIsWiredIntoTheSolution -``` - -Expected: FAIL because `samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj` does not yet exist. - -- [ ] **Step 3: Create the sample project** - -Create `samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj` matching the existing executable-sample convention: - -```xml - - - - - Exe - net8.0;net9.0;net10.0 - Icod.DCurses.Interaction.Sample - Icod.DCurses.Interaction.Sample - false - Debug;Staging;Release - - - - - - -``` - -Create a temporary GPL-headed `Program.cs` containing only: +- Consumes root `Icod.DCurses.csproj` and solution folder GUID `{4A1B4462-96F7-4E95-95A4-059D416B73E7}`. +- Produces solution project GUID `{B427973D-98EF-4F7E-BB0D-6AE640753A21}`. + +- [ ] Add `InteractionSampleProjectIsWiredIntoTheSolution()` which finds the repository root by walking parents from `AppContext.BaseDirectory`, then asserts: + - the sample `.csproj` exists; + - `TargetFrameworks == "net8.0;net9.0;net10.0"`; + - `Configurations == "Debug;Staging;Release"`; + - `IsPackable == "false"`; + - exactly one `ProjectReference` with `Include="..\..\Icod.DCurses.csproj"`; + - `Icod.DCurses.sln` contains the sample project path. +- [ ] Run the focused test and verify RED because the sample project does not exist. +- [ ] Create the sample `.csproj` matching `Icod.DCurses.Layout.Sample` conventions and GPL header. +- [ ] Create temporary GPL-headed `Program.cs`: ```csharp using Icod.DCurses; @@ -159,129 +59,29 @@ await using CursesSession session = await CursesSession.OpenAsync(); return 0; ``` -- [ ] **Step 4: Add the project to `Icod.DCurses.sln`** - -Add: - -```text -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.DCurses.Interaction.Sample", "samples\Icod.DCurses.Interaction.Sample\Icod.DCurses.Interaction.Sample.csproj", "{B427973D-98EF-4F7E-BB0D-6AE640753A21}" -EndProject -``` - -Add Debug/Staging/Release `ActiveCfg` + `Build.0` entries for `{B427973D-98EF-4F7E-BB0D-6AE640753A21}` and nest it under solution folder `{4A1B4462-96F7-4E95-95A4-059D416B73E7}`. - -- [ ] **Step 5: Run the focused contract test and solution build** - -Run: - -```text -dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~InteractionSampleProjectIsWiredIntoTheSolution -dotnet build Icod.DCurses.sln -c Staging -``` - -Expected: PASS; solution builds all three sample TFMs with zero warnings/errors. - -- [ ] **Step 6: Commit the wiring gate** - -```text -git add tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs samples/Icod.DCurses.Interaction.Sample Icod.DCurses.sln -git commit -m "Add T1409 interaction sample project" -``` +- [ ] Add the project to `Icod.DCurses.sln`, including all Debug/Staging/Release `ActiveCfg` and `Build.0` entries and nesting under the `samples` solution folder. +- [ ] Run the focused test and `dotnet build Icod.DCurses.sln -c Staging`; require PASS and zero warnings/errors. +- [ ] Commit: `Add T1409 interaction sample project`. --- -### Task 2: Interactive sample behavior and public-only source contract +### Task 2: Add a RED source-contract test and implement the public-only sample **Files:** - Modify: `tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs` - Replace: `samples/Icod.DCurses.Interaction.Sample/Program.cs` - Create: `samples/Icod.DCurses.Interaction.Sample/InteractionSampleState.cs` -**Interfaces:** -- Consumes public DCurses signatures: - - `new CursesInteractionRouter(CursesScreen)` - - `router.RegisterRegion(CursesInteractionRegionOptions)` - - `router.BindGlobalGesture(CursesKeyGesture, CursesCommand)` - - `region.BindGesture(CursesKeyGesture, CursesCommand)` - - `router.Route(CursesInputEvent)` - - `router.Focus(CursesInteractionRegion)` / `MoveFocus(CursesFocusDirection)` - - `CursesKeyGesture.ForKey(...)`, `ForCharacter(...)`, `ForFunctionKey(...)` - - `session.AcquireInputProtocolsAsync(CursesInputProtocolOptions)` - - `session.AcquirePointerShapeAsync(CursesPointerShape)` - - `session.SynchronizeDimensions()` / `session.Invalidate()` / `session.RefreshAsync()` / `session.ReadEventAsync()` -- Produces: one public-API-only interactive sample implementing every manual acceptance behavior frozen in the spec. - -- [ ] **Step 1: Add a failing source-contract test** - -Append this test to `InteractionSampleProjectContractTests`: - -```csharp -[Fact] -public void InteractionSampleUsesTheFrozenPublicInteractionContract() { - string root = FindRepositoryRoot(); - string programPath = Path.Combine( - root, - "samples", - "Icod.DCurses.Interaction.Sample", - "Program.cs" - ); - string source = File.ReadAllText( programPath ); - - Assert.DoesNotContain( "Icod.Terminal", source, StringComparison.Ordinal ); - Assert.DoesNotContain( "Icod.TermInfo", source, StringComparison.Ordinal ); - - string[] requiredMarkers = [ - "CursesInteractionRouter", - "CursesInteractionRegionOptions", - "BindGlobalGesture", - "BindGesture", - "CursesKeyGesture.ForKey", - "CursesKeyGesture.ForCharacter", - "CursesKeyGesture.ForFunctionKey", - "AcquireInputProtocolsAsync", - "CursesKeyboardReportingMode.EventTypes", - "FocusReporting = true", - "CursesMouseTrackingMode.ButtonEvents", - "router.Route", - "MoveFocus", - "AcquirePointerShapeAsync", - "CursesPointerShapeLease", - "SynchronizeDimensions", - "CursesLayout.SplitColumnsProportional", - "focus.next", - "focus.previous", - "popup.toggle", - "left.action", - "right.action", - "global.x", - "quit" - ]; - - foreach ( string marker in requiredMarkers ) { - Assert.Contains( marker, source, StringComparison.Ordinal ); - } -} -``` - -- [ ] **Step 2: Run the focused source-contract test and verify RED** - -Run: - -```text -dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~InteractionSampleUsesTheFrozenPublicInteractionContract -``` +**Interfaces consumed:** +- `CursesInteractionRouter`, `CursesInteractionRegionOptions`, `BindGlobalGesture`, `BindGesture`, `Route`, `Focus`, `MoveFocus`. +- `CursesKeyGesture.ForKey`, `.ForCharacter`, `.ForFunctionKey`. +- `CursesSession.AcquireInputProtocolsAsync`, `.AcquirePointerShapeAsync`, `.SynchronizeDimensions`, `.Invalidate`, `.RefreshAsync`, `.ReadEventAsync`. -Expected: FAIL against the temporary two-line sample. - -- [ ] **Step 3: Add sample-local state** - -Create GPL-headed `InteractionSampleState.cs`: +- [ ] Add `InteractionSampleUsesTheFrozenPublicInteractionContract()` which reads `Program.cs`, rejects `Icod.Terminal` / `Icod.TermInfo`, and requires these markers: `CursesInteractionRouter`, `CursesInteractionRegionOptions`, `BindGlobalGesture`, `BindGesture`, all three gesture factories, `AcquireInputProtocolsAsync`, keyboard event types, focus reporting, button mouse tracking, `router.Route`, `MoveFocus`, `AcquirePointerShapeAsync`, `CursesPointerShapeLease`, `SynchronizeDimensions`, `CursesLayout.SplitColumnsProportional`, and every frozen command name. +- [ ] Run the focused test and verify RED against the temporary program. +- [ ] Create GPL-headed `InteractionSampleState.cs` with: ```csharp -namespace Icod.DCurses.Interaction.Sample; - -using Icod.DCurses; - internal sealed class InteractionSampleState { internal bool Running { get; set; } = true; internal bool PopupVisible { get; set; } @@ -292,306 +92,59 @@ internal sealed class InteractionSampleState { } ``` -- [ ] **Step 4: Replace `Program.cs` with the full session/protocol/router setup** - -Use only `using Icod.DCurses;` plus BCL namespaces. Open the session and materialize `screen` / `standard`; set `standard.WrapMode = CursesWrapMode.Clip`. - -Acquire three DCurses input-protocol leases via a helper equivalent to the existing Input.Showcase pattern: +- [ ] Replace `Program.cs` with the full application. Use `System.Text` for `Rune` plus `Icod.DCurses`; no Terminal/TermInfo imports. +- [ ] Open one `CursesSession`; materialize `screen` and `standard`; set `standard.WrapMode = Clip`. +- [ ] Acquire three DCurses input-protocol leases using the existing Input.Showcase controlled-result pattern: + - keyboard `EventTypes`; + - `FocusReporting = true`; + - `MouseTrackingMode = ButtonEvents`. +- [ ] Create eight `CursesCommand` instances with the exact frozen names and bind the exact gestures. Local left `x` must shadow global `x`; local right `r` is right-only. +- [ ] Create one router. To support startup below 64x16 without rebuilding identities, create four **valid 1x1 placeholder windows** and one **valid 1x1 hidden popup panel** at `(0,0)` immediately. Register header/left/right/footer with `CursesRectangle.Empty` (or an equivalent zero-size rectangle) so they are initially ineligible; register popup region panel-relative as full `(0,0,8,28)` while the panel stays hidden. These objects are created once and retained for the whole sample. +- [ ] Apply frozen pointer preferences and focusability. Do not attempt initial left focus until a valid 64x16 layout has been applied; after the first valid layout, explicitly focus left if no eligible focus exists. +- [ ] Implement `ComputeLayout(...)`: ```csharp -await TryAcquireInputProtocolAsync( - session, - new CursesInputProtocolOptions { - KeyboardReportingMode = CursesKeyboardReportingMode.EventTypes - }, - protocolLeases, - state -); -await TryAcquireInputProtocolAsync( - session, - new CursesInputProtocolOptions { - FocusReporting = true - }, - protocolLeases, - state -); -await TryAcquireInputProtocolAsync( - session, - new CursesInputProtocolOptions { - MouseTrackingMode = CursesMouseTrackingMode.ButtonEvents - }, - protocolLeases, - state -); -``` - -Create one router and eight command identities with the exact frozen names. Bind global gestures: - -```csharp -router.BindGlobalGesture( - CursesKeyGesture.ForKey( CursesKey.Tab ), - FocusNext -); -router.BindGlobalGesture( - CursesKeyGesture.ForKey( - CursesKey.Tab, - CursesKeyModifiers.Shift - ), - FocusPrevious -); -router.BindGlobalGesture( - CursesKeyGesture.ForFunctionKey( 2 ), - PopupToggle -); -router.BindGlobalGesture( - CursesKeyGesture.ForKey( CursesKey.Escape ), - Escape -); -router.BindGlobalGesture( - CursesKeyGesture.ForCharacter( new Rune( 'x' ) ), - GlobalX -); -router.BindGlobalGesture( - CursesKeyGesture.ForCharacter( new Rune( 'q' ) ), - Quit -); -``` - -After creating left/right regions, bind `x -> left.action` and `r -> right.action` locally. Explicitly focus the left region at startup. - -- [ ] **Step 5: Implement frozen layout and retained geometry** - -Define constants: - -```csharp -const int MinimumColumns = 64; -const int MinimumRows = 16; -const int PopupColumns = 28; -const int PopupRows = 8; -``` - -Implement `ComputeLayout(...)` with: - -```csharp -CursesLayout.Dock( - bounds, - CursesDockEdge.Top, - 1, - out header, - out CursesRectangle afterHeader -); -CursesLayout.Dock( - afterHeader, - CursesDockEdge.Bottom, - 2, - out footer, - out CursesRectangle body -); -CursesLayout.SplitColumnsProportional( - body, - 1, - 1, - out left, - out right -); -``` - -Center a 28x8 popup within `body`. Create header/left/right/footer windows once while the initial screen is large enough; create the popup `CursesPanel` once and hide it initially. Register exactly five regions with the frozen focusability/pointer-shape values; popup region bounds are panel-local `(0,0,PopupRows,PopupColumns)`. - -`ApplyLayout(...)` must update the four window bounds, popup panel bounds, and four ordinary region bounds. The popup region stays panel-local/full-panel. - -When below 64x16, hide the popup and draw only the resize message on the standard screen without destroying router/region/binding state. - -- [ ] **Step 6: Implement structured command handling** - -For every input event: - -```csharp -CursesInteractionResult routed = router.Route( input ); -``` - -Switch on `routed.Command?.Name`: - -- `focus.next` -> `router.MoveFocus( CursesFocusDirection.Forward )`; -- `focus.previous` -> backward; -- `popup.toggle` -> show/focus popup or hide and rely on repair; -- `escape` -> hide popup if visible, otherwise `state.Running = false`; -- `left.action`, `right.action`, `global.x` -> update status text so the precedence result is visible; -- `quit` -> stop. - -For `Targeted` mouse results, update `state.RoutedTarget` with region name, original screen row/column, local row/column, and `Hit.PointerShape`. - -Do not call `router.Focus(...)` from mouse routing. - -- [ ] **Step 7: Implement explicit pointer lease replacement** - -Keep: - -```csharp -CursesPointerShapeLease? pointerLease = null; -CursesPointerShape? appliedPointerShape = null; -``` - -On a mouse result with a desired shape different from the current shape: - -```csharp -CursesPointerShapeLease replacement = - await session.AcquirePointerShapeAsync( desiredShape ); -CursesPointerShapeLease? prior = pointerLease; -pointerLease = replacement; -appliedPointerShape = desiredShape; -state.AppliedPointerShape = desiredShape; -if ( prior is not null ) { - await prior.DisposeAsync(); -} -``` - -On no hit/no desired shape, dispose the current lease and clear the observation. Catch only acquisition failures, keep the prior successfully owned lease, and place the exception message in `state.Status`. - -- [ ] **Step 8: Implement lifecycle, focus-report, draw, and cleanup behavior** - -Handle normalized `CursesInputEventKind.Focus` by updating `state.TerminalFocus = input.Focus?.State`; never mutate router logical focus from that event. - -Handle `Resize` / `Resumed` lifecycle events by: - -```csharp -_ = session.SynchronizeDimensions(); -ApplyLayoutIfLargeEnough(...); -session.Invalidate(); -``` - -Interrupt/termination stop the loop. Draw visibly distinct focused pane/popup state using public `CursesStyle`/text APIs; header shows terminal-focus observation and status, footer shows controls. - -Use `try/finally` to dispose, in order: - -1. current pointer lease; -2. input-protocol leases in reverse acquisition order; -3. router/regions/panel through normal `using` ownership; -4. session via `await using`. - -- [ ] **Step 9: Run focused source contract and full Staging build/tests** - -Run: - -```text -dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~InteractionSampleProjectContractTests -dotnet build Icod.DCurses.sln -c Staging -dotnet test Icod.DCurses.sln -c Staging --no-build -``` - -Expected: all pass, with the public API fingerprint still 62 / 491 / `8afe72de...`. - -- [ ] **Step 10: Commit the interactive behavior** - -```text -git add samples/Icod.DCurses.Interaction.Sample tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs -git commit -m "Implement T1409 interaction acceptance sample" -``` +CursesLayout.Dock( bounds, CursesDockEdge.Top, 1, out header, out CursesRectangle afterHeader ); +CursesLayout.Dock( afterHeader, CursesDockEdge.Bottom, 2, out footer, out CursesRectangle body ); +CursesLayout.SplitColumnsProportional( body, 1, 1, out left, out right ); +``` + +Center an exact 28x8 popup in `body`. +- [ ] Implement `ApplyLayout(...)`: set all four window bounds, popup panel bounds, and the four ordinary region bounds. Keep popup region panel-local and full-panel. +- [ ] When current screen is below 64x16: hide popup, set the four ordinary region bounds to empty/ineligible rectangles, render only a resize message on `standard`, preserve all objects/bindings, and continue reading events. On later growth, apply the real layout and resume normal rendering. +- [ ] Route every input through `router.Route(input)`. Execute commands in application code: + - next/previous -> `MoveFocus`; + - F2 toggle -> show/focus popup or hide and rely on repair; + - Escape -> hide popup if shown, else quit; + - local/global actions -> status text; + - quit -> stop. +- [ ] Mouse routing updates status with target name, screen coordinates, region-local coordinates, and pointer preference. Mouse does not call `Focus`. +- [ ] Maintain one active pointer lease. When desired shape changes: acquire replacement first; if successful, swap ownership then dispose prior lease. When there is no hit/preference, dispose current lease. Scope acquisition exception handling narrowly around the acquisition call and preserve prior ownership on failure. +- [ ] Normalize terminal focus reports into `state.TerminalFocus` only; never mutate logical focus from terminal focus. +- [ ] On `Resize` / `Resumed`: `SynchronizeDimensions()`, apply minimum-size/layout policy, then `session.Invalidate()`. +- [ ] Draw focused left/right/popup state visibly; header shows terminal-focus/status; footer shows controls. +- [ ] In `finally`, dispose pointer lease, then protocol leases in reverse acquisition order. Router/regions/panel/session use structured `using` / `await using` ownership. +- [ ] Run focused sample contract, full Staging build, and full Staging tests. Require fingerprint unchanged at 62 / 491 / `8afe72de...`. +- [ ] Commit: `Implement T1409 interaction acceptance sample`. --- -### Task 3: Sample documentation and T1409 closure evidence +### Task 3: Add a RED documentation contract, document the sample, and close T1409 **Files:** - Modify: `tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs` - Modify: `samples/README.md` - Create: `docs/T1409-Interaction-Acceptance-Sample.md` -- Modify: PR #29 body after exact-head qualification (metadata only, no source SHA change) - -**Interfaces:** -- Consumes: implemented `Icod.DCurses.Interaction.Sample` and exact T1409 CI evidence. -- Produces: repository-visible run instructions, manual acceptance checklist, and exact-head tranche record. - -- [ ] **Step 1: Add a failing documentation-contract test** - -Append: - -```csharp -[Fact] -public void InteractionSampleIsDocumentedForUsers() { - string root = FindRepositoryRoot(); - string readme = File.ReadAllText( - Path.Combine( - root, - "samples", - "README.md" - ) - ); - - Assert.Contains( "## Icod.DCurses.Interaction.Sample", readme, StringComparison.Ordinal ); - Assert.Contains( - "dotnet run --project samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj", - readme, - StringComparison.Ordinal - ); - Assert.Contains( "Tab", readme, StringComparison.Ordinal ); - Assert.Contains( "Shift+Tab", readme, StringComparison.Ordinal ); - Assert.Contains( "F2", readme, StringComparison.Ordinal ); - Assert.Contains( "pointer", readme, StringComparison.OrdinalIgnoreCase ); -} -``` - -- [ ] **Step 2: Verify documentation test RED** - -Run the single test; expected FAIL because `samples/README.md` does not yet contain the Interaction.Sample section. - -- [ ] **Step 3: Update `samples/README.md`** - -Change “eight executable samples” to “nine executable samples”. Add a section after `Layout.Sample` describing: - -- 1.4 interaction routing; -- Tab / Shift+Tab focus traversal; -- F2 popup toggle; -- local-vs-global command precedence; -- mouse/local coordinates and topmost popup precedence; -- explicit pointer-shape application; -- explicit resize relayout; -- terminal focus reports remaining distinct from logical focus; -- the exact `dotnet run --project ...` command. - -- [ ] **Step 4: Run documentation contract and full tests** - -Expected: PASS, public API fingerprint unchanged. - -- [ ] **Step 5: Commit user-facing documentation** - -```text -git add samples/README.md tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs -git commit -m "Document T1409 interaction sample" -``` - -- [ ] **Step 6: Run/observe the exact implementation documentation head through the full PR matrix** - -Required seven jobs: - -- Package candidate; -- Runtime Windows x64; -- Runtime Windows ARM64; -- Runtime Linux x64; -- Runtime Linux ARM64; -- Runtime macOS x64; -- Runtime macOS ARM64. - -Do not write closure evidence until this exact source/documentation head is green. - -- [ ] **Step 7: Create `docs/T1409-Interaction-Acceptance-Sample.md`** - -Record: - -- approved spec path; -- RED project/source/docs checkpoints; -- exact implementation commits; -- public-only dependency audit; -- 64x16 / 28x8 geometry contract; -- command and pointer-shape demonstration matrix; -- exact unchanged 62 / 491 fingerprint; -- exact workflow run and seven-job results; -- manual acceptance checklist and note that CI validates compile/test/package behavior while terminal visual interaction remains a human-run acceptance activity. - -- [ ] **Step 8: Qualify the documentation-complete closure head** - -Run/observe the full seven-job PR matrix again on the closure-doc commit. T1409 is complete only when that exact head is green. - -- [ ] **Step 9: Update PR #29 progress ledger** -Mark T1409 complete with exact closure SHA/workflow, preserve T1401–T1408 evidence, and mark T1410 as next. This metadata update must not change the qualified source SHA. +- [ ] Add `InteractionSampleIsDocumentedForUsers()` asserting `samples/README.md` contains: + - `## Icod.DCurses.Interaction.Sample`; + - exact `dotnet run --project samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj` command; + - `Tab`, `Shift+Tab`, `F2`, and pointer-shape wording. +- [ ] Run that focused test and verify RED. +- [ ] Update sample count from eight to nine and add a full Interaction.Sample section after Layout.Sample describing focus traversal, local/global precedence, F2 popup, mouse/local coordinates, panel precedence, explicit pointer-shape application, explicit resize relayout, and terminal-focus/logical-focus distinction. +- [ ] Run focused contract, full Staging build/tests; require unchanged fingerprint. +- [ ] Commit: `Document T1409 interaction sample`. +- [ ] Qualify that exact implementation/documentation head through all seven PR jobs before writing closure evidence. +- [ ] Create `docs/T1409-Interaction-Acceptance-Sample.md` recording approved spec/plan, RED checkpoints, implementation commits, public-only audit, 64x16/28x8 geometry, command/pointer matrix, unchanged API fingerprint, exact workflow evidence, and manual acceptance checklist. +- [ ] Qualify the closure-doc head through all seven jobs again. +- [ ] Update PR #29 metadata with the exact T1409 closure SHA/workflow and mark T1410 next; do not alter the qualified source SHA. From fae011d310ec9bed9afbb2fa816de7af46dc5655 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:07:43 -0400 Subject: [PATCH 51/90] Add failing T1409 sample project contract test --- .../InteractionSampleProjectContractTests.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs diff --git a/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs b/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs new file mode 100644 index 000000000..ca8f28cef --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs @@ -0,0 +1,88 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using System.Xml.Linq; +using Xunit; + +namespace Icod.DCurses.Tests; + +public sealed class InteractionSampleProjectContractTests { + [Fact] + public void InteractionSampleProjectIsWiredIntoTheSolution() { + string root = FindRepositoryRoot(); + string projectPath = Path.Combine( + root, + "samples", + "Icod.DCurses.Interaction.Sample", + "Icod.DCurses.Interaction.Sample.csproj" + ); + Assert.True( File.Exists( projectPath ) ); + + XDocument project = XDocument.Load( projectPath ); + Assert.Equal( + "net8.0;net9.0;net10.0", + project.Descendants( "TargetFrameworks" ).Single().Value + ); + Assert.Equal( + "Debug;Staging;Release", + project.Descendants( "Configurations" ).Single().Value + ); + Assert.Equal( + "false", + project.Descendants( "IsPackable" ).Single().Value + ); + + XElement reference = project.Descendants( "ProjectReference" ).Single(); + Assert.Equal( + @"..\..\Icod.DCurses.csproj", + reference.Attribute( "Include" )!.Value + ); + + string solution = File.ReadAllText( + Path.Combine( + root, + "Icod.DCurses.sln" + ) + ); + Assert.Contains( + @"samples\Icod.DCurses.Interaction.Sample\Icod.DCurses.Interaction.Sample.csproj", + solution, + StringComparison.Ordinal + ); + } + + private static string FindRepositoryRoot() { + DirectoryInfo? current = new( AppContext.BaseDirectory ); + while ( current is not null ) { + if ( File.Exists( + Path.Combine( + current.FullName, + "Icod.DCurses.sln" + ) + ) ) { + return current.FullName; + } + current = current.Parent; + } + + throw new InvalidOperationException( "Repository root not found." ); + } +} From aa77c8761c17752cb5901752ec655ceca9d7b15f Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:12:17 -0400 Subject: [PATCH 52/90] Add T1409 interaction sample project --- .../Icod.DCurses.Interaction.Sample.csproj | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj diff --git a/samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj b/samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj new file mode 100644 index 000000000..9afbe703b --- /dev/null +++ b/samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj @@ -0,0 +1,36 @@ + + + + + + + + Exe + net8.0;net9.0;net10.0 + Icod.DCurses.Interaction.Sample + Icod.DCurses.Interaction.Sample + false + Debug;Staging;Release + + + + + + From 07f49b520faa487be5563da144dda3b80b075a05 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:12:29 -0400 Subject: [PATCH 53/90] Add T1409 interaction sample placeholder --- .../Program.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 samples/Icod.DCurses.Interaction.Sample/Program.cs diff --git a/samples/Icod.DCurses.Interaction.Sample/Program.cs b/samples/Icod.DCurses.Interaction.Sample/Program.cs new file mode 100644 index 000000000..a96263784 --- /dev/null +++ b/samples/Icod.DCurses.Interaction.Sample/Program.cs @@ -0,0 +1,25 @@ +/* + Icod.DCurses.Interaction.Sample + Interactive 1.4 interaction-routing acceptance sample for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using Icod.DCurses; + +await using CursesSession session = await CursesSession.OpenAsync(); +return 0; From b8b046670b21a227314e442b4d78eb80ae9ecb2c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:13:37 -0400 Subject: [PATCH 54/90] Wire T1409 interaction sample into solution --- Icod.DCurses.sln | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Icod.DCurses.sln b/Icod.DCurses.sln index dfd599e9a..9e746afc1 100644 --- a/Icod.DCurses.sln +++ b/Icod.DCurses.sln @@ -26,6 +26,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.DCurses.Slabtop.Accept EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.DCurses.Top.Acceptance", "samples\Icod.DCurses.Top.Acceptance\Icod.DCurses.Top.Acceptance.csproj", "{F7159ED7-C41C-4BE7-93FA-759B9174DD28}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icod.DCurses.Interaction.Sample", "samples\Icod.DCurses.Interaction.Sample\Icod.DCurses.Interaction.Sample.csproj", "{B427973D-98EF-4F7E-BB0D-6AE640753A21}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,6 +95,12 @@ Global {F7159ED7-C41C-4BE7-93FA-759B9174DD28}.Staging|Any CPU.Build.0 = Staging|Any CPU {F7159ED7-C41C-4BE7-93FA-759B9174DD28}.Release|Any CPU.ActiveCfg = Release|Any CPU {F7159ED7-C41C-4BE7-93FA-759B9174DD28}.Release|Any CPU.Build.0 = Release|Any CPU + {B427973D-98EF-4F7E-BB0D-6AE640753A21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B427973D-98EF-4F7E-BB0D-6AE640753A21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B427973D-98EF-4F7E-BB0D-6AE640753A21}.Staging|Any CPU.ActiveCfg = Staging|Any CPU + {B427973D-98EF-4F7E-BB0D-6AE640753A21}.Staging|Any CPU.Build.0 = Staging|Any CPU + {B427973D-98EF-4F7E-BB0D-6AE640753A21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B427973D-98EF-4F7E-BB0D-6AE640753A21}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -107,5 +115,6 @@ Global {A7D419A2-0A79-4C4F-8A9E-4C0A8F7C5231} = {4A1B4462-96F7-4E95-95A4-059D416B73E7} {6B260854-48F1-4B2A-A4C5-41824A4F62C8} = {4A1B4462-96F7-4E95-95A4-059D416B73E7} {F7159ED7-C41C-4BE7-93FA-759B9174DD28} = {4A1B4462-96F7-4E95-95A4-059D416B73E7} + {B427973D-98EF-4F7E-BB0D-6AE640753A21} = {4A1B4462-96F7-4E95-95A4-059D416B73E7} EndGlobalSection -EndGlobal +EndGlobal \ No newline at end of file From e2b5ff446358595d5ec55f1a2697b8495801a23c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:14:28 -0400 Subject: [PATCH 55/90] Add failing T1409 interaction source contract test --- .../InteractionSampleProjectContractTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs b/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs index ca8f28cef..04686be04 100644 --- a/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs +++ b/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs @@ -69,6 +69,64 @@ public void InteractionSampleProjectIsWiredIntoTheSolution() { ); } + [Fact] + public void InteractionSampleUsesTheFrozenPublicInteractionContract() { + string root = FindRepositoryRoot(); + string programPath = Path.Combine( + root, + "samples", + "Icod.DCurses.Interaction.Sample", + "Program.cs" + ); + string source = File.ReadAllText( programPath ); + + Assert.DoesNotContain( + "Icod.Terminal", + source, + StringComparison.Ordinal + ); + Assert.DoesNotContain( + "Icod.TermInfo", + source, + StringComparison.Ordinal + ); + + string[] requiredMarkers = [ + "CursesInteractionRouter", + "CursesInteractionRegionOptions", + "BindGlobalGesture", + "BindGesture", + "CursesKeyGesture.ForKey", + "CursesKeyGesture.ForCharacter", + "CursesKeyGesture.ForFunctionKey", + "AcquireInputProtocolsAsync", + "CursesKeyboardReportingMode.EventTypes", + "FocusReporting = true", + "CursesMouseTrackingMode.ButtonEvents", + "router.Route", + "MoveFocus", + "AcquirePointerShapeAsync", + "CursesPointerShapeLease", + "SynchronizeDimensions", + "CursesLayout.SplitColumnsProportional", + "focus.next", + "focus.previous", + "popup.toggle", + "left.action", + "right.action", + "global.x", + "quit" + ]; + + foreach ( string marker in requiredMarkers ) { + Assert.Contains( + marker, + source, + StringComparison.Ordinal + ); + } + } + private static string FindRepositoryRoot() { DirectoryInfo? current = new( AppContext.BaseDirectory ); while ( current is not null ) { From 20420fa2ae4a9794b84004a69c1b4076af84a475 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:16:52 -0400 Subject: [PATCH 56/90] Add T1409 interaction sample state --- .../InteractionSampleState.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 samples/Icod.DCurses.Interaction.Sample/InteractionSampleState.cs diff --git a/samples/Icod.DCurses.Interaction.Sample/InteractionSampleState.cs b/samples/Icod.DCurses.Interaction.Sample/InteractionSampleState.cs new file mode 100644 index 000000000..646c1b4ce --- /dev/null +++ b/samples/Icod.DCurses.Interaction.Sample/InteractionSampleState.cs @@ -0,0 +1,56 @@ +/* + Icod.DCurses.Interaction.Sample + Interactive 1.4 interaction-routing acceptance sample for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +namespace Icod.DCurses.Interaction.Sample; + +using Icod.DCurses; + +internal sealed class InteractionSampleState { + internal bool Running { + get; + set; + } = true; + + internal bool PopupVisible { + get; + set; + } + + internal string Status { + get; + set; + } = "Ready"; + + internal CursesFocusState? TerminalFocus { + get; + set; + } + + internal string RoutedTarget { + get; + set; + } = "-"; + + internal CursesPointerShape? AppliedPointerShape { + get; + set; + } +} From 581b708cf1be1d4778eb32bb2c459b9621c4f4fe Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:18:43 -0400 Subject: [PATCH 57/90] Implement T1409 interaction acceptance sample --- .../Program.cs | 811 ++++++++++++++++++ 1 file changed, 811 insertions(+) diff --git a/samples/Icod.DCurses.Interaction.Sample/Program.cs b/samples/Icod.DCurses.Interaction.Sample/Program.cs index a96263784..aa546b6af 100644 --- a/samples/Icod.DCurses.Interaction.Sample/Program.cs +++ b/samples/Icod.DCurses.Interaction.Sample/Program.cs @@ -19,7 +19,818 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +using System.Text; using Icod.DCurses; +using Icod.DCurses.Interaction.Sample; + +const int MinimumColumns = 64; +const int MinimumRows = 16; +const int PopupColumns = 28; +const int PopupRows = 8; + +CursesStyle headingStyle = new( + CursesColor.Default, + CursesColor.Default, + CursesTextAttributes.Bold +); +CursesStyle focusedStyle = new( + CursesColor.Default, + CursesColor.Default, + CursesTextAttributes.Bold | CursesTextAttributes.Reverse +); await using CursesSession session = await CursesSession.OpenAsync(); +CursesScreen screen = session.Screen; +CursesWindow standard = session.StandardScreen; +standard.WrapMode = CursesWrapMode.Clip; + +InteractionSampleState state = new(); +List protocolLeases = []; +CursesPointerShapeLease? pointerLease = null; +CursesPointerShape? appliedPointerShape = null; + +await TryAcquireInputProtocolAsync( + session, + "keyboard event types", + new CursesInputProtocolOptions { + KeyboardReportingMode = CursesKeyboardReportingMode.EventTypes + }, + protocolLeases, + state +); +await TryAcquireInputProtocolAsync( + session, + "focus reporting", + new CursesInputProtocolOptions { + FocusReporting = true + }, + protocolLeases, + state +); +await TryAcquireInputProtocolAsync( + session, + "mouse button events", + new CursesInputProtocolOptions { + MouseTrackingMode = CursesMouseTrackingMode.ButtonEvents + }, + protocolLeases, + state +); + +CursesWindow headerWindow = screen.CreateWindow( + 0, + 0, + 1, + 1 +); +CursesWindow leftWindow = screen.CreateWindow( + 0, + 0, + 1, + 1 +); +CursesWindow rightWindow = screen.CreateWindow( + 0, + 0, + 1, + 1 +); +CursesWindow footerWindow = screen.CreateWindow( + 0, + 0, + 1, + 1 +); +headerWindow.WrapMode = CursesWrapMode.Clip; +leftWindow.WrapMode = CursesWrapMode.Clip; +rightWindow.WrapMode = CursesWrapMode.Clip; +footerWindow.WrapMode = CursesWrapMode.Clip; + +using CursesPanel popup = screen.CreatePanel( + 0, + 0, + 1, + 1 +); +popup.ContentWindow.WrapMode = CursesWrapMode.Clip; +popup.Hide(); + +using CursesInteractionRouter router = new( screen ); +CursesRectangle empty = new( + 0, + 0, + 0, + 0 +); +using CursesInteractionRegion headerRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( empty ) { + PointerShape = CursesPointerShape.Default + } +); +using CursesInteractionRegion leftRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( empty ) { + IsFocusable = true, + TraversalOrder = 0, + PointerShape = CursesPointerShape.Text + } +); +using CursesInteractionRegion rightRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( empty ) { + IsFocusable = true, + TraversalOrder = 1, + PointerShape = CursesPointerShape.Crosshair + } +); +using CursesInteractionRegion footerRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( empty ) { + PointerShape = CursesPointerShape.Pointer + } +); +using CursesInteractionRegion popupRegion = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( + 0, + 0, + PopupRows, + PopupColumns + ) + ) { + Panel = popup, + IsFocusable = true, + TraversalOrder = 2, + PointerShape = CursesPointerShape.Move + } +); + +CursesCommand focusNextCommand = new( "focus.next" ); +CursesCommand focusPreviousCommand = new( "focus.previous" ); +CursesCommand popupToggleCommand = new( "popup.toggle" ); +CursesCommand escapeCommand = new( "escape" ); +CursesCommand leftActionCommand = new( "left.action" ); +CursesCommand rightActionCommand = new( "right.action" ); +CursesCommand globalXCommand = new( "global.x" ); +CursesCommand quitCommand = new( "quit" ); + +router.BindGlobalGesture( + CursesKeyGesture.ForKey( CursesKey.Tab ), + focusNextCommand +); +router.BindGlobalGesture( + CursesKeyGesture.ForKey( + CursesKey.Tab, + CursesKeyModifiers.Shift + ), + focusPreviousCommand +); +router.BindGlobalGesture( + CursesKeyGesture.ForFunctionKey( 2 ), + popupToggleCommand +); +router.BindGlobalGesture( + CursesKeyGesture.ForKey( CursesKey.Escape ), + escapeCommand +); +router.BindGlobalGesture( + CursesKeyGesture.ForCharacter( new Rune( 'x' ) ), + globalXCommand +); +router.BindGlobalGesture( + CursesKeyGesture.ForCharacter( new Rune( 'q' ) ), + quitCommand +); +leftRegion.BindGesture( + CursesKeyGesture.ForCharacter( new Rune( 'x' ) ), + leftActionCommand +); +rightRegion.BindGesture( + CursesKeyGesture.ForCharacter( new Rune( 'r' ) ), + rightActionCommand +); + +bool layoutAvailable = ApplyLayoutPolicy( + screen, + headerWindow, + leftWindow, + rightWindow, + footerWindow, + popup, + router, + headerRegion, + leftRegion, + rightRegion, + footerRegion, + state +); + +try { + while ( state.Running ) { + Draw( + screen, + standard, + headerWindow, + leftWindow, + rightWindow, + footerWindow, + popup, + router, + leftRegion, + rightRegion, + popupRegion, + state, + layoutAvailable, + headingStyle, + focusedStyle + ); + await session.RefreshAsync(); + + CursesEvent current = await session.ReadEventAsync(); + switch ( current.Kind ) { + case CursesEventKind.Input: + if ( current.Input is null ) { + break; + } + + CursesInputEvent input = current.Input; + CursesInteractionResult routed = router.Route( input ); + + if ( CursesInputEventKind.EndOfInput == input.Kind ) { + state.Running = false; + break; + } + + if ( CursesInputEventKind.Focus == input.Kind + && input.Focus is not null ) { + state.TerminalFocus = input.Focus.State; + state.Status = $"Terminal focus: {input.Focus.State}"; + } + + if ( routed.Command is not null ) { + HandleCommand( + routed.Command, + screen, + popup, + router, + leftRegion, + popupRegion, + state + ); + } else if ( CursesInteractionResultKind.Targeted == routed.Kind + && routed.Region is not null + && CursesInputEventKind.Mouse != input.Kind ) { + state.Status = $"Targeted {GetRegionName( + routed.Region, + headerRegion, + leftRegion, + rightRegion, + footerRegion, + popupRegion + )} with {input.Kind}"; + } + + if ( CursesInputEventKind.Mouse == input.Kind + && input.Mouse is not null ) { + CursesPointerShape? desiredShape = routed.Hit?.PointerShape; + if ( routed.Hit is not null ) { + state.RoutedTarget = $"{GetRegionName( + routed.Hit.Region, + headerRegion, + leftRegion, + rightRegion, + footerRegion, + popupRegion + )} screen=({input.Mouse.Row},{input.Mouse.Column}) local=({routed.Hit.LocalRow},{routed.Hit.LocalColumn}) pointer={desiredShape?.ToString() ?? "none"}"; + } else { + state.RoutedTarget = $"none screen=({input.Mouse.Row},{input.Mouse.Column})"; + } + + if ( desiredShape != appliedPointerShape ) { + if ( desiredShape.HasValue ) { + CursesPointerShapeLease? replacement = null; + try { + replacement = await session.AcquirePointerShapeAsync( + desiredShape.Value + ); + } catch ( Exception exception ) { + state.Status = $"Pointer shape failed: {exception.Message}"; + } + + if ( replacement is not null ) { + CursesPointerShapeLease? prior = pointerLease; + pointerLease = replacement; + appliedPointerShape = desiredShape; + state.AppliedPointerShape = desiredShape; + if ( prior is not null ) { + await prior.DisposeAsync(); + } + } + } else { + if ( pointerLease is not null ) { + await pointerLease.DisposeAsync(); + pointerLease = null; + } + appliedPointerShape = null; + state.AppliedPointerShape = null; + } + } + } + break; + + case CursesEventKind.Lifecycle: + if ( current.Lifecycle is null ) { + break; + } + + if ( current.Lifecycle.Kind is CursesLifecycleEventKind.Interrupt + or CursesLifecycleEventKind.Termination ) { + state.Running = false; + break; + } + + if ( current.Lifecycle.Kind is CursesLifecycleEventKind.Resize + or CursesLifecycleEventKind.Resumed ) { + _ = session.SynchronizeDimensions(); + layoutAvailable = ApplyLayoutPolicy( + screen, + headerWindow, + leftWindow, + rightWindow, + footerWindow, + popup, + router, + headerRegion, + leftRegion, + rightRegion, + footerRegion, + state + ); + session.Invalidate(); + } + break; + + case CursesEventKind.Timeout: + break; + } + } +} finally { + if ( pointerLease is not null ) { + await pointerLease.DisposeAsync(); + } + for ( int index = protocolLeases.Count - 1; 0 <= index; index-- ) { + await protocolLeases[ index ].DisposeAsync(); + } +} + return 0; + +static async ValueTask TryAcquireInputProtocolAsync( + CursesSession session, + string name, + CursesInputProtocolOptions options, + ICollection leases, + InteractionSampleState state +) { + ArgumentNullException.ThrowIfNull( session ); + ArgumentException.ThrowIfNullOrWhiteSpace( name ); + ArgumentNullException.ThrowIfNull( options ); + ArgumentNullException.ThrowIfNull( leases ); + ArgumentNullException.ThrowIfNull( state ); + + var result = await session.AcquireInputProtocolsAsync( options ); + if ( result.IsAvailable ) { + leases.Add( result.GetRequiredValue() ); + return; + } + + state.Status = $"{name}: {result.Status}"; +} + +static bool ApplyLayoutPolicy( + CursesScreen screen, + CursesWindow headerWindow, + CursesWindow leftWindow, + CursesWindow rightWindow, + CursesWindow footerWindow, + CursesPanel popup, + CursesInteractionRouter router, + CursesInteractionRegion headerRegion, + CursesInteractionRegion leftRegion, + CursesInteractionRegion rightRegion, + CursesInteractionRegion footerRegion, + InteractionSampleState state +) { + ArgumentNullException.ThrowIfNull( screen ); + ArgumentNullException.ThrowIfNull( headerWindow ); + ArgumentNullException.ThrowIfNull( leftWindow ); + ArgumentNullException.ThrowIfNull( rightWindow ); + ArgumentNullException.ThrowIfNull( footerWindow ); + ArgumentNullException.ThrowIfNull( popup ); + ArgumentNullException.ThrowIfNull( router ); + ArgumentNullException.ThrowIfNull( headerRegion ); + ArgumentNullException.ThrowIfNull( leftRegion ); + ArgumentNullException.ThrowIfNull( rightRegion ); + ArgumentNullException.ThrowIfNull( footerRegion ); + ArgumentNullException.ThrowIfNull( state ); + + if ( screen.Columns < MinimumColumns + || screen.Rows < MinimumRows ) { + popup.Hide(); + state.PopupVisible = false; + CursesRectangle empty = new( + 0, + 0, + 0, + 0 + ); + headerRegion.SetBounds( empty ); + leftRegion.SetBounds( empty ); + rightRegion.SetBounds( empty ); + footerRegion.SetBounds( empty ); + _ = router.FocusedRegion; + state.Status = $"Resize to at least {MinimumColumns}x{MinimumRows}; current {screen.Columns}x{screen.Rows}"; + return false; + } + + ComputeLayout( + screen.Bounds, + out CursesRectangle header, + out CursesRectangle left, + out CursesRectangle right, + out CursesRectangle footer, + out CursesRectangle popupBounds + ); + + headerWindow.SetBounds( header ); + leftWindow.SetBounds( left ); + rightWindow.SetBounds( right ); + footerWindow.SetBounds( footer ); + popup.SetBounds( popupBounds ); + headerRegion.SetBounds( header ); + leftRegion.SetBounds( left ); + rightRegion.SetBounds( right ); + footerRegion.SetBounds( footer ); + + if ( state.PopupVisible ) { + popup.Show(); + } else { + popup.Hide(); + } + + if ( router.FocusedRegion is null ) { + _ = router.Focus( leftRegion ); + } + return true; +} + +static void ComputeLayout( + CursesRectangle bounds, + out CursesRectangle header, + out CursesRectangle left, + out CursesRectangle right, + out CursesRectangle footer, + out CursesRectangle popup +) { + CursesLayout.Dock( + bounds, + CursesDockEdge.Top, + 1, + out header, + out CursesRectangle afterHeader + ); + CursesLayout.Dock( + afterHeader, + CursesDockEdge.Bottom, + 2, + out footer, + out CursesRectangle body + ); + CursesLayout.SplitColumnsProportional( + body, + 1, + 1, + out left, + out right + ); + + popup = new CursesRectangle( + body.Row + ( ( body.Rows - PopupRows ) / 2 ), + body.Column + ( ( body.Columns - PopupColumns ) / 2 ), + PopupRows, + PopupColumns + ); +} + +static void HandleCommand( + CursesCommand command, + CursesScreen screen, + CursesPanel popup, + CursesInteractionRouter router, + CursesInteractionRegion leftRegion, + CursesInteractionRegion popupRegion, + InteractionSampleState state +) { + ArgumentNullException.ThrowIfNull( command ); + ArgumentNullException.ThrowIfNull( screen ); + ArgumentNullException.ThrowIfNull( popup ); + ArgumentNullException.ThrowIfNull( router ); + ArgumentNullException.ThrowIfNull( leftRegion ); + ArgumentNullException.ThrowIfNull( popupRegion ); + ArgumentNullException.ThrowIfNull( state ); + + switch ( command.Name ) { + case "focus.next": + _ = router.MoveFocus( CursesFocusDirection.Forward ); + state.Status = "Focus moved forward"; + break; + + case "focus.previous": + _ = router.MoveFocus( CursesFocusDirection.Backward ); + state.Status = "Focus moved backward"; + break; + + case "popup.toggle": + if ( state.PopupVisible ) { + popup.Hide(); + state.PopupVisible = false; + _ = router.FocusedRegion; + state.Status = "Popup hidden"; + } else if ( screen.Columns >= MinimumColumns + && screen.Rows >= MinimumRows ) { + popup.Show(); + state.PopupVisible = true; + _ = router.Focus( popupRegion ); + state.Status = "Popup shown and explicitly focused"; + } else { + state.Status = "Popup unavailable while terminal is below minimum size"; + } + break; + + case "escape": + if ( state.PopupVisible ) { + popup.Hide(); + state.PopupVisible = false; + _ = router.FocusedRegion; + state.Status = "Popup closed"; + } else { + state.Running = false; + } + break; + + case "left.action": + state.Status = "Left local x won over global x"; + break; + + case "right.action": + state.Status = "Right local r action"; + break; + + case "global.x": + state.Status = "Global x command"; + break; + + case "quit": + state.Running = false; + break; + + default: + state.Status = $"Unknown command: {command.Name}"; + break; + } +} + +static void Draw( + CursesScreen screen, + CursesWindow standard, + CursesWindow headerWindow, + CursesWindow leftWindow, + CursesWindow rightWindow, + CursesWindow footerWindow, + CursesPanel popup, + CursesInteractionRouter router, + CursesInteractionRegion leftRegion, + CursesInteractionRegion rightRegion, + CursesInteractionRegion popupRegion, + InteractionSampleState state, + bool layoutAvailable, + CursesStyle headingStyle, + CursesStyle focusedStyle +) { + ArgumentNullException.ThrowIfNull( screen ); + ArgumentNullException.ThrowIfNull( standard ); + ArgumentNullException.ThrowIfNull( headerWindow ); + ArgumentNullException.ThrowIfNull( leftWindow ); + ArgumentNullException.ThrowIfNull( rightWindow ); + ArgumentNullException.ThrowIfNull( footerWindow ); + ArgumentNullException.ThrowIfNull( popup ); + ArgumentNullException.ThrowIfNull( router ); + ArgumentNullException.ThrowIfNull( leftRegion ); + ArgumentNullException.ThrowIfNull( rightRegion ); + ArgumentNullException.ThrowIfNull( popupRegion ); + ArgumentNullException.ThrowIfNull( state ); + + standard.Clear(); + if ( !layoutAvailable ) { + WriteLine( + standard, + 0, + $"Icod.DCurses 1.4 interaction sample — resize to at least {MinimumColumns}x{MinimumRows}; current {screen.Columns}x{screen.Rows}", + headingStyle + ); + WriteLine( + standard, + 1, + "Router, regions, and command bindings remain retained while geometry is unavailable." + ); + return; + } + + CursesInteractionRegion? focused = router.FocusedRegion; + headerWindow.Clear(); + leftWindow.Clear(); + rightWindow.Clear(); + footerWindow.Clear(); + popup.ContentWindow.Clear(); + + string terminalFocus = state.TerminalFocus?.ToString() ?? "unknown"; + WriteLine( + headerWindow, + 0, + $"Icod.DCurses 1.4 interactions | terminal focus={terminalFocus} | {state.Status}", + headingStyle + ); + + DrawPane( + leftWindow, + "LEFT PANE", + ReferenceEquals( + focused, + leftRegion + ), + "x: local action (shadows global x)", + "Tab / Shift+Tab: traverse logical focus", + headingStyle, + focusedStyle + ); + DrawPane( + rightWindow, + "RIGHT PANE", + ReferenceEquals( + focused, + rightRegion + ), + "r: right-local action", + "x: global action while this pane has focus", + headingStyle, + focusedStyle + ); + + WriteLine( + footerWindow, + 0, + "F2 popup | Esc closes popup then quits | q quit | mouse reports local coordinates" + ); + WriteLine( + footerWindow, + 1, + $"Route: {state.RoutedTarget} | applied pointer={state.AppliedPointerShape?.ToString() ?? "terminal policy"}" + ); + + if ( state.PopupVisible && popup.IsVisible ) { + WriteLine( + popup.ContentWindow, + 0, + ReferenceEquals( + focused, + popupRegion + ) + ? "POPUP [LOGICAL FOCUS]" + : "POPUP", + ReferenceEquals( + focused, + popupRegion + ) + ? focusedStyle + : headingStyle + ); + WriteLine( + popup.ContentWindow, + 2, + "Retained panel wins mouse routing over body panes." + ); + WriteLine( + popup.ContentWindow, + 4, + "Pointer preference: Move" + ); + WriteLine( + popup.ContentWindow, + 6, + "F2 or Esc closes without a nested event loop." + ); + } +} + +static void DrawPane( + CursesWindow window, + string title, + bool focused, + string firstLine, + string secondLine, + CursesStyle headingStyle, + CursesStyle focusedStyle +) { + ArgumentNullException.ThrowIfNull( window ); + ArgumentException.ThrowIfNullOrWhiteSpace( title ); + ArgumentNullException.ThrowIfNull( firstLine ); + ArgumentNullException.ThrowIfNull( secondLine ); + + WriteLine( + window, + 0, + focused + ? $"{title} [LOGICAL FOCUS]" + : title, + focused + ? focusedStyle + : headingStyle + ); + WriteLine( + window, + 2, + firstLine + ); + WriteLine( + window, + 4, + secondLine + ); +} + +static string GetRegionName( + CursesInteractionRegion region, + CursesInteractionRegion header, + CursesInteractionRegion left, + CursesInteractionRegion right, + CursesInteractionRegion footer, + CursesInteractionRegion popup +) { + ArgumentNullException.ThrowIfNull( region ); + ArgumentNullException.ThrowIfNull( header ); + ArgumentNullException.ThrowIfNull( left ); + ArgumentNullException.ThrowIfNull( right ); + ArgumentNullException.ThrowIfNull( footer ); + ArgumentNullException.ThrowIfNull( popup ); + + if ( ReferenceEquals( + region, + header + ) ) { + return "header"; + } + if ( ReferenceEquals( + region, + left + ) ) { + return "left"; + } + if ( ReferenceEquals( + region, + right + ) ) { + return "right"; + } + if ( ReferenceEquals( + region, + footer + ) ) { + return "footer"; + } + if ( ReferenceEquals( + region, + popup + ) ) { + return "popup"; + } + return "unknown"; +} + +static void WriteLine( + CursesWindow window, + int row, + string text, + CursesStyle style = default +) { + ArgumentNullException.ThrowIfNull( window ); + ArgumentNullException.ThrowIfNull( text ); + if ( row < 0 || row >= window.Rows ) { + return; + } + + window.Move( + row, + 0 + ); + window.Write( + text, + style + ); +} From 1e03946053551a6140a4e6772c4e724ee45687b0 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:22:40 -0400 Subject: [PATCH 58/90] Add failing T1409 interaction sample documentation test --- .../InteractionSampleProjectContractTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs b/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs index 04686be04..9194be372 100644 --- a/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs +++ b/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs @@ -127,6 +127,49 @@ public void InteractionSampleUsesTheFrozenPublicInteractionContract() { } } + [Fact] + public void InteractionSampleIsDocumentedForUsers() { + string root = FindRepositoryRoot(); + string readme = File.ReadAllText( + Path.Combine( + root, + "samples", + "README.md" + ) + ); + + Assert.Contains( + "## Icod.DCurses.Interaction.Sample", + readme, + StringComparison.Ordinal + ); + Assert.Contains( + "dotnet run --project samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj", + readme, + StringComparison.Ordinal + ); + Assert.Contains( + "Tab", + readme, + StringComparison.Ordinal + ); + Assert.Contains( + "Shift+Tab", + readme, + StringComparison.Ordinal + ); + Assert.Contains( + "F2", + readme, + StringComparison.Ordinal + ); + Assert.Contains( + "pointer", + readme, + StringComparison.OrdinalIgnoreCase + ); + } + private static string FindRepositoryRoot() { DirectoryInfo? current = new( AppContext.BaseDirectory ); while ( current is not null ) { From 4c0ab569b289e7c8f4628e0262ecd9c4f85834c8 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 00:23:10 -0400 Subject: [PATCH 59/90] Document T1409 interaction sample --- samples/README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/samples/README.md b/samples/README.md index 96fc68d0b..8a68e97c9 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,6 +1,6 @@ # Icod.DCurses Samples -The repository contains eight executable samples. They are intentionally separate so the minimal session lifecycle stays easy to copy without mixing it with the interactive and acceptance-focused showcases. +The repository contains nine executable samples. They are intentionally separate so the minimal session lifecycle stays easy to copy without mixing it with the interactive and acceptance-focused showcases. All sample projects target `net8.0`, `net9.0`, and `net10.0` and consume the repository `Icod.DCurses` project, which currently declares `Icod.Terminal 1.11.1` and `Icod.TermInfo 1.11.0`. @@ -43,6 +43,33 @@ dotnet run --project samples/Icod.DCurses.Layout.Sample/Icod.DCurses.Layout.Samp Resize the terminal while the sample is running to see the two windows and retained panel recompute from the new screen bounds. Press `Q` or `Escape` to exit. +## Icod.DCurses.Interaction.Sample + +`Icod.DCurses.Interaction.Sample` is the 1.4 interaction-routing acceptance sample. It composes public DCurses geometry, retained panels, focus traversal, semantic command bindings, mouse hit testing, pointer-shape preferences, and resize handling inside one application-owned event loop. The sample does not use a widget framework, direct Terminal APIs, callback-driven command execution, automatic layout ownership, or a second input reader. + +Controls: + +```text +Tab Move logical focus forward +Shift+Tab Move logical focus backward +F2 Show/hide the retained popup; showing it explicitly focuses the popup +x Left-local action while the left pane has focus; otherwise global x +r Right-pane local action +Escape Close the popup first; otherwise exit +q Exit +Mouse Report screen and region-local coordinates and apply the routed pointer preference +``` + +The left-pane local `x` binding deliberately shadows the router-global `x` binding, demonstrating focused local-command precedence. The retained popup overlaps the body panes and wins mouse routing through the normal panel z-order rules. Mouse routing itself does not change logical focus. + +The application explicitly applies pointer-shape preferences with `CursesPointerShapeLease` rather than performing terminal I/O inside hit testing or routing. It also acquires keyboard event types, focus reporting, and mouse button events through DCurses input-protocol leases only. + +Resize handling remains application-owned: the sample synchronizes the current terminal dimensions, recomputes header/footer and equal left/right pane rectangles, reapplies window/panel/interaction bounds, and repaints. If the terminal falls below `64x16`, retained router/binding identity survives while the application displays a resize message; growing the terminal restores the normal layout. Terminal focus reports update status text but remain distinct from logical `CursesInteractionRouter` focus. + +```text +dotnet run --project samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj +``` + ## Icod.DCurses.Showcase `Icod.DCurses.Showcase` is the interactive API demonstration. It exercises a timed event loop, retained refreshes, resize repainting, named keys, Unicode cell widths, alert fallback, cursor presentation, and explicit physical-screen invalidation. From 021a040d0a76bb168e3e88b0f71c4401baf3b10c Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 10:25:13 -0400 Subject: [PATCH 60/90] Record T1409 interaction acceptance sample --- docs/T1409-Interaction-Acceptance-Sample.md | 252 ++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/T1409-Interaction-Acceptance-Sample.md diff --git a/docs/T1409-Interaction-Acceptance-Sample.md b/docs/T1409-Interaction-Acceptance-Sample.md new file mode 100644 index 000000000..6266e2b00 --- /dev/null +++ b/docs/T1409-Interaction-Acceptance-Sample.md @@ -0,0 +1,252 @@ +# T1409 — Interaction Acceptance Sample + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1409 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** implementation and user documentation qualified; documentation-complete head requires its own exact-head matrix + +--- + +## Objective + +T1409 proves that the complete 1.4 interaction-routing surface composes into a realistic interactive terminal application without adding a widget framework, hidden event loop, retained layout owner, command callbacks, or a raw Terminal protocol path. + +The acceptance artifact is: + +```text +samples/Icod.DCurses.Interaction.Sample/ + Icod.DCurses.Interaction.Sample.csproj + InteractionSampleState.cs + Program.cs +``` + +The sample consumes only public `Icod.DCurses` APIs. + +## Design and implementation authorities + +The approved written design is: + +`docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md` + +Final design-spec head: + +`9d167356a072c97908a6969234e69a2cd39aee84` + +The implementation plan is: + +`docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md` + +Final reviewed plan head: + +`e4450ccbf4acf968aa837e6e08eb82b782ac22e1` + +The plan review caught and corrected the startup-small case before implementation: the sample creates valid retained placeholder surfaces once, keeps ordinary interaction regions empty/ineligible until geometry is large enough, and then applies real application bounds when the terminal reaches the frozen minimum size. + +## TDD checkpoints + +### Project/solution contract RED + +Test-only head: + +`fae011d310ec9bed9afbb2fa816de7af46dc5655` + +The contract required the new sample project metadata and solution entry before either existed. The asserted sample project path was absent at that head, establishing the project-wiring RED condition. + +The project and complete solution configuration were then added. The sample is a non-packable executable targeting: + +```text +net8.0;net9.0;net10.0 +Debug;Staging;Release +``` + +and references only: + +```text +..\..\Icod.DCurses.csproj +``` + +### Interaction-source contract RED + +Test-only head: + +`e2b5ff446358595d5ec55f1a2697b8495801a23c` + +At that checkpoint `Program.cs` was still the minimal session-open placeholder. The new test required the frozen public 1.4 interaction vocabulary, semantic command bindings, rich-input protocol acquisition, routing, focus traversal, pointer-shape leasing, and explicit resize synchronization; those markers were intentionally absent. + +### Documentation contract RED + +Test-only head: + +`1e03946053551a6140a4e6772c4e724ee45687b0` + +At that checkpoint `samples/README.md` still documented eight executable samples and contained no `Icod.DCurses.Interaction.Sample` section. The final user documentation updates the count to nine and documents the new sample, run command, controls, and the 1.4 interaction contracts demonstrated. + +## Accepted application behavior + +The sample uses one application-owned `CursesInteractionRouter`, one ordinary event loop, and exactly five interaction regions: + +- header/status; +- left pane; +- right pane; +- footer/help; +- retained popup panel region. + +The left, right, and popup regions are focusable. Header/footer remain non-focusable interaction targets. + +The frozen command vocabulary is: + +```text +focus.next +focus.previous +popup.toggle +escape +left.action +right.action +global.x +quit +``` + +The sample demonstrates: + +- Tab / Shift+Tab logical-focus traversal; +- F2 retained popup toggle; +- Escape closing the popup before quitting the application; +- left-local `x` winning over global `x` while the left pane is focused; +- right-local `r` routing independently; +- mouse hit testing and region-local coordinates; +- current panel-over-ordinary precedence when the popup overlaps the body; +- terminal focus observation without clearing logical focus; +- explicit application-owned relayout after dimension synchronization; +- recovery after shrinking below and growing back above the 64x16 minimum; +- explicit pointer-shape application through `CursesPointerShapeLease` rather than router I/O. + +The popup geometry remains 28x8 when the terminal is large enough. + +## Protocol and ownership boundary + +The sample acquires rich input only through public DCurses protocol leases: + +- keyboard event-type reporting; +- terminal focus reporting; +- mouse button-event tracking. + +It never imports or directly calls `Icod.Terminal` or `Icod.TermInfo`. + +Pointer preferences are region metadata: + +- header: `Default`; +- left: `Text`; +- right: `Crosshair`; +- footer: `Pointer`; +- popup: `Move`. + +Hit testing/routing reports the preference without performing output. The sample explicitly acquires a replacement `CursesPointerShapeLease` before disposing the previous successfully owned lease. On no-hit/no-preference input it releases the current DCurses pointer lease and returns physical pointer policy to Terminal ownership. + +All protocol and pointer leases are disposed through structured application ownership before session disposal. + +## Layout and resize boundary + +The minimum supported normal layout is: + +```text +64 columns x 16 rows +``` + +The application derives layout from `CursesScreen.Bounds` using public `CursesLayout.Dock(...)` and `CursesLayout.SplitColumnsProportional(...)`, then explicitly applies resulting rectangles to retained windows, panel, and interaction regions. + +No retained layout rules are stored by the router or screen. + +The sample creates its retained surfaces once and preserves router/region/binding identity while too small. This demonstrates the T1408 coherence contract at application scale instead of rebuilding interaction state after every resize. + +## Automated acceptance + +The complete interactive implementation head is: + +`581b708cf1be1d4778eb32bb2c459b9621c4f4fe` + +Workflow #777 / `34737586564` passed all seven PR jobs: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +On Linux x64 the exact Staging build reported: + +```text +0 warnings +0 errors +``` + +and the test suite reported on each target framework: + +```text +718 passed / 0 failed / 0 skipped +``` + +The README-complete application head is: + +`4c0ab569b289e7c8f4628e0262ecd9c4f85834c8` + +Workflow #779 / `34737765972` initially ended `cancelled` while GitHub-hosted jobs were interrupted. The branch remained on the same exact SHA. The failed/cancelled job set was rerun without changing source, and workflow #779 then completed successfully with all seven package/runtime jobs green on the same head. + +This preserves the evidence distinction between infrastructure interruption and a source/test failure. + +## Public API and dependency result + +T1409 adds no production library source and no public library API. + +The accepted 1.4 alpha fingerprint therefore remains exactly: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + +The published 1.3 compatibility floor and the existing Terminal/TermInfo public dependency allow-list remain unchanged. + +## Files changed by the tranche + +T1409 adds or changes only application/test/documentation/solution artifacts: + +```text +Icod.DCurses.sln +samples/README.md +samples/Icod.DCurses.Interaction.Sample/Icod.DCurses.Interaction.Sample.csproj +samples/Icod.DCurses.Interaction.Sample/InteractionSampleState.cs +samples/Icod.DCurses.Interaction.Sample/Program.cs +tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs +docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md +docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md +docs/T1409-Interaction-Acceptance-Sample.md +``` + +No production file under `src/` changed for T1409. + +## Scope audit + +T1409 introduces no: + +- widget/control hierarchy; +- callback dispatcher; +- automatic focus-on-click library policy; +- automatic layout owner; +- modal/nested event loop; +- raw Terminal protocol parser/emitter; +- pointer capture or drag/drop system; +- accessibility framework; +- navigation framework; +- raster scene graph. + +The acceptance sample therefore demonstrates that a realistic application can consume the 1.4 primitives directly without bypassing their ownership model. + +## Exit gate + +T1409 is complete when this documentation-complete head passes the full PR package/runtime matrix on Windows, Linux, and macOS x64/ARM64. After that exact-head qualification, T1410 may begin performance, allocation, bounds, and adversarial hardening. From 5b7e8fe4654acf2ef99cfe4ff5e0079a3747197f Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 10:29:52 -0400 Subject: [PATCH 61/90] Plan T1410 interaction hardening --- ...cod-dcurses-t1410-interaction-hardening.md | 400 ++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-icod-dcurses-t1410-interaction-hardening.md diff --git a/docs/superpowers/plans/2026-09-13-icod-dcurses-t1410-interaction-hardening.md b/docs/superpowers/plans/2026-09-13-icod-dcurses-t1410-interaction-hardening.md new file mode 100644 index 000000000..d55390fba --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-icod-dcurses-t1410-interaction-hardening.md @@ -0,0 +1,400 @@ +# T1410 Interaction Performance and Adversarial Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Freeze repeatable performance/allocation expectations and adversarial bounds for the accepted 1.4 interaction-routing implementation without expanding its public API or ownership model. + +**Architecture:** Add test-only hardening around the existing router/region/gesture implementation. Common synchronous routing paths are measured after warmup using the same current-thread allocation methodology accepted by T1309; adversarial tests exercise region/binding bounds, panel precedence churn, focus eligibility churn, and failure atomicity. A reflection/source audit proves routing remains callback-free, synchronous, bounded, and free of hidden Terminal I/O. + +**Tech Stack:** C# 13; xUnit; .NET `net8.0;net9.0;net10.0`; `GC.GetAllocatedBytesForCurrentThread`; `Stopwatch`; existing internal test access to normalized `CursesInputEvent` factories. + +**Spec:** `Icod.DCurses-1.4.0-Development-Roadmap.md` T1410, with measurement precedent from `docs/T1309-Layout-Application-Performance-and-Allocation-Acceptance.md`. + +## Global Constraints + +- Work only on branch `1.4.0-interaction-routing` / PR #29. +- Preserve package identity `1.4.0-alpha.1` and `AssemblyVersion 1.0.0.0`. +- Preserve the public 1.4 fingerprint exactly: 62 exported types / 491 canonical contract lines / SHA-256 `8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147`. +- T1410 is expected to be tests/documentation only. Do not modify `src/` unless a new hardening test exposes a concrete defect. +- If a performance gate fails, do not raise the gate merely to make CI green. Inspect the failing path, distinguish fixed runtime noise from per-operation allocation/work, and correct production code only when the measured regression is structural. +- Use the T1309 repeated-sample allocation protocol: warm the exact path first, take eight measurements, and use the minimum measured allocation where the purpose is to isolate nondeterministic fixed runtime charges. +- Do not write benchmark data to stdout/stderr. Assertion messages may carry observed values when a gate fails. +- Use generous elapsed-time ceilings as regression tripwires, not claims of hardware-independent latency. +- Preserve 1TBS formatting and normal parameter-validation conventions. +- No new background task, event loop, callback/delegate registration, asynchronous router API, or Terminal I/O may be introduced. + +## Frozen measurement profile + +Representative topology: + +```text +Screen: 160 x 60 +Ordinary regions: 256 +Local gesture bindings: 64 +Global gesture bindings: 64 +Warmup iterations: 4,096 +Allocation sample count: 8 +Allocation sample length: 10,000 operations +Throughput loop length: 10,000 operations per path +Throughput combined gate: <= 15 seconds +``` + +Allocation ceilings are derived from the current object model rather than arbitrary throughput targets: + +```text +HitTest miss: minimum sample == 0 bytes +MoveFocus traversal: minimum sample == 0 bytes +Successful HitTest: <= 96 bytes / operation +Local/global command Route <= 96 bytes / operation +Mouse-targeted Route: <= 192 bytes / operation +``` + +The successful paths intentionally return immutable reference-type snapshots, so zero allocation is not the contract for those calls. The ceilings allow one expected result snapshot (or hit + route snapshot for mouse) plus alignment headroom while rejecting accidental collection/snapshot allocation in the hot path. + +--- + +### Task 1: Freeze common-path allocation and throughput gates + +**Files:** +- Create: `tests/Icod.DCurses.Tests/src/CursesInteractionPerformanceHardeningTests.cs` + +**Interfaces:** +- Consumes existing `CursesInteractionRouter.HitTest`, `MoveFocus`, `Route`, `RegisterRegion`, `BindGlobalGesture`, `CursesInteractionRegion.BindGesture`, and internal normalized input factories. +- Produces no library API; produces repeatable CI allocation/performance gates. + +- [ ] **Step 1: Add representative topology helpers** + +Create a GPL-headed test class. Use a helper that registers 256 enabled/focusable ordinary regions, each with `Bounds = new CursesRectangle(0, 0, 1, 1)`, monotonically increasing `TraversalOrder`, and identical hit priority. Later registration therefore remains the deterministic hit winner at `(0,0)` while `(59,159)` is a deterministic in-screen miss. + +Bind 64 local numbered-function gestures on the final focused region and 64 global numbered-function gestures on the router. Use local commands named `local.00` through `local.63` and global commands named `global.00` through `global.63`. + +Create all normalized input objects before allocation measurements: + +```csharp +CursesInputEvent localInput = CursesInputEvent.FromKey( + CursesKey.Function, + functionKeyNumber: 63 +); +CursesInputEvent globalInput = CursesInputEvent.FromKey( + CursesKey.Function, + modifiers: CursesKeyModifiers.Alt, + functionKeyNumber: 63 +); +CursesInputEvent mouseInput = CursesInputEvent.FromMouse( + new CursesMouseEvent( + CursesMouseAction.Move, + CursesMouseButton.None, + column: 0, + row: 0 + ) +); +``` + +Use `Alt+F0..F63` for global gestures so local unmodified function bindings cannot shadow them. + +- [ ] **Step 2: Add zero-allocation miss/focus tests** + +Warm each exact path for 4,096 calls. Then take eight 10,000-operation current-thread allocation samples and assert the minimum is exactly zero: + +```csharp +Assert.Equal( 0, MeasureMinimumAllocatedBytes( () => { + CursesInteractionHit? hit = router.HitTest( 59, 159 ); + if ( hit is not null ) { + throw new InvalidOperationException( "Expected a miss." ); + } +} ) ); +``` + +and separately: + +```csharp +Assert.Equal( 0, MeasureMinimumAllocatedBytes( () => { + _ = router.MoveFocus( CursesFocusDirection.Forward ); +} ) ); +``` + +The helper itself must allocate nothing inside the measured inner loop beyond what the supplied operation performs. + +- [ ] **Step 3: Add successful snapshot allocation ceilings** + +For each path, warm 4,096 iterations, then measure eight samples of 10,000 operations and use the minimum sample. + +Assert: + +```text +successful HitTest <= 960,000 bytes +local command Route <= 960,000 bytes +global command Route <= 960,000 bytes +mouse-targeted Route <= 1,920,000 bytes +``` + +Every measured result must also be semantically validated inside the loop so the JIT cannot discard meaningful work: expected winning region, expected command identity, and expected mouse hit/local coordinates. + +- [ ] **Step 4: Add representative elapsed-time regression gate** + +After warmup, time one combined sequence of: + +```text +10,000 successful HitTest calls over 256 overlapping regions +10,000 forward MoveFocus calls over 256 focusable regions +10,000 local command Route calls over 64 local bindings +10,000 global command Route calls over 64 global bindings +``` + +Assert semantic checksums/counters and: + +```csharp +Assert.True( + elapsed <= TimeSpan.FromSeconds( 15 ), + $"Representative interaction loop took {elapsed}." +); +``` + +This is a broad regression tripwire only; the closure document must not present it as a latency guarantee. + +- [ ] **Step 5: Run focused tests** + +Run: + +```text +dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~CursesInteractionPerformanceHardeningTests +``` + +Expected: PASS on all three target frameworks. If only an allocation ceiling fails, inspect the observed bytes from the assertion and the relevant implementation before changing either source or threshold. + +- [ ] **Step 6: Commit Task 1** + +```text +git add tests/Icod.DCurses.Tests/src/CursesInteractionPerformanceHardeningTests.cs +git commit -m "Harden interaction routing performance" +``` + +--- + +### Task 2: Bound, churn, topology, focus, and failure atomicity stress + +**Files:** +- Create: `tests/Icod.DCurses.Tests/src/CursesInteractionAdversarialHardeningTests.cs` + +**Interfaces:** +- Consumes frozen bounds `MaximumRegions`, `MaximumRegionGestureBindings`, `MaximumGlobalGestureBindings`, and `MaximumGestureBindings` plus existing region/panel/focus APIs. +- Produces no library API; freezes adversarial behavior at and around the bounded limits. + +- [ ] **Step 1: Stress region capacity and slot recovery** + +Register exactly `CursesInteractionRouter.MaximumRegions` overlapping regions. Assert the later registration wins hit precedence. Attempt one additional registration and assert `InvalidOperationException` before any observable precedence mutation. + +Dispose every second registered region, register exactly the same number of replacements, and assert: + +- no capacity failure occurs before the recovered slots are consumed; +- the last replacement deterministically wins the overlap hit; +- one additional registration again fails; +- all surviving pre-churn regions remain usable until explicitly disposed. + +- [ ] **Step 2: Stress total gesture-binding capacity and recovery** + +Register 64 ordinary regions. On each region bind exactly 256 character gestures, using the same 256 Unicode scalars per region so gesture identity remains valid while the total reaches exactly 16,384 bindings. + +Assert a new global binding fails with `InvalidOperationException`. Dispose one fully-bound region, then bind 256 distinct global gestures successfully, proving disposal immediately returns capacity to the owning router. The next additional binding must fail again without altering existing command resolution. + +- [ ] **Step 3: Stress overlapping panel topologies** + +Create 16 overlapping visible panels and eight interaction regions per panel, all covering the same local point. Repeatedly: + +- move a selected panel to top; +- vary same-panel `HitTestPriority`; +- hide/show one panel; +- move/resize a panel within the screen; +- call `HitTest` at the shared coordinate. + +Run at least 256 topology mutations and assert every result matches current panel z-order first, then same-panel priority, then registration ordinal. Repeat the exact mutation sequence on a freshly constructed topology and assert the sequence of winning logical indices is identical. + +- [ ] **Step 4: Stress focus eligibility and geometry churn** + +Register 128 focusable regions with deterministic traversal order. Across at least 1,024 iterations, rotate these operations on the currently focused region: + +```text +disable / re-enable +make non-focusable / focusable +move bounds off-screen / restore +explicitly clear and traverse forward/backward +``` + +Assert after each explicit focus boundary that the selected region is the deterministic next eligible region under `(TraversalOrder, RegistrationOrdinal)` and that equivalent replay produces the same focus-index sequence. + +- [ ] **Step 5: Freeze invalid-operation atomicity** + +Add focused cases proving: + +- duplicate global gesture binding throws and the original global command still routes; +- duplicate local binding throws and the original local command still routes; +- invalid/foreign focus attempts leave the prior eligible focus unchanged; +- region-capacity overflow leaves the prior hit winner unchanged; +- operations on disposed router/region objects fail without reviving registrations or bindings. + +Do not inspect private state when a public routing/focus observation can prove atomicity. + +- [ ] **Step 6: Run focused adversarial tests** + +Run: + +```text +dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~CursesInteractionAdversarialHardeningTests +``` + +Expected: PASS on all three target frameworks. + +- [ ] **Step 7: Commit Task 2** + +```text +git add tests/Icod.DCurses.Tests/src/CursesInteractionAdversarialHardeningTests.cs +git commit -m "Stress interaction routing bounds and churn" +``` + +--- + +### Task 3: Audit synchronous ownership and hidden-I/O boundaries + +**Files:** +- Create: `tests/Icod.DCurses.Tests/src/CursesInteractionOwnershipAuditTests.cs` + +**Interfaces:** +- Consumes reflection over public `CursesInteractionRouter` / `CursesInteractionRegion` plus repository source files already located by the test root helper pattern. +- Produces no library API; freezes the absence of callbacks, asynchronous router work, and direct Terminal I/O in routing source. + +- [ ] **Step 1: Freeze callback-free synchronous public shape** + +Use reflection to assert: + +```text +CursesInteractionRouter public events == 0 +CursesInteractionRegion public events == 0 +``` + +For every public declared instance method on those two types: + +- no parameter type derives from `Delegate`; +- return type is not `Task`, `Task`, `ValueTask`, or `ValueTask`; +- no method returns `IAsyncEnumerable`. + +Exclude inherited `object` members by using `BindingFlags.DeclaredOnly`. + +- [ ] **Step 2: Freeze no hidden Terminal-I/O source dependency in routing paths** + +Read these exact repository files: + +```text +src/CursesInteractionRouter.cs +src/CursesInteractionRouter.Routing.cs +src/CursesInteractionRegion.cs +src/CursesInteractionRegion.Bindings.cs +src/CursesInteractionHit.cs +src/CursesInteractionResult.cs +src/CursesKeyGesture.cs +``` + +Assert none contains: + +```text +Icod.Terminal +TerminalSession +WriteAsync( +ReadAsync( +AcquirePointerShapeAsync( +Task.Run( +``` + +Pointer application remains intentionally outside these routing files on `CursesSession`/the DCurses lease wrapper. + +- [ ] **Step 3: Freeze bounded-registry constants and observable enforcement** + +Assert the four public constants remain exactly: + +```text +MaximumRegions = 4096 +MaximumRegionGestureBindings = 256 +MaximumGlobalGestureBindings = 1024 +MaximumGestureBindings = 16384 +``` + +Pair these reflection assertions with one small behavioral enforcement check for each per-router/per-region limit so a future constant change cannot become documentation-only drift. + +- [ ] **Step 4: Run focused audit tests** + +Run: + +```text +dotnet test tests/Icod.DCurses.Tests/Icod.DCurses.Tests.csproj -c Staging --filter FullyQualifiedName~CursesInteractionOwnershipAuditTests +``` + +Expected: PASS on all three target frameworks. + +- [ ] **Step 5: Commit Task 3** + +```text +git add tests/Icod.DCurses.Tests/src/CursesInteractionOwnershipAuditTests.cs +git commit -m "Audit interaction routing ownership boundaries" +``` + +--- + +### Task 4: Full hardening qualification and T1410 closure + +**Files:** +- Create: `docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md` +- Modify: PR #29 body after exact-head qualification + +**Interfaces:** +- Consumes all T1410 test evidence and the unchanged 1.4 API fingerprint. +- Produces the T1410 closure authority; T1411 begins only after its exact documentation-complete head is green. + +- [ ] **Step 1: Run full Staging qualification** + +Run/require the normal PR workflow on the complete hardening-test head: + +```text +Package candidate +Runtime Windows x64 +Runtime Windows ARM64 +Runtime Linux x64 +Runtime Linux ARM64 +Runtime macOS x64 +Runtime macOS ARM64 +``` + +All must be green on the exact same SHA. + +- [ ] **Step 2: Confirm public API fingerprint remains unchanged** + +Require: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + +Any fingerprint movement is a tranche blocker unless separately reviewed as a necessary production correction. + +- [ ] **Step 3: Write closure document** + +Record: + +- exact test-only implementation head and workflow; +- representative topology and iteration counts; +- frozen allocation ceilings and their object-model rationale; +- elapsed-time gate explicitly described as a regression tripwire, not a latency guarantee; +- region/binding max-capacity churn results; +- panel topology and focus churn determinism; +- invalid-operation atomicity evidence; +- callback/background-work/Terminal-I/O audit result; +- whether any production correction was required; +- unchanged dependency/public API surface. + +- [ ] **Step 4: Qualify documentation-complete head** + +After committing the closure document, require a fresh seven-job PR matrix on that exact documentation head. Do not combine T1411 work into this SHA. + +- [ ] **Step 5: Update PR #29 ledger** + +Mark T1410 complete with exact closure SHA/workflow and mark T1411 as next. This metadata-only update must not change the qualified branch SHA. From 829eaeb063d9bfbafdba78c9e154398a660b06f8 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 10:30:53 -0400 Subject: [PATCH 62/90] Harden interaction routing performance --- ...sesInteractionPerformanceHardeningTests.cs | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesInteractionPerformanceHardeningTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionPerformanceHardeningTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionPerformanceHardeningTests.cs new file mode 100644 index 000000000..97462c5f4 --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionPerformanceHardeningTests.cs @@ -0,0 +1,362 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using System.Diagnostics; +using Xunit; + +namespace Icod.DCurses.Tests; + +/// Freezes representative 1.4 interaction-routing allocation and throughput ceilings. +public sealed class CursesInteractionPerformanceHardeningTests { + private const int AllocationIterations = 10000; + private const int AllocationSamples = 8; + private const int BindingCount = 64; + private const int RegionCount = 256; + private const int ThroughputIterations = 10000; + private const int WarmupIterations = 4096; + + [Fact] + public void HitTestMissIsAllocationFreeAfterWarmup() { + using CursesInteractionRouter router = CreateRepresentativeRouter( + out _, + out _, + out _, + out _, + out _, + out _ + ); + + Action operation = () => { + CursesInteractionHit? hit = router.HitTest( + 59, + 159 + ); + if ( hit is not null ) { + throw new InvalidOperationException( "Expected the representative point to miss every region." ); + } + }; + + Assert.Equal( + 0, + MeasureMinimumAllocatedBytes( operation ) + ); + } + + [Fact] + public void FocusTraversalIsAllocationFreeAfterWarmup() { + using CursesInteractionRouter router = CreateRepresentativeRouter( + out _, + out _, + out _, + out _, + out _, + out _ + ); + + Action operation = () => { + CursesInteractionRegion? region = router.MoveFocus( CursesFocusDirection.Forward ); + if ( region is null ) { + throw new InvalidOperationException( "Representative focus traversal unexpectedly found no eligible region." ); + } + }; + + Assert.Equal( + 0, + MeasureMinimumAllocatedBytes( operation ) + ); + } + + [Fact] + public void SuccessfulHitTestStaysWithinSnapshotAllocationCeiling() { + using CursesInteractionRouter router = CreateRepresentativeRouter( + out CursesInteractionRegion winningRegion, + out _, + out _, + out _, + out _, + out _ + ); + + Action operation = () => { + CursesInteractionHit hit = router.HitTest( + 0, + 0 + ) ?? throw new InvalidOperationException( "Expected a representative hit." ); + if ( !ReferenceEquals( + hit.Region, + winningRegion + ) ) { + throw new InvalidOperationException( "Representative hit precedence changed." ); + } + }; + + long allocated = MeasureMinimumAllocatedBytes( operation ); + Assert.InRange( + allocated, + 0, + 96L * AllocationIterations + ); + } + + [Fact] + public void LocalAndGlobalCommandRoutesStayWithinSnapshotAllocationCeiling() { + using CursesInteractionRouter router = CreateRepresentativeRouter( + out CursesInteractionRegion winningRegion, + out CursesInputEvent localInput, + out CursesInputEvent globalInput, + out _, + out CursesCommand localCommand, + out CursesCommand globalCommand + ); + + Action localOperation = () => { + CursesInteractionResult result = router.Route( localInput ); + if ( CursesInteractionResultKind.Command != result.Kind + || !ReferenceEquals( + result.Region, + winningRegion + ) + || result.Command != localCommand ) { + throw new InvalidOperationException( "Representative local-command routing changed." ); + } + }; + Action globalOperation = () => { + CursesInteractionResult result = router.Route( globalInput ); + if ( CursesInteractionResultKind.Command != result.Kind + || result.Region is not null + || result.Command != globalCommand ) { + throw new InvalidOperationException( "Representative global-command routing changed." ); + } + }; + + Assert.InRange( + MeasureMinimumAllocatedBytes( localOperation ), + 0, + 96L * AllocationIterations + ); + Assert.InRange( + MeasureMinimumAllocatedBytes( globalOperation ), + 0, + 96L * AllocationIterations + ); + } + + [Fact] + public void MouseRoutingStaysWithinHitAndResultAllocationCeiling() { + using CursesInteractionRouter router = CreateRepresentativeRouter( + out CursesInteractionRegion winningRegion, + out _, + out _, + out CursesInputEvent mouseInput, + out _, + out _ + ); + + Action operation = () => { + CursesInteractionResult result = router.Route( mouseInput ); + CursesInteractionHit hit = result.Hit + ?? throw new InvalidOperationException( "Expected representative mouse input to produce a hit." ); + if ( CursesInteractionResultKind.Targeted != result.Kind + || !ReferenceEquals( + result.Region, + winningRegion + ) + || !ReferenceEquals( + hit.Region, + winningRegion + ) + || 0 != hit.LocalRow + || 0 != hit.LocalColumn ) { + throw new InvalidOperationException( "Representative mouse routing changed." ); + } + }; + + long allocated = MeasureMinimumAllocatedBytes( operation ); + Assert.InRange( + allocated, + 0, + 192L * AllocationIterations + ); + } + + [Fact] + public void RepresentativeInteractionLoopStaysWithinBroadElapsedTimeGate() { + using CursesInteractionRouter router = CreateRepresentativeRouter( + out CursesInteractionRegion winningRegion, + out CursesInputEvent localInput, + out CursesInputEvent globalInput, + out _, + out CursesCommand localCommand, + out CursesCommand globalCommand + ); + + for ( int index = 0; index < WarmupIterations; index++ ) { + _ = router.HitTest( + 0, + 0 + ); + _ = router.MoveFocus( CursesFocusDirection.Forward ); + _ = router.Route( localInput ); + _ = router.Route( globalInput ); + } + Assert.True( router.Focus( winningRegion ) ); + + Stopwatch stopwatch = Stopwatch.StartNew(); + for ( int index = 0; index < ThroughputIterations; index++ ) { + CursesInteractionHit hit = router.HitTest( + 0, + 0 + ) ?? throw new InvalidOperationException( "Expected representative hit during throughput gate." ); + if ( !ReferenceEquals( + hit.Region, + winningRegion + ) ) { + throw new InvalidOperationException( "Hit precedence changed during throughput gate." ); + } + } + for ( int index = 0; index < ThroughputIterations; index++ ) { + if ( router.MoveFocus( CursesFocusDirection.Forward ) is null ) { + throw new InvalidOperationException( "Focus traversal failed during throughput gate." ); + } + } + Assert.True( router.Focus( winningRegion ) ); + for ( int index = 0; index < ThroughputIterations; index++ ) { + CursesInteractionResult result = router.Route( localInput ); + if ( result.Command != localCommand ) { + throw new InvalidOperationException( "Local routing changed during throughput gate." ); + } + } + for ( int index = 0; index < ThroughputIterations; index++ ) { + CursesInteractionResult result = router.Route( globalInput ); + if ( result.Command != globalCommand ) { + throw new InvalidOperationException( "Global routing changed during throughput gate." ); + } + } + stopwatch.Stop(); + + Assert.True( + stopwatch.Elapsed <= TimeSpan.FromSeconds( 15 ), + $"Representative interaction loop took {stopwatch.Elapsed}." + ); + } + + private static CursesInteractionRouter CreateRepresentativeRouter( + out CursesInteractionRegion winningRegion, + out CursesInputEvent localInput, + out CursesInputEvent globalInput, + out CursesInputEvent mouseInput, + out CursesCommand localCommand, + out CursesCommand globalCommand + ) { + CursesScreen screen = new( + 160, + 60 + ); + CursesInteractionRouter router = new( screen ); + CursesInteractionRegion? latest = null; + for ( int index = 0; index < RegionCount; index++ ) { + latest = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( + 0, + 0, + 1, + 1 + ) + ) { + IsFocusable = true, + TraversalOrder = index + } + ); + } + winningRegion = latest + ?? throw new InvalidOperationException( "Representative router did not register a winning region." ); + + localCommand = new CursesCommand( $"local.{BindingCount - 1:D2}" ); + globalCommand = new CursesCommand( $"global.{BindingCount - 1:D2}" ); + for ( int index = 0; index < BindingCount; index++ ) { + CursesCommand local = index == BindingCount - 1 + ? localCommand + : new CursesCommand( $"local.{index:D2}" ) + ; + CursesCommand global = index == BindingCount - 1 + ? globalCommand + : new CursesCommand( $"global.{index:D2}" ) + ; + winningRegion.BindGesture( + CursesKeyGesture.ForFunctionKey( index ), + local + ); + router.BindGlobalGesture( + CursesKeyGesture.ForFunctionKey( + index, + CursesKeyModifiers.Alt + ), + global + ); + } + if ( !router.Focus( winningRegion ) ) { + throw new InvalidOperationException( "Representative router could not establish initial focus." ); + } + + localInput = CursesInputEvent.FromKey( + CursesKey.Function, + functionKeyNumber: BindingCount - 1 + ); + globalInput = CursesInputEvent.FromKey( + CursesKey.Function, + modifiers: CursesKeyModifiers.Alt, + functionKeyNumber: BindingCount - 1 + ); + mouseInput = CursesInputEvent.FromMouse( + new CursesMouseEvent( + CursesMouseAction.Move, + CursesMouseButton.None, + column: 0, + row: 0 + ) + ); + return router; + } + + private static long MeasureMinimumAllocatedBytes( + Action operation + ) { + ArgumentNullException.ThrowIfNull( operation ); + for ( int index = 0; index < WarmupIterations; index++ ) { + operation(); + } + + long minimum = long.MaxValue; + for ( int sample = 0; sample < AllocationSamples; sample++ ) { + long before = GC.GetAllocatedBytesForCurrentThread(); + for ( int index = 0; index < AllocationIterations; index++ ) { + operation(); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + minimum = Math.Min( + minimum, + allocated + ); + } + return minimum; + } +} From f01f17333fed2fb3bb8e993355f91b957bae8010 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 10:35:49 -0400 Subject: [PATCH 63/90] Stress interaction routing bounds and churn --- ...sesInteractionAdversarialHardeningTests.cs | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesInteractionAdversarialHardeningTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionAdversarialHardeningTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionAdversarialHardeningTests.cs new file mode 100644 index 000000000..44f19dc47 --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionAdversarialHardeningTests.cs @@ -0,0 +1,443 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using System.Text; +using Xunit; + +namespace Icod.DCurses.Tests; + +/// Stresses bounded 1.4 interaction state under deterministic adversarial churn. +public sealed class CursesInteractionAdversarialHardeningTests { + [Fact] + public void RegionCapacityChurnRecoversSlotsWithoutChangingAtomicOverflowBehavior() { + CursesScreen screen = new( 40, 20 ); + using CursesInteractionRouter router = new( screen ); + CursesInteractionRegion[] regions = new CursesInteractionRegion[ CursesInteractionRouter.MaximumRegions ]; + for ( int index = 0; index < regions.Length; index++ ) { + regions[ index ] = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) + ); + } + + CursesInteractionHit initialHit = router.HitTest( + 0, + 0 + ) ?? throw new InvalidOperationException( "Expected a hit at maximum region capacity." ); + Assert.Same( regions[ ^1 ], initialHit.Region ); + + Assert.Throws( + () => { + _ = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) + ); + } + ); + Assert.Same( + regions[ ^1 ], + router.HitTest( + 0, + 0 + )?.Region + ); + + for ( int index = 0; index < regions.Length; index += 2 ) { + regions[ index ].Dispose(); + } + + CursesInteractionRegion? lastReplacement = null; + for ( int index = 0; index < regions.Length / 2; index++ ) { + lastReplacement = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) + ); + } + Assert.NotNull( lastReplacement ); + Assert.Same( + lastReplacement, + router.HitTest( + 0, + 0 + )?.Region + ); + + Assert.Throws( + () => { + _ = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) + ); + } + ); + Assert.Same( + lastReplacement, + router.HitTest( + 0, + 0 + )?.Region + ); + + for ( int index = 1; index < regions.Length; index += 2 ) { + Assert.False( regions[ index ].IsDisposed ); + } + } + + [Fact] + public void TotalBindingCapacityChurnRecoversDisposedRegionCapacityExactly() { + CursesScreen screen = new( 80, 24 ); + using CursesInteractionRouter router = new( screen ); + CursesInteractionRegion[] regions = new CursesInteractionRegion[ 64 ]; + for ( int regionIndex = 0; regionIndex < regions.Length; regionIndex++ ) { + CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) + ); + regions[ regionIndex ] = region; + for ( int bindingIndex = 0; bindingIndex < CursesInteractionRouter.MaximumRegionGestureBindings; bindingIndex++ ) { + region.BindGesture( + CursesKeyGesture.ForCharacter( new Rune( 0x1000 + bindingIndex ) ), + new CursesCommand( $"region.{regionIndex:D2}.{bindingIndex:D3}" ) + ); + } + } + + CursesKeyGesture firstGlobalGesture = CursesKeyGesture.ForCharacter( new Rune( 0x2000 ) ); + Assert.Throws( + () => router.BindGlobalGesture( + firstGlobalGesture, + new CursesCommand( "global.blocked" ) + ) + ); + + regions[ 0 ].Dispose(); + CursesCommand finalGlobalCommand = new( "global.255" ); + for ( int index = 0; index < CursesInteractionRouter.MaximumRegionGestureBindings; index++ ) { + CursesCommand command = index == CursesInteractionRouter.MaximumRegionGestureBindings - 1 + ? finalGlobalCommand + : new CursesCommand( $"global.{index:D3}" ) + ; + router.BindGlobalGesture( + CursesKeyGesture.ForCharacter( new Rune( 0x2000 + index ) ), + command + ); + } + + CursesInputEvent finalInput = CursesInputEvent.FromText( + new Rune( 0x2000 + CursesInteractionRouter.MaximumRegionGestureBindings - 1 ) + ); + Assert.Equal( + finalGlobalCommand, + router.Route( finalInput ).Command + ); + + Assert.Throws( + () => router.BindGlobalGesture( + CursesKeyGesture.ForCharacter( new Rune( 0x3000 ) ), + new CursesCommand( "global.overflow" ) + ) + ); + Assert.Equal( + finalGlobalCommand, + router.Route( finalInput ).Command + ); + } + + [Fact] + public void OverlappingPanelTopologyReplayIsDeterministicAcrossZOrderGeometryAndVisibilityChurn() { + int[] first = RunPanelTopologyReplay(); + int[] second = RunPanelTopologyReplay(); + + Assert.Equal( first, second ); + Assert.Equal( 320, first.Length ); + } + + [Fact] + public void FocusEligibilityReplayRemainsDeterministicAcrossRepeatedChurn() { + int[] first = RunFocusEligibilityReplay(); + int[] second = RunFocusEligibilityReplay(); + + Assert.Equal( first, second ); + Assert.Equal( 1024, first.Length ); + } + + [Fact] + public void InvalidBindingsAndFocusAttemptsFailBeforeObservableStateMutation() { + CursesScreen screen = new( 20, 10 ); + CursesScreen foreignScreen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRouter foreignRouter = new( foreignScreen ); + using CursesInteractionRegion first = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + IsFocusable = true + } + ); + using CursesInteractionRegion second = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 1, 1, 1 ) + ) { + IsFocusable = true, + TraversalOrder = 1 + } + ); + using CursesInteractionRegion foreign = foreignRouter.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + IsFocusable = true + } + ); + + CursesKeyGesture globalGesture = CursesKeyGesture.ForCharacter( new Rune( 'g' ) ); + CursesCommand originalGlobal = new( "global.original" ); + router.BindGlobalGesture( + globalGesture, + originalGlobal + ); + Assert.Throws( + () => router.BindGlobalGesture( + globalGesture, + new CursesCommand( "global.replacement" ) + ) + ); + Assert.Equal( + originalGlobal, + router.Route( CursesInputEvent.FromText( new Rune( 'g' ) ) ).Command + ); + + CursesKeyGesture localGesture = CursesKeyGesture.ForCharacter( new Rune( 'l' ) ); + CursesCommand originalLocal = new( "local.original" ); + first.BindGesture( + localGesture, + originalLocal + ); + Assert.Throws( + () => first.BindGesture( + localGesture, + new CursesCommand( "local.replacement" ) + ) + ); + Assert.True( router.Focus( first ) ); + Assert.Equal( + originalLocal, + router.Route( CursesInputEvent.FromText( new Rune( 'l' ) ) ).Command + ); + + Assert.Throws( + () => { + _ = router.Focus( foreign ); + } + ); + Assert.Same( first, router.FocusedRegion ); + + second.IsEnabled = false; + Assert.False( router.Focus( second ) ); + Assert.Same( first, router.FocusedRegion ); + + CursesInteractionRegion disposed = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 0, 1, 1 ) + ) + ); + disposed.Dispose(); + Assert.Throws( + () => disposed.BindGesture( + CursesKeyGesture.ForCharacter( new Rune( 'z' ) ), + new CursesCommand( "disposed" ) + ) + ); + Assert.Same( first, router.FocusedRegion ); + } + + private static int[] RunFocusEligibilityReplay() { + const int regionCount = 128; + CursesScreen screen = new( 256, 2 ); + using CursesInteractionRouter router = new( screen ); + CursesInteractionRegion[] regions = new CursesInteractionRegion[ regionCount ]; + Dictionary indices = []; + for ( int index = 0; index < regionCount; index++ ) { + CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( + 0, + index, + 1, + 1 + ) + ) { + IsFocusable = true, + TraversalOrder = index + } + ); + regions[ index ] = region; + indices.Add( + region, + index + ); + } + Assert.True( router.Focus( regions[ 0 ] ) ); + + int[] observed = new int[ 1024 ]; + for ( int iteration = 0; iteration < observed.Length; iteration++ ) { + CursesInteractionRegion current = router.FocusedRegion + ?? throw new InvalidOperationException( "Focus unexpectedly cleared during churn." ); + int currentIndex = indices[ current ]; + switch ( iteration % 4 ) { + case 0: + current.IsEnabled = false; + Assert.Equal( + ( currentIndex + 1 ) % regionCount, + indices[ router.FocusedRegion! ] + ); + current.IsEnabled = true; + break; + case 1: + current.IsFocusable = false; + Assert.Equal( + ( currentIndex + 1 ) % regionCount, + indices[ router.FocusedRegion! ] + ); + current.IsFocusable = true; + break; + case 2: + current.SetBounds( new CursesRectangle( 10, 10, 1, 1 ) ); + Assert.Equal( + ( currentIndex + 1 ) % regionCount, + indices[ router.FocusedRegion! ] + ); + current.SetBounds( + new CursesRectangle( + 0, + currentIndex, + 1, + 1 + ) + ); + break; + case 3: + router.ClearFocus(); + CursesFocusDirection direction = 0 == ( iteration / 4 ) % 2 + ? CursesFocusDirection.Forward + : CursesFocusDirection.Backward + ; + CursesInteractionRegion traversed = router.MoveFocus( direction ) + ?? throw new InvalidOperationException( "Traversal unexpectedly found no region." ); + Assert.Equal( + CursesFocusDirection.Forward == direction + ? 0 + : regionCount - 1, + indices[ traversed ] + ); + break; + } + + CursesInteractionRegion focused = router.FocusedRegion + ?? throw new InvalidOperationException( "Focus unexpectedly cleared after churn operation." ); + observed[ iteration ] = indices[ focused ]; + } + return observed; + } + + private static int[] RunPanelTopologyReplay() { + const int panelCount = 16; + const int regionsPerPanel = 8; + CursesScreen screen = new( 80, 30 ); + using CursesInteractionRouter router = new( screen ); + CursesPanel[] panels = new CursesPanel[ panelCount ]; + CursesInteractionRegion[][] regions = new CursesInteractionRegion[ panelCount ][]; + Dictionary identities = []; + try { + for ( int panelIndex = 0; panelIndex < panelCount; panelIndex++ ) { + CursesPanel panel = screen.CreatePanel( 10, 10, 4, 4 ); + panels[ panelIndex ] = panel; + regions[ panelIndex ] = new CursesInteractionRegion[ regionsPerPanel ]; + for ( int regionIndex = 0; regionIndex < regionsPerPanel; regionIndex++ ) { + CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 4, 4 ) + ) { + Panel = panel, + HitTestPriority = regionIndex + } + ); + regions[ panelIndex ][ regionIndex ] = region; + identities.Add( + region, + panelIndex * regionsPerPanel + regionIndex + ); + } + } + + List observed = []; + for ( int iteration = 0; iteration < 256; iteration++ ) { + int panelIndex = iteration % panelCount; + CursesPanel panel = panels[ panelIndex ]; + panel.MoveToTop(); + regions[ panelIndex ][ 0 ].HitTestPriority = 0 == iteration % 2 + ? 100 + : 0 + ; + + if ( 0 == iteration % 4 ) { + panel.Hide(); + CursesInteractionHit hiddenHit = router.HitTest( + 10, + 10 + ) ?? throw new InvalidOperationException( "Expected an underlying panel hit while top panel was hidden." ); + observed.Add( identities[ hiddenHit.Region ] ); + panel.Show(); + } + + if ( 0 == iteration % 3 ) { + panel.SetBounds( new CursesRectangle( 9, 9, 5, 5 ) ); + } else { + panel.SetBounds( new CursesRectangle( 10, 10, 4, 4 ) ); + } + + CursesInteractionHit hit = router.HitTest( + 10, + 10 + ) ?? throw new InvalidOperationException( "Expected a panel hit after topology mutation." ); + int expectedRegionIndex = 0 == iteration % 2 + ? 0 + : regionsPerPanel - 1 + ; + Assert.Same( + regions[ panelIndex ][ expectedRegionIndex ], + hit.Region + ); + observed.Add( identities[ hit.Region ] ); + } + return observed.ToArray(); + } finally { + foreach ( CursesPanel? panel in panels ) { + panel?.Dispose(); + } + } + } +} From 0d5509f684586fe6bd4ce11613afee3f8ede7450 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 10:39:53 -0400 Subject: [PATCH 64/90] Audit interaction routing ownership boundaries --- .../CursesInteractionOwnershipAuditTests.cs | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/Icod.DCurses.Tests/src/CursesInteractionOwnershipAuditTests.cs diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionOwnershipAuditTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionOwnershipAuditTests.cs new file mode 100644 index 000000000..0c285cfce --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionOwnershipAuditTests.cs @@ -0,0 +1,234 @@ +/* + Icod.DCurses.Tests + Automated test suite for Icod.DCurses. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +using System.Reflection; +using System.Text; +using Xunit; + +namespace Icod.DCurses.Tests; + +/// Freezes the synchronous, callback-free, bounded ownership shape of 1.4 interaction routing. +public sealed class CursesInteractionOwnershipAuditTests { + [Fact] + public void RouterAndRegionPublicShapeRemainSynchronousAndCallbackFree() { + Type[] types = [ + typeof( CursesInteractionRouter ), + typeof( CursesInteractionRegion ) + ]; + + foreach ( Type type in types ) { + Assert.Empty( + type.GetEvents( + BindingFlags.Public + | BindingFlags.Instance + | BindingFlags.DeclaredOnly + ) + ); + + MethodInfo[] methods = type.GetMethods( + BindingFlags.Public + | BindingFlags.Instance + | BindingFlags.DeclaredOnly + ); + foreach ( MethodInfo method in methods ) { + foreach ( ParameterInfo parameter in method.GetParameters() ) { + Assert.False( + typeof( Delegate ).IsAssignableFrom( parameter.ParameterType ), + $"{type.Name}.{method.Name} exposes delegate parameter {parameter.ParameterType}." + ); + } + + Assert.False( + IsAsyncReturnType( method.ReturnType ), + $"{type.Name}.{method.Name} exposes asynchronous return type {method.ReturnType}." + ); + } + } + } + + [Fact] + public void RoutingSourcesContainNoHiddenTerminalIoOrBackgroundDispatch() { + string root = FindRepositoryRoot(); + string[] files = [ + "src/CursesInteractionRouter.cs", + "src/CursesInteractionRouter.Routing.cs", + "src/CursesInteractionRegion.cs", + "src/CursesInteractionRegion.Bindings.cs", + "src/CursesInteractionHit.cs", + "src/CursesInteractionResult.cs", + "src/CursesKeyGesture.cs" + ]; + string[] forbidden = [ + "Icod.Terminal", + "TerminalSession", + "WriteAsync(", + "ReadAsync(", + "AcquirePointerShapeAsync(", + "Task.Run(" + ]; + + foreach ( string file in files ) { + string source = File.ReadAllText( + Path.Combine( + root, + file.Replace( + '/', + Path.DirectorySeparatorChar + ) + ) + ); + foreach ( string marker in forbidden ) { + Assert.DoesNotContain( + marker, + source, + StringComparison.Ordinal + ); + } + } + } + + [Fact] + public void FrozenBoundsRemainExact() { + Assert.Equal( + 4096, + CursesInteractionRouter.MaximumRegions + ); + Assert.Equal( + 256, + CursesInteractionRouter.MaximumRegionGestureBindings + ); + Assert.Equal( + 1024, + CursesInteractionRouter.MaximumGlobalGestureBindings + ); + Assert.Equal( + 16384, + CursesInteractionRouter.MaximumGestureBindings + ); + } + + [Fact] + public void PerRegionGestureBoundIsEnforcedWithoutReplacingExistingBindings() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + using CursesInteractionRegion region = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 0, 0, 1, 1 ) + ) { + IsFocusable = true + } + ); + Assert.True( router.Focus( region ) ); + + CursesCommand finalCommand = new( "region.final" ); + for ( int index = 0; index < CursesInteractionRouter.MaximumRegionGestureBindings; index++ ) { + CursesCommand command = index == CursesInteractionRouter.MaximumRegionGestureBindings - 1 + ? finalCommand + : new CursesCommand( $"region.{index:D3}" ) + ; + region.BindGesture( + CursesKeyGesture.ForCharacter( new Rune( 0x5000 + index ) ), + command + ); + } + + Assert.Throws( + () => region.BindGesture( + CursesKeyGesture.ForCharacter( new Rune( 0x6000 ) ), + new CursesCommand( "region.overflow" ) + ) + ); + Assert.Equal( + finalCommand, + router.Route( + CursesInputEvent.FromText( + new Rune( 0x5000 + CursesInteractionRouter.MaximumRegionGestureBindings - 1 ) + ) + ).Command + ); + } + + [Fact] + public void GlobalGestureBoundIsEnforcedWithoutReplacingExistingBindings() { + CursesScreen screen = new( 20, 10 ); + using CursesInteractionRouter router = new( screen ); + CursesCommand finalCommand = new( "global.final" ); + for ( int index = 0; index < CursesInteractionRouter.MaximumGlobalGestureBindings; index++ ) { + CursesCommand command = index == CursesInteractionRouter.MaximumGlobalGestureBindings - 1 + ? finalCommand + : new CursesCommand( $"global.{index:D4}" ) + ; + router.BindGlobalGesture( + CursesKeyGesture.ForCharacter( new Rune( 0x7000 + index ) ), + command + ); + } + + Assert.Throws( + () => router.BindGlobalGesture( + CursesKeyGesture.ForCharacter( new Rune( 0x8000 ) ), + new CursesCommand( "global.overflow" ) + ) + ); + Assert.Equal( + finalCommand, + router.Route( + CursesInputEvent.FromText( + new Rune( 0x7000 + CursesInteractionRouter.MaximumGlobalGestureBindings - 1 ) + ) + ).Command + ); + } + + private static string FindRepositoryRoot() { + DirectoryInfo? current = new( AppContext.BaseDirectory ); + while ( current is not null ) { + if ( File.Exists( + Path.Combine( + current.FullName, + "Icod.DCurses.sln" + ) + ) ) { + return current.FullName; + } + current = current.Parent; + } + + throw new InvalidOperationException( "Repository root not found." ); + } + + private static bool IsAsyncReturnType( + Type returnType + ) { + ArgumentNullException.ThrowIfNull( returnType ); + if ( typeof( Task ) == returnType + || typeof( ValueTask ) == returnType ) { + return true; + } + if ( returnType.IsGenericType ) { + Type genericDefinition = returnType.GetGenericTypeDefinition(); + return typeof( Task<> ) == genericDefinition + || typeof( ValueTask<> ) == genericDefinition + || typeof( IAsyncEnumerable<> ) == genericDefinition; + } + return false; + } +} From 69072508e90b3e21c6dfa58b277610e90867f683 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 10:44:11 -0400 Subject: [PATCH 65/90] Record T1410 interaction hardening --- ...ce-Allocation-and-Adversarial-Hardening.md | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md diff --git a/docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md b/docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md new file mode 100644 index 000000000..d0a263097 --- /dev/null +++ b/docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md @@ -0,0 +1,268 @@ +# T1410 — Interaction Performance, Allocation, and Adversarial Hardening + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1410 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Status:** hardening test head qualified; documentation-complete head requires its own exact-head matrix + +--- + +## Objective + +T1410 freezes repeatable performance/allocation expectations and adversarial bounded-state behavior for the accepted 1.4 interaction-routing model. It does not add features or expand public API. + +The work follows the evidence-first allocation methodology established by T1309: warm the exact path, take repeated current-thread allocation samples, and use the minimum sample where the purpose is to remove fixed runtime/JIT noise from a steady-state allocation assertion. + +Implementation plan: + +`docs/superpowers/plans/2026-09-13-icod-dcurses-t1410-interaction-hardening.md` + +Planning head: + +`5b7e8fe4654acf2ef99cfe4ff5e0079a3747197f` + +## Representative measurement profile + +The frozen common-path profile is: + +```text +Screen: 160 x 60 +Ordinary regions: 256 +Local gesture bindings: 64 +Global gesture bindings: 64 +Warmup iterations: 4,096 +Allocation sample count: 8 +Allocation sample length: 10,000 operations +Throughput loop length: 10,000 operations per path +Combined elapsed gate: <= 15 seconds +``` + +The elapsed-time threshold is intentionally broad. It is a regression tripwire for accidental algorithmic/work amplification, **not** a hardware-independent latency guarantee or benchmark claim. + +## Allocation contract + +T1410 freezes these steady-state ceilings: + +```text +HitTest miss: minimum sample == 0 bytes +MoveFocus traversal: minimum sample == 0 bytes +Successful HitTest: <= 96 bytes / operation +Local command Route: <= 96 bytes / operation +Global command Route: <= 96 bytes / operation +Mouse-targeted Route: <= 192 bytes / operation +``` + +The successful paths intentionally return immutable reference-type snapshots. Therefore their contract is not zero allocation. The successful-hit ceiling permits one expected hit snapshot plus alignment headroom; the command-route ceiling permits one expected routing-result snapshot plus alignment headroom; the mouse-route ceiling permits the expected hit plus routing-result snapshots plus alignment headroom. These limits reject accidental collection or panel-order snapshot allocation in normal routing paths. + +The input objects used during route measurements are created outside the measured loops. + +## Task 1 — common-path measurement qualification + +Test source: + +`tests/Icod.DCurses.Tests/src/CursesInteractionPerformanceHardeningTests.cs` + +Exact head: + +`829eaeb063d9bfbafdba78c9e154398a660b06f8` + +Workflow #782 / `34762887943` passed all seven PR jobs. + +The qualified tests prove: + +- in-screen `HitTest` misses allocate zero bytes in the minimum steady-state sample; +- logical focus traversal allocates zero bytes in the minimum steady-state sample; +- successful hit snapshots stay within the 96-byte-per-operation ceiling; +- local and global semantic command routes stay within the 96-byte-per-operation ceiling; +- targeted mouse routing stays within the 192-byte-per-operation ceiling; +- the combined 256-region / 64-local-binding / 64-global-binding representative loop stays within the broad 15-second regression threshold. + +No production correction and no threshold relaxation was required. + +## Task 2 — bounds and adversarial churn qualification + +Test source: + +`tests/Icod.DCurses.Tests/src/CursesInteractionAdversarialHardeningTests.cs` + +Exact head: + +`f01f17333fed2fb3bb8e993355f91b957bae8010` + +Workflow #783 / `34763127476` completed successfully across package candidate and all six runtime jobs. + +### Region-capacity churn + +The suite registers exactly: + +```text +4,096 live regions +``` + +and proves: + +- later registration remains the deterministic overlap winner; +- one additional registration fails before changing the winner; +- disposing every second region returns exactly 2,048 live-region slots; +- 2,048 replacement registrations consume those slots successfully; +- the final replacement becomes the deterministic overlap winner; +- one further registration again fails atomically; +- surviving original regions remain live. + +### Total gesture-capacity churn + +The suite fills the router to exactly: + +```text +64 regions x 256 local bindings = 16,384 live gesture bindings +``` + +and proves: + +- an additional global binding fails at total capacity; +- disposing one fully-bound region immediately returns 256 total-binding slots; +- exactly 256 global bindings can then be added; +- the final global binding routes correctly; +- one further binding again fails without replacing or corrupting the accepted route. + +### Overlapping panel topology + +The suite constructs: + +```text +16 overlapping panels +8 overlapping interaction regions per panel +256 topology mutations +320 recorded hit observations per replay +``` + +The mutation sequence exercises: + +- current panel z-order changes; +- same-panel hit priority changes; +- panel hide/show; +- panel move/resize; +- repeated hit resolution at the shared coordinate. + +A second fresh construction replays the same mutation sequence and produces the same winner-identity sequence. Cross-panel z-order remains authoritative before same-panel priority and registration order. + +### Focus eligibility churn + +The suite registers 128 focusable regions and runs 1,024 deterministic mutations across: + +- enabled state; +- focusability; +- on-screen/off-screen geometry; +- explicit clear plus forward/backward traversal. + +A second fresh replay produces the same focused-region index sequence, while each repair point follows the frozen `(TraversalOrder, RegistrationOrdinal)` semantics. + +### Failure atomicity + +The suite also proves: + +- duplicate global bindings fail while the original command continues routing; +- duplicate local bindings fail while the original local command continues routing; +- foreign-region focus attempts fail while prior focus remains unchanged; +- ineligible focus attempts do not displace valid focus; +- disposed-region mutations fail without reviving state. + +No production correction was required. + +## Task 3 — synchronous ownership and hidden-I/O audit + +Test source: + +`tests/Icod.DCurses.Tests/src/CursesInteractionOwnershipAuditTests.cs` + +Exact complete hardening-test head: + +`0d5509f684586fe6bd4ce11613afee3f8ede7450` + +Workflow #784 / `34763319143` passed all seven PR jobs. + +The audit freezes these architectural properties: + +- `CursesInteractionRouter` exposes no public events; +- `CursesInteractionRegion` exposes no public events; +- neither type's declared public instance methods accept delegate parameters; +- neither type exposes `Task`, `Task`, `ValueTask`, `ValueTask`, or `IAsyncEnumerable` return types; +- routing source files contain no direct `Icod.Terminal` / `TerminalSession` dependency; +- routing source files contain no `WriteAsync(`, `ReadAsync(`, `AcquirePointerShapeAsync(`, or `Task.Run(` path; +- pointer-shape application therefore remains outside the router in the explicit `CursesSession`/lease boundary. + +The four frozen bounds remain exactly: + +```text +MaximumRegions = 4096 +MaximumRegionGestureBindings = 256 +MaximumGlobalGestureBindings = 1024 +MaximumGestureBindings = 16384 +``` + +The ownership audit additionally exercises the per-region and global binding limits behaviorally so those constants cannot drift into documentation-only values. + +## Full hardening-test qualification + +The exact complete test head is: + +`0d5509f684586fe6bd4ce11613afee3f8ede7450` + +Workflow #784 / `34763319143` passed: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +On Linux x64 the exact Staging build reported: + +```text +0 warnings +0 errors +``` + +and the complete test suite reported on each target framework: + +```text +735 passed / 0 failed / 0 skipped +``` + +for `net8.0`, `net9.0`, and `net10.0`. + +## Public API and dependency result + +T1410 changed tests and documentation only. It introduced no production source correction and no public API change. + +The accepted 1.4 alpha fingerprint remains exactly: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + +Package identity remains `1.4.0-alpha.1`; `AssemblyVersion` remains `1.0.0.0`; runtime dependencies remain `Icod.Terminal 1.11.1` and `Icod.TermInfo 1.11.0`. + +## Scope audit + +T1410 introduced no: + +- production API; +- unbounded interaction collection; +- callback registration surface; +- asynchronous/background router work; +- hidden Terminal I/O; +- automatic focus/layout policy; +- widget/control abstraction; +- performance promise stronger than the measured regression tripwires. + +## Exit gate + +T1410 is complete when this documentation-complete head passes the full seven-job PR matrix without moving the accepted public API fingerprint. Only after that exact-head qualification may T1411 begin the final API/package/documentation/licensing regret gate. From 40cce1c272050285582571b5436caefe7bbcec25 Mon Sep 17 00:00:00 2001 From: uniblab Date: Sun, 13 Sep 2026 11:50:30 -0400 Subject: [PATCH 66/90] optimize keywords/tags --- Icod.DCurses.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Icod.DCurses.csproj b/Icod.DCurses.csproj index 08f3e538e..0b4a67d0c 100644 --- a/Icod.DCurses.csproj +++ b/Icod.DCurses.csproj @@ -54,7 +54,7 @@ true true true - curses;ncurses;pcurses;dcurses;terminal;tui;console;terminfo;termcap;terminal-info;full-screen;screen;windows;linux;macos;cross-platform;dotnet;csharp;icod + cli;command-line;console;cross-platform;csharp;curses;dcurses;dotnet;full-screen;icod;linux;macos;ncurses;pcurses;posix;screen;termcap;terminal;terminal-info;terminfo;text;tui;ui;windows https://github.com/uniblab/Icod.DCurses git false From 48158490b3a43bc3a996499064c917e5dc64d904 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 13:30:50 -0400 Subject: [PATCH 67/90] T1411 expand package-only interaction coverage --- tools/package-smoke/Program.cs | 168 +++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/tools/package-smoke/Program.cs b/tools/package-smoke/Program.cs index 528a67ddc..96c6d1128 100644 --- a/tools/package-smoke/Program.cs +++ b/tools/package-smoke/Program.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using System.Text; using Icod.DCurses; using Icod.Terminal; using Icod.TermInfo; @@ -59,6 +61,7 @@ VerifyColumnTextSurface(); VerifyWindowEditingSurface(); VerifyPadSurface(); +VerifyInteractionSurface(); if ( string.Equals( Environment.GetEnvironmentVariable( "ICOD_DCURSES_SMOKE_INTERACTIVE" ), @@ -441,6 +444,171 @@ static void VerifyPadSurface() { viewport.Present(); } +static void VerifyInteractionSurface() { + CursesScreen logical = new( + 20, + 8 + ); + using CursesInteractionRouter router = new( logical ); + using CursesInteractionRegion first = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( + 1, + 2, + 3, + 5 + ) + ) { + IsFocusable = true, + TraversalOrder = 10, + HitTestPriority = 2, + PointerShape = CursesPointerShape.Pointer + } + ); + using CursesInteractionRegion second = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( + 1, + 8, + 3, + 5 + ) + ) { + IsFocusable = true, + TraversalOrder = 20 + } + ); + + if ( !ReferenceEquals( + logical, + router.Screen + ) || !router.Focus( first ) + || !ReferenceEquals( + first, + router.FocusedRegion + ) ) { + throw new InvalidOperationException( + "DCurses package-only interaction focus surface failed validation." + ); + } + + if ( !ReferenceEquals( + second, + router.MoveFocus( CursesFocusDirection.Forward ) + ) || !ReferenceEquals( + first, + router.MoveFocus( CursesFocusDirection.Forward ) + ) ) { + throw new InvalidOperationException( + "DCurses package-only interaction traversal surface failed validation." + ); + } + + CursesInteractionHit hit = router.HitTest( + 2, + 3 + ) ?? throw new InvalidOperationException( + "DCurses package-only interaction hit-test surface returned no hit." + ); + if ( !ReferenceEquals( + first, + hit.Region + ) || 1 != hit.LocalRow + || 1 != hit.LocalColumn + || CursesPointerShape.Pointer != hit.PointerShape ) { + throw new InvalidOperationException( + "DCurses package-only interaction hit-test surface failed validation." + ); + } + + CursesKeyGesture localGesture = CursesKeyGesture.ForCharacter( + new Rune( 'x' ), + CursesKeyModifiers.Control + ); + CursesCommand localCommand = new( "package-smoke.local" ); + first.BindGesture( + localGesture, + localCommand + ); + if ( !first.UnbindGesture( localGesture ) ) { + throw new InvalidOperationException( + "DCurses package-only local gesture-binding surface failed validation." + ); + } + + CursesKeyGesture globalGesture = CursesKeyGesture.ForFunctionKey( 2 ); + CursesCommand globalCommand = new( "package-smoke.global" ); + router.BindGlobalGesture( + globalGesture, + globalCommand + ); + if ( !router.UnbindGlobalGesture( globalGesture ) ) { + throw new InvalidOperationException( + "DCurses package-only global gesture-binding surface failed validation." + ); + } + + if ( 4096 != CursesInteractionRouter.MaximumRegions + || 256 != CursesInteractionRouter.MaximumRegionGestureBindings + || 1024 != CursesInteractionRouter.MaximumGlobalGestureBindings + || 16384 != CursesInteractionRouter.MaximumGestureBindings + || 30 != Enum.GetValues().Length ) { + throw new InvalidOperationException( + "DCurses package-only interaction bounds or pointer vocabulary changed." + ); + } + + MethodInfo? routeMethod = typeof( CursesInteractionRouter ).GetMethod( + nameof( CursesInteractionRouter.Route ), + [ + typeof( CursesInputEvent ) + ] + ); + if ( routeMethod is null + || typeof( CursesInteractionResult ) != routeMethod.ReturnType ) { + throw new InvalidOperationException( + "DCurses package-only routing surface is unavailable." + ); + } + + MethodInfo? pointerMethod = typeof( CursesSession ).GetMethod( + nameof( CursesSession.AcquirePointerShapeAsync ), + [ + typeof( CursesPointerShape ), + typeof( CancellationToken ) + ] + ); + if ( pointerMethod is null + || typeof( ValueTask ) != pointerMethod.ReturnType + || null == typeof( CursesPointerShapeLease ).GetProperty( + nameof( CursesPointerShapeLease.Shape ) + ) ) { + throw new InvalidOperationException( + "DCurses package-only pointer-shape lease surface is unavailable." + ); + } +} + +static CursesInteractionResult CompileRoutingSurface( + CursesInteractionRouter router, + CursesInputEvent input +) { + ArgumentNullException.ThrowIfNull( router ); + ArgumentNullException.ThrowIfNull( input ); + return router.Route( input ); +} + +static ValueTask CompilePointerShapeSurfaceAsync( + CursesSession session, + CancellationToken cancellationToken = default +) { + ArgumentNullException.ThrowIfNull( session ); + return session.AcquirePointerShapeAsync( + CursesPointerShape.Pointer, + cancellationToken + ); +} + static async Task RunInteractiveAsync() { await using CursesSession session = await CursesSession.OpenAsync( new CursesSessionOptions { From 322b7a8b1a100b7564fb7398becc3e8e3ec1f37e Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 13:34:27 -0400 Subject: [PATCH 68/90] T1411 keep package compile probes live --- tools/package-smoke/Program.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/package-smoke/Program.cs b/tools/package-smoke/Program.cs index 96c6d1128..0e85a3b9c 100644 --- a/tools/package-smoke/Program.cs +++ b/tools/package-smoke/Program.cs @@ -587,6 +587,13 @@ static void VerifyInteractionSurface() { "DCurses package-only pointer-shape lease surface is unavailable." ); } + + Func routeCompiler = + CompileRoutingSurface; + Func> pointerCompiler = + CompilePointerShapeSurfaceAsync; + _ = routeCompiler; + _ = pointerCompiler; } static CursesInteractionResult CompileRoutingSurface( From 99df9d241582173821f227e0708eea3b3ba26532 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 13:50:22 -0400 Subject: [PATCH 69/90] Update Terminal and TermInfo dependencies --- Icod.DCurses.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Icod.DCurses.csproj b/Icod.DCurses.csproj index 0b4a67d0c..07c762c74 100644 --- a/Icod.DCurses.csproj +++ b/Icod.DCurses.csproj @@ -43,7 +43,7 @@ Icod.DCurses Timothy J. Bruce Managed, cross-platform curses-like terminal UI library for .NET, built on Icod.TermInfo and Icod.Terminal. - 1.4.0-alpha.1 begins the interaction-routing release with the bounded application-owned CursesInteractionRouter/CursesInteractionRegion registry and one-way region lifetime semantics over the published 1.3 geometry/panel foundation. Hit testing, focus, gestures, command routing, and pointer-shape integration remain later 1.4 tranches. Runtime dependencies remain Icod.Terminal 1.11.1 and Icod.TermInfo 1.11.0; AssemblyVersion remains 1.0.0.0. + 1.4.0-alpha.1 develops deterministic application-owned interaction routing over the published 1.3 geometry and retained-panel foundation, including bounded interaction regions, panel-aware hit testing, logical focus/traversal/repair, semantic key gestures, local/global command routing, pointer-shape preferences and Terminal-owned pointer leases, lifecycle coherence, an interaction acceptance sample, and allocation/adversarial hardening. Runtime dependencies are Icod.Terminal 1.13.0 and Icod.TermInfo 1.12.0; AssemblyVersion remains 1.0.0.0. README.md icon.png https://github.com/uniblab/Icod.DCurses @@ -116,7 +116,7 @@ - - + + From 87460b39c31b57d5067bf3715a30b8c0adccd823 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 13:51:10 -0400 Subject: [PATCH 70/90] Update sample dependency documentation --- samples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/README.md b/samples/README.md index 8a68e97c9..8dc0e7f83 100644 --- a/samples/README.md +++ b/samples/README.md @@ -2,7 +2,7 @@ The repository contains nine executable samples. They are intentionally separate so the minimal session lifecycle stays easy to copy without mixing it with the interactive and acceptance-focused showcases. -All sample projects target `net8.0`, `net9.0`, and `net10.0` and consume the repository `Icod.DCurses` project, which currently declares `Icod.Terminal 1.11.1` and `Icod.TermInfo 1.11.0`. +All sample projects target `net8.0`, `net9.0`, and `net10.0` and consume the repository `Icod.DCurses` project, which currently declares `Icod.Terminal 1.13.0` and `Icod.TermInfo 1.12.0`. ## Ownership model From 2c519c7284bdeaf0dea9e39e408322141f073c1e Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:00:04 -0400 Subject: [PATCH 71/90] docs: refresh README for 1.4 interaction routing --- README.md | 119 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 87 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index a18483edc..2241e3121 100644 --- a/README.md +++ b/README.md @@ -10,42 +10,42 @@ It sits above `Icod.Terminal` and `Icod.TermInfo`: - `Icod.TermInfo` owns immutable terminal capability descriptions and expansion; -- `Icod.Terminal` owns the live terminal session, host mode, dimensions, lifecycle, input decoding, semantic terminal protocols, and output serialization; -- `Icod.DCurses` owns curses-shaped events, logical screens/windows, pads/viewports, retained panels/layers, cells/styles/metadata, composition, retained refresh policy, and terminal-cell layout primitives. +- `Icod.Terminal` owns the live terminal session, host mode, dimensions, lifecycle, input decoding, semantic terminal protocols, physical pointer protocol/state, and output serialization; +- `Icod.DCurses` owns curses-shaped events, logical screens/windows, pads/viewports, retained panels/layers, cells/styles/metadata, composition, retained refresh policy, terminal-cell layout primitives, interaction regions, logical focus, gesture/command routing, and pointer-shape preferences. ## Status -Current published stable release: `Icod.DCurses 1.2.0`. +Current published stable release: `Icod.DCurses 1.3.0`. -`Icod.DCurses 1.3.0` is complete in source and merged to `main`. The final dependency-qualified source head `b90b444556291434bddb9f56d031f09ac1aabfbf` passed pull-request workflow #719 / `34708925529` across the package candidate plus Windows/Linux/macOS x64/ARM64. PR #27 was then merged as `18255d59136922b9e246f4113dc6fdb6a9ea24a3`, and the resulting `main` Release workflow #17 / `34709142076` passed. A `v1.3.0` tag, GitHub Release, and NuGet publication have not yet been created. +`Icod.DCurses 1.4.0` is the active development line in PR #29. T1401-T1410 are complete; T1411 is qualifying the public API, package, documentation, licensing, refreshed dependencies, and stable-release regret gate before RC/stable-source promotion. Current source identity: ```text -Version 1.3.0 -PackageVersion 1.3.0 +Version 1.4.0-alpha.1 +PackageVersion 1.4.0-alpha.1 AssemblyVersion 1.0.0.0 -Icod.Terminal 1.11.1 -Icod.TermInfo 1.11.0 +Icod.Terminal 1.13.0 +Icod.TermInfo 1.12.0 ``` -Published 1.2 contract: +Published 1.3 contract: ```text -47 exported types -356 canonical declared contract lines -sha256 4810ebb088764acedbb94aca84b231677886b9c1a1f920d9a30f960cbe1dfce7 +51 exported types +406 canonical declared contract lines +sha256 a655bd85e3c88f5bf38ad0d43a148e3bd06a9e943bbf3e3aa2e21575ffb07424 ``` -Frozen 1.3 contract: +Current frozen 1.4 interaction contract: ```text -51 exported types -406 canonical declared contract lines -sha256 a655bd85e3c88f5bf38ad0d43a148e3bd06a9e943bbf3e3aa2e21575ffb07424 +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 ``` -The four new exported types are `CursesRectangle`, `CursesInsets`, `CursesDockEdge`, and `CursesLayout`. +Version 1.4 adds the interaction-routing surface over the published 1.3 geometry foundation: bounded interaction regions, panel-aware hit testing, logical focus/traversal/repair, semantic key gestures and command identities, structured routing results, pointer-shape preferences, and a DCurses pointer-shape lease wrapper over Terminal-owned state. ## Installation @@ -55,7 +55,7 @@ Install the current published package selected by your normal NuGet policy: dotnet add package Icod.DCurses ``` -Until `v1.3.0` is tagged and published, normal package resolution still selects the published 1.2 line. The `main` branch already contains the fully qualified 1.3.0 source. +Normal stable package resolution currently selects the published 1.3 line. The 1.4 development branch is being release-qualified and is not yet the stable published package. ## Architecture @@ -65,10 +65,12 @@ applications / future widgets / compatibility facades Icod.DCurses windows / pads / panels / cells / semantic metadata immutable geometry / pure layout / retained refresh / events + interaction regions / logical focus / gesture-command routing + pointer-shape preferences | Icod.Terminal live session / input / lifecycle / semantic protocols - capability routing / serialized output + physical pointer state / capability routing / serialized output | Icod.TermInfo immutable capability authority @@ -78,7 +80,7 @@ applications / future widgets / compatibility facades `Icod.DCurses` does not maintain a second terminal capability database, install a competing raw-input loop, own terminal modes independently of `Icod.Terminal`, emit private OSC/CSI/DCS/APC framing for Terminal-owned protocols, emulate a terminal, or create/manage PTYs. -Version 1.3 also does not add a retained layout tree or automatic layout owner. Geometry remains caller-owned data and layout recomputation remains explicit application policy. +Version 1.4 also does not add a widget framework, hidden event loop, callback dispatcher, retained layout tree, automatic layout owner, automatic mouse-to-focus policy, or independent pointer-protocol owner. Applications remain responsible for their event loop and command execution. Terminal remains authoritative for physical terminal state and reversible protocol leases. ## Targets @@ -118,6 +120,59 @@ CursesEvent terminalEvent = await session.ReadEventAsync(); A `CursesSession` restores the presentation and Terminal-owned state it acquires when disposed. Applications should consume terminal input and lifecycle activity through the curses/Terminal ownership model rather than adding a parallel byte reader. +## 1.4 interaction routing + +Version 1.4 adds deterministic interaction routing without taking ownership of the application event loop. Applications register bounded logical regions, decide which regions may receive logical focus, bind semantic key gestures to command identities, and route already-normalized `CursesInputEvent` values returned by the ordinary session reader. + +A compact application pattern is: + +```csharp +using CursesInteractionRouter router = new( session.Screen ); +using CursesInteractionRegion body = router.RegisterRegion( + new CursesInteractionRegionOptions( + new CursesRectangle( 1, 0, 20, 80 ) + ) { + IsFocusable = true, + TraversalOrder = 0, + PointerShape = CursesPointerShape.Text + } +); + +CursesCommand focusNext = new( "focus.next" ); +router.BindGlobalGesture( + CursesKeyGesture.ForKey( CursesKey.Tab ), + focusNext +); +_ = router.Focus( body ); + +CursesEvent current = await session.ReadEventAsync(); +if ( CursesEventKind.Input == current.Kind + && current.Input is not null ) { + CursesInteractionResult routed = router.Route( current.Input ); + + if ( routed.Command is not null ) { + // Application policy executes the command. + if ( "focus.next" == routed.Command.Name ) { + _ = router.MoveFocus( CursesFocusDirection.Forward ); + } + } + + if ( routed.Hit?.PointerShape is CursesPointerShape pointerShape ) { + await using CursesPointerShapeLease lease = + await session.AcquirePointerShapeAsync( pointerShape ); + // Keep the lease for as long as this physical preference should apply. + } +} +``` + +The router deliberately returns structured routing data instead of invoking callbacks. Focus changes are explicit application decisions. Mouse hit testing therefore does **not** automatically change logical focus, and logical focus is independent of terminal/window-manager focus reports. + +Panel-associated regions participate in the retained panel stack, so current panel z-order is part of mouse hit-test precedence. Successful mouse hits carry region-local row/column coordinates in addition to the original normalized input. A region's `PointerShape` is only a semantic preference surfaced by hit/routing results; applying that preference requires the application to explicitly acquire and retain a `CursesPointerShapeLease` from the session. + +Region and gesture-binding registries are intentionally bounded and fail before partial mutation when capacity is exhausted. The router owns no background work, event loop, terminal parser, protocol negotiation, or hidden terminal I/O. It routes only the semantic input and geometry state already owned by DCurses/Terminal. + +The `Icod.DCurses.Interaction.Sample` project demonstrates focused local-versus-global bindings, forward/backward traversal, retained popup overlap, panel-aware mouse precedence, screen and region-local coordinates, explicit pointer leases, resize/re-layout, and the distinction between terminal focus reports and logical interaction focus. + ## 1.3 geometry, layout, and resize `CursesRectangle` and `CursesInsets` are immutable terminal-cell value types. Empty rectangles are valid geometry results; applying bounds to a window or panel still requires positive dimensions. @@ -237,13 +292,14 @@ The built-in width provider is pinned to Unicode 17.0.0. East Asian Ambiguous ch The library deliberately uses a narrow ownership model rather than pervasive per-cell locking: -- logical screens, windows, pads, viewports, and panels are single-writer unless documented otherwise; +- logical screens, windows, pads, viewports, panels, and interaction routers are single-writer unless documented otherwise; - one Terminal-owned event wait may coexist with serialized refresh/output work; - caller cancellation does not discard Terminal decoder state; - disposal unblocks pending DCurses waits while preserving authoritative restoration; - output uncertainty invalidates retained physical knowledge so a later refresh can repaint safely; -- suspend/resume invalidates physical knowledge but retains logical panel content; -- lifecycle resize synchronizes the logical screen, while application layout recomputation remains explicit. +- suspend/resume invalidates physical knowledge but retains logical panel and interaction registration state; +- lifecycle resize synchronizes the logical screen, while application layout recomputation remains explicit; +- interaction routing never creates a competing input reader or terminal-output path. ## Validation and packaging @@ -251,7 +307,7 @@ Local wrappers use Debug configuration. Pull requests use Staging with warnings- Runtime validation covers Windows/Linux/macOS x64 and ARM64; the library/test matrix covers `net8.0`, `net9.0`, and `net10.0`. -Package validation verifies `.nupkg`/`.snupkg`, package/assembly identity, dependency groups derived from project declarations, README/license/icon/repository metadata, XML documentation, portable symbols, and a fresh NuGet-only consumer. The 1.3 package consumer compiles and executes the rectangle/inset/layout/bounds/panel-resize surface directly from the packed artifact. Package validation does not impose hard-coded sibling dependency versions. +Package validation verifies `.nupkg`/`.snupkg`, package/assembly identity, dependency groups derived from project declarations, README/license/icon/repository metadata, XML documentation, portable symbols, and a fresh NuGet-only consumer. The package consumer now compiles and executes both the 1.3 geometry/layout/panel-resize surface and the 1.4 interaction surface directly from the packed artifact: regions, logical focus/traversal, hit testing, semantic gestures and command bindings, routing API presence, pointer-shape vocabulary, and pointer-lease surface. Package validation does not impose hard-coded sibling dependency versions. ## Release documentation @@ -259,14 +315,13 @@ Current authorities: - `Icod.DCurses-Development-Roadmap.md` - `Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md` -- `Icod.DCurses-1.3.0-Development-Roadmap.md` -- `docs/Public-API-Fingerprint-1.3.json` -- `docs/Public-API-Baseline-1.3.md` -- `docs/T1309-Layout-Application-Performance-and-Allocation-Acceptance.md` -- `docs/T1310-Public-API-Package-Documentation-and-Regret-Gate.md` -- `docs/T1311-RC-and-Stable-Closure.md` - -Historical 1.0-1.2 closure records remain compatibility authorities and are not rewritten merely to reflect later development state. +- `Icod.DCurses-1.4.0-Development-Roadmap.md` +- `docs/Public-API-Fingerprint-1.4.json` +- `docs/T1401-Interaction-Contract-and-Public-API-Candidate.md` +- `docs/T1409-Interaction-Acceptance-Sample.md` +- `docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md` + +T1411 is the active API/package/documentation/licensing regret gate. Its closure evidence joins this authority list before RC/stable-source promotion. Historical 1.0-1.3 closure records remain compatibility authorities and are not rewritten merely to reflect later development state. ## Authors From 58e10822da25ef10249a970c968e76fe88e955a5 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:00:20 -0400 Subject: [PATCH 72/90] docs: polish sample guide for 1.4 --- samples/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/samples/README.md b/samples/README.md index 8dc0e7f83..5ac32636f 100644 --- a/samples/README.md +++ b/samples/README.md @@ -4,6 +4,20 @@ The repository contains nine executable samples. They are intentionally separate All sample projects target `net8.0`, `net9.0`, and `net10.0` and consume the repository `Icod.DCurses` project, which currently declares `Icod.Terminal 1.13.0` and `Icod.TermInfo 1.12.0`. +## Which sample should I run? + +| Goal | Sample | +| --- | --- | +| Minimal session lifecycle and retained drawing | `Icod.DCurses.Sample` | +| Retained panels, z-order, transparency, and disposal | `Icod.DCurses.Panel.Sample` | +| Explicit geometry/layout and resize recomputation | `Icod.DCurses.Layout.Sample` | +| Interaction regions, logical focus, commands, mouse routing, and pointer preferences | `Icod.DCurses.Interaction.Sample` | +| General interactive API showcase | `Icod.DCurses.Showcase` | +| Raw semantic input inspection | `Icod.DCurses.Input.Showcase` | +| Application-shaped periodic-command acceptance | `Icod.DCurses.Watch.Acceptance` | +| Application-shaped slab-table acceptance | `Icod.DCurses.Slabtop.Acceptance` | +| Larger application-shaped multi-window acceptance | `Icod.DCurses.Top.Acceptance` | + ## Ownership model The samples follow the production ownership model: @@ -64,6 +78,8 @@ The left-pane local `x` binding deliberately shadows the router-global `x` bindi The application explicitly applies pointer-shape preferences with `CursesPointerShapeLease` rather than performing terminal I/O inside hit testing or routing. It also acquires keyboard event types, focus reporting, and mouse button events through DCurses input-protocol leases only. +Mouse, focus, keyboard-protocol behavior, and visible pointer-shape changes ultimately depend on terminal support. A terminal that does not visibly change the pointer shape can still be routing mouse hits and commands correctly; the sample's routed target/local-coordinate status is the relevant routing evidence. + Resize handling remains application-owned: the sample synchronizes the current terminal dimensions, recomputes header/footer and equal left/right pane rectangles, reapplies window/panel/interaction bounds, and repaints. If the terminal falls below `64x16`, retained router/binding identity survives while the application displays a resize message; growing the terminal restores the normal layout. Terminal focus reports update status text but remain distinct from logical `CursesInteractionRouter` focus. ```text From f1af9868487c903f0f9b390f77cc031f8b1b59c4 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:01:56 -0400 Subject: [PATCH 73/90] docs: refresh active 1.4 roadmap status --- Icod.DCurses-Development-Roadmap.md | 62 ++++++++++++++++------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/Icod.DCurses-Development-Roadmap.md b/Icod.DCurses-Development-Roadmap.md index 5c41e7789..86d5d4996 100644 --- a/Icod.DCurses-Development-Roadmap.md +++ b/Icod.DCurses-Development-Roadmap.md @@ -5,11 +5,11 @@ **Published compatibility floor:** `1.3.0` **Current published package:** `1.3.0` **Assembly version:** `1.0.0.0` -**Current declared runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Current declared runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` **Target frameworks:** `net8.0`; `net9.0`; `net10.0` **Configurations:** `Debug`; `Staging`; `Release` **Active development target:** `1.4.0` — deterministic interaction routing, focus, gestures, hit testing, and pointer semantics -**Status:** `1.3.0` is published; `1.4.0` planning is approved and T1401 contract freeze is the next development gate +**Status:** T1401-T1410 complete; T1411 API/package/documentation/licensing/dependency qualification is active; T1412 RC/stable-source closure follows --- @@ -18,10 +18,12 @@ - `Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md` - `Icod.DCurses-1.4.0-Development-Roadmap.md` - `docs/superpowers/specs/2026-09-12-icod-dcurses-1.4-interaction-routing-design.md` -- `docs/Public-API-Fingerprint-1.3.json` -- `docs/Public-API-Baseline-1.3.md` +- `docs/Public-API-Fingerprint-1.4.json` +- `docs/T1401-Interaction-Contract-and-Public-API-Candidate.md` +- `docs/T1409-Interaction-Acceptance-Sample.md` +- `docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md` -The 1.0-1.3 tranche and closure documents remain historical compatibility/release authorities and are not rewritten to simulate current development state. +T1411 is the active stable-release regret gate. Its closure evidence becomes a current authority before RC promotion. The 1.0-1.3 tranche and closure documents remain historical compatibility/release authorities and are not rewritten to simulate current development state. ## Release train @@ -31,7 +33,7 @@ The 1.0-1.3 tranche and closure documents remain historical compatibility/releas | `1.1.0` | Semantic metadata and hyperlinks | Historical stable baseline | | `1.2.0` | Panels/layers/z-order composition | Historical stable baseline | | `1.3.0` | Layout and resize primitives | Current published stable release | -| `1.4.0` | Interaction routing/focus/gestures/hit testing/pointer semantics | Active approved development release | +| `1.4.0` | Interaction routing/focus/gestures/hit testing/pointer semantics | T1411 release qualification active | The progression is intentionally cumulative: @@ -56,11 +58,19 @@ The tagged compatibility baseline is `v1.3.0`, which resolves to commit: c10ca043a666b85225f2d3b8955a1ac2075b0d31 ``` +Current 1.4 interaction fingerprint: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + Version 1.4 remains additive by default. Any proposed break to the published 1.3 surface requires an explicit regret-gate finding, migration justification, and user approval before implementation. ## 1.4 release objective -`Icod.DCurses 1.4.0` will add deterministic, application-owned interaction routing over the existing semantic input, geometry, panel, and Terminal ownership foundations. +`Icod.DCurses 1.4.0` adds deterministic, application-owned interaction routing over the existing semantic input, geometry, panel, and Terminal ownership foundations. The intended flow is: @@ -96,7 +106,7 @@ The 1.4 track is governed by these rules: - `CursesRectangle` remains the coordinate substrate; no second geometry model is introduced. - Interaction regions are application interaction objects, not widgets and not rendering surfaces. - Logical application focus is distinct from terminal/window-manager focus reports represented by `CursesFocusEvent`. -- Screen-relative and panel-associated interaction must use deterministic coordinate conversion and overlap precedence. +- Screen-relative and panel-associated interaction use deterministic coordinate conversion and overlap precedence. - Panel z-order remains the authoritative precedence source for panel-associated hit targets. - Visual blank-cell transparency does not automatically imply input transparency. - Focus traversal in 1.4 is forward/backward deterministic traversal only; spatial focus navigation is deferred. @@ -105,31 +115,31 @@ The 1.4 track is governed by these rules: - Hit testing and routing remain synchronous and perform no terminal I/O. - Pointer-shape protocol ownership stays inside `Icod.Terminal`; DCurses exposes its own curses-shaped semantic abstraction and lease. - Public Terminal/TermInfo dependency exposure remains tightly allow-listed. -- Interaction registries, command bindings, and internal bookkeeping must be bounded and deterministic. +- Interaction registries, command bindings, and internal bookkeeping are bounded and deterministic. - Existing single-writer expectations remain unless a tranche explicitly proves a safe additive concurrency contract. -## Planned 1.4 sequence +## 1.4 tranche sequence ```text -T1401 interaction architecture / terminology / contract freeze -T1402 bounded interaction-region registry -T1403 deterministic hit testing and panel precedence -T1404 logical focus and focus repair -T1405 semantic keyboard gesture model -T1406 command bindings and structured interaction routing -T1407 pointer-shape abstraction and Terminal-owned lease integration -T1408 resize / panel / lifecycle coherence -T1409 application acceptance sample -T1410 hardening / performance / allocation / adversarial acceptance -T1411 public API / package / docs / licensing regret gate -T1412 RC and stable-source closure +T1401 interaction architecture / terminology / contract freeze complete +T1402 bounded interaction-region registry complete +T1403 deterministic hit testing and panel precedence complete +T1404 logical focus and focus repair complete +T1405 semantic keyboard gesture model complete +T1406 command bindings and structured interaction routing complete +T1407 pointer-shape abstraction and Terminal-owned lease integration complete +T1408 resize / panel / lifecycle coherence complete +T1409 application acceptance sample complete +T1410 hardening / performance / allocation / adversarial acceptance complete +T1411 public API / package / docs / licensing / dependency regret gate active +T1412 RC and stable-source closure pending ``` -Every implementation tranche must receive exact-head Staging qualification before being called complete. The final release must retain the existing package-only consumer gate, compiler-derived public API fingerprinting, Windows/Linux/macOS x64/ARM64 coverage, and `net8.0`/`net9.0`/`net10.0` validation. +Every implementation tranche must receive exact-head Staging qualification before being called complete. The final release retains package-only consumer validation, compiler-derived public API fingerprinting, Windows/Linux/macOS x64/ARM64 coverage, and `net8.0`/`net9.0`/`net10.0` validation. ## Deliberate 1.4 non-goals -Version 1.4 will not add: +Version 1.4 does not add: - a widget framework; - buttons, text boxes, menus, controls, or application navigation; @@ -150,6 +160,4 @@ A future widget package should be able to build on the 1.4 mechanisms without by ## Immediate next step -T1401 is the active gate. It freezes terminology, ownership, coordinate spaces, eligibility/focus rules, deterministic overlap semantics, command/gesture normalization, pointer-shape boundaries, concrete registry bounds, failure behavior, and the candidate public API before T1402 begins implementation. - -No 1.4 production API should be treated as frozen until T1401 exits green and the written design authority is approved. +T1411 is the active gate. Complete the public API/package/documentation/licensing regret review against the published 1.3 floor and qualify the refreshed `Icod.Terminal 1.13.0` / `Icod.TermInfo 1.12.0` dependency graph through the normal package/runtime matrix. Only then promote the unchanged accepted implementation/API to T1412 RC/stable-source closure. From cdf8dc9fa800d4189b5bc87f60c85a0ae4a6a5a5 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:02:10 -0400 Subject: [PATCH 74/90] docs: refresh 1.1-1.4 release train status --- ...rses-1.1.0-to-1.4.0-Development-Roadmap.md | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md b/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md index 02bed1145..36c3321c7 100644 --- a/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md +++ b/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md @@ -5,8 +5,8 @@ **Published compatibility floor:** `1.3.0` **Current published package:** `1.3.0` **Assembly version policy:** retain `1.0.0.0` through compatible additive 1.x releases -**Current declared runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` -**Planning status:** 1.3 is complete/published; 1.4 is the active approved development release +**Current declared runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` +**Planning status:** 1.3 is complete/published; 1.4 has completed T1401-T1410 and is in T1411 release qualification --- @@ -16,7 +16,7 @@ 1.1.0 semantic cell metadata + hyperlinks complete/published history 1.2.0 panels/layers + z-order composition complete/published history 1.3.0 layout + resize primitives complete/published -1.4.0 interaction routing/focus/gestures/hit testing active development +1.4.0 interaction routing/focus/gestures/hit testing T1411 release qualification active ``` The sequence is cumulative: 1.1 adds meaning to retained content; 1.2 composes overlapping retained surfaces; 1.3 makes geometry manageable; 1.4 routes semantic input to logical application regions. @@ -27,7 +27,7 @@ DCurses consumes Terminal's live-session/input/lifecycle/semantic-output contrac The 1.3 layout work preserved that ownership principle internally: geometry values are immutable, `CursesLayout` is pure/stateless, and applications explicitly recompute/apply layout rather than delegating control to a background layout owner. -The 1.4 interaction work extends the same principle. DCurses may classify and route already-normalized input, but `Icod.Terminal` remains authoritative for terminal input decoding, rich-input protocol ownership, pointer-shape transport/lifetime, session lifecycle, and output serialization. +The 1.4 interaction work extends the same principle. DCurses classifies and routes already-normalized input, while `Icod.Terminal` remains authoritative for terminal input decoding, rich-input protocol ownership, pointer-shape transport/lifetime, session lifecycle, and output serialization. ## Published 1.3 floor @@ -43,17 +43,25 @@ Tagged baseline: v1.3.0 -> c10ca043a666b85225f2d3b8955a1ac2075b0d31 ``` -Version 1.3 contributes the reusable `CursesRectangle` coordinate substrate, screen/window/panel bounds, explicit rectangle application, retained panel resizing, and lifecycle-driven application relayout model which 1.4 will consume rather than replace. +Current 1.4 interaction fingerprint: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + +Version 1.3 contributes the reusable `CursesRectangle` coordinate substrate, screen/window/panel bounds, explicit rectangle application, retained panel resizing, and lifecycle-driven application relayout model which 1.4 consumes rather than replaces. ## Release 1.4 — deterministic interaction routing Version 1.4 turns the existing semantic input and geometry foundations into an application-facing interaction substrate without introducing widgets or a hidden event loop. -The release is planned to provide: +The implemented surface provides: - bounded screen-bound interaction-region registration; - screen-relative and panel-associated hit targets; -- deterministic overlap resolution using panel z-order plus explicit region precedence; +- deterministic overlap resolution using panel z-order plus region precedence; - region-local coordinate translation; - logical focus independent of terminal/window-manager focus; - explicit focus/clear plus forward/backward traversal; @@ -64,12 +72,15 @@ The release is planned to provide: - pointer-shape preferences and a DCurses-shaped wrapper over Terminal-owned pointer leases; - resize/panel visibility/reorder/disposal coherence; - an application sample proving keyboard, mouse, panel, pointer, and live-resize composition; -- allocation/performance/adversarial qualification before API freeze. +- allocation/performance/adversarial qualification before stable promotion. -The detailed authority is: +The detailed authorities are: - `Icod.DCurses-1.4.0-Development-Roadmap.md` - `docs/superpowers/specs/2026-09-12-icod-dcurses-1.4-interaction-routing-design.md` +- `docs/Public-API-Fingerprint-1.4.json` +- `docs/T1409-Interaction-Acceptance-Sample.md` +- `docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md` ## 1.4 key policy decisions @@ -88,18 +99,18 @@ Pointer-shape preferences are routing data. Synchronous hit testing never perfor ## 1.4 tranche sequence ```text -T1401 contract/design/public-surface candidate freeze -T1402 interaction-region registry -T1403 hit testing / clipping / panel precedence -T1404 logical focus / traversal / repair -T1405 keyboard gestures -T1406 command bindings / routing result -T1407 pointer shape integration -T1408 resize / panel / lifecycle coherence -T1409 application acceptance sample -T1410 performance / allocation / hardening -T1411 API / package / docs / licensing regret gate -T1412 RC / stable-source closure +T1401 contract/design/public-surface candidate freeze complete +T1402 interaction-region registry complete +T1403 hit testing / clipping / panel precedence complete +T1404 logical focus / traversal / repair complete +T1405 keyboard gestures complete +T1406 command bindings / routing result complete +T1407 pointer shape integration complete +T1408 resize / panel / lifecycle coherence complete +T1409 application acceptance sample complete +T1410 performance / allocation / hardening complete +T1411 API / package / docs / licensing / dependency gate active +T1412 RC / stable-source closure pending ``` ## Cross-release rules @@ -114,4 +125,4 @@ Version 1.4 does not add a widget/control library, retained widget hierarchy, ev ## Immediate next step -T1401 freezes the interaction contract before production implementation begins. Once that contract is accepted, T1402 becomes the first source tranche and may advance the development package identity from the published `1.3.0` floor to the 1.4 prerelease line. +T1411 is the active release gate. Finish the API/package/documentation/licensing regret review and qualify the refreshed `Icod.Terminal 1.13.0` / `Icod.TermInfo 1.12.0` dependency graph through the normal package/runtime matrix. T1412 then promotes the accepted implementation through RC and stable-source qualification before merge. From 3b41d35faeab30e18c559d9de7a9352dc30ec718 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:02:46 -0400 Subject: [PATCH 75/90] docs: clarify pointer lease lifetime in 1.4 example --- README.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2241e3121..0eb76fef7 100644 --- a/README.md +++ b/README.md @@ -150,24 +150,27 @@ if ( CursesEventKind.Input == current.Kind && current.Input is not null ) { CursesInteractionResult routed = router.Route( current.Input ); - if ( routed.Command is not null ) { - // Application policy executes the command. - if ( "focus.next" == routed.Command.Name ) { - _ = router.MoveFocus( CursesFocusDirection.Forward ); - } + if ( routed.Command is not null + && "focus.next" == routed.Command.Name ) { + _ = router.MoveFocus( CursesFocusDirection.Forward ); } - if ( routed.Hit?.PointerShape is CursesPointerShape pointerShape ) { - await using CursesPointerShapeLease lease = - await session.AcquirePointerShapeAsync( pointerShape ); - // Keep the lease for as long as this physical preference should apply. - } + // routed.Hit contains region-local mouse coordinates and the + // region's pointer-shape preference, if one was configured. } ``` The router deliberately returns structured routing data instead of invoking callbacks. Focus changes are explicit application decisions. Mouse hit testing therefore does **not** automatically change logical focus, and logical focus is independent of terminal/window-manager focus reports. -Panel-associated regions participate in the retained panel stack, so current panel z-order is part of mouse hit-test precedence. Successful mouse hits carry region-local row/column coordinates in addition to the original normalized input. A region's `PointerShape` is only a semantic preference surfaced by hit/routing results; applying that preference requires the application to explicitly acquire and retain a `CursesPointerShapeLease` from the session. +Panel-associated regions participate in the retained panel stack, so current panel z-order is part of mouse hit-test precedence. Successful mouse hits carry region-local row/column coordinates in addition to the original normalized input. A region's `PointerShape` is only a semantic preference surfaced by hit/routing results; applying that preference requires the application to explicitly acquire and retain a `CursesPointerShapeLease` from the session for as long as the physical preference should remain active: + +```csharp +CursesPointerShapeLease pointerLease = + await session.AcquirePointerShapeAsync( CursesPointerShape.Text ); + +// Keep pointerLease while the preference applies, then restore prior state. +await pointerLease.DisposeAsync(); +``` Region and gesture-binding registries are intentionally bounded and fail before partial mutation when capacity is exhausted. The router owns no background work, event loop, terminal parser, protocol negotiation, or hidden terminal I/O. It routes only the semantic input and geometry state already owned by DCurses/Terminal. From 60cc4cb9b4fb8d20e697c000ae7878fecad64e69 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:03:32 -0400 Subject: [PATCH 76/90] docs: advance 1.4 roadmap to T1411 qualification --- Icod.DCurses-1.4.0-Development-Roadmap.md | 25 ++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Icod.DCurses-1.4.0-Development-Roadmap.md b/Icod.DCurses-1.4.0-Development-Roadmap.md index dd1eff16e..e6538528d 100644 --- a/Icod.DCurses-1.4.0-Development-Roadmap.md +++ b/Icod.DCurses-1.4.0-Development-Roadmap.md @@ -6,12 +6,13 @@ **Published baseline:** `1.3.0` **Baseline tag:** `v1.3.0` **Baseline commit:** `c10ca043a666b85225f2d3b8955a1ac2075b0d31` -**Planning-stage source identity:** `1.3.0` until the first implementation tranche +**Current development package:** `1.4.0-alpha.1` **Final target package:** `1.4.0` **Assembly version policy:** retain `1.0.0.0` for compatible additive 1.x development **Baseline runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` +**Current declared runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` **Theme:** deterministic interaction routing over semantic input, immutable geometry, retained panels, and Terminal-owned protocol state -**Status:** architecture approved; T1401 contract freeze is the first gate +**Status:** T1401-T1410 complete; T1411 API/package/documentation/licensing/dependency qualification is active; T1412 follows --- @@ -75,6 +76,14 @@ CursesWindow.Bounds / SetBounds CursesPanel.Bounds / Resize / SetBounds ``` +Current 1.4 interaction fingerprint: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + Version 1.4 is additive by default. Any breaking correction to the published 1.3 contract requires a concrete regret finding and explicit approval before implementation. ## Architectural invariants @@ -123,9 +132,9 @@ A terminal focus-out report does not erase the application's logical focused reg Registration, traversal, hit-test precedence, gesture resolution, command binding, and focus repair must have deterministic tie-breaking. Registries and binding collections must have documented finite bounds frozen by T1401 before implementation. -## Working public API concepts +## T1401 design-time public API concepts -The following names describe the approved architecture but are **working names until T1401 freezes the public contract**: +The following names record the design-time candidate vocabulary that T1401 used to freeze the interaction contract. The compiler-derived 1.4 fingerprint is the current public-surface authority during T1411 review. ```text CursesInteractionRouter @@ -140,7 +149,7 @@ CursesPointerShape CursesPointerShapeLease ``` -T1401 may rename or consolidate these concepts, but it may not weaken the architectural invariants above without explicit design reapproval. +Historical candidate names in this section are not themselves a promise that every candidate survived unchanged into the final compiler-derived surface. ## T1401 — interaction contract and public-surface candidate freeze @@ -485,6 +494,12 @@ Version 1.4 does not provide: - animation; - PTY/process hosting. +## Current release state + +T1401-T1410 are complete. T1411 is the active release gate and must close the public API/package/documentation/licensing regret review and qualify the refreshed `Icod.Terminal 1.13.0` / `Icod.TermInfo 1.12.0` dependency graph before RC promotion. + +T1412 then promotes the accepted implementation/API through `1.4.0-rc.1` and stable-source `1.4.0` with exact-head package/runtime qualification. Merge, `main` Release qualification, tagging, GitHub Release creation, and NuGet publication remain separate explicit actions. + ## Definition of done `Icod.DCurses 1.4.0` is repository-side complete only when: From 570715e0764f9791fe197462a953df6eccf6105a Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:07:40 -0400 Subject: [PATCH 77/90] Record T1411 release regret audit --- ...I-Package-Documentation-and-Regret-Gate.md | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md diff --git a/docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md b/docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md new file mode 100644 index 000000000..a5f2acaae --- /dev/null +++ b/docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md @@ -0,0 +1,271 @@ +# T1411 — Public API, Package, Documentation, Licensing, and Regret Gate + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1411 +**Package identity:** `1.4.0-alpha.1` +**AssemblyVersion:** `1.0.0.0` +**Published compatibility floor:** `1.3.0` +**Current runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` +**Status:** candidate closure audit complete; exact-head package/runtime qualification required before T1412 + +--- + +## Objective + +T1411 is the final regret gate before release-candidate promotion. It reviews the complete 1.4 interaction surface as a stable 1.x contract rather than as a sequence of implementation tranches. + +The gate asks whether the accepted API, ownership model, packed-artifact behavior, documentation, licensing, and dependency graph are suitable to carry unchanged into `1.4.0-rc.1` and then stable `1.4.0`. + +No new interaction feature belongs in this tranche. Any API correction discovered here would return the affected surface to implementation/requalification rather than being hidden inside release promotion. + +## Published 1.3 compatibility floor + +The published `v1.3.0` contract remains: + +```text +51 exported types +406 canonical declared contract lines +sha256 a655bd85e3c88f5bf38ad0d43a148e3bd06a9e943bbf3e3aa2e21575ffb07424 +``` + +Tagged baseline: + +```text +v1.3.0 -> c10ca043a666b85225f2d3b8955a1ac2075b0d31 +``` + +The current 1.4 interaction contract is: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + +All three supported target frameworks are required to reproduce that one contract before release promotion. + +## Additive exported-type review + +Version 1.4 adds exactly eleven exported types over the 1.3 floor: + +```text +CursesCommand +CursesFocusDirection +CursesInteractionHit +CursesInteractionRegion +CursesInteractionRegionOptions +CursesInteractionResult +CursesInteractionResultKind +CursesInteractionRouter +CursesKeyGesture +CursesPointerShape +CursesPointerShapeLease +``` + +No 1.3 exported type is removed. + +The additions divide into four deliberate groups: + +1. **region/routing mechanism** — `CursesInteractionRouter`, `CursesInteractionRegion`, `CursesInteractionRegionOptions`, `CursesInteractionHit`, `CursesInteractionResult`, and `CursesInteractionResultKind`; +2. **logical focus** — `CursesFocusDirection`; +3. **semantic commands/gestures** — `CursesCommand` and `CursesKeyGesture`; +4. **pointer preference/state bridge** — `CursesPointerShape` and `CursesPointerShapeLease`. + +The surface is additive over the published 1.3 geometry and retained-panel contract. It reuses `CursesRectangle`, `CursesScreen`, `CursesPanel`, `CursesInputEvent`, `CursesKey`, modifiers, and input-event phases instead of introducing parallel geometry or protocol vocabularies. + +## Naming and semantic regret review + +The final names describe mechanism rather than application policy: + +- `InteractionRouter` routes normalized input but does not own an event loop; +- `InteractionRegion` identifies a logical target but is not a widget, window, or renderer; +- `Command` is an identity, not a callback/delegate container; +- `KeyGesture` is semantic and terminal-family independent; +- `FocusDirection` describes deterministic forward/backward logical traversal; +- `PointerShape` is a DCurses semantic preference vocabulary, not a leaked Terminal protocol type; +- `PointerShapeLease` accurately communicates reversible physical-state lifetime. + +No naming collision or misleading abstraction was found which justifies a stable-API break. + +## Mutability and ownership regret review + +The accepted mutability split is intentional: + +- immutable value/result objects represent observations and identities; +- live interaction-region configuration may be changed explicitly by the single writer while the region is live; +- region disposal is one-way; +- router focus is logical application state and changes only through explicit focus/traversal operations or deterministic eligibility repair; +- mouse hit testing does not implicitly focus a region; +- command routing returns structured data and never invokes application callbacks; +- ordinary region geometry remains application-owned across resize; +- panel-associated regions observe the panel's current geometry, visibility, disposal, and z-order; +- pointer preferences are routing observations; physical pointer state remains Terminal-owned through an explicit lease. + +The split avoids hidden lifetime ownership, callback reentrancy, and duplicated Terminal state. + +No mutability or ownership correction was found which warrants changing the accepted public contract. + +## Bounds and failure behavior + +The interaction registries remain deliberately finite: + +```text +MaximumRegions = 4096 +MaximumRegionGestureBindings = 256 +MaximumGlobalGestureBindings = 1024 +MaximumGestureBindings = 16384 +``` + +T1410 adversarial qualification proves capacity exhaustion, duplicate bindings, invalid/foreign focus attempts, disposed-region mutation, and churn fail without corrupting prior accepted state. + +The common routing mechanism remains synchronous and callback-free. Ownership-audit tests freeze the absence of router/region public events, delegate parameters, asynchronous router methods, hidden Terminal I/O, and background dispatch. + +No unbounded collection or hidden event-loop surface entered the release. + +## Public dependency boundary + +The project now declares: + +```text +Icod.Terminal 1.13.0 +Icod.TermInfo 1.12.0 +``` + +The dependency update does not change the frozen DCurses 1.4 public API fingerprint. + +Terminal remains authoritative for: + +- the one live terminal input reader; +- byte/protocol decoding and query-response routing; +- rich-input protocol acquisition/restoration; +- terminal/window-manager focus reporting; +- physical pointer-shape protocol/state and lease lifetime; +- session lifecycle and output serialization. + +DCurses does not expose `TerminalPointerShape`, `TerminalPointerShapeLease`, or other new Terminal pointer implementation types through its public interaction contract. + +No new `Icod.TermInfo.Inspection` or terminal-database ownership enters DCurses. + +The refreshed dependency graph must pass an exact-head package/runtime matrix before this gate is complete. + +## Packed-artifact consumer audit + +`tools/package-smoke/Program.cs` consumes the package artifact rather than repository project internals and now exercises the 1.4 interaction surface in addition to the 1.3 geometry/layout surface. + +The interaction package probe covers: + +- router construction and screen identity; +- ordinary region registration; +- focus and forward traversal; +- deterministic hit testing; +- region-local coordinates; +- pointer-shape preference observations; +- local gesture binding/unbinding; +- global gesture binding/routing; +- semantic command identities; +- routing API availability; +- `CursesPointerShape` vocabulary; +- compile-time `CursesSession.AcquirePointerShapeAsync(...)` / `CursesPointerShapeLease` surface. + +This protects against a source-tree-only success where the packed NuGet artifact would omit or misdeclare the interaction contract. + +## Application acceptance audit + +`Icod.DCurses.Interaction.Sample` uses only public DCurses APIs for the 1.4 mechanism and exercises: + +- one application-owned event loop; +- logical focus and forward/backward traversal; +- region-local versus router-global command precedence; +- retained panel overlap and panel-aware mouse precedence; +- mouse screen and region-local coordinates; +- explicit pointer-shape leases; +- explicit resize/re-layout; +- terminal focus reports without conflating them with logical focus. + +The sample does not reference `Icod.Terminal` or `Icod.TermInfo` directly for its interaction behavior and does not install a second input reader. + +## Documentation audit + +The release-facing root `README.md` now: + +- identifies `1.3.0` as the published stable floor; +- identifies 1.4 as the active release-qualification line; +- records the `Icod.Terminal 1.13.0` / `Icod.TermInfo 1.12.0` graph; +- explains DCurses versus Terminal interaction/pointer ownership; +- documents bounded regions, logical focus, semantic gestures/commands, panel-aware hit testing, structured routing results, local coordinates, and explicit pointer leases; +- distinguishes logical application focus from terminal/window-manager focus; +- states that mouse hits do not automatically mutate logical focus; +- states that routing owns no event loop and performs no hidden terminal I/O; +- describes package-only 1.4 consumer coverage. + +`samples/README.md` now provides a sample-selection table and documents that visible pointer/focus/keyboard protocol behavior depends on terminal support; lack of a visible pointer-shape change is not evidence that hit testing/routing failed. + +The active long-range and 1.4 roadmaps are synchronized to T1411 and the refreshed dependency graph rather than the obsolete T1401 planning state. + +## Source/header and licensing audit + +The 1.4 production additions retain the repository's LGPL-3.0-or-later source preamble. The interaction sample and test additions retain the repository's GPL test/sample preamble. + +The PR source/header sweep covers the new interaction production files, Terminal pointer integration file, sample project/source, and interaction test files. The two files not surfaced in the first paginated header search (`CursesInteractionResultKind.cs` and `CursesInteractionOwnershipAuditTests.cs`) were inspected directly and also contain the expected preambles. + +Package licensing remains: + +```text +PackageLicenseExpression = LGPL-3.0-or-later +PackageRequireLicenseAcceptance = true +``` + +No license change is proposed by 1.4. + +## Performance and adversarial evidence carried into the regret gate + +T1410 qualified the representative hot path with 256 ordinary regions, 64 local bindings, 64 global bindings, repeated 10,000-operation loops, and a broad 15-second combined regression tripwire. + +Frozen steady-state allocation ceilings remain: + +```text +HitTest miss: minimum sample == 0 bytes +MoveFocus traversal: minimum sample == 0 bytes +Successful HitTest: <= 96 bytes / operation +Local command Route: <= 96 bytes / operation +Global command Route: <= 96 bytes / operation +Mouse-targeted Route: <= 192 bytes / operation +``` + +T1410 also qualified maximum-capacity region/binding churn, deterministic overlapping-panel replay, focus-eligibility churn, failure atomicity, and the synchronous/no-hidden-I/O ownership audit without a production or API correction. + +## Regret decision + +The completed static T1411 review found **no public API regret requiring a breaking or additive correction before RC**. + +Specifically, no correction is warranted for: + +- public type/member naming; +- mutable versus immutable responsibilities; +- logical-focus ownership; +- mouse-to-focus behavior; +- local/global command precedence; +- result-versus-callback routing semantics; +- panel precedence or coordinate spaces; +- bounds/capacity behavior; +- pointer preference versus physical lease ownership; +- dependency exposure; +- nullability/exception policy as frozen by T1401 and exercised by the tranche tests. + +The accepted implementation/API should therefore move unchanged into T1412 **only after** the exact head containing this record passes the normal seven-job PR matrix with the refreshed dependencies. + +## Exit gate + +T1411 is complete when the exact candidate-closure head containing this document passes: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +That matrix must restore the published `Icod.Terminal 1.13.0` and `Icod.TermInfo 1.12.0` packages, reproduce the frozen 1.4 fingerprint across `net8.0`, `net9.0`, and `net10.0`, and pass package-only consumption. + +After that exact-head qualification, T1412 may promote the **unchanged implementation/API** first to `1.4.0-rc.1` and then, after RC qualification, to stable-source `1.4.0`. From c284cbb3106279ef2f574bd1c887a2d396ef013a Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:11:35 -0400 Subject: [PATCH 78/90] Promote Icod.DCurses 1.4.0 RC --- Icod.DCurses.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Icod.DCurses.csproj b/Icod.DCurses.csproj index 07c762c74..8bf308360 100644 --- a/Icod.DCurses.csproj +++ b/Icod.DCurses.csproj @@ -34,16 +34,16 @@ Icod.DCurses Icod.DCurses Debug;Staging;Release - 1.4.0-alpha.1 + 1.4.0-rc.1 1.0.0.0 - 1.4.0-alpha.1 + 1.4.0-rc.1 Icod.DCurses Icod.DCurses Timothy J. Bruce Managed, cross-platform curses-like terminal UI library for .NET, built on Icod.TermInfo and Icod.Terminal. - 1.4.0-alpha.1 develops deterministic application-owned interaction routing over the published 1.3 geometry and retained-panel foundation, including bounded interaction regions, panel-aware hit testing, logical focus/traversal/repair, semantic key gestures, local/global command routing, pointer-shape preferences and Terminal-owned pointer leases, lifecycle coherence, an interaction acceptance sample, and allocation/adversarial hardening. Runtime dependencies are Icod.Terminal 1.13.0 and Icod.TermInfo 1.12.0; AssemblyVersion remains 1.0.0.0. + 1.4.0-rc.1 qualifies deterministic application-owned interaction routing over the published 1.3 geometry and retained-panel foundation, including bounded interaction regions, panel-aware hit testing, logical focus/traversal/repair, semantic key gestures, local/global command routing, pointer-shape preferences and Terminal-owned pointer leases, lifecycle coherence, an interaction acceptance sample, and allocation/adversarial hardening. The T1411-qualified implementation and public API are unchanged. Runtime dependencies are Icod.Terminal 1.13.0 and Icod.TermInfo 1.12.0; AssemblyVersion remains 1.0.0.0. README.md icon.png https://github.com/uniblab/Icod.DCurses From 5dc271372c78c343ff4e4037b91eb1d2e257deaa Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:11:52 -0400 Subject: [PATCH 79/90] Begin T1412 RC and stable closure --- docs/T1412-RC-and-Stable-1.4.0-Closure.md | 126 ++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/T1412-RC-and-Stable-1.4.0-Closure.md diff --git a/docs/T1412-RC-and-Stable-1.4.0-Closure.md b/docs/T1412-RC-and-Stable-1.4.0-Closure.md new file mode 100644 index 000000000..3be26b868 --- /dev/null +++ b/docs/T1412-RC-and-Stable-1.4.0-Closure.md @@ -0,0 +1,126 @@ +# T1412 — RC and Stable 1.4.0 Closure + +**Release:** `Icod.DCurses 1.4.0` +**Tranche:** T1412 +**Published baseline:** `1.3.0` +**Qualified T1411 head:** `570715e0764f9791fe197462a953df6eccf6105a` +**Qualified T1411 workflow:** #797 / `34773668892` — all seven jobs green +**RC identity:** `1.4.0-rc.1` +**AssemblyVersion:** `1.0.0.0` +**Runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` +**Status:** RC candidate qualification active + +--- + +## Entry gate + +T1411 closed on the exact candidate head: + +```text +570715e0764f9791fe197462a953df6eccf6105a +``` + +Workflow #797 / `34773668892` passed: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +The run restored and qualified the refreshed published dependency graph: + +```text +Icod.Terminal 1.13.0 +Icod.TermInfo 1.12.0 +``` + +The frozen 1.4 public API remained: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` + +T1411 found no public API, ownership, packaging, documentation, licensing, or dependency-boundary regret requiring an implementation/API correction before RC. + +## RC promotion + +The T1411-qualified implementation/API is promoted unchanged to: + +```text +Version 1.4.0-rc.1 +PackageVersion 1.4.0-rc.1 +AssemblyVersion 1.0.0.0 +``` + +The RC promotion may change only release identity, release-facing documentation, and closure evidence. It does not reopen the interaction contract or add features. + +## RC exact-head acceptance gate + +The final RC candidate head must pass the complete seven-job pull-request matrix: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +The package candidate must continue to validate package metadata, symbols, documentation, dependency groups, the frozen multi-target public API fingerprint, and fresh NuGet-only interaction consumption. + +No stable-source promotion occurs until that exact RC head is green. + +## Stable-source promotion + +After the RC exact-head matrix is green, the same accepted implementation/API may be promoted to: + +```text +Version 1.4.0 +PackageVersion 1.4.0 +AssemblyVersion 1.0.0.0 +``` + +The stable-source promotion must not alter the frozen 1.4 implementation or public API. Only stable package identity, stable release notes/status documentation, and this closure record may change. + +The stable-source exact head must then pass the same complete seven-job matrix again. + +## Merge gate + +A green stable-source branch is release-ready source, but merge remains a separate explicit approval step. + +PR #29 must not be merged merely because T1412 qualification succeeds. After the stable-source exact head is fully green, the release state is reported for explicit merge approval. + +After merge, the resulting `main` Release workflow must be qualified before creating/pushing the stable release tag or publishing release artifacts. + +Tagging, GitHub Release creation, and NuGet publication remain separate explicit release actions. + +## Invariants carried through closure + +T1412 does not reopen the 1.4 architecture. Stable closure retains: + +- application-owned event-loop policy; +- bounded interaction regions and gesture registries; +- deterministic panel-aware hit testing and region-local coordinates; +- logical focus distinct from terminal/window-manager focus; +- explicit focus traversal/repair and no automatic mouse focus; +- semantic gestures and command identities; +- structured results rather than callback execution; +- DCurses pointer preferences with Terminal-owned physical pointer leases; +- no hidden terminal I/O or second terminal reader; +- the published 1.3 compatibility floor; +- one frozen 1.4 fingerprint across `net8.0`, `net9.0`, and `net10.0`. + +## Closure ledger + +| Stage | Head | Workflow | Result | +|---|---|---|---| +| T1411 qualified | `570715e0764f9791fe197462a953df6eccf6105a` | #797 / `34773668892` | seven jobs green | +| `1.4.0-rc.1` | pending | pending | qualification active | +| stable-source `1.4.0` | pending | pending | not promoted | +| PR #29 merge | pending explicit approval | — | not merged | +| `main` Release | pending | pending | not run | From f21110e7d1e75bbe89152ef44c2aa51fd6fac63f Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:12:25 -0400 Subject: [PATCH 80/90] Document Icod.DCurses 1.4 RC qualification --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0eb76fef7..b1544d196 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ It sits above `Icod.Terminal` and `Icod.TermInfo`: Current published stable release: `Icod.DCurses 1.3.0`. -`Icod.DCurses 1.4.0` is the active development line in PR #29. T1401-T1410 are complete; T1411 is qualifying the public API, package, documentation, licensing, refreshed dependencies, and stable-release regret gate before RC/stable-source promotion. +`Icod.DCurses 1.4.0` is in T1412 release-candidate qualification in PR #29. T1401-T1411 are complete; the T1411-qualified implementation and public API have been promoted unchanged to `1.4.0-rc.1` for exact-head package/runtime qualification before stable-source promotion. Current source identity: ```text -Version 1.4.0-alpha.1 -PackageVersion 1.4.0-alpha.1 +Version 1.4.0-rc.1 +PackageVersion 1.4.0-rc.1 AssemblyVersion 1.0.0.0 Icod.Terminal 1.13.0 Icod.TermInfo 1.12.0 @@ -37,7 +37,7 @@ Published 1.3 contract: sha256 a655bd85e3c88f5bf38ad0d43a148e3bd06a9e943bbf3e3aa2e21575ffb07424 ``` -Current frozen 1.4 interaction contract: +Frozen 1.4 interaction contract: ```text 62 exported types @@ -55,7 +55,7 @@ Install the current published package selected by your normal NuGet policy: dotnet add package Icod.DCurses ``` -Normal stable package resolution currently selects the published 1.3 line. The 1.4 development branch is being release-qualified and is not yet the stable published package. +Normal stable package resolution currently selects the published 1.3 line. The 1.4 RC source is being release-qualified and is not yet the stable published package. ## Architecture @@ -310,7 +310,7 @@ Local wrappers use Debug configuration. Pull requests use Staging with warnings- Runtime validation covers Windows/Linux/macOS x64 and ARM64; the library/test matrix covers `net8.0`, `net9.0`, and `net10.0`. -Package validation verifies `.nupkg`/`.snupkg`, package/assembly identity, dependency groups derived from project declarations, README/license/icon/repository metadata, XML documentation, portable symbols, and a fresh NuGet-only consumer. The package consumer now compiles and executes both the 1.3 geometry/layout/panel-resize surface and the 1.4 interaction surface directly from the packed artifact: regions, logical focus/traversal, hit testing, semantic gestures and command bindings, routing API presence, pointer-shape vocabulary, and pointer-lease surface. Package validation does not impose hard-coded sibling dependency versions. +Package validation verifies `.nupkg`/`.snupkg`, package/assembly identity, dependency groups derived from project declarations, README/license/icon/repository metadata, XML documentation, portable symbols, and a fresh NuGet-only consumer. The package consumer compiles and executes both the 1.3 geometry/layout/panel-resize surface and the 1.4 interaction surface directly from the packed artifact: regions, logical focus/traversal, hit testing, semantic gestures and command bindings, routing API presence, pointer-shape vocabulary, and pointer-lease surface. Package validation does not impose hard-coded sibling dependency versions. ## Release documentation @@ -323,8 +323,10 @@ Current authorities: - `docs/T1401-Interaction-Contract-and-Public-API-Candidate.md` - `docs/T1409-Interaction-Acceptance-Sample.md` - `docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md` +- `docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md` +- `docs/T1412-RC-and-Stable-1.4.0-Closure.md` -T1411 is the active API/package/documentation/licensing regret gate. Its closure evidence joins this authority list before RC/stable-source promotion. Historical 1.0-1.3 closure records remain compatibility authorities and are not rewritten merely to reflect later development state. +T1411 is complete on head `570715e0764f9791fe197462a953df6eccf6105a`, qualified by workflow #797 / `34773668892` across all seven jobs. T1412 is now qualifying the unchanged implementation/API under RC identity before stable-source promotion. Historical 1.0-1.3 closure records remain compatibility authorities and are not rewritten merely to reflect later development state. ## Authors From 9e6a17f65079d6052db7183a493b98d523387b31 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:18:09 -0400 Subject: [PATCH 81/90] Harden rich-input acceptance timeout --- .../src/CursesRichInputAcceptanceTests.cs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/Icod.DCurses.Tests/src/CursesRichInputAcceptanceTests.cs b/tests/Icod.DCurses.Tests/src/CursesRichInputAcceptanceTests.cs index 740627df8..0cc42be22 100644 --- a/tests/Icod.DCurses.Tests/src/CursesRichInputAcceptanceTests.cs +++ b/tests/Icod.DCurses.Tests/src/CursesRichInputAcceptanceTests.cs @@ -46,28 +46,27 @@ public async Task RichInputFamiliesAndModifiedKeysUseSingleCursesEventStream() { terminalSession, NoPresentationOptions() ); - using CancellationTokenSource timeout = new( TimeSpan.FromSeconds( 5 ) ); - CursesInputEvent focus = ( await session.ReadEventAsync( timeout.Token ) ).Input!; + CursesInputEvent focus = await ReadInputAsync( session ); Assert.Equal( CursesInputEventKind.Focus, focus.Kind ); Assert.Equal( CursesFocusState.Focused, focus.Focus!.State ); - CursesInputEvent pasteBegin = ( await session.ReadEventAsync( timeout.Token ) ).Input!; - CursesInputEvent pasteData = ( await session.ReadEventAsync( timeout.Token ) ).Input!; - CursesInputEvent pasteEnd = ( await session.ReadEventAsync( timeout.Token ) ).Input!; + CursesInputEvent pasteBegin = await ReadInputAsync( session ); + CursesInputEvent pasteData = await ReadInputAsync( session ); + CursesInputEvent pasteEnd = await ReadInputAsync( session ); Assert.Equal( CursesPastePhase.Begin, pasteBegin.Paste!.Phase ); Assert.Equal( CursesPastePhase.Data, pasteData.Paste!.Phase ); Assert.Equal( "hello", pasteData.Paste.Text ); Assert.Equal( CursesPastePhase.End, pasteEnd.Paste!.Phase ); - CursesInputEvent mouse = ( await session.ReadEventAsync( timeout.Token ) ).Input!; + CursesInputEvent mouse = await ReadInputAsync( session ); Assert.Equal( CursesInputEventKind.Mouse, mouse.Kind ); Assert.Equal( CursesMouseAction.Press, mouse.Mouse!.Action ); Assert.Equal( CursesMouseButton.Primary, mouse.Mouse.Button ); Assert.Equal( 2, mouse.Mouse.Column ); Assert.Equal( 3, mouse.Mouse.Row ); - CursesInputEvent modifiedKey = ( await session.ReadEventAsync( timeout.Token ) ).Input!; + CursesInputEvent modifiedKey = await ReadInputAsync( session ); Assert.Equal( CursesInputEventKind.Key, modifiedKey.Kind ); Assert.Equal( CursesKey.Up, modifiedKey.Key ); Assert.Equal( CursesKeyModifiers.Control, modifiedKey.Modifiers ); @@ -138,6 +137,14 @@ await session.AcquireInputProtocolsAsync( Assert.Equal( TerminalControlStatus.Unavailable, result.Status ); } + private static async ValueTask ReadInputAsync( + CursesSession session + ) { + ArgumentNullException.ThrowIfNull( session ); + using CancellationTokenSource timeout = new( TimeSpan.FromSeconds( 5 ) ); + return ( await session.ReadEventAsync( timeout.Token ) ).Input!; + } + private static CursesSessionOptions NoPresentationOptions() { return new CursesSessionOptions { UseAlternateScreen = false, From 7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:18:34 -0400 Subject: [PATCH 82/90] Record RC timeout hardening evidence --- docs/T1412-RC-and-Stable-1.4.0-Closure.md | 48 +++++++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/T1412-RC-and-Stable-1.4.0-Closure.md b/docs/T1412-RC-and-Stable-1.4.0-Closure.md index 3be26b868..4eef14bb6 100644 --- a/docs/T1412-RC-and-Stable-1.4.0-Closure.md +++ b/docs/T1412-RC-and-Stable-1.4.0-Closure.md @@ -8,7 +8,7 @@ **RC identity:** `1.4.0-rc.1` **AssemblyVersion:** `1.0.0.0` **Runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` -**Status:** RC candidate qualification active +**Status:** final RC candidate qualification active after test-only timeout hardening --- @@ -49,7 +49,7 @@ T1411 found no public API, ownership, packaging, documentation, licensing, or de ## RC promotion -The T1411-qualified implementation/API is promoted unchanged to: +The T1411-qualified implementation/API was promoted unchanged to: ```text Version 1.4.0-rc.1 @@ -57,11 +57,41 @@ PackageVersion 1.4.0-rc.1 AssemblyVersion 1.0.0.0 ``` -The RC promotion may change only release identity, release-facing documentation, and closure evidence. It does not reopen the interaction contract or add features. +The RC promotion changed release identity and release-facing documentation only. It did not reopen the interaction contract or add features. -## RC exact-head acceptance gate +## RC Windows ARM64 timeout investigation -The final RC candidate head must pass the complete seven-job pull-request matrix: +The first RC exact-head workflow, #800 / `34773915278`, passed the package candidate plus Windows x64, both Linux architectures, and both macOS architectures. Its initial Windows ARM64 attempt failed one pre-existing acceptance test on `net10.0`: + +```text +Icod.DCurses.Tests.CursesRichInputAcceptanceTests + .RichInputFamiliesAndModifiedKeysUseSingleCursesEventStream + +System.OperationCanceledException +``` + +The test used one five-second `CancellationTokenSource` across a sequence of seven independent `ReadEventAsync(...)` operations. On the failing Windows ARM64/net10 run, the shared absolute budget expired partway through the scripted rich-input sequence. + +Evidence that this was a test-timeout defect rather than an RC implementation regression: + +- the exact T1411 implementation had already passed Windows ARM64 in workflow #797; +- RC promotion changed package identity/documentation, not production interaction/input code; +- the same RC Windows ARM64 job was rerun unchanged and the full test step passed; +- the other five runtime architectures and package candidate passed the original RC attempt. + +The acceptance test was therefore hardened without changing production code or public API: each expected semantic input event now receives its own five-second bounded read timeout instead of sharing one absolute deadline across the entire multi-event sequence. + +Test-only hardening commit: + +```text +9e6a17f65079d6052db7183a493b98d523387b31 +``` + +This correction preserves hang detection while removing dependence on cumulative runner scheduling time. + +## Final RC exact-head acceptance gate + +Because the test suite changed after the initial RC workflow, the final RC candidate head must pass a fresh complete seven-job pull-request matrix: - Package candidate; - Runtime Windows x64; @@ -73,11 +103,11 @@ The final RC candidate head must pass the complete seven-job pull-request matrix The package candidate must continue to validate package metadata, symbols, documentation, dependency groups, the frozen multi-target public API fingerprint, and fresh NuGet-only interaction consumption. -No stable-source promotion occurs until that exact RC head is green. +No stable-source promotion occurs until that exact final RC head is green. ## Stable-source promotion -After the RC exact-head matrix is green, the same accepted implementation/API may be promoted to: +After the final RC exact-head matrix is green, the same accepted implementation/API may be promoted to: ```text Version 1.4.0 @@ -120,7 +150,9 @@ T1412 does not reopen the 1.4 architecture. Stable closure retains: | Stage | Head | Workflow | Result | |---|---|---|---| | T1411 qualified | `570715e0764f9791fe197462a953df6eccf6105a` | #797 / `34773668892` | seven jobs green | -| `1.4.0-rc.1` | pending | pending | qualification active | +| initial `1.4.0-rc.1` | `f21110e7d1e75bbe89152ef44c2aa51fd6fac63f` | #800 / `34773915278` | six original jobs green; Windows ARM64 timed out once, then passed unchanged rerun | +| RC test hardening | `9e6a17f65079d6052db7183a493b98d523387b31` | — | per-read timeout; production/API unchanged | +| final `1.4.0-rc.1` | pending | pending | fresh qualification active | | stable-source `1.4.0` | pending | pending | not promoted | | PR #29 merge | pending explicit approval | — | not merged | | `main` Release | pending | pending | not run | From 96761c8f9621887e0e55b6debcfae155a8f81d00 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:22:02 -0400 Subject: [PATCH 83/90] Promote Icod.DCurses 1.4.0 stable source --- Icod.DCurses.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Icod.DCurses.csproj b/Icod.DCurses.csproj index 8bf308360..2e5422d97 100644 --- a/Icod.DCurses.csproj +++ b/Icod.DCurses.csproj @@ -34,16 +34,16 @@ Icod.DCurses Icod.DCurses Debug;Staging;Release - 1.4.0-rc.1 + 1.4.0 1.0.0.0 - 1.4.0-rc.1 + 1.4.0 Icod.DCurses Icod.DCurses Timothy J. Bruce Managed, cross-platform curses-like terminal UI library for .NET, built on Icod.TermInfo and Icod.Terminal. - 1.4.0-rc.1 qualifies deterministic application-owned interaction routing over the published 1.3 geometry and retained-panel foundation, including bounded interaction regions, panel-aware hit testing, logical focus/traversal/repair, semantic key gestures, local/global command routing, pointer-shape preferences and Terminal-owned pointer leases, lifecycle coherence, an interaction acceptance sample, and allocation/adversarial hardening. The T1411-qualified implementation and public API are unchanged. Runtime dependencies are Icod.Terminal 1.13.0 and Icod.TermInfo 1.12.0; AssemblyVersion remains 1.0.0.0. + 1.4.0 adds deterministic application-owned interaction routing over the published 1.3 geometry and retained-panel foundation, including bounded interaction regions, panel-aware hit testing, logical focus/traversal/repair, semantic key gestures, local/global command routing, pointer-shape preferences and Terminal-owned pointer leases, lifecycle coherence, an interaction acceptance sample, and allocation/adversarial hardening. The stable source retains the T1411/T1412-qualified implementation and frozen public API. Runtime dependencies are Icod.Terminal 1.13.0 and Icod.TermInfo 1.12.0; AssemblyVersion remains 1.0.0.0. README.md icon.png https://github.com/uniblab/Icod.DCurses From 252f82cfd5ab918569e646347766783a442dae64 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:22:35 -0400 Subject: [PATCH 84/90] Document Icod.DCurses 1.4 stable source --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b1544d196..036bb5d62 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ It sits above `Icod.Terminal` and `Icod.TermInfo`: Current published stable release: `Icod.DCurses 1.3.0`. -`Icod.DCurses 1.4.0` is in T1412 release-candidate qualification in PR #29. T1401-T1411 are complete; the T1411-qualified implementation and public API have been promoted unchanged to `1.4.0-rc.1` for exact-head package/runtime qualification before stable-source promotion. +`Icod.DCurses 1.4.0` is complete in stable-source form in PR #29 and is undergoing the final T1412 exact-head package/runtime qualification before merge. The implementation and frozen public API passed T1411 and the final `1.4.0-rc.1` matrix; stable-source promotion changes release identity and release-facing documentation only. Current source identity: ```text -Version 1.4.0-rc.1 -PackageVersion 1.4.0-rc.1 +Version 1.4.0 +PackageVersion 1.4.0 AssemblyVersion 1.0.0.0 Icod.Terminal 1.13.0 Icod.TermInfo 1.12.0 @@ -55,7 +55,7 @@ Install the current published package selected by your normal NuGet policy: dotnet add package Icod.DCurses ``` -Normal stable package resolution currently selects the published 1.3 line. The 1.4 RC source is being release-qualified and is not yet the stable published package. +Normal stable package resolution currently selects the published 1.3 line until 1.4 has been merged, release-qualified on `main`, tagged, and published. ## Architecture @@ -326,7 +326,7 @@ Current authorities: - `docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md` - `docs/T1412-RC-and-Stable-1.4.0-Closure.md` -T1411 is complete on head `570715e0764f9791fe197462a953df6eccf6105a`, qualified by workflow #797 / `34773668892` across all seven jobs. T1412 is now qualifying the unchanged implementation/API under RC identity before stable-source promotion. Historical 1.0-1.3 closure records remain compatibility authorities and are not rewritten merely to reflect later development state. +T1411 is complete on head `570715e0764f9791fe197462a953df6eccf6105a`, qualified by workflow #797 / `34773668892` across all seven jobs. The final RC head `7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f` passed workflow #802 / `34774226736` across all seven jobs after a test-only timeout hardening correction; production code and the frozen public API were unchanged. T1412 is now qualifying stable-source `1.4.0`. Historical 1.0-1.3 closure records remain compatibility authorities and are not rewritten merely to reflect later development state. ## Authors From 9e951cc25ae4ee670971d47d90d53f3a3d1a2bab Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:22:55 -0400 Subject: [PATCH 85/90] Promote T1412 stable-source qualification --- docs/T1412-RC-and-Stable-1.4.0-Closure.md | 54 ++++++++++++++++------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/docs/T1412-RC-and-Stable-1.4.0-Closure.md b/docs/T1412-RC-and-Stable-1.4.0-Closure.md index 4eef14bb6..163f671c8 100644 --- a/docs/T1412-RC-and-Stable-1.4.0-Closure.md +++ b/docs/T1412-RC-and-Stable-1.4.0-Closure.md @@ -5,10 +5,12 @@ **Published baseline:** `1.3.0` **Qualified T1411 head:** `570715e0764f9791fe197462a953df6eccf6105a` **Qualified T1411 workflow:** #797 / `34773668892` — all seven jobs green -**RC identity:** `1.4.0-rc.1` +**Qualified final RC head:** `7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f` +**Qualified final RC workflow:** #802 / `34774226736` — all seven jobs green +**Stable-source identity:** `1.4.0` **AssemblyVersion:** `1.0.0.0` **Runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` -**Status:** final RC candidate qualification active after test-only timeout hardening +**Status:** stable-source exact-head qualification active; merge remains pending explicit approval --- @@ -47,7 +49,7 @@ sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 T1411 found no public API, ownership, packaging, documentation, licensing, or dependency-boundary regret requiring an implementation/API correction before RC. -## RC promotion +## RC promotion and qualification The T1411-qualified implementation/API was promoted unchanged to: @@ -59,7 +61,7 @@ AssemblyVersion 1.0.0.0 The RC promotion changed release identity and release-facing documentation only. It did not reopen the interaction contract or add features. -## RC Windows ARM64 timeout investigation +### Windows ARM64 timeout investigation The first RC exact-head workflow, #800 / `34773915278`, passed the package candidate plus Windows x64, both Linux architectures, and both macOS architectures. Its initial Windows ARM64 attempt failed one pre-existing acceptance test on `net10.0`: @@ -79,7 +81,7 @@ Evidence that this was a test-timeout defect rather than an RC implementation re - the same RC Windows ARM64 job was rerun unchanged and the full test step passed; - the other five runtime architectures and package candidate passed the original RC attempt. -The acceptance test was therefore hardened without changing production code or public API: each expected semantic input event now receives its own five-second bounded read timeout instead of sharing one absolute deadline across the entire multi-event sequence. +The acceptance test was hardened without changing production code or public API: each expected semantic input event now receives its own five-second bounded read timeout instead of sharing one absolute deadline across the entire multi-event sequence. Test-only hardening commit: @@ -87,11 +89,17 @@ Test-only hardening commit: 9e6a17f65079d6052db7183a493b98d523387b31 ``` -This correction preserves hang detection while removing dependence on cumulative runner scheduling time. +This preserves hang detection while removing dependence on cumulative runner scheduling time. -## Final RC exact-head acceptance gate +### Final RC gate -Because the test suite changed after the initial RC workflow, the final RC candidate head must pass a fresh complete seven-job pull-request matrix: +The final documented RC head was: + +```text +7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f +``` + +Workflow #802 / `34774226736` passed the complete seven-job matrix: - Package candidate; - Runtime Windows x64; @@ -101,13 +109,11 @@ Because the test suite changed after the initial RC workflow, the final RC candi - Runtime macOS x64; - Runtime macOS ARM64. -The package candidate must continue to validate package metadata, symbols, documentation, dependency groups, the frozen multi-target public API fingerprint, and fresh NuGet-only interaction consumption. - -No stable-source promotion occurs until that exact final RC head is green. +The hardened Windows ARM64 acceptance suite passed under the same final RC exact head. No production implementation or public API correction was required. ## Stable-source promotion -After the final RC exact-head matrix is green, the same accepted implementation/API may be promoted to: +The qualified final RC implementation/API is now promoted unchanged to: ```text Version 1.4.0 @@ -115,9 +121,23 @@ PackageVersion 1.4.0 AssemblyVersion 1.0.0.0 ``` -The stable-source promotion must not alter the frozen 1.4 implementation or public API. Only stable package identity, stable release notes/status documentation, and this closure record may change. +Stable-source promotion changes stable package identity, stable release notes/status documentation, and closure evidence only. The production interaction implementation and frozen 1.4 public API remain unchanged from the qualified RC. + +## Stable-source exact-head acceptance gate + +The stable-source exact head must pass the complete seven-job pull-request matrix again: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +The package candidate must continue to validate package metadata, symbols, documentation, dependency groups, the frozen multi-target public API fingerprint, and fresh NuGet-only interaction consumption. -The stable-source exact head must then pass the same complete seven-job matrix again. +Stable-source is release-ready only after that exact head is green. ## Merge gate @@ -150,9 +170,9 @@ T1412 does not reopen the 1.4 architecture. Stable closure retains: | Stage | Head | Workflow | Result | |---|---|---|---| | T1411 qualified | `570715e0764f9791fe197462a953df6eccf6105a` | #797 / `34773668892` | seven jobs green | -| initial `1.4.0-rc.1` | `f21110e7d1e75bbe89152ef44c2aa51fd6fac63f` | #800 / `34773915278` | six original jobs green; Windows ARM64 timed out once, then passed unchanged rerun | +| initial `1.4.0-rc.1` | `f21110e7d1e75bbe89152ef44c2aa51fd6fac63f` | #800 / `34773915278` | Windows ARM64 timed out once; unchanged rerun passed | | RC test hardening | `9e6a17f65079d6052db7183a493b98d523387b31` | — | per-read timeout; production/API unchanged | -| final `1.4.0-rc.1` | pending | pending | fresh qualification active | -| stable-source `1.4.0` | pending | pending | not promoted | +| final `1.4.0-rc.1` | `7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f` | #802 / `34774226736` | seven jobs green | +| stable-source `1.4.0` | pending exact final head | pending | qualification active | | PR #29 merge | pending explicit approval | — | not merged | | `main` Release | pending | pending | not run | From e33dea3bcc721f5961c73ae21d085510da507428 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:24:41 -0400 Subject: [PATCH 86/90] Update development roadmap for 1.4 stable source --- Icod.DCurses-Development-Roadmap.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Icod.DCurses-Development-Roadmap.md b/Icod.DCurses-Development-Roadmap.md index 86d5d4996..19f4b5a95 100644 --- a/Icod.DCurses-Development-Roadmap.md +++ b/Icod.DCurses-Development-Roadmap.md @@ -9,7 +9,7 @@ **Target frameworks:** `net8.0`; `net9.0`; `net10.0` **Configurations:** `Debug`; `Staging`; `Release` **Active development target:** `1.4.0` — deterministic interaction routing, focus, gestures, hit testing, and pointer semantics -**Status:** T1401-T1410 complete; T1411 API/package/documentation/licensing/dependency qualification is active; T1412 RC/stable-source closure follows +**Status:** T1401-T1411 complete; T1412 stable-source exact-head qualification is active; merge remains pending explicit approval --- @@ -22,8 +22,10 @@ - `docs/T1401-Interaction-Contract-and-Public-API-Candidate.md` - `docs/T1409-Interaction-Acceptance-Sample.md` - `docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md` +- `docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md` +- `docs/T1412-RC-and-Stable-1.4.0-Closure.md` -T1411 is the active stable-release regret gate. Its closure evidence becomes a current authority before RC promotion. The 1.0-1.3 tranche and closure documents remain historical compatibility/release authorities and are not rewritten to simulate current development state. +T1411 is complete. The final `1.4.0-rc.1` head passed the full seven-job matrix, and T1412 has promoted the unchanged accepted implementation/API to stable-source `1.4.0`. The stable-source exact head must pass the same matrix before explicit merge approval. The 1.0-1.3 tranche and closure documents remain historical compatibility/release authorities and are not rewritten to simulate current development state. ## Release train @@ -33,7 +35,7 @@ T1411 is the active stable-release regret gate. Its closure evidence becomes a c | `1.1.0` | Semantic metadata and hyperlinks | Historical stable baseline | | `1.2.0` | Panels/layers/z-order composition | Historical stable baseline | | `1.3.0` | Layout and resize primitives | Current published stable release | -| `1.4.0` | Interaction routing/focus/gestures/hit testing/pointer semantics | T1411 release qualification active | +| `1.4.0` | Interaction routing/focus/gestures/hit testing/pointer semantics | Stable-source qualification active | The progression is intentionally cumulative: @@ -131,8 +133,8 @@ T1407 pointer-shape abstraction and Terminal-owned lease integration T1408 resize / panel / lifecycle coherence complete T1409 application acceptance sample complete T1410 hardening / performance / allocation / adversarial acceptance complete -T1411 public API / package / docs / licensing / dependency regret gate active -T1412 RC and stable-source closure pending +T1411 public API / package / docs / licensing / dependency regret gate complete +T1412 RC and stable-source closure active ``` Every implementation tranche must receive exact-head Staging qualification before being called complete. The final release retains package-only consumer validation, compiler-derived public API fingerprinting, Windows/Linux/macOS x64/ARM64 coverage, and `net8.0`/`net9.0`/`net10.0` validation. @@ -160,4 +162,4 @@ A future widget package should be able to build on the 1.4 mechanisms without by ## Immediate next step -T1411 is the active gate. Complete the public API/package/documentation/licensing regret review against the published 1.3 floor and qualify the refreshed `Icod.Terminal 1.13.0` / `Icod.TermInfo 1.12.0` dependency graph through the normal package/runtime matrix. Only then promote the unchanged accepted implementation/API to T1412 RC/stable-source closure. +T1412 is the active gate. Qualify the stable-source `1.4.0` exact head through the normal package candidate plus Windows/Linux/macOS x64/ARM64 matrix. If that exact head is green, the branch is release-ready source and PR #29 may be presented for explicit merge approval. After merge, qualify the resulting `main` Release build before tagging or publishing. From 52f35dfad400d7123cd0d9166d5674bf1eac3425 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:25:00 -0400 Subject: [PATCH 87/90] Update release train for 1.4 stable source --- Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md b/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md index 36c3321c7..59ede9b46 100644 --- a/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md +++ b/Icod.DCurses-1.1.0-to-1.4.0-Development-Roadmap.md @@ -6,7 +6,7 @@ **Current published package:** `1.3.0` **Assembly version policy:** retain `1.0.0.0` through compatible additive 1.x releases **Current declared runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` -**Planning status:** 1.3 is complete/published; 1.4 has completed T1401-T1410 and is in T1411 release qualification +**Planning status:** 1.3 is complete/published; 1.4 has completed T1401-T1411 and is in T1412 stable-source qualification --- @@ -16,7 +16,7 @@ 1.1.0 semantic cell metadata + hyperlinks complete/published history 1.2.0 panels/layers + z-order composition complete/published history 1.3.0 layout + resize primitives complete/published -1.4.0 interaction routing/focus/gestures/hit testing T1411 release qualification active +1.4.0 interaction routing/focus/gestures/hit testing stable-source qualification active ``` The sequence is cumulative: 1.1 adds meaning to retained content; 1.2 composes overlapping retained surfaces; 1.3 makes geometry manageable; 1.4 routes semantic input to logical application regions. @@ -81,6 +81,8 @@ The detailed authorities are: - `docs/Public-API-Fingerprint-1.4.json` - `docs/T1409-Interaction-Acceptance-Sample.md` - `docs/T1410-Interaction-Performance-Allocation-and-Adversarial-Hardening.md` +- `docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md` +- `docs/T1412-RC-and-Stable-1.4.0-Closure.md` ## 1.4 key policy decisions @@ -109,8 +111,8 @@ T1407 pointer shape integration complete T1408 resize / panel / lifecycle coherence complete T1409 application acceptance sample complete T1410 performance / allocation / hardening complete -T1411 API / package / docs / licensing / dependency gate active -T1412 RC / stable-source closure pending +T1411 API / package / docs / licensing / dependency gate complete +T1412 RC / stable-source closure active ``` ## Cross-release rules @@ -125,4 +127,4 @@ Version 1.4 does not add a widget/control library, retained widget hierarchy, ev ## Immediate next step -T1411 is the active release gate. Finish the API/package/documentation/licensing regret review and qualify the refreshed `Icod.Terminal 1.13.0` / `Icod.TermInfo 1.12.0` dependency graph through the normal package/runtime matrix. T1412 then promotes the accepted implementation through RC and stable-source qualification before merge. +T1412 is the active release gate. The final `1.4.0-rc.1` head has passed the full seven-job package/runtime matrix and the unchanged accepted implementation/API has been promoted to stable-source `1.4.0`. Qualify the stable-source exact head through the same matrix, then present PR #29 for explicit merge approval. After merge, qualify the resulting `main` Release workflow before tagging, GitHub Release creation, or NuGet publication. From 571af7e1904eb20233ec4fa66c6b76d86478a3b7 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:25:42 -0400 Subject: [PATCH 88/90] Update 1.4 roadmap for stable-source qualification --- Icod.DCurses-1.4.0-Development-Roadmap.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Icod.DCurses-1.4.0-Development-Roadmap.md b/Icod.DCurses-1.4.0-Development-Roadmap.md index e6538528d..4a885dfaf 100644 --- a/Icod.DCurses-1.4.0-Development-Roadmap.md +++ b/Icod.DCurses-1.4.0-Development-Roadmap.md @@ -6,13 +6,13 @@ **Published baseline:** `1.3.0` **Baseline tag:** `v1.3.0` **Baseline commit:** `c10ca043a666b85225f2d3b8955a1ac2075b0d31` -**Current development package:** `1.4.0-alpha.1` +**Current development package:** `1.4.0` **Final target package:** `1.4.0` **Assembly version policy:** retain `1.0.0.0` for compatible additive 1.x development **Baseline runtime dependencies:** `Icod.Terminal 1.11.1`; `Icod.TermInfo 1.11.0` **Current declared runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` **Theme:** deterministic interaction routing over semantic input, immutable geometry, retained panels, and Terminal-owned protocol state -**Status:** T1401-T1410 complete; T1411 API/package/documentation/licensing/dependency qualification is active; T1412 follows +**Status:** T1401-T1411 complete; final `1.4.0-rc.1` exact head qualified; T1412 stable-source exact-head qualification is active; merge remains pending explicit approval --- @@ -496,9 +496,11 @@ Version 1.4 does not provide: ## Current release state -T1401-T1410 are complete. T1411 is the active release gate and must close the public API/package/documentation/licensing regret review and qualify the refreshed `Icod.Terminal 1.13.0` / `Icod.TermInfo 1.12.0` dependency graph before RC promotion. +T1401-T1411 are complete. T1411 closed on head `570715e0764f9791fe197462a953df6eccf6105a` with workflow #797 / `34773668892` green across all seven package/runtime jobs and the refreshed `Icod.Terminal 1.13.0` / `Icod.TermInfo 1.12.0` dependency graph. -T1412 then promotes the accepted implementation/API through `1.4.0-rc.1` and stable-source `1.4.0` with exact-head package/runtime qualification. Merge, `main` Release qualification, tagging, GitHub Release creation, and NuGet publication remain separate explicit actions. +The final `1.4.0-rc.1` head `7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f` passed workflow #802 / `34774226736` across all seven jobs after a test-only rich-input timeout hardening correction. Production code and the frozen public API were unchanged. + +T1412 has promoted that accepted implementation/API to stable-source `1.4.0`. The stable-source exact head must now pass the full package candidate plus Windows/Linux/macOS x64/ARM64 matrix. If green, PR #29 is release-ready source and may be presented for explicit merge approval. Merge, `main` Release qualification, tagging, GitHub Release creation, and NuGet publication remain separate explicit actions. ## Definition of done From 76a48111c680afa061de4ef046ed22e16ff70034 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:29:49 -0400 Subject: [PATCH 89/90] Close 1.4 stable-source documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 036bb5d62..1f4459ece 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It sits above `Icod.Terminal` and `Icod.TermInfo`: Current published stable release: `Icod.DCurses 1.3.0`. -`Icod.DCurses 1.4.0` is complete in stable-source form in PR #29 and is undergoing the final T1412 exact-head package/runtime qualification before merge. The implementation and frozen public API passed T1411 and the final `1.4.0-rc.1` matrix; stable-source promotion changes release identity and release-facing documentation only. +`Icod.DCurses 1.4.0` is complete in stable-source form in PR #29. T1401-T1412 are complete, the final RC exact head passed the full seven-job matrix, and stable-source head `571af7e1904eb20233ec4fa66c6b76d86478a3b7` passed workflow #808 / `34774590867` across the package candidate plus Windows/Linux/macOS x64/ARM64. The branch is release-ready source pending explicit merge approval. After merge, the resulting `main` Release workflow must pass before tagging or publication. Current source identity: @@ -326,7 +326,7 @@ Current authorities: - `docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md` - `docs/T1412-RC-and-Stable-1.4.0-Closure.md` -T1411 is complete on head `570715e0764f9791fe197462a953df6eccf6105a`, qualified by workflow #797 / `34773668892` across all seven jobs. The final RC head `7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f` passed workflow #802 / `34774226736` across all seven jobs after a test-only timeout hardening correction; production code and the frozen public API were unchanged. T1412 is now qualifying stable-source `1.4.0`. Historical 1.0-1.3 closure records remain compatibility authorities and are not rewritten merely to reflect later development state. +T1411 is complete on head `570715e0764f9791fe197462a953df6eccf6105a`, qualified by workflow #797 / `34773668892` across all seven jobs. The final RC head `7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f` passed workflow #802 / `34774226736` across all seven jobs after a test-only timeout hardening correction; production code and the frozen public API were unchanged. Stable-source head `571af7e1904eb20233ec4fa66c6b76d86478a3b7` then passed workflow #808 / `34774590867` across all seven jobs. T1412 is complete and PR #29 is release-ready source pending explicit merge approval. Historical 1.0-1.3 closure records remain compatibility authorities and are not rewritten merely to reflect later development state. ## Authors From 267f633e323f732ed041735210a86eda608a4697 Mon Sep 17 00:00:00 2001 From: Timothy Bruce Date: Sun, 13 Sep 2026 14:30:10 -0400 Subject: [PATCH 90/90] Close T1412 stable-source qualification --- docs/T1412-RC-and-Stable-1.4.0-Closure.md | 32 +++++++++++++---------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/T1412-RC-and-Stable-1.4.0-Closure.md b/docs/T1412-RC-and-Stable-1.4.0-Closure.md index 163f671c8..a62c8ebde 100644 --- a/docs/T1412-RC-and-Stable-1.4.0-Closure.md +++ b/docs/T1412-RC-and-Stable-1.4.0-Closure.md @@ -7,10 +7,12 @@ **Qualified T1411 workflow:** #797 / `34773668892` — all seven jobs green **Qualified final RC head:** `7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f` **Qualified final RC workflow:** #802 / `34774226736` — all seven jobs green +**Qualified stable-source head:** `571af7e1904eb20233ec4fa66c6b76d86478a3b7` +**Qualified stable-source workflow:** #808 / `34774590867` — all seven jobs green **Stable-source identity:** `1.4.0` **AssemblyVersion:** `1.0.0.0` **Runtime dependencies:** `Icod.Terminal 1.13.0`; `Icod.TermInfo 1.12.0` -**Status:** stable-source exact-head qualification active; merge remains pending explicit approval +**Status:** T1412 complete; release-ready source pending explicit PR #29 merge approval --- @@ -111,9 +113,9 @@ Workflow #802 / `34774226736` passed the complete seven-job matrix: The hardened Windows ARM64 acceptance suite passed under the same final RC exact head. No production implementation or public API correction was required. -## Stable-source promotion +## Stable-source promotion and qualification -The qualified final RC implementation/API is now promoted unchanged to: +The qualified final RC implementation/API was promoted unchanged to: ```text Version 1.4.0 @@ -121,11 +123,15 @@ PackageVersion 1.4.0 AssemblyVersion 1.0.0.0 ``` -Stable-source promotion changes stable package identity, stable release notes/status documentation, and closure evidence only. The production interaction implementation and frozen 1.4 public API remain unchanged from the qualified RC. +Stable-source promotion changed stable package identity, stable release notes/status documentation, and closure evidence only. The production interaction implementation and frozen 1.4 public API remained unchanged from the qualified RC. -## Stable-source exact-head acceptance gate +The stable-source candidate was finalized at: -The stable-source exact head must pass the complete seven-job pull-request matrix again: +```text +571af7e1904eb20233ec4fa66c6b76d86478a3b7 +``` + +Workflow #808 / `34774590867` passed the complete seven-job matrix: - Package candidate; - Runtime Windows x64; @@ -135,17 +141,15 @@ The stable-source exact head must pass the complete seven-job pull-request matri - Runtime macOS x64; - Runtime macOS ARM64. -The package candidate must continue to validate package metadata, symbols, documentation, dependency groups, the frozen multi-target public API fingerprint, and fresh NuGet-only interaction consumption. +That qualification proves stable package identity and metadata, portable symbols/documentation, the refreshed dependency graph, the frozen multi-target public API fingerprint, fresh NuGet-only interaction consumption, and the Windows/Linux/macOS x64/ARM64 runtime matrix. -Stable-source is release-ready only after that exact head is green. +Subsequent closure-only documentation edits do not reopen the implementation/API decision, but the current PR head remains subject to the repository's ordinary required checks before merge. ## Merge gate -A green stable-source branch is release-ready source, but merge remains a separate explicit approval step. - -PR #29 must not be merged merely because T1412 qualification succeeds. After the stable-source exact head is fully green, the release state is reported for explicit merge approval. +The branch is release-ready source. Merge remains a separate explicit approval step. -After merge, the resulting `main` Release workflow must be qualified before creating/pushing the stable release tag or publishing release artifacts. +PR #29 must not be merged without explicit user approval. After merge, the resulting `main` Release workflow must be qualified before creating/pushing the stable release tag or publishing release artifacts. Tagging, GitHub Release creation, and NuGet publication remain separate explicit release actions. @@ -173,6 +177,6 @@ T1412 does not reopen the 1.4 architecture. Stable closure retains: | initial `1.4.0-rc.1` | `f21110e7d1e75bbe89152ef44c2aa51fd6fac63f` | #800 / `34773915278` | Windows ARM64 timed out once; unchanged rerun passed | | RC test hardening | `9e6a17f65079d6052db7183a493b98d523387b31` | — | per-read timeout; production/API unchanged | | final `1.4.0-rc.1` | `7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f` | #802 / `34774226736` | seven jobs green | -| stable-source `1.4.0` | pending exact final head | pending | qualification active | +| stable-source `1.4.0` | `571af7e1904eb20233ec4fa66c6b76d86478a3b7` | #808 / `34774590867` | seven jobs green | | PR #29 merge | pending explicit approval | — | not merged | -| `main` Release | pending | pending | not run | +| `main` Release | pending post-merge | pending | not run |