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..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 @@ -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 +**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-T1411 and is in T1412 stable-source qualification --- @@ -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 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 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 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. -```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,83 @@ 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. +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 -The accepted 1.3 design provides: +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. -- 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 implemented surface provides: -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. +- bounded screen-bound interaction-region registration; +- screen-relative and panel-associated hit targets; +- 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; +- 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 stable promotion. -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. +The detailed authorities are: -## Planned 1.4 focus and interaction mechanics +- `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` +- `docs/T1411-Public-API-Package-Documentation-and-Regret-Gate.md` +- `docs/T1412-RC-and-Stable-1.4.0-Closure.md` -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. +## 1.4 key policy decisions -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. +The approved direction deliberately keeps rendering and interaction separate. -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. +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. + +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. + +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 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 complete +T1412 RC / stable-source closure active +``` ## Cross-release rules @@ -97,6 +121,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. +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. 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..4a885dfaf --- /dev/null +++ b/Icod.DCurses-1.4.0-Development-Roadmap.md @@ -0,0 +1,516 @@ +# 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` +**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-T1411 complete; final `1.4.0-rc.1` exact head qualified; T1412 stable-source exact-head qualification is active; merge remains pending explicit approval + +--- + +## 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 +``` + +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 + +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. + +## T1401 design-time public API concepts + +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 +CursesInteractionRegion +CursesInteractionRegionOptions +CursesInteractionResult +CursesInteractionTarget +CursesFocusDirection +CursesKeyGesture +CursesCommand / CursesCommandId +CursesPointerShape +CursesPointerShapeLease +``` + +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 + +### 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. + +## Current release state + +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. + +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 + +`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..19f4b5a95 100644 --- a/Icod.DCurses-Development-Roadmap.md +++ b/Icod.DCurses-Development-Roadmap.md @@ -2,28 +2,30 @@ **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` +**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` -**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:** T1401-T1411 complete; T1412 stable-source exact-head qualification is active; merge remains pending explicit approval --- ## Current authorities - `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` +- `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/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` -Historical 1.0-1.2 tranche records remain historical compatibility 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 @@ -31,21 +33,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 | Stable-source qualification active | -## 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 +54,112 @@ 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: + +```text +c10ca043a666b85225f2d3b8955a1ac2075b0d31 +``` -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. +Current 1.4 interaction fingerprint: + +```text +62 exported types +491 canonical declared contract lines +sha256 8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147 +``` -## 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` adds 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 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 are bounded and deterministic. +- Existing single-writer expectations remain unless a tranche explicitly proves a safe additive concurrency contract. + +## 1.4 tranche 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 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 complete +T1412 RC and stable-source closure active ``` -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 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 does 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 + +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. diff --git a/Icod.DCurses.csproj b/Icod.DCurses.csproj index 6d38e60aa..2e5422d97 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 1.0.0.0 - 1.3.0 + 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.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 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 @@ -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 @@ -116,7 +116,7 @@ - - + + 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 diff --git a/README.md b/README.md index a18483edc..1f4459ece 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 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: ```text -Version 1.3.0 -PackageVersion 1.3.0 +Version 1.4.0 +PackageVersion 1.4.0 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: +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 until 1.4 has been merged, release-qualified on `main`, tagged, and published. ## 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,62 @@ 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 + && "focus.next" == routed.Command.Name ) { + _ = router.MoveFocus( CursesFocusDirection.Forward ); + } + + // 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 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. + +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 +295,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 +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 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 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 +318,15 @@ 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` +- `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. 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 diff --git a/docs/Public-API-Fingerprint-1.4.json b/docs/Public-API-Fingerprint-1.4.json new file mode 100644 index 000000000..deae0bcd8 --- /dev/null +++ b/docs/Public-API-Fingerprint-1.4.json @@ -0,0 +1,72 @@ +{ + "schema": 1, + "release": "1.4.0-alpha.1", + "status": "alpha", + "sha256": "8afe72deaa5354ee072de8ae17b04d8a1a0a8f730d5e3a737b4a47a539379147", + "exportedTypeCount": 62, + "contractLineCount": 491, + "exportedTypes": [ + "Icod.DCurses.CursesAlertKind", + "Icod.DCurses.CursesAmbiguousWidthPolicy", + "Icod.DCurses.CursesCell", + "Icod.DCurses.CursesCellMetadata", + "Icod.DCurses.CursesColor", + "Icod.DCurses.CursesColorKind", + "Icod.DCurses.CursesCommand", + "Icod.DCurses.CursesCursorVisibility", + "Icod.DCurses.CursesDockEdge", + "Icod.DCurses.CursesEvent", + "Icod.DCurses.CursesEventKind", + "Icod.DCurses.CursesFocusDirection", + "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.CursesInteractionHit", + "Icod.DCurses.CursesInteractionRegion", + "Icod.DCurses.CursesInteractionRegionOptions", + "Icod.DCurses.CursesInteractionResult", + "Icod.DCurses.CursesInteractionResultKind", + "Icod.DCurses.CursesInteractionRouter", + "Icod.DCurses.CursesKey", + "Icod.DCurses.CursesKeyEventPhase", + "Icod.DCurses.CursesKeyGesture", + "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.CursesPointerShape", + "Icod.DCurses.CursesPointerShapeLease", + "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" + ] +} 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 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. 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. 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. 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. 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. 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. 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. 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. 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. 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`. 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..a62c8ebde --- /dev/null +++ b/docs/T1412-RC-and-Stable-1.4.0-Closure.md @@ -0,0 +1,182 @@ +# 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 +**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:** T1412 complete; release-ready source pending explicit PR #29 merge approval + +--- + +## 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 and qualification + +The T1411-qualified implementation/API was promoted unchanged to: + +```text +Version 1.4.0-rc.1 +PackageVersion 1.4.0-rc.1 +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. + +### 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`: + +```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 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 preserves hang detection while removing dependence on cumulative runner scheduling time. + +### Final RC gate + +The final documented RC head was: + +```text +7f6bcedf70b9cd5cd15bf2a2a53437e23dac3c2f +``` + +Workflow #802 / `34774226736` passed the complete seven-job matrix: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +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 and qualification + +The qualified final RC implementation/API was promoted unchanged to: + +```text +Version 1.4.0 +PackageVersion 1.4.0 +AssemblyVersion 1.0.0.0 +``` + +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. + +The stable-source candidate was finalized at: + +```text +571af7e1904eb20233ec4fa66c6b76d86478a3b7 +``` + +Workflow #808 / `34774590867` passed the complete seven-job matrix: + +- Package candidate; +- Runtime Windows x64; +- Runtime Windows ARM64; +- Runtime Linux x64; +- Runtime Linux ARM64; +- Runtime macOS x64; +- Runtime macOS ARM64. + +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. + +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 + +The branch is release-ready source. Merge remains a separate explicit approval step. + +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. + +## 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 | +| 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` | `571af7e1904eb20233ec4fa66c6b76d86478a3b7` | #808 / `34774590867` | seven jobs green | +| PR #29 merge | pending explicit approval | — | not merged | +| `main` Release | pending post-merge | pending | not run | 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..ea354d62c --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-icod-dcurses-t1409-interaction-sample.md @@ -0,0 +1,150 @@ +# 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 demonstrating focus traversal, local/global commands, mouse hit testing, panel precedence, explicit pointer-shape leases, resize relayout, and terminal-focus/logical-focus independence. + +**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`; 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 + +- 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: 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` (temporary compile placeholder) +- Modify: `Icod.DCurses.sln` + +**Interfaces:** +- 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; + +await using CursesSession session = await CursesSession.OpenAsync(); +return 0; +``` + +- [ ] 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: 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 consumed:** +- `CursesInteractionRouter`, `CursesInteractionRegionOptions`, `BindGlobalGesture`, `BindGesture`, `Route`, `Focus`, `MoveFocus`. +- `CursesKeyGesture.ForKey`, `.ForCharacter`, `.ForFunctionKey`. +- `CursesSession.AcquireInputProtocolsAsync`, `.AcquirePointerShapeAsync`, `.SynchronizeDimensions`, `.Invalidate`, `.RefreshAsync`, `.ReadEventAsync`. + +- [ ] 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 +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; } +} +``` + +- [ ] 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 +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: 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` + +- [ ] 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. 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. 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. 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..b4af72a62 --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-icod-dcurses-t1409-interaction-sample-design.md @@ -0,0 +1,397 @@ +# 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 shows 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 minimum supported geometry is **64 columns x 16 rows**. + +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: + +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 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.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: + +1. call `session.SynchronizeDimensions()`; +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. 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. + +## 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 exactly these interaction regions: + +- header/status region — non-focusable; +- left pane region — focusable; +- right pane region — focusable; +- footer/help region — non-focusable; +- popup panel-associated region — focusable. + +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. + +## 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 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 mutate logical focus. The sample intentionally leaves click-to-focus absent so the distinction between target routing and focus policy remains visible. + +## Command vocabulary and bindings + +The sample uses semantic `CursesCommand` identities rather than callbacks registered in the router. + +The sample-private command names are frozen as: + +```text +focus.next +focus.previous +popup.toggle +escape +left.action +right.action +global.x +quit +``` + +Bindings are frozen as follows: + +- 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`. + +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`. + +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 + +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 displays 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 + +Pointer preferences are frozen as: + +- 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. + +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 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. + +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. + +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. + +Escape behaves as follows: + +- when the popup is visible, hide it and keep the application running; +- when the popup is hidden, exit the application. + +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 one small sample-local application-state type containing only: + +- `Running`; +- `PopupVisible`; +- 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` lifecycle events: + +- call `SynchronizeDimensions()`; +- recompute application layout explicitly; +- apply window/panel/region bounds; +- call `session.Invalidate()` before the next refresh. + +For normalized terminal Focused/Unfocused input reports: + +- 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. + +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 remains pedagogical rather than wrapping every call in broad exception handling. + +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 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 + +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 +``` + +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. + +## 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 **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 must demonstrate all of the following in one session: + +- left/right logical focus is visibly distinguishable; +- Tab and Shift+Tab traverse focus deterministically; +- 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 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 + +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. 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 + + + + + + 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; + } +} diff --git a/samples/Icod.DCurses.Interaction.Sample/Program.cs b/samples/Icod.DCurses.Interaction.Sample/Program.cs new file mode 100644 index 000000000..aa546b6af --- /dev/null +++ b/samples/Icod.DCurses.Interaction.Sample/Program.cs @@ -0,0 +1,836 @@ +/* + 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 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 + ); +} diff --git a/samples/README.md b/samples/README.md index 96fc68d0b..5ac32636f 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,8 +1,22 @@ # 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`. +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 @@ -43,6 +57,35 @@ 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. + +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 +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. 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; + } +} 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/CursesInteractionHit.cs b/src/CursesInteractionHit.cs new file mode 100644 index 000000000..94ecc5dc1 --- /dev/null +++ b/src/CursesInteractionHit.cs @@ -0,0 +1,64 @@ +/* + 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; + this.PointerShape = region.PointerShape; + } + + /// 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; + } + + /// Gets the semantic pointer-shape preference captured when this hit was resolved. + public CursesPointerShape? PointerShape { + get; + } +} 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 + ); + } + } +} diff --git a/src/CursesInteractionRegion.cs b/src/CursesInteractionRegion.cs new file mode 100644 index 000000000..9e6df01db --- /dev/null +++ b/src/CursesInteractionRegion.cs @@ -0,0 +1,168 @@ +/* + 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 partial class CursesInteractionRegion : IDisposable { + private readonly CursesInteractionRouter owner; + private CursesRectangle bounds; + private bool isEnabled; + private bool isFocusable; + private int traversalOrder; + private int hitTestPriority; + private CursesPointerShape? pointerShape; + 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 ) ); + } + if ( options.PointerShape.HasValue + && !Enum.IsDefined( options.PointerShape.Value ) ) { + throw new ArgumentOutOfRangeException( nameof( options.PointerShape ) ); + } + + 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.pointerShape = options.PointerShape; + 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(); + if ( this.isEnabled == value ) { + return; + } + + this.isEnabled = value; + this.owner.HandleRegionEligibilityChanged( this ); + } + } + + /// Gets or sets whether this region participates in logical focus traversal. + public bool IsFocusable { + get => this.isFocusable; + set { + this.ThrowIfDisposed(); + if ( this.isFocusable == value ) { + return; + } + + this.isFocusable = value; + this.owner.HandleRegionEligibilityChanged( this ); + } + } + + /// 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; + } + } + + /// 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( + 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. + 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 ) ); + } + } +} diff --git a/src/CursesInteractionRegionOptions.cs b/src/CursesInteractionRegionOptions.cs new file mode 100644 index 000000000..eba63ec3f --- /dev/null +++ b/src/CursesInteractionRegionOptions.cs @@ -0,0 +1,78 @@ +/* + 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; + } + + /// Gets the optional semantic pointer-shape preference reported by hit testing. + public CursesPointerShape? PointerShape { + get; + init; + } +} 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 + ); + } +} 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 +} 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 + ); + } + } +} diff --git a/src/CursesInteractionRouter.cs b/src/CursesInteractionRouter.cs new file mode 100644 index 000000000..f7ec96525 --- /dev/null +++ b/src/CursesInteractionRouter.cs @@ -0,0 +1,607 @@ +/* + 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 partial 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 CursesInteractionRegion? focusedRegion; + 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; + } + + /// 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. + 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; + } + + /// 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. + /// 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 ) { + return; + } + + this.disposed = true; + this.focusedRegion = null; + CursesInteractionRegion[] snapshot = this.regions.ToArray(); + foreach ( CursesInteractionRegion region in snapshot ) { + region.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 + ) { + ArgumentNullException.ThrowIfNull( region ); + if ( !ReferenceEquals( + region.Owner, + this + ) ) { + throw new ArgumentException( + "The interaction region belongs to another router.", + nameof( 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( + 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 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, + 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/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 ) ); + } + } +} 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 +} 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(); + } +} 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/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 ) ) + }; + } +} 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() { 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 @@ - + + 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(); + } + } + } +} 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(); + } + } +} 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 + } + ); + } +} 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 ); + } + ); + } +} 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; + } +} 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; + } +} diff --git a/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs b/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs new file mode 100644 index 000000000..c62fabaff --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/CursesInteractionRegionRegistryTests.cs @@ -0,0 +1,279 @@ +/* + 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; + } + ); + } +} 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 + } + ); + } +} 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 ) ); + } +} 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(); + } + } +} 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, diff --git a/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs b/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs new file mode 100644 index 000000000..9194be372 --- /dev/null +++ b/tests/Icod.DCurses.Tests/src/InteractionSampleProjectContractTests.cs @@ -0,0 +1,189 @@ +/* + 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 + ); + } + + [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 + ); + } + } + + [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 ) { + if ( File.Exists( + Path.Combine( + current.FullName, + "Icod.DCurses.sln" + ) + ) ) { + return current.FullName; + } + current = current.Parent; + } + + throw new InvalidOperationException( "Repository root not found." ); + } +} diff --git a/tools/package-smoke/Program.cs b/tools/package-smoke/Program.cs index 528a67ddc..0e85a3b9c 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,178 @@ 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." + ); + } + + Func routeCompiler = + CompileRoutingSurface; + Func> pointerCompiler = + CompilePointerShapeSurfaceAsync; + _ = routeCompiler; + _ = pointerCompiler; +} + +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 {