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

commit10634222e4450c564063d978bc27da77c560b29a
parentd7bc6df1a3
authorLucas Galante <[email protected]>
date2026-08-14 13:54
fix: a press on the scrollbar or a clipped sliver no longer selects a row

`FuzzelWidget::on_event` gated the row math on `scroll_box.hit()`, which spans the
whole region including the scrollbar strip, and then clamped the computed index only
against `filtered_items.len()`. Two consequences, both of which emit a selection the
user did not make — and in Dmenu/switcher mode a press commits and closes the popup,
so the wrong item goes straight to stdout:

- Pressing the scrollbar selected whatever row sat at that y. (The strip is also the
  only thing there: `ScrollRegion::press`/`release`/`cursor_moved` are never called in
  this crate, so the scrollbar cannot actually be dragged — a separate gap, left as is.)
- `get_draw_y` requires a row to be FULLY inside the viewport, but the click math
  accepted any y in the region. Wheel steps are 24px against a 25px item height, so
  after any scroll a 1-24px band at the viewport edge was blank but clickable and
  resolved to the partially-clipped row behind it.

Both gates now use what the paint uses: reject `hit_scrollbar`, and require
`get_draw_y(...).is_some()` for the row that was hit — the same predicate the paint
loop virtualizes on, so only a row actually drawn is selectable.

Live-verified in --dmenu with 100 items (against the restarted daemon, since a bare
invocation is a client of the old one): clicking the scrollbar printed "item-5" and
closed; it now prints nothing and stays open, while a normal row click still commits
"item-5".

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/main.rs          | 16 ++++++++++++++--
 src/scroll_region.rs |  2 +-
 2 files changed, 15 insertions(+), 3 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index c1e6662..999562d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -657,10 +657,22 @@ impl cce_ui::widget::Input for FuzzelWidget {
         } = event
         {
             let item_h = 25.0;
-            if self.scroll_box.hit(*px, *py) {
+            // `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, or on the partially-clipped sliver at the viewport edge, resolved
+            // to a row. In Dmenu/switcher mode a press commits and closes, so that emitted
+            // an item the user never clicked (and, on the sliver, never even saw).
+            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;
-                if clicked_idx < self.filtered_items.len() {
+                // 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;
                 }
diff --git a/src/scroll_region.rs b/src/scroll_region.rs
index 8126331..6b61c04 100644
--- a/src/scroll_region.rs
+++ b/src/scroll_region.rs
@@ -125,7 +125,7 @@ impl ScrollRegion {
         (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
     }
 
-    fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
+    pub fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
         if self.content_h <= self.viewport_h {
             return false;
         }