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

commit37265f24e6522035592d2c0c252833548f53346b
parentd985d810b0
authorLucas Galante <[email protected]>
date2026-09-04 12:27
feat(list): hover highlight on the launcher rows

The list had no pointer feedback: only the keyboard selection was drawn,
so the row a click would pick was invisible until it was pressed. Now the
row under the pointer gets a flat translucent wash of the selection
colour on the chip's footprint plus a brighter label — flat rather than
bevelled so it reads as "what a click picks" next to the chip's "what
Enter picks", and never on the selected row itself.

`row_at` is the single predicate shared by hover and click (inside the
list, off the scrollbar strip, a row get_draw_y places in the viewport),
so what lights is what a press selects. The hovered row is re-derived
whenever rows move under a stationary pointer — wheel glide (per-frame
tick), refilter, keyboard snap, thumb drag — and on the pointer Enter,
which is how a pointer crossing onto the list from outside arrives (no
Motion follows it); Leave clears it.

Verified headless: entering onto CMake lights it, moving to FontForge
moves the wash, leaving the surface clears it. 16 unit tests pass.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/main.rs | 143 +++++++++++++++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 118 insertions(+), 25 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 41ec2c7..29a8ddb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -609,6 +609,14 @@ pub struct FuzzelWidget {
     /// every row, not just the ones that resolved, so a list with one missing
     /// icon keeps a straight text edge instead of ragging in and out.
     icon_gutter: f32,
+    /// 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.
+    hovered: 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.
+    cursor: Option<(f32, f32)>,
     pub scroll_box: ScrollRegion,
 }
 
@@ -630,6 +638,8 @@ impl FuzzelWidget {
             selected: 0,
             icons: std::collections::HashMap::new(),
             icon_gutter: 0.0,
+            hovered: None,
+            cursor: None,
             // Designer raise/sink treatment: the bar idles sunk under the
             // list's translucent bg (dimly visible through it) and raises over
             // the rows on scroll. The region already sits inset from the popup
@@ -684,6 +694,50 @@ impl FuzzelWidget {
         let viewport_h = self.h - (pad + search_h + 10.0) - pad;
         let content_h = self.filtered_items.len() as f32 * item_h;
         self.scroll_box.update_bounds_raw(content_h, viewport_y, viewport_h);
+        self.refresh_hover();
+    }
+
+    /// Filtered index of the row drawn at `(px, py)` — the ONE predicate the
+    /// click and the hover share, so what lights up is what a press picks:
+    /// inside the list, off the scrollbar strip (`hit()` spans it, and a
+    /// press there once resolved to a row and committed it in dmenu mode),
+    /// and a row `get_draw_y` places in the viewport — partially visible
+    /// rows included, drawn cut by the clip, so an edge sliver counts.
+    fn row_at(&self, px: f32, py: f32) -> Option<usize> {
+        let item_h = 25.0;
+        if !self.scroll_box.hit(px, py) || self.scroll_box.hit_scrollbar(px, py) {
+            return None;
+        }
+        let virtual_y = py - self.scroll_box.viewport_y + self.scroll_box.scroll_y;
+        if virtual_y < 0.0 {
+            return None;
+        }
+        let idx = (virtual_y / item_h).floor() as usize;
+        (idx < self.filtered_items.len()
+            && self.scroll_box.get_draw_y(idx as f32 * item_h, item_h).is_some())
+            .then_some(idx)
+    }
+
+    /// The pointer moved to `(px, py)`; true when the hovered row changed.
+    pub fn hover_at(&mut self, px: f32, py: f32) -> bool {
+        self.cursor = Some((px, py));
+        self.refresh_hover()
+    }
+
+    /// The pointer left the surface; true when a row was lit.
+    pub fn clear_hover(&mut self) -> bool {
+        self.cursor = None;
+        self.refresh_hover()
+    }
+
+    /// Re-derive the hovered row from the last pointer position — the rows
+    /// move under a stationary pointer on every scroll and refilter. True on
+    /// change, so callers can skip the re-upload when nothing moved.
+    pub fn refresh_hover(&mut self) -> bool {
+        let now = self.cursor.and_then(|(px, py)| self.row_at(px, py));
+        let changed = now != self.hovered;
+        self.hovered = now;
+        changed
     }
 
     pub fn snap_to_selected(&mut self) {
@@ -712,6 +766,7 @@ impl FuzzelWidget {
         if (self.scroll_box.scroll_y - old_scroll).abs() > 0.01 {
             self.scroll_box.notify_scrolled();
         }
+        self.refresh_hover();
     }
 }
 
@@ -793,6 +848,23 @@ impl cce_ui::widget::Paint for FuzzelWidget {
                 }
             }
 
+            // Hover wash — a flat, translucent pass of the selection colour
+            // on the chip's footprint under the pointer. Flat on purpose: the
+            // bevelled chip says "this is what Enter picks", the wash only
+            // "this is what a click would pick". Never on the selected row,
+            // which already wears the chip.
+            if let Some(idx) = self.hovered.filter(|&i| i != self.selected) {
+                if let Some(draw_y) = self.scroll_box.get_draw_y(idx as f32 * item_h, item_h) {
+                    let hov = Rect {
+                        x: self.x + pad + 2.0,
+                        y: draw_y,
+                        width: self.w - pad * 2.0 - 4.0,
+                        height: item_h - 2.0,
+                    };
+                    ctx.quad(hov, [0.20, 0.35, 0.65, 0.35]);
+                }
+            }
+
             // App icons, on the same virtualization predicate as the labels:
             // only rows `get_draw_y` places in the viewport are emitted, so a
             // 300-app list still costs one image quad per visible row.
@@ -838,28 +910,12 @@ impl cce_ui::widget::Input for FuzzelWidget {
             ..
         } = event
         {
-            let item_h = 25.0;
-            // `hit()` spans the whole region, scrollbar strip included, and the row math
-            // below accepts any y inside it — so without these two gates a press on the
-            // scrollbar resolved to a row. In Dmenu/switcher mode a press commits and
-            // closes, so that emitted an item the user never clicked. The `get_draw_y`
-            // gate keeps click and paint on the SAME predicate: it now returns partially
-            // visible rows too (drawn cut by the viewport clip), so a press on an edge
-            // sliver selects the row the user can see — visible ⇒ clickable, culled ⇒ not.
-            if self.scroll_box.hit(*px, *py) && !self.scroll_box.hit_scrollbar(*px, *py) {
-                let click_virtual_y = *py - self.scroll_box.viewport_y + self.scroll_box.scroll_y;
-                let clicked_idx = (click_virtual_y / item_h).floor() as usize;
-                // Same predicate the paint loop virtualizes on, so only a row actually
-                // drawn this frame is selectable.
-                if clicked_idx < self.filtered_items.len()
-                    && self
-                        .scroll_box
-                        .get_draw_y(clicked_idx as f32 * item_h, item_h)
-                        .is_some()
-                {
-                    self.selected = clicked_idx;
-                    return true;
-                }
+            // `row_at` is the same predicate the hover and the paint loop use
+            // (visible ⇒ clickable, culled ⇒ not), so a press picks the row
+            // that is lit under the pointer.
+            if let Some(idx) = self.row_at(*px, *py) {
+                self.selected = idx;
+                return true;
             }
         }
         false
@@ -1991,10 +2047,34 @@ impl PointerHandler for AppState {
                                 st.upload_vertices();
                                 self.redraw = true;
                             }
-                        } else if st.fuzzel.scroll_box.cursor_moved(cx, cy) {
+                        } else {
                             // Returns true only while a thumb drag is live; it also keeps
-                            // `hovered` current for the wheel/keyboard scope either way.
-                            st.fuzzel.update_scroll();
+                            // the region's `hovered` current for the wheel/keyboard scope
+                            // either way. A drag moves the rows under the pointer, so the
+                            // hover row is re-derived after it (update_scroll does that).
+                            let dragged = st.fuzzel.scroll_box.cursor_moved(cx, cy);
+                            if dragged {
+                                st.fuzzel.update_scroll();
+                            }
+                            if st.fuzzel.hover_at(cx, cy) || dragged {
+                                st.upload_vertices();
+                                self.redraw = true;
+                            }
+                        }
+                    }
+                    // The entry into the surface arrives as Enter with the
+                    // position, not as a Motion — a pointer that crosses onto
+                    // the list from outside lands ON a row and must light it.
+                    PointerEventKind::Enter { .. } => {
+                        st.cursor_x = cx;
+                        st.cursor_y = cy;
+                        if st.mode != LauncherMode::Json && st.fuzzel.hover_at(cx, cy) {
+                            st.upload_vertices();
+                            self.redraw = true;
+                        }
+                    }
+                    PointerEventKind::Leave { .. } => {
+                        if st.mode != LauncherMode::Json && st.fuzzel.clear_hover() {
                             st.upload_vertices();
                             self.redraw = true;
                         }
@@ -2867,6 +2947,11 @@ fn run_standalone() {
             if state.fuzzel.scroll_box.tick(dt) {
                 app.redraw = true;
             }
+            // A wheel glide moves the rows under a stationary pointer.
+            if state.fuzzel.refresh_hover() {
+                state.upload_vertices();
+                app.redraw = true;
+            }
         }
 
         if app.redraw {
@@ -3381,6 +3466,11 @@ fn run_daemon(socket_path: &str) {
                 if st.fuzzel.scroll_box.tick(dt) {
                     app.redraw = true;
                 }
+                // A wheel glide moves the rows under a stationary pointer.
+                if st.fuzzel.refresh_hover() {
+                    st.upload_vertices();
+                    app.redraw = true;
+                }
             }
 
             if app.redraw {
@@ -4074,6 +4164,9 @@ impl FuzzelWidget {
             if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_y, item_h) {
                 let color = if idx == self.selected {
                     [0xff, 0xff, 0xff]
+                } else if self.hovered == Some(idx) {
+                    // A step toward the selected white, over the hover wash.
+                    [0xe6, 0xe6, 0xee]
                 } else {
                     [0xbb, 0xbb, 0xc5]
                 };