git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commite6d6eea04f5c00f512b6641b59d47f7227301493
parent7a10f27af1
authorClaude <noreply@anthropic.com>
date2026-10-04 18:50
refactor(backend): input routing moves into a platform-neutral Driver

The Wayland handlers did two jobs at once: translating the window
system's events and deciding what they mean. The meaning is cce-ui's,
and a second shell (macOS, the browser) needs exactly the same: the
undo/redo and plate-navigation chords, key repeat, modifier tracking,
the CSD hit zones, the outside-press popover close, held-button release
on a lost pointer, the scroll phase, the pinch fallback, the app tick
that does not double-tick a context the app already ticked.

backend/driver.rs now holds all of that. `Driver` owns the input state
(modifiers, held key, pointer position, held buttons, pinch scale, the
chords); each call takes a `Turn` (the app plus the runner's redraw and
exit flags), so the app stays on EngineState, where clients'
register_sources callbacks reach `inner` and `redraw`. The Wayland
shell is left with translation and execution: evdev codes to
MouseButton, xkb keysyms to Key, wl_pointer axis frames to a neutral
ScrollFrame, and carrying out the Resize/Move grab or cursor the
driver returns. A press the shell cannot grab for (no window or seat)
is the app's, as it always was on a layer surface.

The logic moves as it was, line for line where it could; the scroll
phase/delta arithmetic is a pure `scroll_delta` so a test can check it
without publishing the process-wide phase the parameters_bg tests race
on. Seven new tests drive the routing with a mock app and no
compositor: a lost pointer releases what was held then clears hover;
a border press is a grab only when the shell can grab, never through
the menu popup; a press's message reaches update; the undo chord goes
to the app hook before key dispatch; a held key repeats after the
delay and stops on release; losing focus drops the key and modifiers
(logo kept, as before); a scroll frame's phase and delta. Removing
the can_grab gate or the synthetic releases fails them.

Verified against the pre-change binary: test results unchanged (512
pass, the same 2 pre-existing failures), the plate golden
byte-identical, and the demo identical to the pixel over 24 scripted
steps under headless sway, extended with held-key repeat (BackSpace
clearing a field, Left walking to the start) and presses on the CSD
border and titlebar band.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ

 CLAUDE.md                    |  10 +
 src/backend/driver.rs        | 961 +++++++++++++++++++++++++++++++++++++++++++
 src/backend/mod.rs           |   1 +
 src/backend/window_runner.rs | 842 ++++++++-----------------------------
 4 files changed, 1145 insertions(+), 669 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9d65066..8f29627 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1086,6 +1086,16 @@ cce-system-interface) to confirm behavior, not just the test suite.
   of a one-line field an app draws itself — cce-browser's URL bar and dialog fields) and
   `core.rs`. (The KDL/JSON-driven `json_layout.rs` is dissolved; `scene/layout.rs` is the
   box model.)
+- `backend/` — the runner, split (since 2026-10-03) so a second shell (macOS, the browser)
+  can share everything that is not Wayland: `app.rs` (the `Application` trait, `AppSender`,
+  the plain types it speaks in), `driver.rs` (`Driver`: input state and routing — modifiers,
+  key repeat, the undo/redo and plate-navigation chords, the CSD hit zones, the
+  outside-press popover close, held-button release on a lost pointer, the scroll phase,
+  the pinch fallback — fed in cce-ui's own terms and unit-tested with no compositor),
+  `tessellate.rs`, `text.rs`, and `window_runner.rs`, the Wayland shell: it maps evdev
+  buttons, xkb keysyms and `wl_pointer` axis frames into driver calls and carries out the
+  grabs and cursors the driver asks for. A routing change belongs in `driver.rs`, never in
+  a Wayland handler. `menu_popup.rs` and `dnd.rs` are Wayland-only.
 - `protocol.rs` — inline-generated Wayland protocol bindings.
 - `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
   the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
diff --git a/src/backend/driver.rs b/src/backend/driver.rs
new file mode 100644
index 0000000..f213d3a
--- /dev/null
+++ b/src/backend/driver.rs
@@ -0,0 +1,961 @@
+//! The runner's platform-neutral half: what happens to input once a shell has
+//! it in cce-ui's own terms. A shell (the Wayland runner today) translates its
+//! window system's events — evdev buttons, xkb keysyms, `wl_pointer` axis
+//! frames — into the calls here, and carries out what they ask back of the
+//! window (a cursor, an interactive move or resize). Everything between —
+//! modifier tracking, the undo/redo and plate-navigation chords, key repeat,
+//! the CSD hit zones, the outside-press popover close, held-button release on
+//! a lost pointer, the scroll phase, the pinch fallback — lives here once, so
+//! a second shell routes exactly as the first does.
+//!
+//! The app is never owned here: each call takes a [`Turn`], the app plus the
+//! runner's `redraw` / `exit` flags, borrowed from wherever the shell keeps
+//! them (the Wayland runner keeps them on `EngineState`, where clients'
+//! `register_sources` callbacks reach `inner` and `redraw` directly).
+
+use std::time::Instant;
+
+use super::app::{Application, LogicalPosition, LogicalSize};
+use super::window_runner::PointerCursorIcon as CursorIcon;
+use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position};
+
+/// A key held down, for the runner's own key repeat.
+pub struct PressedKey {
+    pub logical_key: Key,
+    pub text: Option<String>,
+    pub first_pressed: Instant,
+    pub last_repeated: Instant,
+}
+
+/// Held this long before the first repeat…
+pub const KEY_REPEAT_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
+/// …then one repeat per this.
+pub const KEY_REPEAT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
+
+fn is_repeatable_key(key: &Key) -> bool {
+    match key {
+        Key::Named(NamedKey::Backspace) |
+        Key::Named(NamedKey::Delete) |
+        Key::Named(NamedKey::ArrowLeft) |
+        Key::Named(NamedKey::ArrowRight) |
+        Key::Named(NamedKey::ArrowUp) |
+        Key::Named(NamedKey::ArrowDown) |
+        Key::Named(NamedKey::Home) |
+        Key::Named(NamedKey::End) |
+        Key::Character(_) => true,
+        _ => false,
+    }
+}
+
+/// The modifier keys as the keyboard last reported them.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct Modifiers {
+    pub ctrl: bool,
+    pub shift: bool,
+    pub alt: bool,
+    pub logo: bool,
+}
+
+/// A window edge an interactive resize grabs: the window system's own enum,
+/// spelled without it. The Wayland shell maps it onto `xdg_toplevel`'s.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ResizeEdge {
+    Top,
+    Bottom,
+    Left,
+    Right,
+    TopLeft,
+    TopRight,
+    BottomLeft,
+    BottomRight,
+}
+
+/// What a press turned out to be: the app's, or a grab of the window itself
+/// that the shell carries out (and the app never hears about).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Press {
+    Dispatched,
+    Resize(ResizeEdge),
+    Move,
+}
+
+/// Where a press landed, in the window's terms.
+#[derive(Debug, Clone, Copy)]
+pub struct PressSite {
+    /// The surface's logical size: the CSD borders are measured from it.
+    pub size: LogicalSize,
+    /// The press came through the context menu's popup surface: it is the
+    /// menu's, never a border or a movable plate of the window.
+    pub on_popup: bool,
+    /// The shell can start an interactive move / resize right now (a window
+    /// and a seat to grab with). When it cannot, a press that would have been
+    /// a grab is the app's instead — which is what the Wayland runner did on
+    /// a layer surface, and what a shell with no grabs at all always does.
+    pub can_grab: bool,
+}
+
+/// Where a scroll came from, as far as the phase cares.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ScrollSource {
+    Wheel,
+    Finger,
+    Continuous,
+    WheelTilt,
+}
+
+/// One frame of scroll input, coalesced: the shell sums a frame's axis events
+/// (in its own window system's units — `wl_pointer` axis values here) and
+/// hands the total over once.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ScrollFrame {
+    pub h: f64,
+    pub v: f64,
+    pub discrete_h: i32,
+    pub discrete_v: i32,
+    /// The frame's source, if it named one.
+    pub source: Option<ScrollSource>,
+    /// A finger lifted (an axis stop) in this frame.
+    pub stop: bool,
+}
+
+/// The app and the runner's two flags, for the length of one dispatch.
+pub struct Turn<'a, A: Application> {
+    pub app: &'a mut A,
+    pub redraw: &'a mut bool,
+    pub exit: &'a mut bool,
+}
+
+impl<A: Application> Turn<'_, A> {
+    /// Hand a message from an input handler to `update`, and fold the two
+    /// rebuild requests into the redraw flag — the shape every dispatch had.
+    fn deliver(&mut self, msg: Option<A::Message>, mut rebuild: bool) {
+        if let Some(msg) = msg {
+            let mut update_rebuild = false;
+            self.app.update(msg, &mut update_rebuild, self.exit);
+            if update_rebuild {
+                rebuild = true;
+            }
+        }
+        if rebuild {
+            *self.redraw = true;
+        }
+    }
+}
+
+/// The input state a session carries, and the routing over it.
+pub struct Driver {
+    pub mods: Modifiers,
+    pub pressed_key: Option<PressedKey>,
+    /// The pointer's last position, window-logical (popup events translated).
+    pub cursor_pos: (f32, f32),
+    /// Mouse buttons held, as a bitmask (1 Left / 2 Right / 4 Middle). On a
+    /// lost pointer the real release goes to whatever surface takes the
+    /// pointer next (fullscreen switches, layout animations), so
+    /// [`pointer_leave`](Self::pointer_leave) synthesizes releases for the
+    /// held set — a drag must end, not stay armed and steered by later
+    /// motion — and only then runs the off-screen hover-clear (which would
+    /// otherwise corrupt the drag: a ramp key snapped to the graph corner).
+    pub buttons_down: u32,
+    pub last_pinch_scale: f32,
+    /// The `undo` / `redo` chords, resolved from `input.kdl` when the
+    /// session starts.
+    pub undo_chord: String,
+    pub redo_chord: String,
+    /// `focus_next_group` / `focus_prev_group` (input.kdl, cce-ui domain):
+    /// the plate-navigation group jump, for apps that opt in.
+    pub group_next_chord: String,
+    pub group_prev_chord: String,
+}
+
+impl Default for Driver {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+/// The CSD border's width, in logical px.
+const CSD_BORDER: f32 = 8.0;
+
+fn button_bit(btn: MouseButton) -> u32 {
+    match btn {
+        MouseButton::Left => 1,
+        MouseButton::Right => 2,
+        _ => 4,
+    }
+}
+
+impl Driver {
+    pub fn new() -> Self {
+        Self {
+            mods: Modifiers::default(),
+            pressed_key: None,
+            cursor_pos: (0.0, 0.0),
+            buttons_down: 0,
+            last_pinch_scale: 1.0,
+            undo_chord: crate::input::app_chord("undo", "ctrl+z"),
+            redo_chord: crate::input::app_chord("redo", "ctrl+shift+z"),
+            group_next_chord: crate::input::app_chord("focus_next_group", "ctrl+tab"),
+            group_prev_chord: crate::input::app_chord("focus_prev_group", "ctrl+shift+tab"),
+        }
+    }
+
+    /// Copy the modifiers into the app's widget context, where widgets read them.
+    fn sync_mods<A: Application>(&self, app: &mut A) {
+        if let Some(ctx) = app.ui_context_mut() {
+            ctx.ctrl_pressed = self.mods.ctrl;
+            ctx.shift_pressed = self.mods.shift;
+            ctx.alt_pressed = self.mods.alt;
+            ctx.logo_pressed = self.mods.logo;
+        }
+    }
+
+    /// The cursor for the pointer at (lx, ly): the app's
+    /// [`Application::cursor_icon`] override, else the standard-CSD edge
+    /// cursors (status bars and non-standard-CSD apps fall back to Default).
+    pub fn cursor_icon_at<A: Application>(&self, app: &A, lx: f32, ly: f32, size: LogicalSize) -> CursorIcon {
+        // Over the context menu the pointer is the menu's,
+        // whatever of the app lies at that place under it (a splitter, a
+        // resize border) — and in their popups that place may be outside
+        // the window altogether.
+        if crate::widget::context_menu::is_visible() && crate::widget::context_menu::hit_test(lx, ly) {
+            return CursorIcon::Default;
+        }
+        if let Some(icon) = app.cursor_icon(lx, ly) {
+            return icon;
+        }
+        if app.settings().app_id.starts_with("cce-status")
+            || !app.standard_csd()
+            || !app.csd_resize_borders()
+        {
+            return CursorIcon::Default;
+        }
+        match csd_edge(lx, ly, size) {
+            Some(ResizeEdge::TopLeft) => CursorIcon::NwResize,
+            Some(ResizeEdge::TopRight) => CursorIcon::NeResize,
+            Some(ResizeEdge::Top) => CursorIcon::NResize,
+            Some(ResizeEdge::BottomLeft) => CursorIcon::SwResize,
+            Some(ResizeEdge::BottomRight) => CursorIcon::SeResize,
+            Some(ResizeEdge::Bottom) => CursorIcon::SResize,
+            Some(ResizeEdge::Left) => CursorIcon::WResize,
+            Some(ResizeEdge::Right) => CursorIcon::EResize,
+            None => CursorIcon::Default,
+        }
+    }
+
+    /// The pointer entered at `pos`. Enter carries the pointer's position but
+    /// no motion follows until it actually moves — without this the app's
+    /// hover state is stale from enter to first move, and a press in that
+    /// window can misroute (e.g. a divider press falling through to the
+    /// movable-root plate window drag).
+    pub fn pointer_enter<A: Application>(&mut self, t: Turn<'_, A>, pos: LogicalPosition) {
+        self.pointer_motion(t, pos);
+    }
+
+    /// The pointer moved to `pos`.
+    pub fn pointer_motion<A: Application>(&mut self, t: Turn<'_, A>, pos: LogicalPosition) {
+        let mut rebuild = false;
+        t.app.handle_pointer_move(pos, &mut rebuild);
+        if rebuild {
+            *t.redraw = true;
+        }
+    }
+
+    /// The pointer left the window. Focus can move mid-gesture (a fullscreen
+    /// switch, a relayout sliding the window away): the real release then
+    /// lands on another surface, and an armed drag would live forever. End
+    /// held gestures with synthetic releases at the last known position
+    /// first; then clear hover with an off-screen move — safe now that no
+    /// drag is held.
+    pub fn pointer_leave<A: Application>(&mut self, mut t: Turn<'_, A>) {
+        if self.buttons_down != 0 {
+            let (px, py) = self.cursor_pos;
+            for btn in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
+                if self.buttons_down & button_bit(btn) == 0 {
+                    continue;
+                }
+                let mut rebuild = false;
+                let msg = t.app.handle_mouse_input(
+                    btn,
+                    ElementState::Released,
+                    LogicalPosition::new(px, py),
+                    &mut rebuild,
+                );
+                t.deliver(msg, rebuild);
+            }
+            self.buttons_down = 0;
+        }
+        let mut rebuild = false;
+        t.app.handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
+        if rebuild {
+            *t.redraw = true;
+        }
+    }
+
+    /// A button went down at `pos`. Returns a grab for the shell to start
+    /// when the press is the window's (a CSD border, the titlebar band, a
+    /// movable root plate); otherwise the press has been dispatched.
+    pub fn pointer_press<A: Application>(
+        &mut self,
+        mut t: Turn<'_, A>,
+        btn: MouseButton,
+        pos: LogicalPosition,
+        site: PressSite,
+    ) -> Press {
+        self.buttons_down |= button_bit(btn);
+        let (lx, ly) = (pos.x, pos.y);
+
+        // Client-side decorations: drag and resize. Never on the menu popup:
+        // its presses are the menu's, and its coordinates, translated into the
+        // window's, would otherwise read as a resize border or a movable plate.
+        if site.can_grab
+            && btn == MouseButton::Left
+            && !site.on_popup
+            && !t.app.settings().app_id.starts_with("cce-status")
+            && t.app.standard_csd()
+        {
+            // Resize borders off: the compositor's own band outside the
+            // window handles it; the move checks still run, so drag-to-move
+            // still works.
+            if t.app.csd_resize_borders() {
+                if let Some(edge) = csd_edge(lx, ly, site.size) {
+                    return Press::Resize(edge);
+                }
+            }
+            // The titlebar band: y in [8, 32), clear of the top-right buttons.
+            let is_widget = t.app.ui_context().is_some_and(|ctx| ctx.is_widget_at(lx, ly));
+            if (!is_widget
+                && t.app.csd_titlebar_move()
+                && ly >= CSD_BORDER
+                && ly < 32.0
+                && lx < site.size.width - 70.0)
+                || t.app.is_movable_root_plate_at(lx, ly)
+            {
+                return Press::Move;
+            }
+        }
+
+        // Outside-press close for open popovers, BEFORE the app's dispatch:
+        // apps commonly region-gate their routing, so an open menu's owner may
+        // never hear about a press elsewhere.
+        if btn == MouseButton::Left {
+            let app = &mut *t.app;
+            let offsets: Vec<_> = app
+                .ui_context()
+                .map(|ctx| ctx.popover_owners())
+                .unwrap_or_default()
+                .into_iter()
+                .map(|id| (id, app.popover_offset(id)))
+                .collect();
+            if let Some(ctx) = app.ui_context_mut() {
+                ctx.close_popovers_missed_by_press_with(lx, ly, |id| {
+                    offsets.iter().find(|(o, _)| *o == id).map_or((0.0, 0.0), |&(_, d)| d)
+                });
+            }
+        }
+
+        let mut rebuild = false;
+        let msg = t.app.handle_mouse_input(btn, ElementState::Pressed, pos, &mut rebuild);
+        t.deliver(msg, rebuild);
+        Press::Dispatched
+    }
+
+    /// A button came up at `pos`.
+    pub fn pointer_release<A: Application>(&mut self, mut t: Turn<'_, A>, btn: MouseButton, pos: LogicalPosition) {
+        self.buttons_down &= !button_bit(btn);
+        let mut rebuild = false;
+        let msg = t.app.handle_mouse_input(btn, ElementState::Released, pos, &mut rebuild);
+        t.deliver(msg, rebuild);
+    }
+
+    /// One coalesced frame of scrolling at `pos`. Publishes the frame's
+    /// phase (`scroll_motion::set_scroll_phase`) before the app sees it.
+    pub fn scroll<A: Application>(&mut self, t: Turn<'_, A>, frame: ScrollFrame, pos: LogicalPosition) {
+        let ScrollFrame { h, v, discrete_h, discrete_v, source, stop } = frame;
+        // Per-app scroll factors from input.kdl (`<app>`/`cce-ui` domain
+        // `input { }` blocks); the compositor's global device scaling has
+        // already been applied at the source.
+        let factors = crate::input::scroll_factors();
+        let (phase, delta) = scroll_delta(&frame, factors);
+        crate::widget::scroll_motion::set_scroll_phase(phase);
+        if crate::scroll_debug() {
+            static T0: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
+            let ms = T0.get_or_init(Instant::now).elapsed().as_millis();
+            eprintln!(
+                "[scroll {ms}ms] runner: coalesced=({h:.2},{v:.2}) discrete=({discrete_h},{discrete_v}) source={source:?} stop={stop} phase={phase:?} factors=(tp {:.2}, m {:.2}) -> {delta:?} at ({:.0},{:.0})",
+                factors.trackpad, factors.mouse, pos.x, pos.y
+            );
+        }
+        let mut rebuild = false;
+        self.sync_mods(t.app);
+        t.app.handle_mouse_wheel(&delta, pos, &mut rebuild);
+        if rebuild {
+            *t.redraw = true;
+        }
+    }
+
+    /// A pinch gesture began.
+    pub fn pinch_begin(&mut self) {
+        self.last_pinch_scale = 1.0;
+    }
+
+    /// A pinch gesture ended.
+    pub fn pinch_end(&mut self) {
+        self.last_pinch_scale = 1.0;
+    }
+
+    /// The pinch's cumulative `scale` moved, with the pointer where it last was.
+    pub fn pinch_update<A: Application>(&mut self, t: Turn<'_, A>, scale: f32) {
+        let factor = scale / self.last_pinch_scale;
+        self.last_pinch_scale = scale;
+
+        let (px, py) = self.cursor_pos;
+        let mut rebuild = false;
+
+        // First offer the gesture as-is: apps with true pinch surfaces (the
+        // designer's 3D viewport) consume it here at 1:1 scale instead of
+        // through the wheel synthesis below.
+        if t.app.handle_pinch(factor, LogicalPosition::new(px, py), &mut rebuild) {
+            if rebuild {
+                *t.redraw = true;
+            }
+            return;
+        }
+
+        // Calculate the y_delta for PixelDelta mapping.
+        // Since cce-graph interprets factor = 1.0 + y_delta * 0.015, we reverse it:
+        let y_delta = (factor - 1.0) / 0.015;
+        let delta = MouseScrollDelta::PixelDelta(Position {
+            x: 0.0,
+            y: y_delta as f64,
+        });
+
+        if let Some(ctx) = t.app.ui_context_mut() {
+            ctx.ctrl_pressed = true; // Force ctrl_pressed = true for the pinch event
+        }
+        // A synthesized delta, not a scroll gesture: no glide, no fling.
+        crate::widget::scroll_motion::set_scroll_phase(crate::widget::ScrollPhase::Wheel);
+
+        t.app.handle_mouse_wheel(&delta, LogicalPosition::new(px, py), &mut rebuild);
+
+        if let Some(ctx) = t.app.ui_context_mut() {
+            ctx.ctrl_pressed = self.mods.ctrl; // Restore original state
+        }
+
+        if rebuild {
+            *t.redraw = true;
+        }
+    }
+
+    /// The keyboard reported new modifier state.
+    pub fn set_modifiers<A: Application>(&mut self, app: &mut A, mods: Modifiers) {
+        self.mods = mods;
+        self.sync_mods(app);
+    }
+
+    /// The window gained (`true`) or lost keyboard focus. Losing it drops a
+    /// held key and the held modifiers, whose releases go elsewhere.
+    pub fn keyboard_focus<A: Application>(&mut self, t: Turn<'_, A>, focused: bool) {
+        if !focused {
+            self.pressed_key = None;
+            self.mods.ctrl = false;
+            self.mods.shift = false;
+            self.mods.alt = false;
+        }
+        let mut rebuild = false;
+        t.app.handle_focus_change(focused, &mut rebuild);
+        if rebuild {
+            *t.redraw = true;
+        }
+    }
+
+    /// A key went down or up, already in cce-ui's terms: the shell maps its
+    /// window system's key (an xkb keysym here) to `logical_key`, and passes
+    /// the text the key types, if any.
+    pub fn key<A: Application>(
+        &mut self,
+        mut t: Turn<'_, A>,
+        logical_key: Key,
+        text: Option<String>,
+        state: ElementState,
+    ) {
+        let event = KeyEvent {
+            state,
+            logical_key,
+            text,
+            repeat: false,
+            ctrl: self.mods.ctrl,
+            shift: self.mods.shift,
+            alt: self.mods.alt,
+        };
+
+        if state == ElementState::Pressed {
+            if is_repeatable_key(&event.logical_key) {
+                self.pressed_key = Some(PressedKey {
+                    logical_key: event.logical_key.clone(),
+                    text: event.text.clone(),
+                    first_pressed: Instant::now(),
+                    last_repeated: Instant::now(),
+                });
+            } else {
+                self.pressed_key = None;
+            }
+        } else if state == ElementState::Released {
+            if let Some(ref pk) = self.pressed_key {
+                if pk.logical_key == event.logical_key {
+                    self.pressed_key = None;
+                }
+            }
+        }
+
+        self.sync_mods(t.app);
+
+        // Escape dismisses the shared context menu before app dispatch — the
+        // toolkit-wide default, mirroring the click-outside dismissal. Consumed:
+        // while a menu is open, Escape means "close it", nothing else.
+        if state == ElementState::Pressed
+            && event.logical_key == Key::Named(NamedKey::Escape)
+            && crate::widget::context_menu::is_visible()
+        {
+            crate::widget::context_menu::hide();
+            *t.redraw = true;
+            return;
+        }
+
+        let mut rebuild = false;
+        if self.route_history_chord(t.app, &event, &mut rebuild)
+            || self.route_plate_navigation(t.app, &event, &mut rebuild)
+        {
+            *t.redraw = true;
+            return;
+        }
+        let msg = t.app.handle_key_input(&event, &mut rebuild);
+        t.deliver(msg, rebuild);
+    }
+
+    /// The runner's key repeat: once a held key has been down
+    /// [`KEY_REPEAT_DELAY`], deliver it again every [`KEY_REPEAT_INTERVAL`].
+    /// Called once per loop turn.
+    pub fn repeat_keys<A: Application>(&mut self, mut t: Turn<'_, A>) {
+        let Some(ref mut pk) = self.pressed_key else { return };
+        let now = Instant::now();
+        if now.duration_since(pk.first_pressed) < KEY_REPEAT_DELAY
+            || now.duration_since(pk.last_repeated) < KEY_REPEAT_INTERVAL
+        {
+            return;
+        }
+        pk.last_repeated = now;
+        let event = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: pk.logical_key.clone(),
+            text: pk.text.clone(),
+            repeat: true,
+            ctrl: self.mods.ctrl,
+            shift: self.mods.shift,
+            alt: self.mods.alt,
+        };
+
+        self.sync_mods(t.app);
+
+        let mut rebuild = false;
+        if self.route_history_chord(t.app, &event, &mut rebuild)
+            || self.route_plate_navigation(t.app, &event, &mut rebuild)
+        {
+            *t.redraw = true;
+        } else {
+            let msg = t.app.handle_key_input(&event, &mut rebuild);
+            t.deliver(msg, false);
+        }
+        if rebuild {
+            *t.redraw = true;
+        }
+    }
+
+    /// Advance the app by `dt` seconds: its own `tick`, then its retained
+    /// `UiContext`'s (widget tick receivers — e.g. an animating Dropdown
+    /// popover) — but only when the app's own tick did not already tick the
+    /// context this turn. Receivers integrate `dt` (scroll glides, slider
+    /// inertia), so the old "double-ticking is harmless" assumption ran
+    /// every glide at twice its configured rate in apps that tick the
+    /// context themselves.
+    pub fn tick<A: Application>(&mut self, t: Turn<'_, A>, dt: f32) {
+        let mut rebuild = false;
+        let roster_ticks_before = t.app.ui_context_mut().map(|ctx| ctx.tick_count());
+        t.app.tick(dt, &mut rebuild);
+        if rebuild {
+            *t.redraw = true;
+        }
+        if let Some(ctx) = t.app.ui_context_mut() {
+            if Some(ctx.tick_count()) == roster_ticks_before && ctx.tick(dt) {
+                *t.redraw = true;
+            }
+        }
+    }
+
+    /// The toolkit's Tab traversal, for apps that opt in
+    /// (`Application::plate_navigation`): a bare Tab / Shift+Tab press moves
+    /// keyboard focus to the next / previous plate or well. Returns whether it
+    /// moved; otherwise the key is dispatched as usual.
+    fn route_plate_navigation<A: Application>(&self, app: &mut A, event: &KeyEvent, rebuild: &mut bool) -> bool {
+        if event.state != ElementState::Pressed {
+            return false;
+        }
+        // The group jump first (its chords carry ctrl); then a bare Tab.
+        let group_next = crate::widget::match_key_shortcut(event, &self.group_next_chord);
+        let group_prev = !group_next && crate::widget::match_key_shortcut(event, &self.group_prev_chord);
+        let bare_tab = event.logical_key == Key::Named(NamedKey::Tab)
+            && !self.mods.ctrl
+            && !self.mods.alt
+            && !self.mods.logo;
+        if !group_next && !group_prev && !bare_tab {
+            return false;
+        }
+        let reverse = if bare_tab { self.mods.shift } else { group_prev };
+        if !app.plate_navigation() {
+            return false;
+        }
+        let moved = app
+            .ui_context_mut()
+            .is_some_and(|ctx| if bare_tab { ctx.focus_step(reverse) } else { ctx.focus_step_group(reverse) });
+        if moved {
+            app.focus_stepped();
+            *rebuild = true;
+        }
+        moved
+    }
+
+    /// The toolkit-wide undo/redo routing: a press matching the `undo` /
+    /// `redo` chord goes to the focused widget first (`ContextAction::Undo`
+    /// / `Redo` — a text box that is editing steps its own typing), then to
+    /// the app's `Application::undo` / `redo`. Returns whether either took
+    /// it; otherwise the key is dispatched as usual, so an app with its own
+    /// scheme is undisturbed. Runs for repeats too — holding the chord walks
+    /// the history like holding Backspace walks the text.
+    fn route_history_chord<A: Application>(&self, app: &mut A, event: &KeyEvent, rebuild: &mut bool) -> bool {
+        if event.state != ElementState::Pressed {
+            return false;
+        }
+        let undo = crate::widget::match_key_shortcut(event, &self.undo_chord);
+        let redo = !undo && crate::widget::match_key_shortcut(event, &self.redo_chord);
+        if !undo && !redo {
+            return false;
+        }
+        let action = if undo { crate::widget::ContextAction::Undo } else { crate::widget::ContextAction::Redo };
+        if let Some(ctx) = app.ui_context_mut() {
+            if ctx.focused_context_action(action) {
+                *rebuild = true;
+                return true;
+            }
+        }
+        let taken = if undo { app.undo(rebuild) } else { app.redo(rebuild) };
+        if taken {
+            *rebuild = true;
+        }
+        taken
+    }
+}
+
+/// A coalesced scroll frame as the delta the app is handed, and the phase it
+/// belongs to. Smooth-scroll phase: a finger lift is a stop frame (no
+/// delta); finger/continuous sources track 1:1 and may fling on the lift;
+/// everything else is a wheel notch that glides.
+fn scroll_delta(frame: &ScrollFrame, factors: crate::input::ScrollFactors) -> (crate::widget::ScrollPhase, MouseScrollDelta) {
+    let ScrollFrame { h, v, discrete_h, discrete_v, source, stop } = *frame;
+    let no_delta = h == 0.0 && v == 0.0 && discrete_h == 0 && discrete_v == 0;
+    let phase = if stop && no_delta {
+        crate::widget::ScrollPhase::FingerEnd
+    } else if discrete_h == 0
+        && discrete_v == 0
+        && matches!(source, None | Some(ScrollSource::Finger) | Some(ScrollSource::Continuous))
+    {
+        crate::widget::ScrollPhase::Finger
+    } else {
+        crate::widget::ScrollPhase::Wheel
+    };
+    let delta = if discrete_h == 0 && discrete_v == 0 {
+        // Pixel scroll event from touchpad / smooth mouse
+        MouseScrollDelta::PixelDelta(Position {
+            x: -h * factors.trackpad,
+            y: -v * factors.trackpad,
+        })
+    } else {
+        // Discrete scroll event (e.g. wheel clicks)
+        let h_lines = if discrete_h != 0 { discrete_h as f32 } else { h as f32 / 10.0 };
+        let v_lines = if discrete_v != 0 { discrete_v as f32 } else { v as f32 / 10.0 };
+        MouseScrollDelta::LineDelta(-h_lines * factors.mouse as f32, -v_lines * factors.mouse as f32)
+    };
+    (phase, delta)
+}
+
+/// The CSD resize edge under (lx, ly), if the point is within the border.
+fn csd_edge(lx: f32, ly: f32, size: LogicalSize) -> Option<ResizeEdge> {
+    let b = CSD_BORDER;
+    if ly < b {
+        Some(if lx < b {
+            ResizeEdge::TopLeft
+        } else if lx > size.width - b {
+            ResizeEdge::TopRight
+        } else {
+            ResizeEdge::Top
+        })
+    } else if ly > size.height - b {
+        Some(if lx < b {
+            ResizeEdge::BottomLeft
+        } else if lx > size.width - b {
+            ResizeEdge::BottomRight
+        } else {
+            ResizeEdge::Bottom
+        })
+    } else if lx < b {
+        Some(ResizeEdge::Left)
+    } else if lx > size.width - b {
+        Some(ResizeEdge::Right)
+    } else {
+        None
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    //! The routing, driven with no window system: a mock app records what
+    //! reaches it.
+    use super::*;
+    use crate::backend::app::{AppSender, WindowSettings};
+
+    #[derive(Debug, Clone, PartialEq)]
+    enum Seen {
+        Move(f32, f32),
+        Button(MouseButton, ElementState, f32, f32),
+        Wheel(MouseScrollDelta),
+        Key(Key, bool),
+        Focus(bool),
+        Undo,
+        Update(u32),
+    }
+
+    struct Mock {
+        seen: Vec<Seen>,
+        csd: bool,
+        takes_undo: bool,
+        /// A press makes this message, which `update` records.
+        press_msg: Option<u32>,
+    }
+
+    impl Application for Mock {
+        type Message = u32;
+        fn create(_: AppSender<u32>) -> Self {
+            unreachable!("built directly")
+        }
+        fn settings(&self) -> WindowSettings {
+            WindowSettings {
+                title: String::new(),
+                app_id: "mock".into(),
+                width: 400,
+                height: 300,
+                fullscreen: false,
+                min_size: None,
+            }
+        }
+        fn update(&mut self, msg: u32, needs_rebuild: &mut bool, _exit: &mut bool) {
+            self.seen.push(Seen::Update(msg));
+            *needs_rebuild = true;
+        }
+        fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+        fn handle_pointer_move(&mut self, pos: LogicalPosition, _: &mut bool) {
+            self.seen.push(Seen::Move(pos.x, pos.y));
+        }
+        fn handle_mouse_input(
+            &mut self,
+            button: MouseButton,
+            state: ElementState,
+            pos: LogicalPosition,
+            _: &mut bool,
+        ) -> Option<u32> {
+            self.seen.push(Seen::Button(button, state, pos.x, pos.y));
+            if state == ElementState::Pressed { self.press_msg } else { None }
+        }
+        fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, _: LogicalPosition, _: &mut bool) {
+            self.seen.push(Seen::Wheel(delta.clone()));
+        }
+        fn handle_key_input(&mut self, event: &KeyEvent, _: &mut bool) -> Option<u32> {
+            self.seen.push(Seen::Key(event.logical_key.clone(), event.repeat));
+            None
+        }
+        fn handle_focus_change(&mut self, focused: bool, _: &mut bool) {
+            self.seen.push(Seen::Focus(focused));
+        }
+        fn undo(&mut self, _: &mut bool) -> bool {
+            self.seen.push(Seen::Undo);
+            self.takes_undo
+        }
+        fn csd_resize_borders(&self) -> bool {
+            self.csd
+        }
+        fn csd_titlebar_move(&self) -> bool {
+            self.csd
+        }
+    }
+
+    fn mock() -> Mock {
+        Mock { seen: Vec::new(), csd: false, takes_undo: false, press_msg: None }
+    }
+
+    /// A driver with known chords, whatever the machine's input.kdl says.
+    fn driver() -> Driver {
+        Driver {
+            undo_chord: "ctrl+z".into(),
+            redo_chord: "ctrl+shift+z".into(),
+            group_next_chord: "ctrl+tab".into(),
+            group_prev_chord: "ctrl+shift+tab".into(),
+            ..Driver::new()
+        }
+    }
+
+    struct Flags {
+        redraw: bool,
+        exit: bool,
+    }
+
+    fn turn<'a>(app: &'a mut Mock, f: &'a mut Flags) -> Turn<'a, Mock> {
+        Turn { app, redraw: &mut f.redraw, exit: &mut f.exit }
+    }
+
+    const SIZE: LogicalSize = LogicalSize { width: 400.0, height: 300.0 };
+
+    fn site(can_grab: bool) -> PressSite {
+        PressSite { size: SIZE, on_popup: false, can_grab }
+    }
+
+    #[test]
+    fn a_lost_pointer_releases_what_was_held_then_clears_hover() {
+        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+        let at = LogicalPosition::new(50.0, 60.0);
+        d.cursor_pos = (50.0, 60.0);
+        d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, at, site(false));
+        d.pointer_press(turn(&mut app, &mut f), MouseButton::Middle, at, site(false));
+        assert_eq!(d.buttons_down, 1 | 4);
+        app.seen.clear();
+
+        d.pointer_leave(turn(&mut app, &mut f));
+        assert_eq!(
+            app.seen,
+            vec![
+                Seen::Button(MouseButton::Left, ElementState::Released, 50.0, 60.0),
+                Seen::Button(MouseButton::Middle, ElementState::Released, 50.0, 60.0),
+                Seen::Move(-10000.0, -10000.0),
+            ]
+        );
+        assert_eq!(d.buttons_down, 0);
+    }
+
+    #[test]
+    fn a_press_on_the_border_is_the_windows_only_when_the_shell_can_grab() {
+        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+        app.csd = true;
+        let corner = LogicalPosition::new(2.0, 2.0);
+        let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, site(true));
+        assert_eq!(r, Press::Resize(ResizeEdge::TopLeft));
+        assert!(app.seen.is_empty(), "a grab never reaches the app: {:?}", app.seen);
+
+        let band = LogicalPosition::new(100.0, 20.0);
+        let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, band, site(true));
+        assert_eq!(r, Press::Move);
+
+        // No grab to start (a layer surface, a shell without grabs): the app's.
+        let r = d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, site(false));
+        assert_eq!(r, Press::Dispatched);
+        assert_eq!(app.seen, vec![Seen::Button(MouseButton::Left, ElementState::Pressed, 2.0, 2.0)]);
+
+        // Nor is a press through the menu popup ever a grab.
+        app.seen.clear();
+        let popup = PressSite { on_popup: true, ..site(true) };
+        assert_eq!(d.pointer_press(turn(&mut app, &mut f), MouseButton::Left, corner, popup), Press::Dispatched);
+        assert_eq!(app.seen.len(), 1);
+    }
+
+    #[test]
+    fn a_pressed_message_reaches_update_and_asks_for_a_frame() {
+        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+        app.press_msg = Some(7);
+        d.pointer_press(turn(&mut app, &mut f), MouseButton::Right, LogicalPosition::new(9.0, 9.0), site(true));
+        assert_eq!(app.seen.last(), Some(&Seen::Update(7)));
+        assert!(f.redraw);
+    }
+
+    #[test]
+    fn the_undo_chord_goes_to_the_app_hook_before_key_dispatch() {
+        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+        d.set_modifiers(&mut app, Modifiers { ctrl: true, ..Modifiers::default() });
+        app.takes_undo = true;
+        d.key(turn(&mut app, &mut f), Key::Character("z".into()), None, ElementState::Pressed);
+        assert_eq!(app.seen, vec![Seen::Undo]);
+        assert!(f.redraw);
+
+        // Declined by the app, the key is dispatched as usual.
+        app.seen.clear();
+        app.takes_undo = false;
+        d.key(turn(&mut app, &mut f), Key::Character("z".into()), None, ElementState::Pressed);
+        assert_eq!(app.seen, vec![Seen::Undo, Seen::Key(Key::Character("z".into()), false)]);
+    }
+
+    #[test]
+    fn a_held_key_repeats_after_the_delay_and_stops_on_release() {
+        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+        let bs = Key::Named(NamedKey::Backspace);
+        d.key(turn(&mut app, &mut f), bs.clone(), None, ElementState::Pressed);
+        app.seen.clear();
+
+        // Inside the delay: nothing.
+        d.repeat_keys(turn(&mut app, &mut f));
+        assert!(app.seen.is_empty());
+
+        // Past it: one repeat per turn that is an interval on.
+        let pk = d.pressed_key.as_mut().unwrap();
+        pk.first_pressed -= KEY_REPEAT_DELAY;
+        pk.last_repeated -= KEY_REPEAT_INTERVAL;
+        d.repeat_keys(turn(&mut app, &mut f));
+        d.repeat_keys(turn(&mut app, &mut f));
+        assert_eq!(app.seen, vec![Seen::Key(bs.clone(), true)]);
+
+        d.key(turn(&mut app, &mut f), bs, None, ElementState::Released);
+        assert!(d.pressed_key.is_none());
+    }
+
+    #[test]
+    fn losing_focus_drops_the_held_key_and_modifiers() {
+        let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+        d.set_modifiers(&mut app, Modifiers { ctrl: true, shift: true, alt: true, logo: true });
+        d.key(turn(&mut app, &mut f), Key::Character("a".into()), Some("a".into()), ElementState::Pressed);
+        assert!(d.pressed_key.is_some());
+
+        d.keyboard_focus(turn(&mut app, &mut f), false);
+        assert!(d.pressed_key.is_none());
+        // Logo is left as it was, as the runner always left it.
+        assert_eq!(d.mods, Modifiers { ctrl: false, shift: false, alt: false, logo: true });
+        assert_eq!(app.seen.last(), Some(&Seen::Focus(false)));
+    }
+
+    #[test]
+    fn a_scroll_frame_is_its_phase_and_delta() {
+        // The pure half of `scroll`: the dispatch itself publishes the phase
+        // process-wide, which a parallel suite must not race.
+        use crate::input::ScrollFactors;
+        use crate::widget::ScrollPhase;
+        let unit = ScrollFactors { mouse: 1.0, trackpad: 1.0 };
+        let notch = ScrollFrame { v: 15.0, discrete_v: 1, source: Some(ScrollSource::Wheel), ..ScrollFrame::default() };
+        assert_eq!(scroll_delta(&notch, unit), (ScrollPhase::Wheel, MouseScrollDelta::LineDelta(-0.0, -1.0)));
+
+        let finger = ScrollFrame { v: 4.0, source: Some(ScrollSource::Finger), ..ScrollFrame::default() };
+        assert_eq!(
+            scroll_delta(&finger, ScrollFactors { mouse: 1.0, trackpad: 2.0 }),
+            (ScrollPhase::Finger, MouseScrollDelta::PixelDelta(Position { x: -0.0, y: -8.0 }))
+        );
+        // No source named is a finger too; a lift with no delta ends it.
+        let unnamed = ScrollFrame { h: 3.0, ..ScrollFrame::default() };
+        assert_eq!(scroll_delta(&unnamed, unit).0, ScrollPhase::Finger);
+        let lift = ScrollFrame { stop: true, source: Some(ScrollSource::Finger), ..ScrollFrame::default() };
+        assert_eq!(scroll_delta(&lift, unit).0, ScrollPhase::FingerEnd);
+        // A tilt wheel, or anything newer, glides like a wheel.
+        let tilt = ScrollFrame { h: 5.0, source: Some(ScrollSource::WheelTilt), ..ScrollFrame::default() };
+        assert_eq!(scroll_delta(&tilt, unit).0, ScrollPhase::Wheel);
+    }
+}
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 81b1861..25b57d7 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,4 +1,5 @@
 pub mod app;
+pub mod driver;
 pub mod dnd;
 pub mod menu_popup;
 pub mod tessellate;
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 36437e9..aab9a28 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1,4 +1,3 @@
-use std::time::Instant;
 use smithay_client_toolkit::{
     compositor::{CompositorHandler, CompositorState},
     data_device_manager::DataDeviceManagerState,
@@ -37,36 +36,16 @@ pub use smithay_client_toolkit::seat::pointer::CursorIcon as PointerCursorIcon;
 use calloop::EventLoop;
 use calloop_wayland_source::WaylandSource;
 use cosmic_text::FontSystem;
-use crate::widget::{TextItem, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
+use crate::widget::{TextItem, MouseButton, ElementState, Key, NamedKey};
 use crate::wayland::detect_scale_factor;
 use crate::vk::{Frame2D, VkRenderer};
 
 pub use super::app::*;
+pub use super::driver::PressedKey;
+use super::driver::{Driver, Modifiers, Press, PressSite, ResizeEdge, ScrollFrame, ScrollSource, Turn};
 pub use super::tessellate::*;
 pub use super::text::*;
 
-pub struct PressedKey {
-    pub logical_key: Key,
-    pub text: Option<String>,
-    pub first_pressed: Instant,
-    pub last_repeated: Instant,
-}
-
-fn is_repeatable_key(key: &Key) -> bool {
-    match key {
-        Key::Named(NamedKey::Backspace) |
-        Key::Named(NamedKey::Delete) |
-        Key::Named(NamedKey::ArrowLeft) |
-        Key::Named(NamedKey::ArrowRight) |
-        Key::Named(NamedKey::ArrowUp) |
-        Key::Named(NamedKey::ArrowDown) |
-        Key::Named(NamedKey::Home) |
-        Key::Named(NamedKey::End) |
-        Key::Character(_) => true,
-        _ => false,
-    }
-}
-
 /// Default cap on the runner's idle sleep — see `Application::idle_poll_interval`.
 pub const IDLE_DISPATCH: std::time::Duration = std::time::Duration::from_millis(1000);
 
@@ -155,18 +134,9 @@ pub struct EngineState<A: Application> {
     /// 1; a persistent count means no frame is presenting and deserves a warn.
     pub extent_gate_skips: u32,
     pub first_configure_received: bool,
-    pub ctrl_pressed: bool,
-    /// The `undo` / `redo` chords, resolved from `input.kdl` at startup.
-    pub undo_chord: String,
-    /// `focus_next_group` / `focus_prev_group` (input.kdl, cce-ui domain):
-    /// the plate-navigation group jump, for apps that opt in.
-    pub group_next_chord: String,
-    pub group_prev_chord: String,
-    pub redo_chord: String,
-    pub shift_pressed: bool,
-    pub alt_pressed: bool,
-    pub logo_pressed: bool,
-    pub pressed_key: Option<PressedKey>,
+    /// The session's input state and routing: modifiers, the held key, the
+    /// pointer's place and held buttons, the chords (see [`Driver`]).
+    pub driver: Driver,
     pub sender: calloop::channel::Sender<A::Message>,
     pub current_cursor_icon: Option<CursorIcon>,
     pub qh: QueueHandle<EngineState<A>>,
@@ -179,19 +149,9 @@ pub struct EngineState<A: Application> {
     /// Latest unrendered grid_patch (serial, x, y, w, h, scale) — a newer
     /// event supersedes an unconsumed older one, per protocol.
     pub pending_grid_patch: Option<(u32, f64, f64, f64, f64, f64)>,
-    pub last_pinch_scale: f32,
-    pub cursor_pos: (f32, f32),
     /// Serial of the most recent pointer press, kept for
     /// [`Application::take_window_action`] move/resize grabs.
     pub last_press_serial: Option<u32>,
-    /// Mouse buttons currently held, as a bitmask (1 Left / 2 Right /
-    /// 4 Middle). On pointer Leave mid-gesture the real Release goes to
-    /// whatever surface takes the pointer next (fullscreen switches, layout
-    /// animations), so Leave synthesizes releases for the held set — a drag
-    /// must end, not stay armed and steered by later motion — and only then
-    /// runs the off-screen hover-clear (which would otherwise corrupt the
-    /// drag: a ramp key snapped to the graph corner).
-    pub buttons_down: u32,
     /// This frame's display-list text, shaped and held here so the `TextSpan`s built
     /// in the render pass can borrow the buffers (Phase 6 —
     /// [`Application::display_list_text`]).
@@ -403,51 +363,22 @@ impl<A: Application> EngineState<A> {
         self.sent_popover_region = next;
     }
 
-    /// The cursor for the pointer at (lx, ly): the app's
-    /// [`Application::cursor_icon`] override, else the standard-CSD edge
-    /// cursors (status bars and non-standard-CSD apps fall back to Default).
+    /// The driver and the app's turn, borrowed apart: every input dispatch
+    /// is `let (driver, t) = self.turn(); driver.<event>(t, ..)`.
+    fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
+        (
+            &mut self.driver,
+            Turn { app: self.inner.as_mut().unwrap(), redraw: &mut self.redraw, exit: &mut self.exit },
+        )
+    }
+
+    fn logical_size(&self) -> LogicalSize {
+        LogicalSize::new(self.logical_width, self.logical_height)
+    }
+
+    /// The cursor for the pointer at (lx, ly) — see [`Driver::cursor_icon_at`].
     fn cursor_icon_at(&self, lx: f32, ly: f32) -> CursorIcon {
-        // Over the context menu the pointer is the menu's,
-        // whatever of the app lies at that place under it (a splitter, a
-        // resize border) — and in their popups that place may be outside
-        // the window altogether.
-        if crate::widget::context_menu::is_visible() && crate::widget::context_menu::hit_test(lx, ly) {
-            return CursorIcon::Default;
-        }
-        let inner = self.inner.as_ref().unwrap();
-        if let Some(icon) = inner.cursor_icon(lx, ly) {
-            return icon;
-        }
-        if inner.settings().app_id.starts_with("cce-status")
-            || !inner.standard_csd()
-            || !inner.csd_resize_borders()
-        {
-            return CursorIcon::Default;
-        }
-        let border = 8.0f32;
-        if ly < border {
-            if lx < border {
-                CursorIcon::NwResize
-            } else if lx > self.logical_width - border {
-                CursorIcon::NeResize
-            } else {
-                CursorIcon::NResize
-            }
-        } else if ly > self.logical_height - border {
-            if lx < border {
-                CursorIcon::SwResize
-            } else if lx > self.logical_width - border {
-                CursorIcon::SeResize
-            } else {
-                CursorIcon::SResize
-            }
-        } else if lx < border {
-            CursorIcon::WResize
-        } else if lx > self.logical_width - border {
-            CursorIcon::EResize
-        } else {
-            CursorIcon::Default
-        }
+        self.driver.cursor_icon_at(self.inner.as_ref().unwrap(), lx, ly, self.logical_size())
     }
 
     pub fn render(&mut self) {
@@ -1033,13 +964,8 @@ impl<A: Application> PointerHandler for EngineState<A> {
         events: &[smithay_client_toolkit::seat::pointer::PointerEvent],
     ) {
         use smithay_client_toolkit::seat::pointer::PointerEventKind;
-        let mut coalesced_h = 0.0f64;
-        let mut coalesced_v = 0.0f64;
-        let mut discrete_h = 0;
-        let mut discrete_v = 0;
+        let mut scroll = ScrollFrame::default();
         let mut has_scroll = false;
-        let mut axis_source: Option<wl_pointer::AxisSource> = None;
-        let mut axis_stop = false;
         let (mut last_lx, mut last_ly) = (0.0f32, 0.0f32);
 
         // Forced mode: pointer positions arrive in the compositor's scale-1
@@ -1060,19 +986,13 @@ impl<A: Application> PointerHandler for EngineState<A> {
                 Some((ox, oy)) => (lx + ox, ly + oy),
                 None => (lx, ly),
             };
+            let pos = LogicalPosition::new(lx, ly);
 
-            self.cursor_pos = (lx, ly);
+            self.driver.cursor_pos = (lx, ly);
             match &event.kind {
                 PointerEventKind::Enter { .. } => {
-                    // Enter carries the pointer's position but no Motion follows until it
-                    // actually moves — without this the app's hover state is stale from
-                    // enter to first move, and a press in that window can misroute (e.g. a
-                    // divider press falling through to the movable-root plate window drag).
-                    let mut rebuild = false;
-                    self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(lx, ly), &mut rebuild);
-                    if rebuild {
-                        self.redraw = true;
-                    }
+                    let (driver, t) = self.turn();
+                    driver.pointer_enter(t, pos);
 
                     let cursor_icon = self.cursor_icon_at(lx, ly);
                     self.current_cursor_icon = Some(cursor_icon);
@@ -1082,56 +1002,14 @@ impl<A: Application> PointerHandler for EngineState<A> {
                 }
                 PointerEventKind::Leave { .. } => {
                     self.current_cursor_icon = None;
-                    // Focus can move mid-gesture (a fullscreen switch, a
-                    // relayout sliding the window away): the real Release
-                    // then lands on another surface, and an armed drag would
-                    // live forever, steered by whatever motion arrives next.
-                    // End held gestures with synthetic releases at the last
-                    // known cursor position before anything else.
-                    if self.buttons_down != 0 {
-                        let (px, py) = self.cursor_pos;
-                        for (bit, btn) in
-                            [(1u32, MouseButton::Left), (2, MouseButton::Right), (4, MouseButton::Middle)]
-                        {
-                            if self.buttons_down & bit == 0 {
-                                continue;
-                            }
-                            let mut rebuild = false;
-                            if let Some(msg) = self.inner.as_mut().unwrap().handle_mouse_input(
-                                btn,
-                                ElementState::Released,
-                                LogicalPosition::new(px, py),
-                                &mut rebuild,
-                            ) {
-                                let mut update_rebuild = false;
-                                self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
-                                if update_rebuild {
-                                    rebuild = true;
-                                }
-                            }
-                            if rebuild {
-                                self.redraw = true;
-                            }
-                        }
-                        self.buttons_down = 0;
-                    }
-                    // Then clear hover with an off-screen move — safe now
-                    // that no drag is held.
-                    let mut rebuild = false;
-                    self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(-10000.0, -10000.0), &mut rebuild);
-                    if rebuild {
-                        self.redraw = true;
-                    }
+                    let (driver, t) = self.turn();
+                    driver.pointer_leave(t);
                 }
                 PointerEventKind::Motion { .. } => {
-                    let mut rebuild = false;
-                    self.inner.as_mut().unwrap().handle_pointer_move(LogicalPosition::new(lx, ly), &mut rebuild);
-                    if rebuild {
-                        self.redraw = true;
-                    }
+                    let (driver, t) = self.turn();
+                    driver.pointer_motion(t, pos);
 
                     let cursor_icon = self.cursor_icon_at(lx, ly);
-
                     if self.current_cursor_icon != Some(cursor_icon) {
                         self.current_cursor_icon = Some(cursor_icon);
                         if let Some(ref themed_pointer) = self.pointer {
@@ -1140,158 +1018,41 @@ impl<A: Application> PointerHandler for EngineState<A> {
                     }
                 }
                 PointerEventKind::Press { button, serial, .. } => {
-                    let btn = match *button {
-                        272 => MouseButton::Left,
-                        273 => MouseButton::Right,
-                        274 => MouseButton::Middle,
-                        _ => continue,
-                    };
+                    let Some(btn) = evdev_button(*button) else { continue };
                     self.last_press_serial = Some(*serial);
-                    self.buttons_down |= match btn {
-                        MouseButton::Left => 1,
-                        MouseButton::Right => 2,
-                        _ => 4,
+                    let seat = self.seats.first().cloned().or_else(|| self.seat_state.seats().next());
+                    let site = PressSite {
+                        size: self.logical_size(),
+                        on_popup,
+                        can_grab: self.window.is_some() && seat.is_some(),
                     };
-
-                    // Client-Side Decorations (CSD) Drag & Resize Handling
-                    let is_status_bar = self.inner.as_ref().unwrap().settings().app_id.starts_with("cce-status");
-                    // Never on the menu popup: its presses are the menu's, and
-                    // its coordinates, translated into the window's, would
-                    // otherwise read as a resize border or a movable plate.
-                    if btn == MouseButton::Left && !on_popup && !is_status_bar && self.inner.as_ref().unwrap().standard_csd() {
-                        let border = 8.0f32;
-                        let mut edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::None;
-                        if !self.inner.as_ref().unwrap().csd_resize_borders() {
-                            // Resize borders are off: the compositor's own band
-                            // outside the window handles it. Fall through to the
-                            // move checks so drag-to-move still works.
-                        } else if ly < border {
-                            if lx < border {
-                                edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::TopLeft;
-                            } else if lx > self.logical_width - border {
-                                edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::TopRight;
-                            } else {
-                                edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::Top;
-                            }
-                        } else if ly > self.logical_height - border {
-                            if lx < border {
-                                edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::BottomLeft;
-                            } else if lx > self.logical_width - border {
-                                edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::BottomRight;
-                            } else {
-                                edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::Bottom;
-                            }
-                        } else if lx < border {
-                            edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::Left;
-                        } else if lx > self.logical_width - border {
-                            edge = smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::Right;
-                        }
-
-                        if edge != smithay_client_toolkit::reexports::protocols::xdg::shell::client::xdg_toplevel::ResizeEdge::None {
-                            if let Some(ref window) = self.window {
-                                let seat_owned = self.seats.first().cloned().or_else(|| self.seat_state.seats().next());
-                                if let Some(ref seat) = seat_owned {
-                                    window.resize(seat, *serial, edge);
-                                    continue;
-                                }
-                            }
-                        }
-
-                        // Titlebar drag check: y is in [8.0, 32.0], and x is not in the top-right button area
-                        let mut should_move = false;
-                        let mut is_widget = false;
-                        if let Some(ctx) = self.inner.as_ref().unwrap().ui_context() {
-                            if ctx.is_widget_at(lx, ly) {
-                                is_widget = true;
-                            }
-                        }
-                        if !is_widget
-                            && self.inner.as_ref().unwrap().csd_titlebar_move()
-                            && ly >= border && ly < 32.0 && lx < self.logical_width - 70.0
-                        {
-                            should_move = true;
-                        } else if self.inner.as_ref().unwrap().is_movable_root_plate_at(lx, ly) {
-                            should_move = true;
-                        }
-
-                        if should_move {
-                            if let Some(ref window) = self.window {
-                                let seat_owned = self.seats.first().cloned().or_else(|| self.seat_state.seats().next());
-                                if let Some(ref seat) = seat_owned {
-                                    window.move_(seat, *serial);
-                                    continue;
-                                }
-                            }
-                        }
-                    }
-
-                    // Outside-press close for open popovers, BEFORE the app's
-                    // dispatch: apps commonly region-gate their routing, so an
-                    // open menu's owner may never hear about a press elsewhere.
-                    if btn == MouseButton::Left {
-                        let app = self.inner.as_mut().unwrap();
-                        let offsets: Vec<_> = app
-                            .ui_context()
-                            .map(|ctx| ctx.popover_owners())
-                            .unwrap_or_default()
-                            .into_iter()
-                            .map(|id| (id, app.popover_offset(id)))
-                            .collect();
-                        if let Some(ctx) = app.ui_context_mut() {
-                            ctx.close_popovers_missed_by_press_with(lx, ly, |id| {
-                                offsets.iter().find(|(o, _)| *o == id).map_or((0.0, 0.0), |&(_, d)| d)
-                            });
-                        }
-                    }
-
-                    let mut rebuild = false;
-                    if let Some(msg) = self.inner.as_mut().unwrap().handle_mouse_input(btn, ElementState::Pressed, LogicalPosition::new(lx, ly), &mut rebuild) {
-                        let mut update_rebuild = false;
-                        self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
-                        if update_rebuild {
-                            rebuild = true;
+                    let (driver, t) = self.turn();
+                    let press = driver.pointer_press(t, btn, pos, site);
+                    if let (Some(window), Some(seat)) = (&self.window, &seat) {
+                        match press {
+                            Press::Dispatched => {}
+                            Press::Resize(edge) => window.resize(seat, *serial, xdg_resize_edge(edge)),
+                            Press::Move => window.move_(seat, *serial),
                         }
                     }
-                    if rebuild {
-                        self.redraw = true;
-                    }
                 }
                 PointerEventKind::Release { button, .. } => {
-                    let btn = match *button {
-                        272 => MouseButton::Left,
-                        273 => MouseButton::Right,
-                        274 => MouseButton::Middle,
-                        _ => continue,
-                    };
-                    self.buttons_down &= !match btn {
-                        MouseButton::Left => 1,
-                        MouseButton::Right => 2,
-                        _ => 4,
-                    };
-                    let mut rebuild = false;
-                    if let Some(msg) = self.inner.as_mut().unwrap().handle_mouse_input(btn, ElementState::Released, LogicalPosition::new(lx, ly), &mut rebuild) {
-                        let mut update_rebuild = false;
-                        self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
-                        if update_rebuild {
-                            rebuild = true;
-                        }
-                    }
-                    if rebuild {
-                        self.redraw = true;
-                    }
+                    let Some(btn) = evdev_button(*button) else { continue };
+                    let (driver, t) = self.turn();
+                    driver.pointer_release(t, btn, pos);
                 }
                 PointerEventKind::Axis { horizontal, vertical, source, .. } => {
-                    coalesced_h += horizontal.absolute;
-                    coalesced_v += vertical.absolute;
-                    discrete_h += horizontal.discrete;
-                    discrete_v += vertical.discrete;
+                    scroll.h += horizontal.absolute;
+                    scroll.v += vertical.absolute;
+                    scroll.discrete_h += horizontal.discrete;
+                    scroll.discrete_v += vertical.discrete;
                     // The source and the finger-lift stop ride in the same
                     // frame as the deltas (or alone, for the lift): they
-                    // decide the smooth-scroll phase below.
-                    if source.is_some() {
-                        axis_source = *source;
+                    // decide the smooth-scroll phase.
+                    if let Some(source) = source {
+                        scroll.source = Some(scroll_source(*source));
                     }
-                    axis_stop |= horizontal.stop || vertical.stop;
+                    scroll.stop |= horizontal.stop || vertical.stop;
                     last_lx = lx;
                     last_ly = ly;
                     has_scroll = true;
@@ -1300,58 +1061,8 @@ impl<A: Application> PointerHandler for EngineState<A> {
         }
 
         if has_scroll {
-            // Per-app scroll factors from input.kdl (`<app>`/`cce-ui` domain
-            // `input { }` blocks); the compositor's global device scaling has
-            // already been applied at the source.
-            let factors = crate::input::scroll_factors();
-            // Smooth-scroll phase for this dispatch: a finger lift is a stop
-            // frame (no delta); finger/continuous sources track 1:1 and may
-            // fling on the lift; everything else is a wheel notch that glides.
-            let no_delta = coalesced_h == 0.0 && coalesced_v == 0.0 && discrete_h == 0 && discrete_v == 0;
-            let phase = if axis_stop && no_delta {
-                crate::widget::ScrollPhase::FingerEnd
-            } else if discrete_h == 0 && discrete_v == 0
-                && matches!(
-                    axis_source,
-                    None | Some(wl_pointer::AxisSource::Finger) | Some(wl_pointer::AxisSource::Continuous)
-                )
-            {
-                crate::widget::ScrollPhase::Finger
-            } else {
-                crate::widget::ScrollPhase::Wheel
-            };
-            crate::widget::scroll_motion::set_scroll_phase(phase);
-            let delta = if discrete_h == 0 && discrete_v == 0 {
-                // Pixel scroll event from touchpad / smooth mouse
-                MouseScrollDelta::PixelDelta(Position {
-                    x: -coalesced_h * factors.trackpad,
-                    y: -coalesced_v * factors.trackpad,
-                })
-            } else {
-                // Discrete scroll event (e.g. wheel clicks)
-                let h_lines = if discrete_h != 0 { discrete_h as f32 } else { coalesced_h as f32 / 10.0 };
-                let v_lines = if discrete_v != 0 { discrete_v as f32 } else { coalesced_v as f32 / 10.0 };
-                MouseScrollDelta::LineDelta(-h_lines * factors.mouse as f32, -v_lines * factors.mouse as f32)
-            };
-            if crate::scroll_debug() {
-                static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
-                let t = T0.get_or_init(std::time::Instant::now).elapsed().as_millis();
-                eprintln!(
-                    "[scroll {t}ms] runner: coalesced=({coalesced_h:.2},{coalesced_v:.2}) discrete=({discrete_h},{discrete_v}) source={axis_source:?} stop={axis_stop} phase={phase:?} factors=(tp {:.2}, m {:.2}) -> {delta:?} at ({last_lx:.0},{last_ly:.0})",
-                    factors.trackpad, factors.mouse
-                );
-            }
-            let mut rebuild = false;
-            if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
-                ctx.ctrl_pressed = self.ctrl_pressed;
-                ctx.shift_pressed = self.shift_pressed;
-                ctx.alt_pressed = self.alt_pressed;
-                ctx.logo_pressed = self.logo_pressed;
-            }
-            self.inner.as_mut().unwrap().handle_mouse_wheel(&delta, LogicalPosition::new(last_lx, last_ly), &mut rebuild);
-            if rebuild {
-                self.redraw = true;
-            }
+            let (driver, t) = self.turn();
+            driver.scroll(t, scroll, LogicalPosition::new(last_lx, last_ly));
         }
 
         // App-driven window move/resize (non-standard CSD; see WindowAction):
@@ -1370,6 +1081,41 @@ impl<A: Application> PointerHandler for EngineState<A> {
     }
 }
 
+/// An evdev button code as one of cce-ui's buttons; the rest are not routed.
+fn evdev_button(code: u32) -> Option<MouseButton> {
+    match code {
+        272 => Some(MouseButton::Left),
+        273 => Some(MouseButton::Right),
+        274 => Some(MouseButton::Middle),
+        _ => None,
+    }
+}
+
+fn xdg_resize_edge(edge: ResizeEdge) -> xdg_toplevel::ResizeEdge {
+    match edge {
+        ResizeEdge::Top => xdg_toplevel::ResizeEdge::Top,
+        ResizeEdge::Bottom => xdg_toplevel::ResizeEdge::Bottom,
+        ResizeEdge::Left => xdg_toplevel::ResizeEdge::Left,
+        ResizeEdge::Right => xdg_toplevel::ResizeEdge::Right,
+        ResizeEdge::TopLeft => xdg_toplevel::ResizeEdge::TopLeft,
+        ResizeEdge::TopRight => xdg_toplevel::ResizeEdge::TopRight,
+        ResizeEdge::BottomLeft => xdg_toplevel::ResizeEdge::BottomLeft,
+        ResizeEdge::BottomRight => xdg_toplevel::ResizeEdge::BottomRight,
+    }
+}
+
+/// A `wl_pointer` axis source as the driver's. Anything newer than the four
+/// known sources scrolls as a wheel, as it did when the runner matched on
+/// the protocol enum itself.
+fn scroll_source(source: wl_pointer::AxisSource) -> ScrollSource {
+    match source {
+        wl_pointer::AxisSource::Finger => ScrollSource::Finger,
+        wl_pointer::AxisSource::Continuous => ScrollSource::Continuous,
+        wl_pointer::AxisSource::WheelTilt => ScrollSource::WheelTilt,
+        _ => ScrollSource::Wheel,
+    }
+}
+
 impl<A: Application> KeyboardHandler for EngineState<A> {
     fn enter(
         &mut self,
@@ -1381,11 +1127,8 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
         _raw_modifiers: &[u32],
         _keysyms: &[xkeysym::Keysym],
     ) {
-        let mut rebuild = false;
-        self.inner.as_mut().unwrap().handle_focus_change(true, &mut rebuild);
-        if rebuild {
-            self.redraw = true;
-        }
+        let (driver, t) = self.turn();
+        driver.keyboard_focus(t, true);
     }
 
     fn leave(
@@ -1396,17 +1139,10 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
         _surface: &wl_surface::WlSurface,
         _serial: u32,
     ) {
-        self.pressed_key = None;
-        self.ctrl_pressed = false;
-        self.shift_pressed = false;
-        self.alt_pressed = false;
-        let mut rebuild = false;
-        self.inner.as_mut().unwrap().handle_focus_change(false, &mut rebuild);
-        if rebuild {
-            self.redraw = true;
-        }
+        let (driver, t) = self.turn();
+        driver.keyboard_focus(t, false);
     }
-    
+
     fn press_key(
         &mut self,
         _conn: &Connection,
@@ -1417,7 +1153,7 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
     ) {
         self.handle_key(event, ElementState::Pressed);
     }
-    
+
     fn release_key(
         &mut self,
         _conn: &Connection,
@@ -1428,7 +1164,7 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
     ) {
         self.handle_key(event, ElementState::Released);
     }
-    
+
     fn update_modifiers(
         &mut self,
         _conn: &Connection,
@@ -1438,17 +1174,13 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
         modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
         _layout: u32,
     ) {
-        self.ctrl_pressed = modifiers.ctrl;
-        self.shift_pressed = modifiers.shift;
-        self.alt_pressed = modifiers.alt;
-        self.logo_pressed = modifiers.logo;
-
-        if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
-            ctx.ctrl_pressed = self.ctrl_pressed;
-            ctx.shift_pressed = self.shift_pressed;
-            ctx.alt_pressed = self.alt_pressed;
-            ctx.logo_pressed = self.logo_pressed;
-        }
+        let mods = Modifiers {
+            ctrl: modifiers.ctrl,
+            shift: modifiers.shift,
+            alt: modifiers.alt,
+            logo: modifiers.logo,
+        };
+        self.driver.set_modifiers(self.inner.as_mut().unwrap(), mods);
     }
 
     fn update_repeat_info(
@@ -1469,191 +1201,71 @@ impl<A: Application> KeyboardHandler for EngineState<A> {
 }
 
 impl<A: Application> EngineState<A> {
-    /// The toolkit-wide undo/redo routing: a press matching the `undo` /
-    /// `redo` chord goes to the focused widget first (`ContextAction::Undo`
-    /// / `Redo` — a text box that is editing steps its own typing), then to
-    /// the app's `Application::undo` / `redo`. Returns whether either took
-    /// it; otherwise the key is dispatched as usual, so an app with its own
-    /// scheme is undisturbed. Runs for repeats too — holding the chord walks
-    /// the history like holding Backspace walks the text.
-    /// The toolkit's Tab traversal, for apps that opt in
-    /// (`Application::plate_navigation`): a bare Tab / Shift+Tab press moves
-    /// keyboard focus to the next / previous plate or well. Returns whether it
-    /// moved; otherwise the key is dispatched as usual.
-    fn route_plate_navigation(&mut self, event: &KeyEvent, rebuild: &mut bool) -> bool {
-        if event.state != ElementState::Pressed {
-            return false;
-        }
-        // The group jump first (its chords carry ctrl); then a bare Tab.
-        let group_next = crate::widget::match_key_shortcut(event, &self.group_next_chord);
-        let group_prev = !group_next && crate::widget::match_key_shortcut(event, &self.group_prev_chord);
-        let bare_tab = event.logical_key == Key::Named(NamedKey::Tab)
-            && !self.ctrl_pressed
-            && !self.alt_pressed
-            && !self.logo_pressed;
-        if !group_next && !group_prev && !bare_tab {
-            return false;
-        }
-        let reverse = if bare_tab { self.shift_pressed } else { group_prev };
-        let app = self.inner.as_mut().unwrap();
-        if !app.plate_navigation() {
-            return false;
-        }
-        let moved = app.ui_context_mut().is_some_and(|ctx| if bare_tab { ctx.focus_step(reverse) } else { ctx.focus_step_group(reverse) });
-        if moved {
-            app.focus_stepped();
-            *rebuild = true;
-        }
-        moved
-    }
-
-    fn route_history_chord(&mut self, event: &KeyEvent, rebuild: &mut bool) -> bool {
-        if event.state != ElementState::Pressed {
-            return false;
-        }
-        let undo = crate::widget::match_key_shortcut(event, &self.undo_chord);
-        let redo = !undo && crate::widget::match_key_shortcut(event, &self.redo_chord);
-        if !undo && !redo {
-            return false;
-        }
-        let app = self.inner.as_mut().unwrap();
-        let action = if undo { crate::widget::ContextAction::Undo } else { crate::widget::ContextAction::Redo };
-        if let Some(ctx) = app.ui_context_mut() {
-            if ctx.focused_context_action(action) {
-                *rebuild = true;
-                return true;
-            }
-        }
-        let taken = if undo { app.undo(rebuild) } else { app.redo(rebuild) };
-        if taken {
-            *rebuild = true;
-        }
-        taken
+    fn handle_key(&mut self, event: smithay_client_toolkit::seat::keyboard::KeyEvent, state: ElementState) {
+        let Some(logical_key) = xkb_logical_key(&event, self.driver.mods.ctrl) else { return };
+        let (driver, t) = self.turn();
+        driver.key(t, logical_key, event.utf8, state);
     }
+}
 
-    fn handle_key(&mut self, event: smithay_client_toolkit::seat::keyboard::KeyEvent, state: ElementState) {
-        let logical_key = match event.keysym {
-            xkeysym::Keysym::Escape => Key::Named(NamedKey::Escape),
-            xkeysym::Keysym::Return => Key::Named(NamedKey::Enter),
-            xkeysym::Keysym::BackSpace => Key::Named(NamedKey::Backspace),
-            xkeysym::Keysym::Down => Key::Named(NamedKey::ArrowDown),
-            xkeysym::Keysym::Up => Key::Named(NamedKey::ArrowUp),
-            xkeysym::Keysym::Left => Key::Named(NamedKey::ArrowLeft),
-            xkeysym::Keysym::Right => Key::Named(NamedKey::ArrowRight),
-            // xkb reports Shift+Tab as ISO_Left_Tab; apps see plain Tab plus
-            // the shift modifier, matching winit.
-            xkeysym::Keysym::Tab | xkeysym::Keysym::ISO_Left_Tab => Key::Named(NamedKey::Tab),
-            xkeysym::Keysym::Delete => Key::Named(NamedKey::Delete),
-            xkeysym::Keysym::space => Key::Named(NamedKey::Space),
-            xkeysym::Keysym::Page_Up => Key::Named(NamedKey::PageUp),
-            xkeysym::Keysym::Page_Down => Key::Named(NamedKey::PageDown),
-            xkeysym::Keysym::Home => Key::Named(NamedKey::Home),
-            xkeysym::Keysym::End => Key::Named(NamedKey::End),
-            xkeysym::Keysym::Super_L | xkeysym::Keysym::Super_R => Key::Named(NamedKey::Super),
-            xkeysym::Keysym::Alt_L | xkeysym::Keysym::Alt_R => Key::Named(NamedKey::Alt),
-            xkeysym::Keysym::Control_L | xkeysym::Keysym::Control_R => Key::Named(NamedKey::Control),
-            xkeysym::Keysym::Shift_L | xkeysym::Keysym::Shift_R => Key::Named(NamedKey::Shift),
-            xkeysym::Keysym::F1 => Key::Named(NamedKey::F1),
-            xkeysym::Keysym::F2 => Key::Named(NamedKey::F2),
-            xkeysym::Keysym::F3 => Key::Named(NamedKey::F3),
-            xkeysym::Keysym::F4 => Key::Named(NamedKey::F4),
-            xkeysym::Keysym::F5 => Key::Named(NamedKey::F5),
-            xkeysym::Keysym::F6 => Key::Named(NamedKey::F6),
-            xkeysym::Keysym::F7 => Key::Named(NamedKey::F7),
-            xkeysym::Keysym::F8 => Key::Named(NamedKey::F8),
-            xkeysym::Keysym::F9 => Key::Named(NamedKey::F9),
-            xkeysym::Keysym::F10 => Key::Named(NamedKey::F10),
-            xkeysym::Keysym::F11 => Key::Named(NamedKey::F11),
-            xkeysym::Keysym::F12 => Key::Named(NamedKey::F12),
-            _ => {
-                // With Ctrl held, xkb's utf8 goes through the legacy control-character
-                // transformation (ctrl+j = "\n", ctrl+a = 0x01, ...); the keysym is
-                // untransformed, so prefer it there or ctrl+<letter> shortcuts can
-                // never match their letter.
-                if self.ctrl_pressed {
-                    if let Some(ch) = event.keysym.key_char() {
-                        Key::Character(ch.to_string())
-                    } else if let Some(ref text) = event.utf8 {
-                        Key::Character(text.clone())
-                    } else {
-                        return;
-                    }
+/// An xkb key event as one of cce-ui's keys, or `None` for a key with no
+/// meaning to it (no name here and no text).
+fn xkb_logical_key(event: &smithay_client_toolkit::seat::keyboard::KeyEvent, ctrl: bool) -> Option<Key> {
+    Some(match event.keysym {
+        xkeysym::Keysym::Escape => Key::Named(NamedKey::Escape),
+        xkeysym::Keysym::Return => Key::Named(NamedKey::Enter),
+        xkeysym::Keysym::BackSpace => Key::Named(NamedKey::Backspace),
+        xkeysym::Keysym::Down => Key::Named(NamedKey::ArrowDown),
+        xkeysym::Keysym::Up => Key::Named(NamedKey::ArrowUp),
+        xkeysym::Keysym::Left => Key::Named(NamedKey::ArrowLeft),
+        xkeysym::Keysym::Right => Key::Named(NamedKey::ArrowRight),
+        // xkb reports Shift+Tab as ISO_Left_Tab; apps see plain Tab plus
+        // the shift modifier, matching winit.
+        xkeysym::Keysym::Tab | xkeysym::Keysym::ISO_Left_Tab => Key::Named(NamedKey::Tab),
+        xkeysym::Keysym::Delete => Key::Named(NamedKey::Delete),
+        xkeysym::Keysym::space => Key::Named(NamedKey::Space),
+        xkeysym::Keysym::Page_Up => Key::Named(NamedKey::PageUp),
+        xkeysym::Keysym::Page_Down => Key::Named(NamedKey::PageDown),
+        xkeysym::Keysym::Home => Key::Named(NamedKey::Home),
+        xkeysym::Keysym::End => Key::Named(NamedKey::End),
+        xkeysym::Keysym::Super_L | xkeysym::Keysym::Super_R => Key::Named(NamedKey::Super),
+        xkeysym::Keysym::Alt_L | xkeysym::Keysym::Alt_R => Key::Named(NamedKey::Alt),
+        xkeysym::Keysym::Control_L | xkeysym::Keysym::Control_R => Key::Named(NamedKey::Control),
+        xkeysym::Keysym::Shift_L | xkeysym::Keysym::Shift_R => Key::Named(NamedKey::Shift),
+        xkeysym::Keysym::F1 => Key::Named(NamedKey::F1),
+        xkeysym::Keysym::F2 => Key::Named(NamedKey::F2),
+        xkeysym::Keysym::F3 => Key::Named(NamedKey::F3),
+        xkeysym::Keysym::F4 => Key::Named(NamedKey::F4),
+        xkeysym::Keysym::F5 => Key::Named(NamedKey::F5),
+        xkeysym::Keysym::F6 => Key::Named(NamedKey::F6),
+        xkeysym::Keysym::F7 => Key::Named(NamedKey::F7),
+        xkeysym::Keysym::F8 => Key::Named(NamedKey::F8),
+        xkeysym::Keysym::F9 => Key::Named(NamedKey::F9),
+        xkeysym::Keysym::F10 => Key::Named(NamedKey::F10),
+        xkeysym::Keysym::F11 => Key::Named(NamedKey::F11),
+        xkeysym::Keysym::F12 => Key::Named(NamedKey::F12),
+        _ => {
+            // With Ctrl held, xkb's utf8 goes through the legacy control-character
+            // transformation (ctrl+j = "\n", ctrl+a = 0x01, ...); the keysym is
+            // untransformed, so prefer it there or ctrl+<letter> shortcuts can
+            // never match their letter.
+            if ctrl {
+                if let Some(ch) = event.keysym.key_char() {
+                    Key::Character(ch.to_string())
                 } else if let Some(ref text) = event.utf8 {
                     Key::Character(text.clone())
-                } else if let Some(ch) = event.keysym.key_char() {
-                    Key::Character(ch.to_string())
                 } else {
-                    return;
+                    return None;
                 }
-            }
-        };
-
-        let custom_event = KeyEvent {
-            state,
-            logical_key,
-            text: event.utf8.clone(),
-            repeat: false,
-            ctrl: self.ctrl_pressed,
-            shift: self.shift_pressed,
-            alt: self.alt_pressed,
-        };
-
-        if state == ElementState::Pressed {
-            if is_repeatable_key(&custom_event.logical_key) {
-                self.pressed_key = Some(PressedKey {
-                    logical_key: custom_event.logical_key.clone(),
-                    text: custom_event.text.clone(),
-                    first_pressed: Instant::now(),
-                    last_repeated: Instant::now(),
-                });
+            } else if let Some(ref text) = event.utf8 {
+                Key::Character(text.clone())
+            } else if let Some(ch) = event.keysym.key_char() {
+                Key::Character(ch.to_string())
             } else {
-                self.pressed_key = None;
-            }
-        } else if state == ElementState::Released {
-            if let Some(ref pk) = self.pressed_key {
-                if pk.logical_key == custom_event.logical_key {
-                    self.pressed_key = None;
-                }
-            }
-        }
-
-        if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
-            ctx.ctrl_pressed = self.ctrl_pressed;
-            ctx.shift_pressed = self.shift_pressed;
-            ctx.alt_pressed = self.alt_pressed;
-            ctx.logo_pressed = self.logo_pressed;
-        }
-
-        // Escape dismisses the shared context menu before app dispatch — the
-        // toolkit-wide default, mirroring the click-outside dismissal. Consumed:
-        // while a menu is open, Escape means "close it", nothing else.
-        if state == ElementState::Pressed
-            && custom_event.logical_key == Key::Named(NamedKey::Escape)
-            && crate::widget::context_menu::is_visible()
-        {
-            crate::widget::context_menu::hide();
-            self.redraw = true;
-            return;
-        }
-
-        let mut rebuild = false;
-        if self.route_history_chord(&custom_event, &mut rebuild)
-            || self.route_plate_navigation(&custom_event, &mut rebuild)
-        {
-            self.redraw = true;
-            return;
-        }
-        if let Some(msg) = self.inner.as_mut().unwrap().handle_key_input(&custom_event, &mut rebuild) {
-            let mut update_rebuild = false;
-            self.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut self.exit);
-            if update_rebuild {
-                rebuild = true;
+                return None;
             }
         }
-        if rebuild {
-            self.redraw = true;
-        }
-    }
+    })
 }
 
 impl<A: Application> wayland_client::Dispatch<wl_registry::WlRegistry, GlobalList, Self> for EngineState<A> {
@@ -1813,54 +1425,12 @@ impl<A: Application> wayland_client::Dispatch<ZwpPointerGesturePinchV1, ()> for
         _qh: &QueueHandle<Self>,
     ) {
         match event {
-            zwp_pointer_gesture_pinch_v1::Event::Begin { .. } => {
-                state.last_pinch_scale = 1.0;
-            }
+            zwp_pointer_gesture_pinch_v1::Event::Begin { .. } => state.driver.pinch_begin(),
             zwp_pointer_gesture_pinch_v1::Event::Update { scale, .. } => {
-                let scale_f32 = scale as f32;
-                let factor = scale_f32 / state.last_pinch_scale;
-                state.last_pinch_scale = scale_f32;
-
-                let (px, py) = state.cursor_pos;
-                let mut rebuild = false;
-
-                // First offer the gesture as-is: apps with true pinch
-                // surfaces (the designer's 3D viewport) consume it here at
-                // 1:1 scale instead of through the wheel synthesis below.
-                if state.inner.as_mut().unwrap().handle_pinch(factor, LogicalPosition::new(px, py), &mut rebuild) {
-                    if rebuild {
-                        state.redraw = true;
-                    }
-                    return;
-                }
-
-                // Calculate the y_delta for PixelDelta mapping.
-                // Since cce-graph interprets factor = 1.0 + y_delta * 0.015, we reverse it:
-                let y_delta = (factor - 1.0) / 0.015;
-                let delta = MouseScrollDelta::PixelDelta(Position {
-                    x: 0.0,
-                    y: y_delta as f64,
-                });
-
-                if let Some(ctx) = state.inner.as_mut().unwrap().ui_context_mut() {
-                    ctx.ctrl_pressed = true; // Force ctrl_pressed = true for the pinch event
-                }
-                // A synthesized delta, not a scroll gesture: no glide, no fling.
-                crate::widget::scroll_motion::set_scroll_phase(crate::widget::ScrollPhase::Wheel);
-
-                state.inner.as_mut().unwrap().handle_mouse_wheel(&delta, LogicalPosition::new(px, py), &mut rebuild);
-
-                if let Some(ctx) = state.inner.as_mut().unwrap().ui_context_mut() {
-                    ctx.ctrl_pressed = state.ctrl_pressed; // Restore original state
-                }
-
-                if rebuild {
-                    state.redraw = true;
-                }
-            }
-            zwp_pointer_gesture_pinch_v1::Event::End { .. } => {
-                state.last_pinch_scale = 1.0;
+                let (driver, t) = state.turn();
+                driver.pinch_update(t, scale as f32);
             }
+            zwp_pointer_gesture_pinch_v1::Event::End { .. } => state.driver.pinch_end(),
             _ => {}
         }
     }
@@ -2232,15 +1802,7 @@ fn run_session<'l, A: Application>(
         warm_until: None,
         extent_gate_skips: 0,
         first_configure_received: false,
-        ctrl_pressed: false,
-        undo_chord: crate::input::app_chord("undo", "ctrl+z"),
-        redo_chord: crate::input::app_chord("redo", "ctrl+shift+z"),
-        group_next_chord: crate::input::app_chord("focus_next_group", "ctrl+tab"),
-        group_prev_chord: crate::input::app_chord("focus_prev_group", "ctrl+shift+tab"),
-        shift_pressed: false,
-        alt_pressed: false,
-        logo_pressed: false,
-        pressed_key: None,
+        driver: Driver::new(),
         sender,
         current_cursor_icon: None,
         qh: qh.clone(),
@@ -2249,10 +1811,7 @@ fn run_session<'l, A: Application>(
         pinch_gesture: None,
         cce_toplevel: None,
         pending_grid_patch: None,
-        last_pinch_scale: 1.0,
-        cursor_pos: (0.0, 0.0),
         last_press_serial: None,
-        buttons_down: 0,
         dl_text_items: Vec::new(),
     };
 
@@ -2404,9 +1963,6 @@ fn run_session<'l, A: Application>(
         engine_state.inner.as_mut().unwrap().register_sources(&loop_handle);
     }
 
-    const KEY_REPEAT_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
-    const KEY_REPEAT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
-
     /// Same switch as the renderer's present tracer, resolved once — this sits
     /// in the per-iteration path, so a `std::env::var` call here would be I/O
     /// on the loop that is under measurement.
@@ -2552,25 +2108,9 @@ fn run_session<'l, A: Application>(
             dt = dt.min(1.0 / 60.0);
         }
 
-        let mut rebuild = false;
-        let roster_ticks_before =
-            engine_state.inner.as_mut().unwrap().ui_context_mut().map(|ctx| ctx.tick_count());
-        engine_state.inner.as_mut().unwrap().tick(dt, &mut rebuild);
-        if rebuild {
-            engine_state.redraw = true;
-        }
-        // Tick the app's retained UiContext (widget tick_receivers — e.g. an
-        // animating Dropdown popover) for apps that expose it — but only when
-        // the app's own tick did not already do so this frame. Receivers
-        // integrate `dt` (scroll glides, slider inertia), so the old
-        // "double-ticking is harmless" assumption ran every glide at twice
-        // its configured rate in apps that tick the context themselves.
-        if let Some(ctx) = engine_state.inner.as_mut().unwrap().ui_context_mut() {
-            if Some(ctx.tick_count()) == roster_ticks_before {
-                if ctx.tick(dt) {
-                    engine_state.redraw = true;
-                }
-            }
+        {
+            let (driver, t) = engine_state.turn();
+            driver.tick(t, dt);
         }
 
         let just_configured = engine_state.just_configured;
@@ -2612,45 +2152,9 @@ fn run_session<'l, A: Application>(
         }
         engine_state.sync_menu_popup();
 
-        if let Some(ref mut pk) = engine_state.pressed_key {
-            let now = std::time::Instant::now();
-            if now.duration_since(pk.first_pressed) >= KEY_REPEAT_DELAY {
-                if now.duration_since(pk.last_repeated) >= KEY_REPEAT_INTERVAL {
-                    pk.last_repeated = now;
-                    let custom_event = KeyEvent {
-                        state: ElementState::Pressed,
-                        logical_key: pk.logical_key.clone(),
-                        text: pk.text.clone(),
-                        repeat: true,
-                        ctrl: engine_state.ctrl_pressed,
-                        shift: engine_state.shift_pressed,
-                        alt: engine_state.alt_pressed,
-                    };
-
-                    if let Some(ctx) = engine_state.inner.as_mut().unwrap().ui_context_mut() {
-                        ctx.ctrl_pressed = engine_state.ctrl_pressed;
-                        ctx.shift_pressed = engine_state.shift_pressed;
-                        ctx.alt_pressed = engine_state.alt_pressed;
-                        ctx.logo_pressed = engine_state.logo_pressed;
-                    }
-
-                    let mut key_rebuild = false;
-                    if engine_state.route_history_chord(&custom_event, &mut key_rebuild)
-                        || engine_state.route_plate_navigation(&custom_event, &mut key_rebuild)
-                    {
-                        engine_state.redraw = true;
-                    } else if let Some(msg) = engine_state.inner.as_mut().unwrap().handle_key_input(&custom_event, &mut key_rebuild) {
-                        let mut update_rebuild = false;
-                        engine_state.inner.as_mut().unwrap().update(msg, &mut update_rebuild, &mut engine_state.exit);
-                        if update_rebuild {
-                            key_rebuild = true;
-                        }
-                    }
-                    if key_rebuild {
-                        engine_state.redraw = true;
-                    }
-                }
-            }
+        {
+            let (driver, t) = engine_state.turn();
+            driver.repeat_keys(t);
         }
         let current_title = engine_state.inner.as_ref().unwrap().settings().title;
         if current_title != last_title {
@@ -2736,7 +2240,7 @@ fn run_session<'l, A: Application>(
         let warm = engine_state
             .warm_until
             .is_some_and(|t| std::time::Instant::now() < t);
-        let busy = engine_state.redraw || rendered || warm || engine_state.pressed_key.is_some();
+        let busy = engine_state.redraw || rendered || warm || engine_state.driver.pressed_key.is_some();
         next_timeout = if busy {
             ACTIVE_DISPATCH
         } else {