client: drive App through felis-owned seams so its state machines are testable without winit #234

Merged
natsukium merged 4 commits from client/app-host-seam into main 2026-10-23 22:47:30 +09:00
Owner

Closes #233.

The window-level state machines in felis-client (session switching, parked pushes, the exit ladder, the confirmation bar) could only be reached through a live winit event loop, so the 100-odd tests in tests.rs covered pure helpers only. This confines winit to a thin adapter and drives App from tests through felis-owned types. winit stays on 0.30.

Commits

  1. Build and drive App without a winit event loop. Background tasks post through EventSink instead of EventLoopProxy. user_event becomes App::on_app_event, which records a window close in exit_requested for the adapter to forward. The window is built by App::new.

  2. Route window effects through WindowHost. Every effect on an existing window (title, cursor, attention, IME area, fullscreen, decorations, backdrop, …) goes through one trait, which the winit window implements. App holds the window and the renderer separately, so a test can install a recording window without a GPU.

  3. Hand App its input as felis types. window_event translates input into WindowInput (KeyPress after Option composition, WheelDelta, ImeInput, …), and App::on_window_input handles it. A bare modifier is its own PressedKey::Modifier, because the wire Key::Other would mix it up with dead keys, which the confirmation bar must still treat as an answer. Window events that act on the renderer (resize, scale factor, redraw, occlusion) stay in the adapter.

  4. App-level tests for the rules these seams expose:

    • a reattach parked behind a failing switch runs when that switch fails
    • the title says "disconnected" while reconnecting
    • a BEL stamped with a stale connection generation does not flash the window
    • a bare modifier keeps the scrollback view and a typed key leaves it
    • confirmation bar: a bare modifier keeps it open, a dead or unbound key cancels, Y sends
    • the last parked push wins
    • a reattach to the current place dials nothing
    • a shell exit during a landing waits for that landing, then the exit ladder ends in a close
    • a refused roster fetch frees the next switch chord

    Each test in commit 4 fails under a hand mutation of the code it pins.

docs/reference/testing.md now mentions these window-less unit tests next to the frontend smoke.

Verification

  • just check: green except deny, which fails on a yanked clipboard-win. Cargo.lock is unchanged by this PR, so main should hit the same failure.
  • Windows cross-clippy: passes.
  • just smoke-headless: passes.

Not included

  • No regression test for a pipe/run chord on a session a chain re-pointed to. A successful landing needs a real carrier pair, which costs more than the test is worth here.
Closes #233. The window-level state machines in `felis-client` (session switching, parked pushes, the exit ladder, the confirmation bar) could only be reached through a live winit event loop, so the 100-odd tests in `tests.rs` covered pure helpers only. This confines winit to a thin adapter and drives `App` from tests through felis-owned types. winit stays on 0.30. ## Commits 1. **Build and drive `App` without a winit event loop.** Background tasks post through `EventSink` instead of `EventLoopProxy`. `user_event` becomes `App::on_app_event`, which records a window close in `exit_requested` for the adapter to forward. The window is built by `App::new`. 2. **Route window effects through `WindowHost`.** Every effect on an existing window (title, cursor, attention, IME area, fullscreen, decorations, backdrop, …) goes through one trait, which the winit window implements. `App` holds the window and the renderer separately, so a test can install a recording window without a GPU. 3. **Hand `App` its input as felis types.** `window_event` translates input into `WindowInput` (`KeyPress` after Option composition, `WheelDelta`, `ImeInput`, …), and `App::on_window_input` handles it. A bare modifier is its own `PressedKey::Modifier`, because the wire `Key::Other` would mix it up with dead keys, which the confirmation bar must still treat as an answer. Window events that act on the renderer (resize, scale factor, redraw, occlusion) stay in the adapter. 4. **App-level tests** for the rules these seams expose: - a reattach parked behind a failing switch runs when that switch fails - the title says "disconnected" while reconnecting - a BEL stamped with a stale connection generation does not flash the window - a bare modifier keeps the scrollback view and a typed key leaves it - confirmation bar: a bare modifier keeps it open, a dead or unbound key cancels, `Y` sends - the last parked push wins - a reattach to the current place dials nothing - a shell exit during a landing waits for that landing, then the exit ladder ends in a close - a refused roster fetch frees the next switch chord Each test in commit 4 fails under a hand mutation of the code it pins. `docs/reference/testing.md` now mentions these window-less unit tests next to the frontend smoke. ## Verification - `just check`: green except `deny`, which fails on a yanked `clipboard-win`. `Cargo.lock` is unchanged by this PR, so `main` should hit the same failure. - Windows cross-clippy: passes. - `just smoke-headless`: passes. ## Not included - No regression test for a pipe/run chord on a session a chain re-pointed to. A successful landing needs a real carrier pair, which costs more than the test is worth here.
The window-level state machines (session switching, parked pushes,
the exit ladder) had no test at the App level because App reached its
loop through winit types a test cannot construct: an EventLoopProxy
for every event a background task posts, and an ActiveEventLoop for
every handler that closes the window.

Tasks now post through EventSink, a felis-owned closure the window
build backs with the proxy and a test with a channel. The user_event
handler is App::on_app_event, which records a close in exit_requested
for the winit adapter to forward, and the window's construction is
App::new, which a test calls with no warmup and no pump. A trait
object for the loop was rejected: the flag keeps every handler free
of a loop parameter, and only the adapter needs the loop.

The first test pins the parked-push contract: a reattach push that
arrives during a switch runs when that switch fails.

Assisted-by: Claude Code
App reached its window through the same Option as its renderer, so
an App without a GPU could neither change nor be observed changing
its title, cursor or attention state, and every window effect was a
direct winit call scattered over the handlers.

WindowHost names every effect App has on an existing window, and the
winit window implements it. App now holds the window and the renderer
separately: a window build sets both in resumed, and a test sets only
a recorder. Creation stays on the concrete winit window, since the
renderer needs it and a test has no use for it.

Assisted-by: Claude Code
The key, mouse, wheel and IME handlers took winit's own event types,
so a test could not press a key or move the pointer without a window,
and a winit release that reshapes those types would reach every input
handler.

The window_event adapter now translates what App handles into
WindowInput, a felis-owned enum, and App::on_window_input dispatches
it. Keys arrive as a KeyPress after Option composition; the bare
modifier the confirmation bar lets through is a PressedKey of its own,
since the wire Key folds it into Other together with the dead keys the
bar must still treat as an answer. The modifiers App tracks are the
felis set the wire and the keymap already use. Window events that act
on the renderer (resize, scale, redraw, occlusion) stay in the adapter.

Assisted-by: Claude Code
client: pin the window's switch, push and confirmation rules at App level
Some checks failed
darwin / detect relevant changes (pull_request) Successful in 10s
windows / detect relevant changes (pull_request) Successful in 11s
darwin / darwin docs-only no-op (pull_request) Has been skipped
windows / windows docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Failing after 55s
windows / cargo nextest (Windows) (pull_request) Successful in 2m27s
windows / frontend smoke (Windows) (pull_request) Successful in 37s
pr / nix flake check (pull_request) Successful in 16m1s
pr / cargo build / clippy / test / deny (pull_request) Failing after 9m19s
pr / wire schema is compatible with the base (pull_request) Successful in 20s
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
650e05f335
Each of these rules is a decision a pure helper already makes, but the
failures they guard against live in how App strings the decisions
together: a modifier that answers the confirmation bar, a parked push
lost to the one that replaced it, an exit ladder started beside a
landing still in flight, a refused roster fetch that leaves every later
switch chord queued behind it. Each test drives App through the events
and keys a window receives.

Assisted-by: Claude Code
natsukium force-pushed client/app-host-seam from 650e05f335
Some checks failed
darwin / detect relevant changes (pull_request) Successful in 10s
windows / detect relevant changes (pull_request) Successful in 11s
darwin / darwin docs-only no-op (pull_request) Has been skipped
windows / windows docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Failing after 55s
windows / cargo nextest (Windows) (pull_request) Successful in 2m27s
windows / frontend smoke (Windows) (pull_request) Successful in 37s
pr / nix flake check (pull_request) Successful in 16m1s
pr / cargo build / clippy / test / deny (pull_request) Failing after 9m19s
pr / wire schema is compatible with the base (pull_request) Successful in 20s
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
to 36c55eaf0e
Some checks failed
darwin / detect relevant changes (pull_request) Successful in 10s
darwin / darwin docs-only no-op (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 10s
windows / windows docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 1m12s
windows / cargo nextest (Windows) (pull_request) Successful in 2m25s
windows / frontend smoke (Windows) (pull_request) Successful in 37s
pr / nix flake check (pull_request) Successful in 4m9s
pr / cargo build / clippy / test / deny (pull_request) Failing after 6m6s
pr / wire schema is compatible with the base (pull_request) Successful in 6s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m38s
windows / cargo clippy (Windows cross) (pull_request) Successful in 1m7s
2026-10-23 20:07:47 +09:00
Compare
natsukium force-pushed client/app-host-seam from 36c55eaf0e
Some checks failed
darwin / detect relevant changes (pull_request) Successful in 10s
darwin / darwin docs-only no-op (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 10s
windows / windows docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 1m12s
windows / cargo nextest (Windows) (pull_request) Successful in 2m25s
windows / frontend smoke (Windows) (pull_request) Successful in 37s
pr / nix flake check (pull_request) Successful in 4m9s
pr / cargo build / clippy / test / deny (pull_request) Failing after 6m6s
pr / wire schema is compatible with the base (pull_request) Successful in 6s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m38s
windows / cargo clippy (Windows cross) (pull_request) Successful in 1m7s
to c4fca227d2
All checks were successful
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 19s
darwin / darwin docs-only no-op (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 8s
windows / windows docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 1m12s
windows / cargo nextest (Windows) (pull_request) Successful in 2m53s
windows / frontend smoke (Windows) (pull_request) Successful in 1m0s
bench / instruction-count regression gate (pull_request) Successful in 6m4s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 4m28s
pr / nix flake check (pull_request) Successful in 4m42s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m22s
pr / wire schema is compatible with the base (pull_request) Successful in 4s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 36s
windows / cargo clippy (Windows cross) (pull_request) Successful in 15s
2026-10-23 21:29:58 +09:00
Compare
natsukium scheduled this pull request to auto merge when all checks succeed 2026-10-23 21:50:34 +09:00
natsukium deleted branch client/app-host-seam 2026-10-23 22:47:30 +09:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
felis-terminal/felis!234
No description provided.