feat(gpu): 1-pre --- the input seam, on the lifecycle family

GUI Stage 1-pre. `App::window_event` decided and performed everything
in one 655-line match, and nothing below it could be witnessed without
a display: `ActiveEventLoop` is non-constructible outside a live event
loop, and the arms that matter reach a GPU surface or a socket.

This is the seam, given its shape on the three smallest arms before the
194-line `KeyboardInput` one.

Deciding is `route_event(&WindowEvent) -> Route`, a free function over
the event alone, composing one decision function per family ---
`route_lifecycle` is the first. Performing stays on `App`: the `Resized`
body moves verbatim to `App::apply_resize`.

A route names its LOCAL EFFECT, not merely the family that claims it,
and the harness records routes rather than outbound protocol traffic.
That is deliberate and load-bearing: `CloseRequested` exits and sends
the daemon nothing, so a transcript of daemon traffic alone cannot tell
a handled arm from a dropped one. `RedrawRequested` is the second such
arm and lands next.

The zero-extent clamp moves into the router with the decision. wgpu
rejects a zero-extent surface configuration and a minimize delivers
0x0, so `.max(1)` is a rule rather than defensive padding --- and
deciding it in a pure function is what makes it witnessable with no
surface at all.

No behaviour change. The router is not yet reached for the families
still inline in `window_event`; each subsequent commit moves one, and
when the last goes the match collapses to the router call.

Evidence --- 6 rows, 5 mutations, each failing its own row and no other
beyond a stated dependency:

  M1 `CloseRequested` -> no family      -> the exit row (+ transcript)
  M2 `Resized` -> `Exit`                -> both resize rows (+ transcript)
  M3 clamp dropped                      -> the zero-extent row ALONE
  M4 harness records outbound only      -> the transcript row ALONE
  M5 `ModifiersChanged` drops the state -> the modifiers row (+ transcript)

The transcript row is the only one that fails under M4, because the
per-variant rows assert `feed`'s return value; that row alone owns P2,
which is what makes M4 discriminating rather than a blanket failure.

P3 --- that `window_event` DELEGATES rather than deciding for itself
--- is the framing's accepted structural exception, and it was
demonstrated rather than assumed: deleting the whole delegation, which
would leave the GUI unable to close, resize, or track a modifier, left
all six rows green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-11 23:22:58 +02:00
parent 2d9f0550e0
commit 014110fb90
No known key found for this signature in database
1 changed files with 263 additions and 24 deletions

View File

@ -2679,6 +2679,257 @@ impl App {
}
}
}
/// Perform [`LifecycleRoute::Resize`]. `width`/`height` arrive
/// already clamped away from zero by the router.
fn apply_resize(&mut self, width: u32, height: u32) {
let vp = self
.state
.as_mut()
.and_then(|state| state.resize(width, height));
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: resize send_viewport failed: {e}");
}
// Vterm Stage 3 — a resize is a real geometry change, so
// the cell grid is re-derived and declared here. The
// daemon resizes the shared PTY only if this frontend is
// the durable controller.
self.flush_terminal_declaration();
// Q#BP15a — and so is the panel's whole-frame capacity. An
// identical cell total is not re-declared: nothing the
// daemon can act on changed.
self.flush_panel_geometry(GeometryTrigger::Surface);
}
}
/// GUI Stage 1-pre — the input seam.
///
/// `window_event` receives a `WindowEvent` and, until this seam existed,
/// decided *and performed* everything in one 655-line match. Nothing
/// below it could be witnessed without a display: `ActiveEventLoop` is
/// non-constructible outside a live event loop, and the arms that matter
/// reach a GPU surface or a socket.
///
/// The seam splits the two halves. **Deciding** is [`route_event`] — a
/// free function over `&WindowEvent` alone, so a headless test can drive
/// every family. **Performing** stays on `App`, in the `apply_*` methods
/// the router's variants name.
///
/// The decision is what the route *is*, not merely which family claims
/// it: `Exit` is the local exit effect, `Resize` carries the clamped
/// surface extent, `Modifiers` carries the state mutation. Two arms
/// (`CloseRequested`, and `RedrawRequested` once it moves here) send
/// nothing outbound at all, so a harness recording only protocol traffic
/// would leave them invisible — which is why a route names its local
/// effect and the harness records routes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Route {
/// The lifecycle family — see [`route_lifecycle`].
Lifecycle(LifecycleRoute),
/// No extracted family claims this event. During 1-pre that covers
/// both the families still inline in `window_event` and the events
/// pmacs genuinely ignores; when the last family moves here it means
/// only the second.
Unrouted,
}
/// The lifecycle family: the three arms that read no pointer state, hold
/// no `State` borrow, and reach the socket only through the resize
/// declaration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LifecycleRoute {
/// `CloseRequested` — leave the event loop. Q#S1-1: a native close
/// detaches this frontend, it does not shut the daemon down.
Exit,
/// `ModifiersChanged` — the new modifier state, already unwrapped
/// from winit's `Modifiers` wrapper.
Modifiers(winit::keyboard::ModifiersState),
/// `Resized` — the new surface extent, **already clamped away from
/// zero**. A minimize delivers `0×0`, and wgpu rejects a zero-extent
/// surface configuration, so the clamp is a rule rather than
/// defensive padding; deciding it here is what makes it witnessable
/// without a surface.
Resize { width: u32, height: u32 },
}
/// Decide what `window_event` should do with an event, from the event
/// alone. See [`Route`].
fn route_event(event: &WindowEvent) -> Route {
if let Some(lifecycle) = route_lifecycle(event) {
return Route::Lifecycle(lifecycle);
}
Route::Unrouted
}
/// The lifecycle family's decision. `None` means some other family owns
/// the event.
fn route_lifecycle(event: &WindowEvent) -> Option<LifecycleRoute> {
match event {
WindowEvent::CloseRequested => Some(LifecycleRoute::Exit),
WindowEvent::ModifiersChanged(mods) => Some(LifecycleRoute::Modifiers(mods.state())),
WindowEvent::Resized(size) => Some(LifecycleRoute::Resize {
width: size.width.max(1),
height: size.height.max(1),
}),
_ => None,
}
}
/// GUI Stage 1-pre — the headless routing harness (P2).
///
/// It feeds `WindowEvent`s through the production [`route_event`] and
/// records what each one routed to. The transcript is of **routes**,
/// deliberately, not of outbound protocol traffic: `CloseRequested`
/// exits and `RedrawRequested` repaints, and neither sends the daemon
/// anything, so a transcript of daemon traffic alone cannot tell a
/// handled arm from a dropped one. A route names its local effect —
/// exit, resize extent, modifier mutation — which is what makes those
/// arms observable here at all.
///
/// P3 is the stated exception: that `window_event` *calls* the router
/// rather than deciding for itself is a code-review invariant, not a
/// tested one. `ActiveEventLoop` cannot be constructed outside a live
/// event loop, so the real callback is unreachable from a test. The
/// mutation evidence below covers every router arm and **not** the
/// delegation.
#[cfg(test)]
#[derive(Debug, Default)]
struct RoutingHarness {
transcript: Vec<Route>,
}
#[cfg(test)]
impl RoutingHarness {
fn feed(&mut self, event: &WindowEvent) -> Route {
let route = route_event(event);
self.transcript.push(route);
route
}
fn transcript(&self) -> &[Route] {
&self.transcript
}
}
#[cfg(test)]
mod input_routing_tests {
use super::*;
use winit::dpi::PhysicalSize;
use winit::keyboard::ModifiersState;
fn modifiers_changed(state: ModifiersState) -> WindowEvent {
WindowEvent::ModifiersChanged(state.into())
}
/// P1 — `CloseRequested`. It sends the daemon nothing and its whole
/// effect is local, so this row exists only because the route names
/// the effect.
#[test]
fn close_requested_routes_to_exit() {
let mut harness = RoutingHarness::default();
assert_eq!(
harness.feed(&WindowEvent::CloseRequested),
Route::Lifecycle(LifecycleRoute::Exit)
);
}
/// P1 — `ModifiersChanged` carries the unwrapped state, which is the
/// mutation `window_event` performs.
#[test]
fn modifiers_changed_routes_the_new_state() {
let mut harness = RoutingHarness::default();
let mods = ModifiersState::CONTROL | ModifiersState::ALT;
assert_eq!(
harness.feed(&modifiers_changed(mods)),
Route::Lifecycle(LifecycleRoute::Modifiers(mods))
);
// An empty state is a real transition (every modifier released),
// not an absent one.
assert_eq!(
harness.feed(&modifiers_changed(ModifiersState::empty())),
Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::empty()))
);
}
/// P1 — `Resized` carries the extent through unchanged when it is
/// already non-zero.
#[test]
fn resized_routes_the_new_extent() {
let mut harness = RoutingHarness::default();
assert_eq!(
harness.feed(&WindowEvent::Resized(PhysicalSize::new(1280, 720))),
Route::Lifecycle(LifecycleRoute::Resize {
width: 1280,
height: 720,
})
);
}
/// A minimize delivers `0×0` and wgpu rejects a zero-extent surface
/// configuration. The clamp is per axis, so an extent that collapses
/// on one axis only still keeps the other.
#[test]
fn a_zero_extent_resize_clamps_per_axis() {
let mut harness = RoutingHarness::default();
assert_eq!(
harness.feed(&WindowEvent::Resized(PhysicalSize::new(0, 0))),
Route::Lifecycle(LifecycleRoute::Resize {
width: 1,
height: 1,
})
);
assert_eq!(
harness.feed(&WindowEvent::Resized(PhysicalSize::new(0, 720))),
Route::Lifecycle(LifecycleRoute::Resize {
width: 1,
height: 720,
})
);
assert_eq!(
harness.feed(&WindowEvent::Resized(PhysicalSize::new(1280, 0))),
Route::Lifecycle(LifecycleRoute::Resize {
width: 1280,
height: 1,
})
);
}
/// An event no family claims. `Occluded` is chosen because pmacs has
/// never handled it and no later slice will — a still-inline family
/// would also read `Unrouted` today and stop doing so when it moves.
#[test]
fn an_unclaimed_event_is_unrouted() {
let mut harness = RoutingHarness::default();
assert_eq!(harness.feed(&WindowEvent::Occluded(true)), Route::Unrouted);
}
/// P2 — the harness records a transcript, and the transcript
/// distinguishes the three lifecycle effects from each other and
/// from an unclaimed event. Two of these four rows produce no
/// outbound traffic whatsoever.
#[test]
fn the_harness_records_each_local_effect_in_order() {
let mut harness = RoutingHarness::default();
harness.feed(&WindowEvent::Resized(PhysicalSize::new(800, 600)));
harness.feed(&modifiers_changed(ModifiersState::SHIFT));
harness.feed(&WindowEvent::Occluded(false));
harness.feed(&WindowEvent::CloseRequested);
assert_eq!(
harness.transcript(),
&[
Route::Lifecycle(LifecycleRoute::Resize {
width: 800,
height: 600,
}),
Route::Lifecycle(LifecycleRoute::Modifiers(ModifiersState::SHIFT)),
Route::Unrouted,
Route::Lifecycle(LifecycleRoute::Exit),
]
);
}
}
impl ApplicationHandler<AppEvent> for App {
@ -2733,8 +2984,6 @@ impl ApplicationHandler<AppEvent> for App {
#[allow(clippy::too_many_lines)] // linear per-event dispatch; splitting hides the input flow.
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::ModifiersChanged(mods) => self.modifiers = mods.state(),
WindowEvent::KeyboardInput { event: key, .. } => {
if key.state != ElementState::Pressed {
return;
@ -2929,27 +3178,6 @@ impl ApplicationHandler<AppEvent> for App {
eprintln!("pmacs-gpu: send_key failed: {e}");
}
}
WindowEvent::Resized(size) => {
let vp = self
.state
.as_mut()
.and_then(|state| state.resize(size.width.max(1), size.height.max(1)));
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: resize send_viewport failed: {e}");
}
// Vterm Stage 3 — a resize is a real geometry change, so
// the cell grid is re-derived and declared here. The
// daemon resizes the shared PTY only if this frontend is
// the durable controller.
self.flush_terminal_declaration();
// Q#BP15a — and so is the panel's whole-frame capacity. An
// identical cell total is not re-declared: nothing the
// daemon can act on changed.
self.flush_panel_geometry(GeometryTrigger::Surface);
}
// Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md).
WindowEvent::CursorMoved { position, .. } => {
let Some(state) = self.state.as_mut() else {
@ -3383,7 +3611,18 @@ impl ApplicationHandler<AppEvent> for App {
state.render();
}
}
_ => {}
// Stage 1-pre — everything the router already claims. The
// arms above are the families not yet moved behind it; when
// the last one goes, this match collapses to the call below
// and `window_event` is the thin call-through P3 asks for.
ref routed => match route_event(routed) {
Route::Lifecycle(LifecycleRoute::Exit) => event_loop.exit(),
Route::Lifecycle(LifecycleRoute::Modifiers(mods)) => self.modifiers = mods,
Route::Lifecycle(LifecycleRoute::Resize { width, height }) => {
self.apply_resize(width, height);
}
Route::Unrouted => {}
},
}
}