system settings
git clone https://git.lucas.co/cce-system-interface.git
fix(pages): every list inside its section's clip
Services, Timers, Packages, Network and Accounts laid their lists out at
`left + margin`, as Processes did until 34ff51a — the margin they were
written against before sections clipped their content to the toolkit's
content box (`left + 2 * padding + margin`). Each list ran 2 * padding
past that box on both sides. On Network and Accounts, whose lists are
framed, the clip cut the frame off flat at both ends, the rows filling
it edge to edge; on the frameless lists it took the ends of the hover
band and crowded the scrollbar against the clip. Every list now fills
`content_left()` / `content_width()`.
A test per list page renders it with rows and checks that the clip the
list pushes for its rows survives whole — clips intersect, so a list
sticking out of its section has that clip narrowed, and the rows'
bounds show it. All five fail on the old layout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/pages/accounts.rs | 7 ++-
src/pages/mod.rs | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/pages/network.rs | 8 ++--
src/pages/packages.rs | 7 +--
src/pages/services.rs | 8 ++--
src/pages/timers.rs | 8 ++--
6 files changed, 150 insertions(+), 14 deletions(-)
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 7ee0759..cf559c0 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -655,9 +655,12 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
// drawing at a different height than it virtualizes on would drift
// the rows out from under their own hit boxes.
let item_h = state.list.item_height;
- let list_x = sec.left + m;
+ // Across the section's content box — the box the section clips
+ // to. At `left + m` the framed list ran 2 * padding past it on
+ // both sides, cut off flat there.
+ let list_x = sec.content_left();
let list_y = sec.ay();
- let list_w = sec.cw - 2.0 * m;
+ let list_w = sec.content_width();
let rows_shown = state.accounts.len().min(LIST_MAX_ROWS);
let list_h = rows_shown as f32 * (item_h + LIST_GAP) + 8.0;
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index eaeb621..21ee7be 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -154,3 +154,129 @@ pub trait AppPage {
}
+
+#[cfg(test)]
+mod list_clip_tests {
+ use super::*;
+ use cce_ui::widget::ScrollRegion;
+
+ fn render(page: &mut dyn AppPage) -> crate::app::PageContent {
+ let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+ let mut ctx = cce_ui::context::UiContext::new();
+ page.view(10.0, 20.0, 820.0, 640.0, false, &[false; 4], &mut layout, &mut ctx)
+ }
+
+ /// A list sits inside its section's clip exactly when the clip it
+ /// pushes for its rows survives whole. Clips intersect, so a list
+ /// sticking out past the section's content box — laid out at
+ /// `left + margin` — gets its row clip narrowed to that box, the cut
+ /// that took the edges off the framed lists and the first letter off
+ /// the Processes summary. Row texts carry the clip in their bounds
+ /// (`[x0, y0, x1, y1]`), row buttons beside them (`[x, y, w, h]`).
+ fn assert_list_inside(name: &str, pc: &crate::app::PageContent, list: &ScrollRegion) {
+ let text_clips = pc.texts.iter().filter_map(|t| t.6);
+ let button_clips = pc.buttons.iter().filter_map(|b| b.2).map(|[x, y, w, h]| [x, y, x + w, y + h]);
+ let widest = text_clips
+ .chain(button_clips)
+ .filter(|b| b[1] >= list.y - 0.5 && b[3] <= list.y + list.h + 0.5)
+ .max_by(|a, b| (a[2] - a[0]).total_cmp(&(b[2] - b[0])))
+ .unwrap_or_else(|| panic!("{name}: no row drawn under the list's clip"));
+ let (l, r) = (list.x, list.x + list.w);
+ assert!(
+ (widest[0] - l).abs() < 0.5 && (widest[2] - r).abs() < 0.5,
+ "{name}: rows clip to [{}, {}], the list spans [{l}, {r}]",
+ widest[0],
+ widest[2]
+ );
+ }
+
+ #[test]
+ fn services_list_inside_its_section() {
+ let mut s = services::ServicesState::default();
+ s.loaded = true;
+ s.services = (0..5)
+ .map(|i| services::ServiceInfo {
+ name: format!("s{i}.service"),
+ description: "d".into(),
+ active_state: "active".into(),
+ sub_state: "running".into(),
+ is_system: true,
+ })
+ .collect();
+ let pc = render(&mut s);
+ assert_list_inside("services", &pc, &s.list);
+ }
+
+ #[test]
+ fn timers_list_inside_its_section() {
+ let mut s = timers::TimersState::default();
+ s.loaded = true;
+ s.timers = (0..5)
+ .map(|i| timers::TimerInfo {
+ unit: format!("t{i}.timer"),
+ activates: format!("t{i}.service"),
+ next_usec: None,
+ last_usec: None,
+ active: true,
+ file_state: "enabled".into(),
+ is_system: true,
+ editable: false,
+ })
+ .collect();
+ let pc = render(&mut s);
+ assert_list_inside("timers", &pc, &s.list);
+ }
+
+ #[test]
+ fn packages_list_inside_its_section() {
+ let mut s = packages::PackagesState::default();
+ s.loaded = true;
+ s.installed = (0..5)
+ .map(|i| packages::PackageInfo { name: format!("p{i}"), version: "1.0".into() })
+ .collect();
+ let pc = render(&mut s);
+ assert_list_inside("packages", &pc, &s.installed_list);
+ }
+
+ #[test]
+ fn network_list_inside_its_section() {
+ let mut s = network::NetworkState::default();
+ s.loaded = true;
+ s.wifi_enabled = true;
+ s.available = (0..3)
+ .map(|i| network::WifiNetwork { ssid: format!("net{i}"), signal: 50, secured: true, in_use: i == 0 })
+ .collect();
+ let pc = render(&mut s);
+ assert_list_inside("network", &pc, &s.wifi_list);
+ }
+
+ #[test]
+ fn accounts_list_inside_its_section() {
+ let mut s = accounts::AccountsState::default_mock();
+ s.loaded = true;
+ s.accounts = vec![serde_json::from_str(
+ r#"{"email":"a@example.org","imap":"imap.example.org:993","smtp":"smtp.example.org:465","is_default":true,"password":""}"#,
+ )
+ .unwrap()];
+ let pc = render(&mut s);
+ assert_list_inside("accounts", &pc, &s.list);
+ }
+
+ #[test]
+ fn processes_list_inside_its_section() {
+ let mut s = processes::ProcessesState { loaded: true, ..Default::default() };
+ s.processes = (0..5)
+ .map(|i| processes::ProcessRow {
+ pid: i.to_string(),
+ cpu: "1.0".into(),
+ mem_pct: "1.0".into(),
+ rss_kb: 1024,
+ command: "p".into(),
+ watts: None,
+ wakeups: None,
+ })
+ .collect();
+ let pc = render(&mut s);
+ assert_list_inside("processes", &pc, &s.cpu_list);
+ }
+}
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 0106072..216d695 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -169,7 +169,6 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
// ── WiFi (label-less well) ──
builder.add_section_spanned(&mut final_pc, "", 1, root_focused, |sec| {
let sec_w = sec.cw;
- let rx = sec.left;
let padding = sec.padding();
let row_gap = cce_ui::layout::label_margin();
let margin = padding.max(12.0);
@@ -212,9 +211,12 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
}
if state.wifi_enabled && !state.available.is_empty() {
- let list_box_x = rx + margin;
+ // Across the section's content box — the box the section
+ // clips to. At `left + margin` the framed list ran
+ // 2 * padding past it on both sides, cut off flat there.
+ let list_box_x = sec.content_left();
let list_box_y = sec.ay();
- let list_box_w = sec_w - 2.0 * margin;
+ let list_box_w = sec.content_width();
let list_box_h = 160.0;
// Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index aa57a76..f85d235 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -517,10 +517,11 @@ pub fn view(
section_divider(stack.context);
- // ── List fills the rest of the page ──
- let list_box_x = sec.left + m;
+ // ── List fills the rest of the page, across the section's content
+ // box (the box the section clips to) like the services list ──
+ let list_box_x = sec.content_left();
let list_box_y = sec.ay();
- let list_box_w = sec_w - 2.0 * m;
+ let list_box_w = sec.content_width();
let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
let query = if state.search_box.editing {
diff --git a/src/pages/services.rs b/src/pages/services.rs
index dc40362..9526e0e 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -117,10 +117,12 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
stack.add_widget(&mut state.search_box, search_w, search_h, ctx);
stack.context.spacing(cce_ui::layout::plate_gap());
- // Scroll box list
- let list_box_x = sec.left + m;
+ // Scroll box list, across the section's content box — the box
+ // the section clips to. At `left + m` the list ran 2 * padding
+ // past it on both sides, and the clip cut its edges off flat.
+ let list_box_x = sec.content_left();
let list_box_y = sec.ay();
- let list_box_w = sec_w - 2.0 * m;
+ let list_box_w = sec.content_width();
// Fill the page: the well's bottom wall lands at the page bottom,
// the list keeps one margin above it.
let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index c7ca359..65da023 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -502,10 +502,12 @@ pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
stack.context.spacing(cce_ui::layout::plate_gap());
- // Scroll box list, filling the page like the services list.
- let list_box_x = sec.left + m;
+ // Scroll box list, filling the page like the services list, and
+ // across the section's content box like it (the box the section
+ // clips to).
+ let list_box_x = sec.content_left();
let list_box_y = sec.ay();
- let list_box_w = sec_w - 2.0 * m;
+ let list_box_w = sec.content_width();
let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
let now = now_usec();