git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commit2cfa95272cbd4789b19646d7f6b5f8ba706e8d61
parenta0c977fcfa
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 19:00
Switcher: moving the pointer onto a row selects it

Releasing Super/Alt now switches to the window under the pointer. Only
real motion onto a new row takes the selection, so a popup mapping under
a resting pointer, or rows shifting under a still one, leaves the row
Super+Tab advanced to; Tab can still move the chip off a jiggling pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md   |  2 +-
 src/main.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 77 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 30f9124..340fbb8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -86,7 +86,7 @@ needs-stdin decision, in `run_client()`).
 
 - `Dmenu` — items from stdin, selection echoed out. Magic stdin lines
   `__cce_switcher_next__` / `__cce_switcher_select_and_close__` drive the compositor's
-  window switcher (`--switcher` starts in Dmenu with its hold modifier down, and commits when neither Super nor Alt is held, so it works bound to Super+Tab or Alt+Tab).
+  window switcher (`--switcher` starts in Dmenu with its hold modifier down, and commits when neither Super nor Alt is held, so it works bound to Super+Tab or Alt+Tab; moving the pointer onto a row selects it — `FuzzelWidget::pointer_moved`, motion only, so a popup mapping under a resting pointer does not steal the row Super+Tab advanced to).
 - `Path` — executables scanned from `$PATH`.
 **Key repeat** is the client's job on Wayland: the keyboard is bound with
 `get_keyboard_with_repeat` on the calloop loop, which re-fires a held key at
diff --git a/src/main.rs b/src/main.rs
index f8e3782..fb30ea2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -880,12 +880,19 @@ pub struct FuzzelWidget {
     icon_gutter: f32,
     /// Rows are the window switcher's `Title (app_id)` lines: draw only the
     /// title (see [`split_switcher_row`]). The item text — what filtering
-    /// matches and what a selection echoes back — keeps the suffix.
+    /// matches and what a selection echoes back — keeps the suffix. Also
+    /// makes pointer motion select (see [`Self::pointer_moved`]).
     pub switcher_rows: bool,
     /// Row under the pointer, by filtered index — the hover wash and the
     /// brighter label. Distinct from `selected`: hovering never moves the
-    /// keyboard selection, only a click does.
+    /// keyboard selection, only a click does — except in the switcher, where
+    /// MOVING onto a row selects it ([`Self::pointer_moved`]).
     hovered: Option<usize>,
+    /// The row the switcher's last pointer motion selected, so the pointer
+    /// only takes the selection when it moves onto a NEW row: Tab can still
+    /// move the chip away from a resting (or jiggling) pointer. Cleared when
+    /// the pointer leaves, so the first motion after it enters selects.
+    motion_row: Option<usize>,
     /// Last pointer position seen over the surface, so the hovered row can be
     /// re-derived when the rows move under a STATIONARY pointer — a wheel
     /// glide, a keystroke refiltering the list, a keyboard snap.
@@ -959,6 +966,7 @@ impl FuzzelWidget {
             icon_gutter: 0.0,
             switcher_rows: false,
             hovered: None,
+            motion_row: None,
             cursor: None,
             // Designer raise/sink treatment: the bar idles sunk under the
             // list's translucent bg (dimly visible through it) and raises over
@@ -1236,9 +1244,33 @@ impl FuzzelWidget {
         self.refresh_hover()
     }
 
+    /// The pointer MOVED to `(px, py)` — a Motion, unlike the Enter that a
+    /// popup mapping under a resting pointer also sends. In the switcher,
+    /// moving onto a row selects it, so releasing the hold modifier switches
+    /// to the window under the pointer. Only motion does this: rows shifting
+    /// under a still pointer (a refilter, a scroll, the popup mapping where
+    /// it rests) must not steal the selection Super+Tab just advanced. True
+    /// when anything drawn changed.
+    pub fn pointer_moved(&mut self, px: f32, py: f32) -> bool {
+        let moved = self.cursor != Some((px, py));
+        let mut changed = self.hover_at(px, py);
+        if self.switcher_rows && moved && self.hovered != self.motion_row {
+            self.motion_row = self.hovered;
+            // No `snap_to_selected`: the row is already drawn (`row_at`), and
+            // scrolling a cut edge row fully in would slide the next row under
+            // the pointer and select that one too.
+            if let Some(idx) = self.hovered.filter(|&i| i != self.selected) {
+                self.selected = idx;
+                changed = true;
+            }
+        }
+        changed
+    }
+
     /// The pointer left the surface; true when a row was lit.
     pub fn clear_hover(&mut self) -> bool {
         self.cursor = None;
+        self.motion_row = None;
         self.refresh_hover()
     }
 
@@ -2710,7 +2742,7 @@ impl PointerHandler for AppState {
                             if dragged {
                                 st.fuzzel.update_scroll();
                             }
-                            if st.fuzzel.hover_at(cx, cy) || dragged {
+                            if st.fuzzel.pointer_moved(cx, cy) || dragged {
                                 st.upload_vertices();
                                 self.redraw = true;
                             }
@@ -2800,10 +2832,11 @@ impl PointerHandler for AppState {
                                     // here), so the click IS the choice, exactly as Enter.
                                     // (The old gate gated Apps/Path on `selected ==
                                     // prev_selected`, which read as the click doing
-                                    // nothing.) The SWITCHER keeps two-click: its rows are
-                                    // live windows, and focusing one on a stray first
-                                    // click would be destructive — click to inspect the
-                                    // selection, click it again to commit.
+                                    // nothing.) The SWITCHER commits only a click on the
+                                    // row already selected — which, since moving onto a
+                                    // row selects it (`pointer_moved`), is any row the
+                                    // pointer reached by motion. A click on a row it only
+                                    // rests on (the popup mapped under it) selects first.
                                     let commit = !st.switcher_mode || st.fuzzel.selected == prev_selected;
                                     if commit {
                                         if let Some(item) = st.fuzzel.filtered_items.get(st.fuzzel.selected) {
@@ -4927,6 +4960,42 @@ mod tests {
         assert!(f.tab_at(20.0, 20.0).is_none());
     }
 
+    #[test]
+    fn switcher_motion_onto_a_row_selects_it() {
+        let mut f = FuzzelWidget::new("Search: ".to_string());
+        f.set_rect(0.0, 0.0, 600.0, 400.0);
+        f.switcher_rows = true;
+        f.set_items((0..4).map(|i| format!("Window {i} (app{i})")).collect());
+        f.selected = 1; // Super+Tab advanced past the focused window
+        let x = 100.0;
+        let (list_y, item_h) = (f.list_y(), f.item_h());
+        let row_y = |i: usize| list_y + item_h * (i as f32 + 0.5);
+
+        // The popup mapping under a resting pointer is an Enter, not motion:
+        // it lights the row but leaves the selection where Super+Tab put it.
+        f.hover_at(x, row_y(3));
+        assert_eq!(f.selected, 1);
+
+        // Any motion over a row selects it...
+        assert!(f.pointer_moved(x, row_y(3) + 1.0));
+        assert_eq!(f.selected, 3);
+        assert!(f.pointer_moved(x, row_y(2)));
+        assert_eq!(f.selected, 2);
+
+        // ...but Tab can still move the chip off a pointer jiggling in place.
+        f.selected = 0;
+        f.pointer_moved(x, row_y(2) + 1.0);
+        assert_eq!(f.selected, 0);
+
+        // Outside the switcher, hovering never moves the selection.
+        let mut g = FuzzelWidget::new("Search: ".to_string());
+        g.set_rect(0.0, 0.0, 600.0, 400.0);
+        g.set_items(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
+        let (list_y, item_h) = (g.list_y(), g.item_h());
+        g.pointer_moved(x, list_y + item_h * 2.5);
+        assert_eq!(g.selected, 0);
+    }
+
     #[test]
     fn every_system_row_runs_something() {
         // A row whose label no longer matches its command silently does