GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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(¬ch, 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 {