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

commitb48b9bfc531dfbb7b8b9cb74fbc16dc0180079ef
parentc1c74e1e1e
authorLucas Galante <[email protected]>
date2026-07-11 23:20
refactor: root Backplate + fuzzel ScrollBox dissolved (Phase 6as)

The root Backplate was a pure value-holder — its color-at-backplate-
opacity / radius / rect emission is now inlined at the draw site (no
border, no children, no events). The fuzzel list's ScrollBox becomes the
app-owned ScrollRegion (via cce-email) with two ScrollBox-shaped shims
(update_bounds_raw content-height form, get_draw_y virtual-y form);
push_quads reproduces ScrollBox::extra_quads verbatim. Standalone A/B
(daemon socket held): fuzzel + --json masks empty at 8%. Cloud no longer
constructs Backplate or ScrollBox.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018u7qTwzX95dd5ysAkaSCLk

 src/main.rs          |  66 +++++------
 src/scroll_region.rs | 316 +++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 349 insertions(+), 33 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 83f5dff..7f92eeb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,6 @@
+mod scroll_region;
+use scroll_region::ScrollRegion;
+
 use std::sync::{Arc, Mutex};
 use std::io::{self, BufRead, IsTerminal};
 
@@ -482,7 +485,7 @@ pub struct FuzzelWidget {
     all_items: Vec<String>,
     filtered_items: Vec<String>,
     selected: usize,
-    pub scroll_box: cce_ui::widget::ScrollBox,
+    pub scroll_box: ScrollRegion,
 }
 
 impl FuzzelWidget {
@@ -497,7 +500,7 @@ impl FuzzelWidget {
             all_items: Vec::new(),
             filtered_items: Vec::new(),
             selected: 0,
-            scroll_box: cce_ui::widget::ScrollBox::new(),
+            scroll_box: ScrollRegion::new(22.0, 0.0),
         }
     }
 
@@ -522,7 +525,7 @@ impl FuzzelWidget {
         let viewport_y = self.y + pad + search_h + 10.0;
         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(content_h, viewport_y, viewport_h);
+        self.scroll_box.update_bounds_raw(content_h, viewport_y, viewport_h);
     }
 
     pub fn snap_to_selected(&mut self) {
@@ -606,13 +609,13 @@ impl Element for FuzzelWidget {
         quads.push((bx + bw - 1.0, by, 1.0, bh, border_color));
 
         // ScrollBox quads
-        quads.extend(self.scroll_box.extra_quads());
+        self.scroll_box.push_quads(&mut quads);
 
         // Selected Item Highlight
         let item_h = 25.0;
         if !self.filtered_items.is_empty() {
             let virtual_selected_y = self.selected as f32 * item_h;
-            if let Some(draw_y) = self.scroll_box.get_item_draw_y(virtual_selected_y, item_h) {
+            if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_selected_y, item_h) {
                 let scrollbar_w = if self.scroll_box.content_h > self.scroll_box.viewport_h { 10.0 } else { 0.0 };
                 quads.push((
                     self.x + pad + 2.0,
@@ -628,10 +631,10 @@ impl Element for FuzzelWidget {
     }
 
 
-    fn mouse_input(&mut self, button: cce_ui::widget::MouseButton, state: cce_ui::widget::ElementState, px: f32, py: f32, ctx: &mut cce_ui::context::UiContext) -> bool {
+    fn mouse_input(&mut self, button: cce_ui::widget::MouseButton, state: cce_ui::widget::ElementState, px: f32, py: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
         if button == cce_ui::widget::MouseButton::Left && state == cce_ui::widget::ElementState::Pressed {
             let item_h = 25.0;
-            if self.scroll_box.hit_test(px, py, ctx) {
+            if self.scroll_box.hit(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() {
@@ -714,7 +717,12 @@ struct State {
     switcher_mode: bool,
     last_tick: std::time::Instant,
     ui_context: cce_ui::context::UiContext,
-    root_window: cce_ui::widget::Backplate,
+    /// Dissolved root Backplate (Phase 6as): the plate was a pure value-holder for the
+    /// window background — color (at backplate opacity), radius, rect. No border, no
+    /// children, no events.
+    window_rect: (f32, f32, f32, f32),
+    window_bg: [f32; 4],
+    window_radius: f32,
     select_and_close_requested: bool,
 }
 
@@ -1054,9 +1062,8 @@ impl State {
 
         cce_ui::scale::set_app_id("cce-cloud".to_string());
         let bg_color = cce_ui::color::page_low_color();
-        let root_window = cce_ui::widget::Backplate::new(0.0, 0.0, lw, lh)
-            .with_background(bg_color)
-            .with_radius(cce_ui::color::backplate_corner_radius());
+        let window_rect = (0.0, 0.0, lw, lh);
+        let window_radius = cce_ui::color::backplate_corner_radius();
 
         let mut state = Self {
             window: Some(window),
@@ -1093,7 +1100,9 @@ impl State {
             switcher_mode,
             last_tick: std::time::Instant::now(),
             ui_context: cce_ui::context::UiContext::new(),
-            root_window,
+            window_rect,
+            window_bg: bg_color,
+            window_radius,
             select_and_close_requested: false,
         };
 
@@ -1253,7 +1262,7 @@ impl State {
 
     fn apply_layout(&mut self) {
         let (w, h) = (self.width, self.height);
-        self.root_window.set_rect(0.0, 0.0, w, h);
+        self.window_rect = (0.0, 0.0, w, h);
         if self.mode == LauncherMode::Json {
             if let Some(jl) = &mut self.json_layout {
                 cce_ui::scale::set_scale_factor(self.scale as f32);
@@ -1269,28 +1278,19 @@ impl State {
         let sh = self.height;
         let mut verts = Vec::new();
 
-        // 1. Draw root_window background (with rounded corners if radius is set)
-        let bg_color = self.root_window.color();
+        // 1. Window background — the dissolved Backplate's exact emission: base color at
+        // the configured backplate opacity, all corners rounded when the radius is set.
+        let mut bg_color = self.window_bg;
+        if bg_color[3] > 0.001 {
+            bg_color[3] = cce_ui::color::active_backplate_opacity();
+        }
         if bg_color[3] > 0.0 {
-            let r = self.root_window.corner_radius();
-            let corners = self.root_window.rounded_corners();
-            let (wx, wy, ww, wh) = self.root_window.rect();
+            let r = self.window_radius;
+            let corners = if r > 0.1 { (true, true, true, true) } else { (false, false, false, false) };
+            let (wx, wy, ww, wh) = self.window_rect;
             verts.extend(rounded_rect_vertices_corners(wx, wy, ww, wh, r, sw, sh, bg_color, corners));
         }
 
-        // 2. Draw root_window border
-        if let Some((b_color, b_thickness)) = self.root_window.solid_border() {
-            let (wx, wy, ww, wh) = self.root_window.rect();
-            // Top border
-            verts.extend(quad_vertices(wx, wy, ww, b_thickness, sw, sh, b_color));
-            // Bottom border
-            verts.extend(quad_vertices(wx, wy + wh - b_thickness, ww, b_thickness, sw, sh, b_color));
-            // Left border
-            verts.extend(quad_vertices(wx, wy, b_thickness, wh, sw, sh, b_color));
-            // Right border
-            verts.extend(quad_vertices(wx + ww - b_thickness, wy, b_thickness, wh, sw, sh, b_color));
-        }
-
         // 3. Draw child widgets
         if self.mode == LauncherMode::Json {
             if let Some(jl) = &self.json_layout {
@@ -1844,7 +1844,7 @@ impl PointerHandler for AppState {
                         let h_scroll = horizontal.absolute as f32;
                         let v_scroll = vertical.absolute as f32;
                         let delta = cce_ui::widget::MouseScrollDelta::LineDelta(-h_scroll / 10.0, -v_scroll / 10.0);
-                        if st.fuzzel.scroll_box.mouse_wheel(&delta, st.cursor_x, st.cursor_y, &mut st.ui_context) {
+                        if st.fuzzel.scroll_box.wheel(&delta, st.cursor_x, st.cursor_y) {
                             st.fuzzel.update_scroll();
                             st.upload_vertices();
                             self.redraw = true;
@@ -3241,7 +3241,7 @@ impl FuzzelWidget {
         let item_h = 25.0;
         for (idx, item_text) in self.filtered_items.iter().enumerate() {
             let virtual_y = idx as f32 * item_h;
-            if let Some(draw_y) = self.scroll_box.get_item_draw_y(virtual_y, item_h) {
+            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 {
diff --git a/src/scroll_region.rs b/src/scroll_region.rs
new file mode 100644
index 0000000..a5a6e58
--- /dev/null
+++ b/src/scroll_region.rs
@@ -0,0 +1,316 @@
+//! App-owned scroll region replacing the dissolved `ScrollBox` embedded base (the Phase
+//! 6q ScrollRegion, ported via cce-email). The fuzzel list drew its own rows at
+//! `get_draw_y` positions; the ScrollBox contributed only the flat background, the
+//! scrollbar, the scroll math, and wheel input — `push_quads` and the ScrollBox-shaped
+//! shims below reproduce those verbatim.
+
+use cce_ui::widget::{ElementState, Key, KeyEvent, MouseScrollDelta, NamedKey};
+
+#[derive(Debug, Clone)]
+pub struct ScrollRegion {
+    pub x: f32,
+    pub y: f32,
+    pub w: f32,
+    pub h: f32,
+    /// Row height, with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
+    pub item_height: f32,
+    pub item_gap: f32,
+    pub scroll_y: f32,
+    pub content_h: f32,
+    pub viewport_y: f32,
+    pub viewport_h: f32,
+    pub dragging: bool,
+    drag_offset_y: f32,
+    pub hovered: bool,
+    /// Local stand-in for the legacy global focus flag (`ScrollBox::focus()` on any press
+    /// inside the frame): set on a press that hits the region, cleared on one that misses.
+    pub focused: bool,
+}
+
+impl ScrollRegion {
+    pub fn new(item_height: f32, item_gap: f32) -> Self {
+        let (_, font_size) = cce_ui::layout::list_font_parsed();
+        Self {
+            x: 0.0,
+            y: 0.0,
+            w: 0.0,
+            h: 0.0,
+            item_height: item_height.max(font_size + 14.0),
+            item_gap,
+            scroll_y: 0.0,
+            content_h: 0.0,
+            viewport_y: 0.0,
+            viewport_h: 0.0,
+            dragging: false,
+            drag_offset_y: 0.0,
+            hovered: false,
+            focused: false,
+        }
+    }
+
+    pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+        self.x = x;
+        self.y = y;
+        self.w = w;
+        self.h = h;
+    }
+
+    /// The `List::update_bounds` count math: `content_h = count * (item_height + gap) + 4`.
+    pub fn update_bounds(&mut self, count: usize, viewport_y: f32, viewport_h: f32) {
+        self.content_h = count as f32 * (self.item_height + self.item_gap) + 4.0;
+        self.viewport_y = viewport_y;
+        self.viewport_h = viewport_h;
+        self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
+    }
+
+    pub fn set_scroll_y(&mut self, val: f32) {
+        self.scroll_y = val;
+    }
+
+    fn max_scroll(&self) -> f32 {
+        (self.content_h - self.viewport_h).max(0.0)
+    }
+
+    pub fn hit(&self, px: f32, py: f32) -> bool {
+        px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
+    }
+
+    /// Row virtualization (`List::get_item_draw_y`): screen y for row `idx`, or `None`
+    /// when the row isn't fully inside the viewport.
+    /// `ScrollBox::update_bounds` shape: raw content height, not a row count.
+    pub fn update_bounds_raw(&mut self, content_h: f32, viewport_y: f32, viewport_h: f32) {
+        self.content_h = content_h;
+        self.viewport_y = viewport_y;
+        self.viewport_h = viewport_h;
+        let max_scroll = (content_h - viewport_h).max(0.0);
+        self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
+    }
+
+    /// `ScrollBox::get_item_draw_y` shape: a precomputed virtual y + item height.
+    pub fn get_draw_y(&self, virtual_y: f32, item_h: f32) -> Option<f32> {
+        let draw_y = self.viewport_y + virtual_y - self.scroll_y;
+        if draw_y >= self.viewport_y - 1.0 && draw_y + item_h <= self.viewport_y + self.viewport_h + 1.0 {
+            Some(draw_y)
+        } else {
+            None
+        }
+    }
+
+    pub fn get_item_draw_y(&self, idx: usize, offset: f32) -> Option<f32> {
+        let virtual_y = idx as f32 * (self.item_height + self.item_gap) + offset;
+        let draw_y = self.viewport_y + virtual_y - self.scroll_y;
+        if draw_y >= self.viewport_y - 1.0
+            && draw_y + self.item_height <= self.viewport_y + self.viewport_h + 1.0
+        {
+            Some(draw_y)
+        } else {
+            None
+        }
+    }
+
+    /// Scrollbar geometry (`ScrollBox::extra_quads`): (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h).
+    fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
+        let sb_w = cce_ui::layout::scrollbar_width();
+        let sb_x = self.x + self.w - sb_w - 4.0;
+        let track_h = self.viewport_h - 8.0;
+        let track_y = self.viewport_y + 4.0;
+        let visible_ratio = self.viewport_h / self.content_h.max(1.0);
+        let thumb_h = if track_h <= 20.0 {
+            track_h
+        } else {
+            (track_h * visible_ratio).clamp(20.0, track_h)
+        };
+        let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
+        let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
+        (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
+    }
+
+    fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
+        if self.content_h <= self.viewport_h {
+            return false;
+        }
+        let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
+        px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
+    }
+
+    /// Left press: scrollbar thumb grab or track jump (`ScrollBox::mouse_input`), plus the
+    /// press-inside focus / press-outside unfocus bookkeeping. Returns true only when the
+    /// scrollbar consumed the press — a press on the rows falls through to them.
+    pub fn press(&mut self, px: f32, py: f32) -> bool {
+        self.focused = self.hit(px, py);
+        if !self.hit_scrollbar(px, py) {
+            self.dragging = false;
+            return false;
+        }
+        self.dragging = true;
+        let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
+        let click_offset = py - thumb_y;
+        if click_offset >= 0.0 && click_offset <= thumb_h {
+            self.drag_offset_y = click_offset;
+        } else {
+            self.drag_offset_y = thumb_h / 2.0;
+            let target = py - self.drag_offset_y;
+            let ratio = if track_h - thumb_h > 0.0 {
+                ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
+            } else {
+                0.0
+            };
+            self.scroll_y = ratio * self.max_scroll();
+        }
+        true
+    }
+
+    /// Returns whether a thumb drag was in progress (the caller's redraw signal).
+    pub fn release(&mut self) -> bool {
+        std::mem::take(&mut self.dragging)
+    }
+
+    fn drag_move(&mut self, py: f32) -> bool {
+        let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
+        let target = py - self.drag_offset_y;
+        let ratio = if track_h - thumb_h > 0.0 {
+            ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
+        } else {
+            0.0
+        };
+        let old = self.scroll_y;
+        self.scroll_y = ratio * self.max_scroll();
+        (self.scroll_y - old).abs() > 0.01
+    }
+
+    /// Pointer-move bookkeeping: forwards to an active thumb drag (returns true so the host
+    /// treats it as a high-priority drag override), else just tracks hover for the border
+    /// tint and the keyboard scope.
+    pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+        self.hovered = self.hit(px, py);
+        if self.dragging {
+            self.drag_move(py);
+            return true;
+        }
+        false
+    }
+
+    pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
+        if !self.hit(px, py) {
+            return false;
+        }
+        let dy = match delta {
+            MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
+            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+        };
+        let old = self.scroll_y;
+        self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
+        (self.scroll_y - old).abs() > 0.01
+    }
+
+    /// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
+    /// when focused or hovered; the dissolved region keeps both via its local flags).
+    pub fn keyboard(&mut self, event: &KeyEvent) -> bool {
+        if (!self.hovered && !self.focused) || event.state != ElementState::Pressed {
+            return false;
+        }
+        let max = self.max_scroll();
+        let old = self.scroll_y;
+        if event.ctrl {
+            match &event.logical_key {
+                Key::Character(c) if c == "n" || c == "N" => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
+                Key::Character(c) if c == "p" || c == "P" => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
+                _ => return false,
+            }
+        } else {
+            match &event.logical_key {
+                Key::Named(NamedKey::ArrowDown) => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
+                Key::Named(NamedKey::ArrowUp) => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
+                Key::Named(NamedKey::PageDown) => self.scroll_y = (self.scroll_y + self.viewport_h).clamp(0.0, max),
+                Key::Named(NamedKey::PageUp) => self.scroll_y = (self.scroll_y - self.viewport_h).clamp(0.0, max),
+                Key::Named(NamedKey::Home) => self.scroll_y = 0.0,
+                Key::Named(NamedKey::End) => self.scroll_y = max,
+                _ => return false,
+            }
+        }
+        (self.scroll_y - old).abs() > 0.01
+    }
+
+    /// The legacy frame, single-drawn: 1px rounded border (focus/hover tinted, from
+    /// `List::solid_border`), inset rounded bg, then the scrollbar track and thumb ON TOP.
+    /// The legacy `ScrollBox::extra_quads` emission: flat background, then the
+    /// scrollbar track + thumb when the content overflows.
+    pub fn push_quads(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
+        quads.push((self.x, self.y, self.w, self.h, cce_ui::color::list_bg_color()));
+        if self.content_h > self.viewport_h {
+            let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
+            quads.push((sb_x, track_y, sb_w, track_h, cce_ui::color::scrollbar_track_color()));
+            quads.push((sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color()));
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn region() -> ScrollRegion {
+        // item_height clamps to list_font + 14, so pick one comfortably above any config.
+        let mut r = ScrollRegion::new(40.0, 4.0);
+        r.set_rect(10.0, 20.0, 200.0, 100.0);
+        r
+    }
+
+    #[test]
+    fn wheel_scrolls_and_clamps() {
+        let mut r = region();
+        r.update_bounds(10, 20.0, 100.0); // content_h = 444 > 100
+        assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
+        assert_eq!(r.scroll_y, 48.0);
+        assert!(!r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 500.0, 50.0)); // miss
+        r.wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), 50.0, 50.0);
+        assert_eq!(r.scroll_y, 344.0); // clamped to max_scroll
+    }
+
+    #[test]
+    fn virtualization_matches_list_math() {
+        let mut r = region();
+        r.update_bounds(10, 20.0, 100.0);
+        r.set_scroll_y(0.0);
+        // Row 0 at viewport_y + 0*(44) + 4 = 24; fits (24 + 40 <= 121).
+        assert_eq!(r.get_item_draw_y(0, 4.0), Some(24.0));
+        // Row 2 at 20 + 92 - 0 = 112; 112 + 40 > 121 → culled.
+        assert!(r.get_item_draw_y(2, 4.0).is_none());
+    }
+
+    #[test]
+    fn press_focuses_and_grabs_only_scrollbar() {
+        let mut r = region();
+        r.update_bounds(10, 20.0, 100.0);
+        // Press in the rows area: focused, not dragging, falls through.
+        assert!(!r.press(50.0, 50.0));
+        assert!(r.focused && !r.dragging);
+        // Press on the scrollbar strip (x + w - sb_w - 4 ± 4): consumed.
+        let sb_x = 10.0 + 200.0 - cce_ui::layout::scrollbar_width() - 4.0;
+        assert!(r.press(sb_x + 1.0, 50.0));
+        assert!(r.dragging);
+        assert!(r.release());
+        // Press outside: unfocuses.
+        assert!(!r.press(500.0, 500.0));
+        assert!(!r.focused);
+    }
+
+    #[test]
+    fn keyboard_is_hover_or_focus_scoped() {
+        let mut r = region();
+        r.update_bounds(10, 20.0, 100.0);
+        let down = KeyEvent {
+            state: ElementState::Pressed,
+            logical_key: Key::Named(NamedKey::ArrowDown),
+            text: None,
+            repeat: false,
+            ctrl: false,
+            shift: false,
+        };
+        assert!(!r.keyboard(&down)); // neither hovered nor focused
+        r.cursor_moved(50.0, 50.0);
+        assert!(r.hovered);
+        assert!(r.keyboard(&down));
+        assert_eq!(r.scroll_y, 24.0);
+    }
+}