git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit88c974294d99a2f3a737c3b2fe1a2b19230ca900
parentb749cd9ce2
authorLucas Galante <[email protected]>
date2026-08-18 08:40
feat: memory columns on Processes, and horizontal scrolling in ScrollRegion

The process list grows MEM (humanized RSS) and MEM % columns next to CPU %, and the row model becomes a named struct instead of a widening tuple. The fetch now reads ps's cmd field and shows the basename of argv[0] rather than comm — comm is truncated at the kernel's 15-char cap, which cut every longer cce binary to nonsense like "cce-system-inte"; kernel threads keep their bracketed names.

Columns sit at fixed offsets in content space (650px) and every draw subtracts scroll_x, so the whole table pans as one; headers ride along under their own clip, while the row hover band and the divider stay viewport-fixed. When the box is narrower than the content a bottom scrollbar appears and the rows reserve room above it.

ScrollRegion gains the horizontal axis as a strict opt-in: content_w defaults to 0, so the five other pages using it are untouched — verified by their unchanged tests. When a page declares a wider content width it gets the bottom pill bar (track stops short of the vertical bar's strip so the corners never overlap), x wheel/trackpad deltas, thumb drag with the same grab/jump semantics as the vertical bar, and Left/Right arrows — which keep falling through to other handlers on vertical-only lists. release() takes both drag flags bitwise on purpose: || would short-circuit past the second take.

Verified live in a headless shadow session at 560px wide: the bar appears, wheel-x pans headers and columns in lockstep, the thumb tracks, and the pan clamps at content edge. Unit tests cover opt-in/clamping, h-thumb grab, column panning through the view, command basename display, and RSS humanization.

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

 src/pages/processes.rs | 144 +++++++++++++++++++++++++++++++++++++------
 src/scroll_region.rs   | 162 +++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 283 insertions(+), 23 deletions(-)

diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 062e3ac..ae137d7 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -2,10 +2,19 @@ use crate::app::{AppAction, PageContent};
 use crate::scroll_region::ScrollRegion;
 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
 
+#[derive(Debug, Clone)]
+pub struct ProcessRow {
+    pub pid: String,
+    pub cpu: String,
+    pub mem_pct: String,
+    pub rss_kb: u64,
+    pub command: String,
+}
+
 #[derive(Debug, Clone)]
 pub struct ProcessesState {
     pub loaded: bool,
-    pub processes: Vec<(String, String, String)>, // (pid, cpu, comm)
+    pub processes: Vec<ProcessRow>,
     pub cpu_list: ScrollRegion,
 }
 
@@ -25,21 +34,50 @@ pub enum ProcessesMessage {
     None,
 }
 
+/// Process name from a `ps … cmd` field: basename of argv[0], so cce binaries
+/// longer than the kernel's 15-char `comm` cap display whole (`comm` showed
+/// "cce-system-inte"). Kernel threads (`[kworker/0:1]`) keep their brackets.
+pub fn command_display(cmd: &str) -> String {
+    let first = cmd.split_whitespace().next().unwrap_or(cmd);
+    if first.starts_with('[') {
+        return first.to_string();
+    }
+    std::path::Path::new(first)
+        .file_name()
+        .and_then(|n| n.to_str())
+        .unwrap_or(first)
+        .to_string()
+}
+
+/// Humanized RSS from ps's KiB figure.
+pub fn format_rss(kb: u64) -> String {
+    if kb >= 1_048_576 {
+        format!("{:.1} GB", kb as f64 / 1_048_576.0)
+    } else if kb >= 1024 {
+        format!("{} MB", kb / 1024)
+    } else {
+        format!("{} KB", kb)
+    }
+}
+
 pub async fn fetch_processes_state() -> ProcessesState {
     let processes = {
         let mut list = Vec::new();
         if let Some(o) = tokio::process::Command::new("ps")
-            .args(["-eo", "pid,%cpu,comm", "--sort=-%cpu"])
+            .args(["-eo", "pid,%cpu,%mem,rss,cmd", "--sort=-%cpu"])
             .output().await.ok()
         {
             let text = String::from_utf8_lossy(&o.stdout);
             for line in text.lines().skip(1) {
                 let parts: Vec<&str> = line.split_whitespace().collect();
-                if parts.len() >= 3 {
-                    let pid = parts[0].to_string();
-                    let cpu = parts[1].to_string();
-                    let comm = parts[2..].join(" ");
-                    list.push((pid, cpu, comm));
+                if parts.len() >= 5 {
+                    list.push(ProcessRow {
+                        pid: parts[0].to_string(),
+                        cpu: parts[1].to_string(),
+                        mem_pct: parts[2].to_string(),
+                        rss_kb: parts[3].parse().unwrap_or(0),
+                        command: command_display(&parts[4..].join(" ")),
+                    });
                 }
             }
         }
@@ -79,26 +117,49 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
 
             // Dissolved List (Phase 6v): scroll state + frame prims are app-owned. The
             // scrollable viewport starts below the header.
+            //
+            // Columns live in CONTENT space at fixed offsets; every draw
+            // subtracts scroll_x. CONTENT_W > box width = the h-bar appears.
+            const COL_PID: f32 = 12.0;
+            const COL_COMMAND: f32 = 80.0;
+            const COL_RSS: f32 = 420.0;
+            const COL_MEM: f32 = 510.0;
+            const COL_CPU: f32 = 580.0;
+            const CONTENT_W: f32 = 650.0;
+
             let header_h = 22.0;
             state.cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
-            state.cpu_list.update_bounds(state.processes.len(), list_box_y + header_h, list_box_h - header_h - 6.0);
+            state.cpu_list.set_content_w(CONTENT_W);
+            // The bottom scrollbar needs its own band: rows must stop above
+            // it or the last row draws under the pills.
+            let bottom_reserve = if state.cpu_list.h_scroll_active() { 18.0 } else { 6.0 };
+            state.cpu_list.update_bounds(state.processes.len(), list_box_y + header_h, list_box_h - header_h - bottom_reserve);
             state.cpu_list.push_prims(sec.pc);
 
+            let ox = state.cpu_list.scroll_x;
+
             // Header row is background-less (the well shows through); only the
             // divider separates it from the rows.
             sec.pc.rect([0.18, 0.18, 0.24, 1.0], list_box_x + 1.0, list_box_y + header_h, list_box_w - 2.0, 1.0); // Divider
 
-            sec.pc.text("PID", list_box_x + 12.0, list_box_y + 5.0, 11.0, [0.53, 0.53, 0.60, 1.0]);
-            sec.pc.text("COMMAND", list_box_x + 80.0, list_box_y + 5.0, 11.0, [0.53, 0.53, 0.60, 1.0]);
-            sec.pc.text("CPU %", list_box_x + list_box_w - 60.0, list_box_y + 5.0, 11.0, [0.53, 0.53, 0.60, 1.0]);
+            // Header labels pan with the columns, clipped to the box.
+            sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
+            sec.pc.text("PID", list_box_x + COL_PID - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
+            sec.pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
+            sec.pc.text("MEM", list_box_x + COL_RSS - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
+            sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
+            sec.pc.text("CPU %", list_box_x + COL_CPU - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
+            sec.pc.pop_clip_rect();
 
             let row_h = 24.0;
 
             // Visible process rows rendering (virtualized/clipped)
             sec.pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, list_box_h - header_h);
-            for (idx, (pid, cpu, comm)) in state.processes.iter().enumerate() {
+            for (idx, p) in state.processes.iter().enumerate() {
                 if let Some(draw_y) = state.cpu_list.get_item_draw_y(idx, 4.0) {
-                    // Standard row action button (transparent background, highlights on hover)
+                    // Standard row action button (transparent background, highlights
+                    // on hover). Viewport-fixed on purpose: the hover band spans the
+                    // visible row whatever the horizontal pan.
                     sec.pc.button(
                         "",
                         list_box_x + 2.0,
@@ -110,10 +171,12 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
                         [0.0, 0.0, 0.0, 0.0],
                         AppAction::Processes(ProcessesMessage::None),
                     );
-                    
-                    sec.pc.text(pid, list_box_x + 12.0, draw_y + 6.0, 12.0, [0.80, 0.80, 0.85, 1.0]);
-                    sec.pc.text(comm, list_box_x + 80.0, draw_y + 6.0, 12.0, [0.80, 0.80, 0.85, 1.0]);
-                    sec.pc.text(&format!("{}%", cpu), list_box_x + list_box_w - 60.0, draw_y + 6.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
+
+                    sec.pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, [0.80, 0.80, 0.85, 1.0]);
+                    sec.pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, [0.80, 0.80, 0.85, 1.0]);
+                    sec.pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, [0.62, 0.72, 0.88, 1.0]);
+                    sec.pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, [0.62, 0.72, 0.88, 1.0]);
+                    sec.pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
                 }
             }
             sec.pc.pop_clip_rect();
@@ -203,4 +266,51 @@ mod tests {
         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
         assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
     }
+
+    #[test]
+    fn command_display_prefers_basename_and_keeps_kernel_threads() {
+        // Longer than the 15-char comm cap ps used to truncate at.
+        assert_eq!(command_display("/home/x/.local/bin/cce-system-interface --flag"), "cce-system-interface");
+        assert_eq!(command_display("bash"), "bash");
+        assert_eq!(command_display("[kworker/0:1-events]"), "[kworker/0:1-events]");
+    }
+
+    #[test]
+    fn format_rss_humanizes() {
+        assert_eq!(format_rss(512), "512 KB");
+        assert_eq!(format_rss(4096), "4 MB");
+        assert_eq!(format_rss(2_200_000), "2.1 GB");
+    }
+
+    fn row(pid: &str) -> ProcessRow {
+        ProcessRow {
+            pid: pid.to_string(),
+            cpu: "1.0".to_string(),
+            mem_pct: "2.0".to_string(),
+            rss_kb: 1024,
+            command: "proc".to_string(),
+        }
+    }
+
+    #[test]
+    fn columns_pan_with_horizontal_scroll() {
+        let mut state = ProcessesState { loaded: true, ..Default::default() };
+        state.processes = (0..3).map(|i| row(&i.to_string())).collect();
+        let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+        let sec_focused = vec![false];
+        let mut ctx = cce_ui::context::UiContext::new();
+
+        let header_x = |pc: &crate::app::PageContent, s: &str| {
+            pc.texts.iter().find(|t| t.0 == s).map(|t| t.2).unwrap()
+        };
+        let pc0 = view(&mut state, 10.0, 20.0, 500.0, 400.0, false, &sec_focused, &mut layout, &mut ctx);
+        let x0 = header_x(&pc0, "MEM %");
+        // Content (650) is wider than the ~500px page, so the list is
+        // h-scrollable; pan and every column shifts left by exactly that.
+        assert!(state.cpu_list.h_scroll_active());
+        state.cpu_list.scroll_x = 40.0;
+        let pc1 = view(&mut state, 10.0, 20.0, 500.0, 400.0, false, &sec_focused, &mut layout, &mut ctx);
+        assert_eq!(header_x(&pc1, "MEM %"), x0 - 40.0);
+        assert_eq!(header_x(&pc1, "CPU %"), header_x(&pc0, "CPU %") - 40.0);
+    }
 }
diff --git a/src/scroll_region.rs b/src/scroll_region.rs
index cd70837..1587eb8 100644
--- a/src/scroll_region.rs
+++ b/src/scroll_region.rs
@@ -26,8 +26,15 @@ pub struct ScrollRegion {
     pub content_h: f32,
     pub viewport_y: f32,
     pub viewport_h: f32,
+    /// Horizontal scrolling is opt-in per list: it activates only when a page
+    /// declares a content width wider than the box (`set_content_w`). The
+    /// default 0 keeps every existing vertical-only list exactly as it was.
+    pub scroll_x: f32,
+    pub content_w: f32,
     pub dragging: bool,
+    dragging_h: bool,
     drag_offset_y: f32,
+    drag_offset_x: 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.
@@ -51,8 +58,12 @@ impl ScrollRegion {
             content_h: 0.0,
             viewport_y: 0.0,
             viewport_h: 0.0,
+            scroll_x: 0.0,
+            content_w: 0.0,
             dragging: false,
+            dragging_h: false,
             drag_offset_y: 0.0,
+            drag_offset_x: 0.0,
             hovered: false,
             focused: false,
             draw_frame: true,
@@ -83,10 +94,27 @@ impl ScrollRegion {
         self.scroll_y = val;
     }
 
+    /// Declare how wide the content really is. Wider than the box = the list
+    /// scrolls horizontally (bottom scrollbar, x wheel deltas, arrow keys).
+    pub fn set_content_w(&mut self, w: f32) {
+        self.content_w = w;
+        self.scroll_x = self.scroll_x.clamp(0.0, self.max_scroll_x());
+    }
+
+    /// Whether horizontal scrolling is live (content declared wider than the
+    /// box). Pages use this to reserve bottom room for the h-bar.
+    pub fn h_scroll_active(&self) -> bool {
+        self.content_w > self.w
+    }
+
     fn max_scroll(&self) -> f32 {
         (self.content_h - self.viewport_h).max(0.0)
     }
 
+    fn max_scroll_x(&self) -> f32 {
+        (self.content_w - self.w).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
     }
@@ -131,11 +159,60 @@ impl ScrollRegion {
         px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
     }
 
+    /// Bottom scrollbar geometry, mirroring [`Self::scrollbar_geom`] with the
+    /// axes swapped: (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w). The
+    /// track stops short of the vertical bar's strip so the pills never
+    /// overlap in the corner.
+    fn h_scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
+        let sb_h = cce_ui::layout::scrollbar_width();
+        let sb_y = self.y + self.h - sb_h - 4.0;
+        let right_reserve = if self.content_h > self.viewport_h { sb_h + 8.0 } else { 0.0 };
+        let track_x = self.x + 4.0;
+        let track_w = self.w - 8.0 - right_reserve;
+        let visible_ratio = self.w / self.content_w.max(1.0);
+        let thumb_w = if track_w <= 20.0 {
+            track_w
+        } else {
+            (track_w * visible_ratio).clamp(20.0, track_w)
+        };
+        let ratio = if self.max_scroll_x() > 0.0 { self.scroll_x / self.max_scroll_x() } else { 0.0 };
+        let thumb_x = track_x + ratio * (track_w - thumb_w);
+        (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w)
+    }
+
+    fn hit_h_scrollbar(&self, px: f32, py: f32) -> bool {
+        if !self.h_scroll_active() {
+            return false;
+        }
+        let (track_x, sb_y, track_w, sb_h, _, _) = self.h_scrollbar_geom();
+        py >= sb_y - 4.0 && py <= sb_y + sb_h + 4.0 && px >= track_x && px <= track_x + track_w
+    }
+
     /// 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);
+        // The bottom bar first: its ±4 slop strip sits inside the box, where
+        // the vertical hit test can never claim it.
+        if self.hit_h_scrollbar(px, py) {
+            self.dragging_h = true;
+            let (track_x, _, track_w, _, thumb_x, thumb_w) = self.h_scrollbar_geom();
+            let click_offset = px - thumb_x;
+            if click_offset >= 0.0 && click_offset <= thumb_w {
+                self.drag_offset_x = click_offset;
+            } else {
+                self.drag_offset_x = thumb_w / 2.0;
+                let target = px - self.drag_offset_x;
+                let ratio = if track_w - thumb_w > 0.0 {
+                    ((target - track_x) / (track_w - thumb_w)).clamp(0.0, 1.0)
+                } else {
+                    0.0
+                };
+                self.scroll_x = ratio * self.max_scroll_x();
+            }
+            return true;
+        }
         if !self.hit_scrollbar(px, py) {
             self.dragging = false;
             return false;
@@ -160,7 +237,9 @@ impl ScrollRegion {
 
     /// 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)
+        // Bitwise on purpose: both drags must reset even when the first
+        // operand is already true (|| would short-circuit the take).
+        std::mem::take(&mut self.dragging) | std::mem::take(&mut self.dragging_h)
     }
 
     fn drag_move(&mut self, py: f32) -> bool {
@@ -185,6 +264,17 @@ impl ScrollRegion {
             self.drag_move(py);
             return true;
         }
+        if self.dragging_h {
+            let (track_x, _, track_w, _, _, thumb_w) = self.h_scrollbar_geom();
+            let target = px - self.drag_offset_x;
+            let ratio = if track_w - thumb_w > 0.0 {
+                ((target - track_x) / (track_w - thumb_w)).clamp(0.0, 1.0)
+            } else {
+                0.0
+            };
+            self.scroll_x = ratio * self.max_scroll_x();
+            return true;
+        }
         false
     }
 
@@ -192,13 +282,17 @@ impl ScrollRegion {
         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 (dx, dy) = match delta {
+            MouseScrollDelta::LineDelta(x, y) => (-x * 24.0, -y * 24.0),
+            MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
         };
-        let old = self.scroll_y;
+        let old_y = self.scroll_y;
         self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
-        (self.scroll_y - old).abs() > 0.01
+        // Sideways wheel/trackpad deltas pan an h-scrollable list; a
+        // vertical-only list ignores them (max_scroll_x = 0 clamps to 0).
+        let old_x = self.scroll_x;
+        self.scroll_x = (self.scroll_x + dx).clamp(0.0, self.max_scroll_x());
+        (self.scroll_y - old_y).abs() > 0.01 || (self.scroll_x - old_x).abs() > 0.01
     }
 
     /// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
@@ -216,6 +310,8 @@ impl ScrollRegion {
                 _ => return false,
             }
         } else {
+            let old_x = self.scroll_x;
+            let max_x = self.max_scroll_x();
             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),
@@ -223,8 +319,19 @@ impl ScrollRegion {
                 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,
+                // Only an h-scrollable list claims the horizontal arrows —
+                // elsewhere they keep falling through to other handlers.
+                Key::Named(NamedKey::ArrowRight) if max_x > 0.0 => {
+                    self.scroll_x = (self.scroll_x + 24.0).clamp(0.0, max_x)
+                }
+                Key::Named(NamedKey::ArrowLeft) if max_x > 0.0 => {
+                    self.scroll_x = (self.scroll_x - 24.0).clamp(0.0, max_x)
+                }
                 _ => return false,
             }
+            if (self.scroll_x - old_x).abs() > 0.01 {
+                return true;
+            }
         }
         (self.scroll_y - old).abs() > 0.01
     }
@@ -260,6 +367,12 @@ impl ScrollRegion {
             pc.rect_with_radius_corners(cce_ui::color::scrollbar_track_color(), sb_x, track_y, sb_w, track_h, sb_w.min(track_h) * 0.5, all);
             pc.rect_with_radius_corners(cce_ui::color::scrollbar_thumb_color(), sb_x, thumb_y, sb_w, thumb_h, sb_w.min(thumb_h) * 0.5, all);
         }
+        if self.h_scroll_active() {
+            let (track_x, sb_y, track_w, sb_h, thumb_x, thumb_w) = self.h_scrollbar_geom();
+            let all = (true, true, true, true);
+            pc.rect_with_radius_corners(cce_ui::color::scrollbar_track_color(), track_x, sb_y, track_w, sb_h, sb_h.min(track_w) * 0.5, all);
+            pc.rect_with_radius_corners(cce_ui::color::scrollbar_thumb_color(), thumb_x, sb_y, thumb_w, sb_h, sb_h.min(thumb_w) * 0.5, all);
+        }
     }
 }
 
@@ -330,6 +443,43 @@ mod tests {
         assert!(!r.focused);
     }
 
+    #[test]
+    fn horizontal_scroll_is_opt_in_and_clamps() {
+        let mut r = region();
+        r.update_bounds(10, 20.0, 100.0);
+        // No content width declared: x wheel deltas change nothing and the
+        // vertical-only behavior (including the y component) is untouched.
+        assert!(!r.wheel(&MouseScrollDelta::LineDelta(-2.0, 0.0), 50.0, 50.0));
+        assert_eq!(r.scroll_x, 0.0);
+        assert!(!r.h_scroll_active());
+
+        // Content wider than the 200px box: x deltas pan and clamp.
+        r.set_content_w(500.0);
+        assert!(r.h_scroll_active());
+        assert!(r.wheel(&MouseScrollDelta::LineDelta(-2.0, 0.0), 50.0, 50.0));
+        assert_eq!(r.scroll_x, 48.0);
+        r.wheel(&MouseScrollDelta::LineDelta(-100.0, 0.0), 50.0, 50.0);
+        assert_eq!(r.scroll_x, 300.0); // max = 500 - 200
+        r.wheel(&MouseScrollDelta::LineDelta(100.0, 0.0), 50.0, 50.0);
+        assert_eq!(r.scroll_x, 0.0);
+    }
+
+    #[test]
+    fn h_thumb_press_grabs_and_releases() {
+        let mut r = region();
+        r.update_bounds(2, 20.0, 100.0); // no vertical overflow
+        r.set_content_w(500.0);
+        // The bottom strip: y + h - sb_w - 4, thumb starts at track_x.
+        let sb_y = 20.0 + 100.0 - cce_ui::layout::scrollbar_width() - 4.0;
+        assert!(r.press(20.0, sb_y + 1.0));
+        // Drag right: scroll_x follows.
+        assert!(r.cursor_moved(120.0, sb_y + 1.0));
+        assert!(r.scroll_x > 0.0);
+        assert!(r.release());
+        // A rows-area press still falls through (no h-bar hit).
+        assert!(!r.press(50.0, 50.0));
+    }
+
     #[test]
     fn keyboard_is_hover_or_focus_scoped() {
         let mut r = region();