system settings
git clone https://git.lucas.co/cce-system-interface.git
refactor: the settings pages lay out on scene::layout, spaced by the ladder
All 14 pages declare each section's contents into a cce-ui Form instead of
placing them with the section cursor, and every inset and gap comes from the
spacing ladder rather than the page (+ 14, - 28, 44.0, spacing(4/6/10)).
Controls stand control_gap apart; runs of text lines are blocks with no gap.
New shared pieces in app.rs: form_button / form_button_fit (a page button as
a cell, fixed or sized to its label and sharing the row's slack), form_pairs
(label / value lines, the labels in a column as wide as the widest, values
wrapped beside them), form_divider, and button_need / wrap_to_width moved
here from the System page. section_kv_row, section_divider and
SectionContextExt went with the cursor.
What changed on screen, by choice: the ladder's control gap; Storage draws
each value beside its label (it drew it a line below); label / value lists
share a label column and wrap long values; buttons that stand alone are as
wide as their labels; audio's device names share one column so the sliders
line up; Bluetooth names are cut at the row's end rather than by a character
estimate. Lists that fill the page do so through Form::fill_height. The rows
inside the scrolling lists and the Processes table keep their own layout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/app.rs | 150 +++++++-----
src/pages/accounts.rs | 423 +++++++++++++++-------------------
src/pages/audio.rs | 145 +++++-------
src/pages/bluetooth.rs | 135 ++++-------
src/pages/browser.rs | 71 +++---
src/pages/default_apps.rs | 12 +-
src/pages/network.rs | 132 ++++++-----
src/pages/notifications.rs | 55 ++---
src/pages/packages.rs | 552 +++++++++++++++++++--------------------------
src/pages/power.rs | 120 +++++-----
src/pages/processes.rs | 337 ++++++++++++++-------------
src/pages/services.rs | 399 +++++++++++++++-----------------
src/pages/storage.rs | 146 +++++-------
src/pages/system_info.rs | 313 +++++++++----------------
src/pages/timers.rs | 437 ++++++++++++++++-------------------
15 files changed, 1505 insertions(+), 1922 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index f5c5ec2..503008d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -502,69 +502,115 @@ pub fn section_margin() -> f32 {
cce_ui::layout::SectionContext::<PageContent>::DEFAULT_MARGIN_X
}
-/// A dim label / bright value pair on one line (the shared details idiom).
-pub fn section_kv_row(sc: &mut cce_ui::layout::SectionContext<'_, PageContent>, label: &str, value: &str, value_color: [f32; 4]) {
- let mut y = sc.content_y;
- if y > sc.content_start_y {
- y += sc.row_gap;
- }
- let lx = sc.ax(section_margin());
- sc.pc.text(label, lx, y, 12.0, [0.53, 0.53, 0.60, 1.0]);
- sc.pc.text(value, lx + 130.0, y, 12.0, value_color);
- sc.content_y = y + 18.0;
- for h in &mut sc.grid.col_heights {
- *h = sc.content_y;
- }
+/// Width a label needs on a button plate. Feeds [`form_button_fit`], so a row of
+/// buttons is divided by what is written on them rather than into equal
+/// slices — "Reboot" and "Hibernate" are not the same size and a row that
+/// pretends otherwise clips one and pads the other.
+///
+/// Asks the Button itself (`intrinsic_size`: its label shaped in the button
+/// font, plus its 8px inset each side) rather than measuring here. This used
+/// `measure_text_width` in the control-label font, an inked extent that runs
+/// ~20% short of the shaped run under Berkeley Mono, so the row handed every
+/// button less than its label and "Hibernate" / "Power Off" were cut off.
+pub fn button_need(label: &str) -> f32 {
+ cce_ui::widget::Button::new(0.0, 0.0, 0.0, 0.0)
+ .with_label(label)
+ .intrinsic_size()
+ .map_or(0.0, |s| s.width)
}
-/// A hairline separating a well's zones.
-pub fn section_divider(sc: &mut cce_ui::layout::SectionContext<'_, PageContent>) {
- let y = sc.content_y + sc.row_gap + 4.0;
- let x = sc.ax(section_margin());
- let w = sc.cw - 2.0 * (sc.padding() + section_margin());
- sc.pc.rect([1.0, 1.0, 1.0, 0.06], x, y, w, 1.0);
- sc.content_y = y + 5.0;
- for h in &mut sc.grid.col_heights {
- *h = sc.content_y;
- }
+/// A page button (`PageContent::button`) as a form piece, a button's height: `w` wide, or
+/// the width of its column (and, in a row, the row's slack) when `w` is 0. `colors` are the
+/// face, the hover face and the label.
+pub fn form_button<'w>(
+ g: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
+ label: impl Into<String>,
+ w: f32,
+ colors: ([f32; 4], [f32; 4], [f32; 4]),
+ action: AppAction,
+) {
+ let label = label.into();
+ g.draw(w, cce_ui::layout::button_height(), w == 0.0, move |pc, r, _| {
+ pc.button(&label, r.x, r.y, r.width, r.height, colors.0, colors.1, colors.2, action);
+ });
}
-pub trait SectionContextExt {
- fn button(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction);
- fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction);
- /// [`PageContent::button_icon`] inside a section — `label` is the fallback
- /// for a missing icon set, `alpha` dims the glyph.
- #[allow(clippy::too_many_arguments)]
- fn button_icon(&mut self, icon: &str, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], alpha: f32, action: AppAction);
+/// A page button as a row cell that asks for `need` (its label's own width) and shares the
+/// row's slack with the other cells — a row of buttons sized to their labels.
+pub fn form_button_fit<'w>(
+ g: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
+ label: impl Into<String>,
+ need: f32,
+ colors: ([f32; 4], [f32; 4], [f32; 4]),
+ action: AppAction,
+) {
+ let label = label.into();
+ g.draw(need, cce_ui::layout::button_height(), true, move |pc, r, _| {
+ pc.button(&label, r.x, r.y, r.width, r.height, colors.0, colors.1, colors.2, action);
+ });
}
-impl<'a> SectionContextExt for cce_ui::layout::SectionContext<'a, PageContent> {
- fn button(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction) {
- self.pc.button(label, x, y, w, h, bg, hover_bg, label_color, action);
- self.content_y = self.content_y.max(y + h);
- for height in &mut self.grid.col_heights {
- *height = height.max(self.content_y);
+/// `text` broken into lines no wider than `width` at `size`, measured as the
+/// renderer shapes it (the default UI face, as `PageContent::text` draws).
+/// Breaks after a space or before a `/`, so a path splits on its separators;
+/// a run with neither is cut wherever it overflows. A section's text is
+/// clipped to its content box, so anything that does not fit has to wrap or
+/// it is simply lost — a GPU name, the CPU model, a pending install path.
+pub fn wrap_to_width(text: &str, width: f32, size: f32) -> Vec<String> {
+ let offsets = cce_ui::geometry_font_system()
+ .lock()
+ .map(|mut fs| cce_ui::backend::text::shaped_cluster_offsets(&mut fs, text, size, None))
+ .unwrap_or_default();
+ if offsets.last().map_or(true, |&(_, total)| total <= width) {
+ return vec![text.to_string()];
+ }
+ let mut lines = Vec::new();
+ let (mut start, mut start_x) = (0usize, 0.0f32);
+ // Where the next line would begin if this one broke at the last chance.
+ let mut chance: Option<(usize, f32)> = None;
+ for pair in offsets.windows(2) {
+ let ((b, x), (next_b, next_x)) = (pair[0], pair[1]);
+ if next_x - start_x > width && b > start {
+ let (cut, cut_x) = chance.filter(|&(c, _)| c > start).unwrap_or((b, x));
+ lines.push(text[start..cut].trim_end().to_string());
+ (start, start_x, chance) = (cut, cut_x, None);
}
- }
-
- fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: AppAction) {
- self.pc.button_left(label, x, y, w, h, bg, hover_bg, label_color, action);
- self.content_y = self.content_y.max(y + h);
- for height in &mut self.grid.col_heights {
- *height = height.max(self.content_y);
+ match &text[b..next_b] {
+ " " => chance = Some((next_b, next_x)),
+ "/" if b > start => chance = Some((b, x)),
+ _ => {}
}
}
+ lines.push(text[start..].trim_end().to_string());
+ lines.retain(|l| !l.is_empty());
+ lines
+}
- fn button_icon(&mut self, icon: &str, label: &str, x: f32, y: f32, w: f32, h: f32, bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], alpha: f32, action: AppAction) {
- self.pc.button_icon(icon, label, x, y, w, h, bg, hover_bg, label_color, alpha, action);
- self.content_y = self.content_y.max(y + h);
- for height in &mut self.grid.col_heights {
- *height = height.max(self.content_y);
+/// Label / value pairs as one block of text: the labels in a column as wide as the widest of
+/// them, each value beside its label and wrapped to the room left of the content box, the
+/// label on the value's first line. Each pair is (label, label colour, value, value colour).
+pub fn form_pairs<'w>(
+ col: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
+ size: f32,
+ pairs: Vec<(String, [f32; 4], String, [f32; 4])>,
+) {
+ use cce_ui::scene::layout::{CrossAlign, Style};
+ let label_w = pairs.iter().map(|p| cce_ui::layout::form_text_width(&p.0, size)).fold(0.0, f32::max);
+ let line_h = cce_ui::layout::form_line_height(size);
+ let value_w = (col.form_width() - label_w - cce_ui::layout::control_gap()).max(1.0);
+ // Lines of text, not controls: they stand a line apart, with no control gap between.
+ col.group(Style::column().cross_align(CrossAlign::Stretch), false, |lines| {
+ for (label, label_color, value, value_color) in pairs {
+ lines.group(Style::controls_row(), true, |row| {
+ row.draw(label_w, line_h, false, move |pc, r, _| pc.text(&label, r.x, r.y, size, label_color));
+ row.lines(wrap_to_width(&value, value_w, size), size, value_color);
+ });
}
- }
+ });
}
-
-
-
+/// The hairline that parts a well's zones, as a form piece.
+pub fn form_divider(g: &mut cce_ui::layout::FormGroup<'_, '_, PageContent>) {
+ g.rule([1.0, 1.0, 1.0, 0.06]);
+}
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 1fd08c7..f4a8a1d 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -1,8 +1,8 @@
-use crate::app::{AppAction, PageContent, SectionContextExt, section_divider, section_kv_row};
+use crate::app::{form_button, form_divider, form_pairs, AppAction, PageContent};
use cce_ui::widget::Owned;
use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::widget::ScrollRegion;
-use cce_ui::widget::{TextBox, WidgetHost};
+use cce_ui::widget::TextBox;
/// Secret Service entries are keyed by (service, address) — the same pair
/// cce-mail resolves passwords through. `KEYRING_SERVICE_LEGACY` is the
@@ -633,198 +633,156 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
let widget_h = cce_ui::layout::spinbox_height();
let btn_h = cce_ui::layout::button_height();
- let m = crate::app::section_margin();
- let gap = cce_ui::layout::plate_gap();
builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+ let mut form = sec.form();
if !state.loaded {
- sec.text("Loading online accounts...", 12.0, 0.0, 12.0, TEXT_DIM);
+ form.column().text("Loading online accounts...", 12.0, TEXT_DIM);
+ sec.place(form, ctx);
return;
}
- let item_w = sec.cw - 2.0 * (sec.padding() + m);
- let rx = sec.left;
+ let narrow = form.width() < 520.0;
+ // A login in flight is an active mode too — tint whichever button could
+ // have started it, so the "already waiting on the browser" reply is not
+ // the only clue.
+ let login_bg = if state.oauth_listener_running { (ACCENT_BG, ACCENT_BG) } else { BTN_NEUTRAL };
+ let add_bg = if state.adding_new { (ACCENT_BG, ACCENT_BG) } else { BTN_PRIMARY };
+ let sel = state.selected_idx.filter(|&i| i < state.accounts.len());
+ let mut col = form.column();
// ── Account list: a scroll region, selection tinted, default marked ──
- // Laid out on the section context BEFORE the vstack, the way network's
- // wifi list is. A VStack derives each row's y from
- // `max(grid.max_height(), content_y)`, so a region that advances only
- // `content_y` is invisible to the grid half of that and every following
- // row lands back on top of the list.
- if !state.accounts.is_empty() {
+ // It grows with the account count up to LIST_MAX_ROWS and scrolls past it.
+ if state.accounts.is_empty() {
+ col.text("No accounts configured.", 12.0, TEXT_DIM);
+ } else {
// Row height comes from the region, not from spinbox_height():
// ScrollRegion floors item_height at the list font's line box, and
// 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;
- // 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.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;
-
- // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
- state.list.set_rect(list_x, list_y, list_w, list_h);
- state.list.update_bounds(state.accounts.len(), list_y, list_h);
- state.list.push_prims(sec.pc);
-
- let btn_w = list_w - 2.0 * m;
- sec.pc.push_clip_rect(list_x, list_y, list_w, list_h);
- for (idx, acc) in state.accounts.iter().enumerate() {
- // Same predicate the region virtualizes on — a row scrolled out
- // of the box is not emitted at all.
- let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) else {
- continue;
- };
- // The stranded-vault tell, visible without selecting the row.
- let missing = state.keyring.get(&acc.email) == Some(&KeyringStatus::Missing);
- let is_selected = state.selected_idx == Some(idx) && !state.adding_new;
- let (bg, hover) = if is_selected {
- (ACCENT_BG, [0.22, 0.44, 0.70, 0.45])
- } else {
- ([1.0, 1.0, 1.0, 0.04], [1.0, 1.0, 1.0, 0.10])
- };
- sec.pc.button_left(
- &acc.email,
- list_x + m,
- draw_y,
- btn_w,
- item_h,
- bg,
- hover,
- TEXT_BTN,
- AppAction::Accounts(AccountsMessage::SelectAccount(idx)),
- );
- // The row's marks run on after the address: a `star` glyph
- // and "default", a `warning` glyph and "no password". Each
- // is its glyph and its word — the word alone when the icon
- // set is missing — placed past the address as the renderer
- // shapes it, in the button's own face.
- let marks: &[(&str, &str, [f32; 4])] = match (acc.is_default, missing) {
- (true, true) => &[("star", "default", TEXT_BTN), ("warning", "no password", TEXT_WARN)],
- (true, false) => &[("star", "default", TEXT_BTN)],
- (false, true) => &[("warning", "no password", TEXT_WARN)],
- (false, false) => &[],
- };
- if !marks.is_empty() {
- let font = cce_ui::layout::button_font();
- let size = 12.0;
- let ty = crate::app::label_y_in(draw_y, item_h, size, Some(&font));
- let g = 11.0;
- let mut mx = list_x + m + cce_ui::layout::CONTROL_TEXT_INSET
- + crate::app::text_width(&acc.email, size, Some(&font));
- for (icon, word, color) in marks {
- mx += 18.0;
- if sec.pc.icon(icon, mx, draw_y + (item_h - g) / 2.0, g, g, *color) {
- mx += g + 4.0;
+ // The list's own inset of its rows inside its frame.
+ let m = crate::app::section_margin();
+ let list = &mut state.list;
+ let accounts = &state.accounts;
+ let keyring = &state.keyring;
+ let (selected_idx, adding_new) = (state.selected_idx, state.adding_new);
+ col.draw(0.0, list_h, false, move |pc, r, _| {
+ let (list_x, list_y, list_w, list_h) = (r.x, r.y, r.width, r.height);
+ // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+ list.set_rect(list_x, list_y, list_w, list_h);
+ list.update_bounds(accounts.len(), list_y, list_h);
+ list.push_prims(pc);
+ let btn_w = list_w - 2.0 * m;
+ pc.push_clip_rect(list_x, list_y, list_w, list_h);
+ for (idx, acc) in accounts.iter().enumerate() {
+ // Same predicate the region virtualizes on — a row scrolled out
+ // of the box is not emitted at all.
+ let Some(draw_y) = list.get_item_draw_y(idx, 4.0) else {
+ continue;
+ };
+ // The stranded-vault tell, visible without selecting the row.
+ let missing = keyring.get(&acc.email) == Some(&KeyringStatus::Missing);
+ let is_selected = selected_idx == Some(idx) && !adding_new;
+ let (bg, hover) = if is_selected {
+ (ACCENT_BG, [0.22, 0.44, 0.70, 0.45])
+ } else {
+ ([1.0, 1.0, 1.0, 0.04], [1.0, 1.0, 1.0, 0.10])
+ };
+ pc.button_left(
+ &acc.email,
+ list_x + m,
+ draw_y,
+ btn_w,
+ item_h,
+ bg,
+ hover,
+ TEXT_BTN,
+ AppAction::Accounts(AccountsMessage::SelectAccount(idx)),
+ );
+ // The row's marks run on after the address: a `star` glyph
+ // and "default", a `warning` glyph and "no password". Each
+ // is its glyph and its word — the word alone when the icon
+ // set is missing — placed past the address as the renderer
+ // shapes it, in the button's own face.
+ let marks: &[(&str, &str, [f32; 4])] = match (acc.is_default, missing) {
+ (true, true) => &[("star", "default", TEXT_BTN), ("warning", "no password", TEXT_WARN)],
+ (true, false) => &[("star", "default", TEXT_BTN)],
+ (false, true) => &[("warning", "no password", TEXT_WARN)],
+ (false, false) => &[],
+ };
+ if !marks.is_empty() {
+ let font = cce_ui::layout::button_font();
+ let size = 12.0;
+ let ty = crate::app::label_y_in(draw_y, item_h, size, Some(&font));
+ let g = 11.0;
+ let mut mx = list_x + m + cce_ui::layout::CONTROL_TEXT_INSET
+ + crate::app::text_width(&acc.email, size, Some(&font));
+ for (icon, word, color) in marks {
+ mx += 18.0;
+ if pc.icon(icon, mx, draw_y + (item_h - g) / 2.0, g, g, *color) {
+ mx += g + 4.0;
+ }
+ pc.text_with_font(word, mx, ty, size, TEXT_BTN, &font);
+ mx += crate::app::text_width(word, size, Some(&font));
}
- sec.pc.text_with_font(word, mx, ty, size, TEXT_BTN, &font);
- mx += crate::app::text_width(word, size, Some(&font));
}
}
- }
- sec.pc.pop_clip_rect();
- // The scrollbar's fore copy, over the rows at the raise's fade.
- state.list.push_scrollbar_fore(sec.pc);
- // Reserve the region's height through `spacing`, NOT `content_y +=`:
- // SectionContext keeps a parallel per-column Grid, and its own
- // `spacing` recomputes `content_y = grid.max_height()`. A manual
- // `content_y` bump that leaves the grid untouched is therefore
- // discarded by the next spacing call, and every following row lands
- // back on top of the list. (Network's list gets away with the bare
- // `content_y +=` only because nothing follows it in that section.)
- sec.spacing(list_h + LIST_GAP);
- }
-
- let mut stack = sec.vstack(gap);
- if state.accounts.is_empty() {
- stack.context.text("No accounts configured.", 12.0, 0.0, 12.0, TEXT_DIM);
+ pc.pop_clip_rect();
+ // The scrollbar's fore copy, over the rows at the raise's fade.
+ list.push_scrollbar_fore(pc);
+ });
}
- stack.context.spacing(6.0);
-
// ── Global actions ──
- // Adding is the only one left. Google sign-in lives inside the form
- // (next to Save) because adding an account is a single intent, and the
- // Google API client id/secret is file-backed config edited in
- // ~/.config/cce/google_client.json — set once or never, so it follows
- // input.kdl's precedent of having no settings UI at all.
- let add_bg = if state.adding_new { (ACCENT_BG, ACCENT_BG) } else { BTN_PRIMARY };
- // A login in flight is an active mode too — tint whichever button could
- // have started it, so the "already waiting on the browser" reply is not
- // the only clue.
- let login_bg = if state.oauth_listener_running { (ACCENT_BG, ACCENT_BG) } else { BTN_NEUTRAL };
- let narrow = item_w < 520.0;
-
- // Add, Edit, Delete in one row of squares. Edit and Delete used to live
- // down in the selected-account zone; they belong next to Add because
- // all three act on the account LIST, while everything below the divider
- // is about one account's fields.
- //
- // Icon faces, so each is a square the height of a button rather than a
- // share of the section width — the row is drawn as one full-width cell
- // with the buttons placed inside it, since equal columns would stretch
- // a 26px glyph across a third of the section.
- //
- // Edit and Delete need a selection, so they appear only with one. They
- // are OMITTED rather than dimmed: an icon's only disabled state is
+ // Add, Edit, Delete in one row of squares. Edit and Delete act on the
+ // account LIST, so they sit beside Add; everything below the divider is
+ // about one account's fields. Icon faces, so each is a square the height
+ // of a button. Edit and Delete need a selection, so they appear only with
+ // one: OMITTED rather than dimmed, since an icon's only disabled state is
// opacity, and a faint square that still takes the click reads as a
- // control that ignored you. `narrow` still governs the fallback width —
- // without an icon set these go back to being word buttons.
- let sel = state.selected_idx.filter(|&i| i < state.accounts.len());
+ // control that ignored you. Without an icon set they are word buttons,
+ // and `narrow` picks their width. Google sign-in lives inside the add form.
let icons_ok = cce_ui::upload_icon("plus", 32).is_some();
- let (sq, bgap) = if icons_ok { (btn_h, 8.0) } else if narrow { (86.0, 6.0) } else { (110.0, 8.0) };
- stack.add_row(1, gap, btn_h, |c, _, x, _w| {
- // ONE y for the whole row. `c.ay()` reads the section's running
- // content_y, and each button emitted advances it past its own
- // bottom — normally right, because `add_row` resets content_y
- // between COLUMNS. Three buttons inside a single column get no such
- // reset, so re-reading `ay()` per button walked them diagonally
- // down the page, one button-height at a time.
- let y = c.ay();
- c.button_icon("plus", "Add Account", x, y, sq, btn_h,
- add_bg.0, add_bg.1, TEXT_BTN, 1.0,
- AppAction::Accounts(AccountsMessage::AddAccountStart));
+ let sq = if icons_ok { btn_h } else if narrow { 86.0 } else { 110.0 };
+ col.row(|r| {
+ let icon_button = |r: &mut cce_ui::layout::FormGroup<'_, '_, PageContent>, icon: &'static str, word: &'static str,
+ colors: ([f32; 4], [f32; 4]), text: [f32; 4], action: AccountsMessage| {
+ r.draw(sq, btn_h, false, move |pc, c, _| {
+ pc.button_icon(icon, word, c.x, c.y, c.width, c.height, colors.0, colors.1, text, 1.0, AppAction::Accounts(action));
+ });
+ };
+ icon_button(r, "plus", "Add Account", add_bg, TEXT_BTN, AccountsMessage::AddAccountStart);
if let Some(i) = sel {
- c.button_icon("pencil", "Edit", x + sq + bgap, y, sq, btn_h,
- BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN, 1.0,
- AppAction::Accounts(AccountsMessage::EditAccountStart(i)));
- c.button_icon("trash", "Delete", x + 2.0 * (sq + bgap), y, sq, btn_h,
- BTN_DANGER.0, BTN_DANGER.1, TEXT_DANGER, 1.0,
- AppAction::Accounts(AccountsMessage::DeleteAccount(i)));
+ icon_button(r, "pencil", "Edit", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::EditAccountStart(i));
+ icon_button(r, "trash", "Delete", BTN_DANGER, TEXT_DANGER, AccountsMessage::DeleteAccount(i));
}
});
- section_divider(stack.context);
+ form_divider(&mut col);
// ── Context zone: add form / edit form / selected details ──
+ let heading = [0.35, 0.65, 0.90, 1.0];
+ let kv = |label: &str, value: &str, color: [f32; 4]| (label.to_string(), TEXT_DIM, value.to_string(), color);
if state.adding_new {
- stack.context.text("Add New Account", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
- stack.context.text("Gmail signs in with Google below; iCloud requires an App Password.", 12.0, 0.0, 11.0, TEXT_DIM);
-
- state.email_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.email_box, item_w, widget_h, ctx);
- state.password_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.password_box, item_w, widget_h, ctx);
- state.imap_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
- state.smtp_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
-
- stack.context.spacing(4.0);
- stack.add_row(4, gap, btn_h, |c, i, x, w| {
- let (label, colors, text_col, action) = match i {
- 0 => ("Save", BTN_PRIMARY, TEXT_BTN, AccountsMessage::AddAccountSave),
- 1 => ("Cancel", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::AddAccountCancel),
- // Four buttons in one row is the tightest cell on the page —
- // the full labels clip below ~440px of section width, so they
- // ride the same `narrow` switch the header row uses.
- 2 => (if narrow { "Google" } else { "Login (Google)" }, login_bg, TEXT_BTN, AccountsMessage::GoogleLoginInit),
- _ => (if narrow { "iCloud" } else { "Login (iCloud)" }, BTN_NEUTRAL, TEXT_BTN, AccountsMessage::ICloudLoginHelp),
- };
- c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, text_col, AppAction::Accounts(action));
+ col.block(|b| {
+ b.text("Add New Account", 14.0, heading);
+ b.text("Gmail signs in with Google below; iCloud requires an App Password.", 11.0, TEXT_DIM);
+ });
+ col.widget(&mut state.email_box, widget_h)
+ .widget(&mut state.password_box, widget_h)
+ .widget(&mut state.imap_box, widget_h)
+ .widget(&mut state.smtp_box, widget_h);
+ col.row(|r| {
+ form_button(r, "Save", 0.0, (BTN_PRIMARY.0, BTN_PRIMARY.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::AddAccountSave));
+ form_button(r, "Cancel", 0.0, (BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::AddAccountCancel));
+ // Four buttons in one row is the tightest cell on the page — the full
+ // labels clip below ~440px of section width, so they ride `narrow`.
+ form_button(r, if narrow { "Google" } else { "Login (Google)" }, 0.0, (login_bg.0, login_bg.1, TEXT_BTN),
+ AppAction::Accounts(AccountsMessage::GoogleLoginInit));
+ form_button(r, if narrow { "iCloud" } else { "Login (iCloud)" }, 0.0, (BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN),
+ AppAction::Accounts(AccountsMessage::ICloudLoginHelp));
});
} else if let Some(acc) = state
.editing_email
@@ -832,99 +790,73 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
.and_then(|e| state.accounts.iter().find(|a| a.email == *e))
.cloned()
{
- stack.context.text("Edit Account", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
- section_kv_row(stack.context, "Email", &acc.email, TEXT_BTN);
- stack.context.text("The address identifies the account \u{2014} delete and re-add to change it.", 12.0, 0.0, 11.0, TEXT_DIM);
+ col.text("Edit Account", 14.0, heading);
+ form_pairs(&mut col, 12.0, vec![kv("Email", &acc.email, TEXT_BTN)]);
+ col.text("The address identifies the account \u{2014} delete and re-add to change it.", 11.0, TEXT_DIM);
// An OAuth account has no password to edit; a password one has no
// client credentials. Neither ever shows the other's fields.
if acc.is_oauth {
- state.imap_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
- state.smtp_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
-
- stack.context.spacing(4.0);
- stack.context.text("Credentials this account refreshes tokens with, taking effect", 12.0, 0.0, 11.0, TEXT_DIM);
- stack.context.text("on the next refresh \u{2014} Re-login to re-issue the tokens now.", 12.0, 0.0, 11.0, TEXT_DIM);
- state.oauth_client_id_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.oauth_client_id_box, item_w, widget_h, ctx);
- state.oauth_client_secret_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.oauth_client_secret_box, item_w, widget_h, ctx);
+ col.widget(&mut state.imap_box, widget_h).widget(&mut state.smtp_box, widget_h);
+ col.block(|b| {
+ b.text("Credentials this account refreshes tokens with, taking effect", 11.0, TEXT_DIM);
+ b.text("on the next refresh \u{2014} Re-login to re-issue the tokens now.", 11.0, TEXT_DIM);
+ });
+ col.widget(&mut state.oauth_client_id_box, widget_h).widget(&mut state.oauth_client_secret_box, widget_h);
} else {
- state.password_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.password_box, item_w, widget_h, ctx);
- state.imap_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
- state.smtp_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
+ col.widget(&mut state.password_box, widget_h)
+ .widget(&mut state.imap_box, widget_h)
+ .widget(&mut state.smtp_box, widget_h);
}
-
- stack.context.spacing(4.0);
- stack.add_row(2, gap, btn_h, |c, i, x, w| {
- let (label, colors, action) = match i {
- 0 => ("Save", BTN_PRIMARY, AccountsMessage::EditAccountSave),
- _ => ("Cancel", BTN_NEUTRAL, AccountsMessage::EditAccountCancel),
- };
- c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, TEXT_BTN, AppAction::Accounts(action));
+ col.row(|r| {
+ form_button(r, "Save", 0.0, (BTN_PRIMARY.0, BTN_PRIMARY.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::EditAccountSave));
+ form_button(r, "Cancel", 0.0, (BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN), AppAction::Accounts(AccountsMessage::EditAccountCancel));
});
- } else if let Some(selected_idx) = state.selected_idx {
- if selected_idx < state.accounts.len() {
- let acc = state.accounts[selected_idx].clone();
-
- section_kv_row(stack.context, "Email", &acc.email, TEXT_BTN);
- let auth_type = if acc.is_oauth { "OAuth2 (Google)" } else { "Password" };
- section_kv_row(stack.context, "Authentication", auth_type, TEXT_BTN);
- // Where the password actually lives — the row that would have
- // shown the 08-29 vault stranding at a glance. Only password
- // accounts carry it; the probe skips OAuth and mock.
- if let Some(status) = state.keyring.get(&acc.email) {
- let (text, color) = match status {
- KeyringStatus::InKeyring => ("in keyring", TEXT_BTN),
- KeyringStatus::OnDiskPlaintext => {
- ("on disk (plaintext) \u{2014} migrates to keyring", TEXT_WARN)
- }
- KeyringStatus::Missing => {
- ("MISSING \u{2014} mail cannot sign in; Edit to set it", TEXT_DANGER)
- }
- };
- section_kv_row(stack.context, "Password", text, color);
- }
- section_kv_row(stack.context, "IMAP", &acc.imap, TEXT_BTN);
- section_kv_row(stack.context, "SMTP", &acc.smtp, TEXT_BTN);
-
- stack.context.spacing(6.0);
-
- // What is left of the per-account actions once Edit and Delete
- // moved up beside Add. The row is sized to what is actually
- // there — a fixed count left ragged gaps whenever an account
- // was default or password — and with only these two left it can
- // now be EMPTY, for a default password account, so it is
- // skipped rather than drawn as a bare gap.
- let mut actions: Vec<(&str, ([f32; 4], [f32; 4]), [f32; 4], AccountsMessage)> = Vec::new();
- if !acc.is_default {
- actions.push(("Make Default", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::MakeDefault(selected_idx)));
- }
- if acc.is_oauth {
- let relogin = if narrow { "Re-login" } else { "Re-login (Browser)" };
- actions.push((relogin, login_bg, TEXT_BTN, AccountsMessage::GoogleLoginInit));
- }
- if !actions.is_empty() {
- stack.add_row(actions.len(), gap, btn_h, |c, i, x, w| {
- if let Some((label, colors, text_col, action)) = actions.get(i).cloned() {
- c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, text_col, AppAction::Accounts(action));
- }
- });
- }
+ } else if let Some(selected_idx) = sel {
+ let acc = state.accounts[selected_idx].clone();
+ let auth_type = if acc.is_oauth { "OAuth2 (Google)" } else { "Password" };
+ let mut pairs = vec![kv("Email", &acc.email, TEXT_BTN), kv("Authentication", auth_type, TEXT_BTN)];
+ // Where the password actually lives — the row that would have
+ // shown the 08-29 vault stranding at a glance. Only password
+ // accounts carry it; the probe skips OAuth and mock.
+ if let Some(status) = state.keyring.get(&acc.email) {
+ let (text, color) = match status {
+ KeyringStatus::InKeyring => ("in keyring", TEXT_BTN),
+ KeyringStatus::OnDiskPlaintext => ("on disk (plaintext) \u{2014} migrates to keyring", TEXT_WARN),
+ KeyringStatus::Missing => ("MISSING \u{2014} mail cannot sign in; Edit to set it", TEXT_DANGER),
+ };
+ pairs.push(kv("Password", text, color));
+ }
+ pairs.push(kv("IMAP", &acc.imap, TEXT_BTN));
+ pairs.push(kv("SMTP", &acc.smtp, TEXT_BTN));
+ form_pairs(&mut col, 12.0, pairs);
+
+ // What is left of the per-account actions once Edit and Delete moved
+ // up beside Add. The row holds only what applies, and for a default
+ // password account that is nothing, so it is skipped.
+ let mut actions: Vec<(&str, ([f32; 4], [f32; 4]), AccountsMessage)> = Vec::new();
+ if !acc.is_default {
+ actions.push(("Make Default", BTN_NEUTRAL, AccountsMessage::MakeDefault(selected_idx)));
+ }
+ if acc.is_oauth {
+ let relogin = if narrow { "Re-login" } else { "Re-login (Browser)" };
+ actions.push((relogin, login_bg, AccountsMessage::GoogleLoginInit));
+ }
+ if !actions.is_empty() {
+ col.row(|r| {
+ for (label, colors, action) in actions {
+ form_button(r, label, 0.0, (colors.0, colors.1, TEXT_BTN), AppAction::Accounts(action));
+ }
+ });
}
} else {
- stack.context.text("Select an account to view details, or add one.", 12.0, 0.0, 12.0, TEXT_DIM);
+ col.text("Select an account to view details, or add one.", 12.0, TEXT_DIM);
}
if let Some(ref msg) = state.status_msg {
- stack.context.spacing(6.0);
- stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
+ col.text(msg.clone(), 12.0, [0.56, 0.83, 0.56, 1.0]);
}
+ sec.place(form, ctx);
});
final_pc
}
@@ -1361,6 +1293,7 @@ impl crate::pages::AppPage for AccountsState {
#[cfg(test)]
mod tests {
+ use cce_ui::widget::WidgetHost;
use super::*;
use cce_ui::layout::PageFlow;
diff --git a/src/pages/audio.rs b/src/pages/audio.rs
index f77b826..5e60c53 100644
--- a/src/pages/audio.rs
+++ b/src/pages/audio.rs
@@ -1,7 +1,7 @@
-use crate::app::{AppAction, PageContent, SectionContextExt, section_divider};
+use crate::app::{form_button, form_divider, AppAction, PageContent};
use cce_ui::widget::Owned;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow};
-use cce_ui::widget::{Spinbox, Slider, WidgetHost};
+use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::widget::{Spinbox, Slider};
#[derive(Debug, Clone)]
pub struct AudioSink {
@@ -224,127 +224,98 @@ const BTN_DANGER: ([f32; 4], [f32; 4]) = ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20,
const TEXT_BTN: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
const TEXT_DANGER: [f32; 4] = [0.95, 0.55, 0.55, 1.0];
-/// One device on one line: name, volume slider, spinbox, mute — or a dim
-/// "inactive" note. The widgets stay index-aligned with the device vecs.
+/// One device on one line: name, volume slider, spinbox, mute — or a dim "inactive" note.
+/// The names stand in a column `name_w` wide, so every row's controls line up. The widgets
+/// stay index-aligned with the device vecs.
#[allow(clippy::too_many_arguments)]
-fn device_row(
- stack: &mut cce_ui::layout::VStack<'_, '_, PageContent>,
- name: &str,
+fn device_row<'w>(
+ col: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
+ name: String,
+ name_w: f32,
active: bool,
muted: bool,
volume: f32,
- slider: &mut Owned<cce_ui::widget::Adapted<Slider>>,
- spin: &mut Owned<cce_ui::widget::Adapted<Spinbox>>,
+ slider: &'w mut Owned<cce_ui::widget::Adapted<Slider>>,
+ spin: &'w mut Owned<cce_ui::widget::Adapted<Spinbox>>,
mute_action: AppAction,
- ctx: &mut cce_ui::context::UiContext,
) {
+ let line_h = cce_ui::layout::form_line_height(12.0);
if !active {
- let sc = &mut *stack.context;
- let mut y = sc.content_y;
- if y > sc.content_start_y {
- y += sc.row_gap;
- }
- let lx = sc.ax(12.0);
- sc.pc.text(name, lx, y, 12.0, TEXT_DIM);
- sc.pc.text("inactive", lx + 150.0, y, 12.0, TEXT_DIM);
- sc.content_y = y + 18.0;
- for h in &mut sc.grid.col_heights {
- *h = sc.content_y;
- }
+ col.row(|r| {
+ r.draw(name_w, line_h, false, move |pc, c, _| pc.text(&name, c.x, c.y, 12.0, TEXT_DIM));
+ r.text("inactive", 12.0, TEXT_DIM);
+ });
return;
}
slider.set_value(volume);
spin.value = (volume * 100.0).round() as i32;
-
- let sb_h = cce_ui::layout::spinbox_height();
- let sl_h = cce_ui::layout::slider_height();
- let btn_h = cce_ui::layout::button_height();
- let row_h = sb_h.max(sl_h).max(btn_h);
let (mute_label, colors, mute_text) = if muted {
("Unmute", BTN_DANGER, TEXT_DANGER)
} else {
("Mute", BTN_NEUTRAL, TEXT_BTN)
};
-
- stack.add_row(1, 0.0, row_h, |c, _, x, w| {
- let name_w = 150.0;
- let spin_w = 90.0;
- let mute_w = 80.0;
- let gap = 8.0;
- let slider_w = (w - name_w - spin_w - mute_w - 2.0 * gap).max(60.0);
- let y = c.ay();
-
- c.pc.text(name, x, y + (row_h - 14.0) / 2.0, 12.0, TEXT_FG);
- render_widget(c.pc, slider, x + name_w, y + (row_h - sl_h) / 2.0, slider_w, sl_h, ctx);
- let spin_x = x + name_w + slider_w + gap;
- spin.set_row_rect(spin_x, spin_w);
- render_widget(c.pc, spin, spin_x, y + (row_h - sb_h) / 2.0, spin_w, sb_h, ctx);
- c.button(
- mute_label,
- spin_x + spin_w + gap,
- y + (row_h - btn_h) / 2.0,
- mute_w,
- btn_h,
- colors.0,
- colors.1,
- mute_text,
- mute_action.clone(),
- );
+ col.row(|r| {
+ r.draw(name_w, line_h, false, move |pc, c, _| {
+ pc.text_with_bounds(&name, c.x, c.y, 12.0, TEXT_FG, Some([c.x, c.y - 12.0, c.x + c.width, c.y + 24.0]));
+ });
+ r.widget(slider, cce_ui::layout::slider_height());
+ r.widget_w(spin, SPIN_W, cce_ui::layout::spinbox_height());
+ form_button(r, mute_label, MUTE_W, (colors.0, colors.1, mute_text), mute_action);
});
}
+/// The volume readout's width: three digits, a unit and the −/+ run.
+const SPIN_W: f32 = 90.0;
+/// The mute button's width: "Unmute" at the button font.
+const MUTE_W: f32 = 80.0;
+
pub fn view(state: &mut AudioState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
let mut final_pc = PageContent::new();
let sec_w = 320.0f32;
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
+ let mut form = sec.form();
+ let mut col = form.column();
if !state.loaded {
- sec.text("Loading audio devices...", 12.0, 0.0, 12.0, TEXT_DIM);
+ col.text("Loading audio devices...", 12.0, TEXT_DIM);
+ sec.place(form, ctx);
return;
}
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
-
- stack.context.text("Output", 12.0, 0.0, 14.0, HEADING);
+ // One name column for both lists, as wide as the widest name (and never so wide that
+ // the slider has no room).
+ let name_w = state
+ .sinks
+ .iter()
+ .map(|d| d.name.as_str())
+ .chain(state.sources.iter().map(|d| d.name.as_str()))
+ .map(|n| cce_ui::layout::form_text_width(n, 12.0))
+ .fold(0.0, f32::max)
+ .min(col.form_width() / 3.0);
+
+ col.text("Output", 14.0, HEADING);
if state.sinks.is_empty() {
- stack.context.text("No output devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+ col.text("No output devices found", 12.0, TEXT_DIM);
}
- let sinks = state.sinks.clone();
- for (idx, sink) in sinks.iter().enumerate() {
- device_row(
- &mut stack,
- &sink.name,
- sink.active,
- sink.muted,
- sink.volume,
- &mut state.sink_sliders[idx],
- &mut state.sink_spinboxes[idx],
- AppAction::Audio(AudioMessage::SinkMute(sink.id)),
- ctx,
- );
+ let devices = state.sinks.iter().zip(state.sink_sliders.iter_mut()).zip(state.sink_spinboxes.iter_mut());
+ for ((sink, slider), spin) in devices {
+ let action = AppAction::Audio(AudioMessage::SinkMute(sink.id));
+ device_row(&mut col, sink.name.clone(), name_w, sink.active, sink.muted, sink.volume, slider, spin, action);
}
- section_divider(stack.context);
+ form_divider(&mut col);
- stack.context.text("Input", 12.0, 0.0, 14.0, HEADING);
+ col.text("Input", 14.0, HEADING);
if state.sources.is_empty() {
- stack.context.text("No input devices found", 12.0, 0.0, 12.0, TEXT_DIM);
+ col.text("No input devices found", 12.0, TEXT_DIM);
}
- let sources = state.sources.clone();
- for (idx, src) in sources.iter().enumerate() {
- device_row(
- &mut stack,
- &src.name,
- src.active,
- src.muted,
- src.volume,
- &mut state.source_sliders[idx],
- &mut state.source_spinboxes[idx],
- AppAction::Audio(AudioMessage::SourceMute(src.id)),
- ctx,
- );
+ let devices = state.sources.iter().zip(state.source_sliders.iter_mut()).zip(state.source_spinboxes.iter_mut());
+ for ((src, slider), spin) in devices {
+ let action = AppAction::Audio(AudioMessage::SourceMute(src.id));
+ device_row(&mut col, src.name.clone(), name_w, src.active, src.muted, src.volume, slider, spin, action);
}
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/bluetooth.rs b/src/pages/bluetooth.rs
index f3609ad..1dfd990 100644
--- a/src/pages/bluetooth.rs
+++ b/src/pages/bluetooth.rs
@@ -1,6 +1,6 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{form_button, AppAction, PageContent};
use cce_ui::widget::Owned;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow};
+use cce_ui::layout::{PageLayoutBuilder, PageFlow, RenderTarget};
use cce_ui::widget::{Adapted, Toggle};
#[derive(Debug, Clone)]
@@ -160,114 +160,79 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
let bt_sec_w = sec.cw;
- let padding = sec.padding();
- let row_gap = cce_ui::layout::label_margin();
- let margin = padding.max(12.0);
let font_size = 12.0;
- let btn_h = cce_ui::layout::button_height();
+ let mut form = sec.form();
+ let mut col = form.column();
if !state.loaded {
- sec.text("Loading Bluetooth status...", margin, 0.0, font_size, TEXT_DIM);
- } else if !state.installed {
- sec.text("Bluetooth tools (bluez) not installed", margin, 0.0, font_size, TEXT_DIM);
- let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
- let yt = sec.ay();
- sec.button("Install Tools", sec.ax(margin), yt, btn_w, btn_h,
- TOGGLE_ON, BTN_HOVER, WHITE,
- AppAction::Bluetooth(BluetoothMessage::InstallTools));
- sec.content_y = yt + btn_h + row_gap;
- } else if !state.service_active {
- sec.text("Bluetooth service is stopped", margin, 0.0, font_size, TEXT_DIM);
+ col.text("Loading Bluetooth status...", font_size, TEXT_DIM);
+ } else if !state.installed || !state.service_active {
+ let (note, label, msg) = if !state.installed {
+ ("Bluetooth tools (bluez) not installed", "Install Tools", BluetoothMessage::InstallTools)
+ } else {
+ ("Bluetooth service is stopped", "Start Service", BluetoothMessage::StartService)
+ };
+ col.text(note, font_size, TEXT_DIM);
let btn_w = if bt_sec_w < 200.0 { 100.0 } else { 120.0 };
- let yt = sec.ay();
- sec.button("Start Service", sec.ax(margin), yt, btn_w, btn_h,
- TOGGLE_ON, BTN_HOVER, WHITE,
- AppAction::Bluetooth(BluetoothMessage::StartService));
- sec.content_y = yt + btn_h + row_gap;
+ col.row(|r| form_button(r, label, btn_w, (TOGGLE_ON, BTN_HOVER, WHITE), AppAction::Bluetooth(msg)));
} else {
- let yt = sec.ay();
let bt_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 60.0 };
let scan_btn_w = if bt_sec_w < 200.0 { 40.0 } else { 52.0 };
- let scan_btn_x = margin + bt_btn_w + row_gap;
-
state.toggle.set_toggled(state.enabled);
state.toggle.set_label(if state.enabled { "ON" } else { "OFF" });
- // Hand-placed: sec.widget grid-places at full column width, which
- // would sit the toggle under the Scan button.
- let tx = sec.ax(margin);
- let toggle_h = cce_ui::layout::toggle_height();
- render_widget(sec.pc, &mut state.toggle, tx, yt, bt_btn_w, toggle_h, ctx);
- sec.button("Scan", sec.ax(scan_btn_x), yt, scan_btn_w, btn_h,
- TOGGLE_OFF, BTN_HOVER, WHITE,
- AppAction::Bluetooth(BluetoothMessage::Scan));
- sec.content_y = yt + btn_h.max(toggle_h) + row_gap;
+ col.row(|r| {
+ r.widget_w(&mut state.toggle, bt_btn_w, cce_ui::layout::toggle_height());
+ form_button(r, "Scan", scan_btn_w, (TOGGLE_OFF, BTN_HOVER, WHITE), AppAction::Bluetooth(BluetoothMessage::Scan));
+ });
if state.devices.is_empty() {
if state.enabled {
let no_devices_msg = if bt_sec_w < 200.0 { "No paired devices" } else { "No paired devices found" };
- sec.text(no_devices_msg, margin, 0.0, font_size, TEXT_DIM);
+ col.text(no_devices_msg, font_size, TEXT_DIM);
}
} else {
- let item_h = btn_h;
for dev in &state.devices {
let btn_w = if bt_sec_w < 250.0 { 42.0 } else { 70.0 };
- let action_label = if dev.connected {
- if bt_sec_w < 250.0 { "Disc" } else { "Disconnect" }
- } else {
- if bt_sec_w < 250.0 { "Conn" } else { "Connect" }
+ let action_label = match (dev.connected, bt_sec_w < 250.0) {
+ (true, true) => "Disc",
+ (true, false) => "Disconnect",
+ (false, true) => "Conn",
+ (false, false) => "Connect",
};
-
- let label_max_w = (bt_sec_w - btn_w - 2.0 * padding - margin - row_gap - (font_size + 6.0)).max(20.0);
- let label_max_chars = ((label_max_w / 6.0) as usize).max(5);
-
let is_unknown = dev.name.replace('-', ":").eq_ignore_ascii_case(&dev.mac);
- let label = if is_unknown {
- if bt_sec_w < 350.0 {
- dev.mac.clone()
- } else {
- format!("Unknown Device ({})", dev.mac)
- }
+ let label = match (is_unknown, bt_sec_w < 350.0) {
+ (true, true) => dev.mac.clone(),
+ (true, false) => format!("Unknown Device ({})", dev.mac),
+ (false, true) => dev.name.clone(),
+ (false, false) => format!("{} ({})", dev.name, dev.mac),
+ };
+ let (face, action) = if dev.connected {
+ (TOGGLE_OFF, AppAction::Bluetooth(BluetoothMessage::Disconnect(dev.mac.clone())))
} else {
- if bt_sec_w < 350.0 {
- let name_truncated = if dev.name.len() > label_max_chars {
- format!("{}...", &dev.name[..label_max_chars.saturating_sub(3)])
- } else {
- dev.name.clone()
- };
- name_truncated
- } else {
- let full_label = format!("{} ({})", dev.name, dev.mac);
- if full_label.len() > label_max_chars {
- format!("{}...", &full_label[..label_max_chars.saturating_sub(3)])
- } else {
- full_label
- }
- }
+ (TOGGLE_ON, AppAction::Bluetooth(BluetoothMessage::Connect(dev.mac.clone())))
};
-
- let yt = sec.ay();
- let btn_x = margin;
- let text_x = margin + btn_w + row_gap;
- let text_y_offset = (item_h - font_size) / 2.0;
- sec.button(action_label, sec.ax(btn_x), yt, btn_w, item_h,
- if dev.connected { TOGGLE_OFF } else { TOGGLE_ON }, BTN_HOVER, WHITE,
- if dev.connected {
- AppAction::Bluetooth(BluetoothMessage::Disconnect(dev.mac.clone()))
- } else {
- AppAction::Bluetooth(BluetoothMessage::Connect(dev.mac.clone()))
+ let connected = dev.connected;
+ col.row(|r| {
+ form_button(r, action_label, btn_w, (face, BTN_HOVER, WHITE), action);
+ // A connected device wears a `check` glyph ahead of its name (it was
+ // a ">" in the label); every row keeps the glyph's room so the names
+ // line up either way. The name is cut where the row ends.
+ let line_h = cce_ui::layout::form_line_height(font_size);
+ r.draw(0.0, line_h, true, move |pc, cell, _| {
+ let g = font_size;
+ if connected {
+ pc.icon("check", cell.x, cell.y + (line_h - g) / 2.0, g, g, ACCENT);
+ }
+ let tx = cell.x + g + cce_ui::layout::CONTROL_TEXT_INSET;
+ let color = if connected { ACCENT } else { TEXT_FG };
+ pc.text_with_bounds(&label, tx, cell.y, font_size, color,
+ Some([tx, cell.y - font_size, cell.x + cell.width, cell.y + 2.0 * font_size]));
});
- // A connected device wears a `check` glyph ahead of its
- // name (it was a ">" in the label); every row keeps the
- // glyph's column so the names line up either way.
- let g = font_size;
- if dev.connected {
- sec.pc.icon("check", sec.ax(text_x), yt + (item_h - g) / 2.0, g, g, ACCENT);
- }
- sec.text(&label, text_x + g + 6.0, text_y_offset, font_size, if dev.connected { ACCENT } else { TEXT_FG });
- sec.content_y = yt + item_h + row_gap;
+ });
}
}
}
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/browser.rs b/src/pages/browser.rs
index a8f957a..94ffe63 100644
--- a/src/pages/browser.rs
+++ b/src/pages/browser.rs
@@ -10,9 +10,9 @@ use std::fs;
use cce_ui::widget::Owned;
use cce_ui::layout::{PageFlow, PageLayoutBuilder};
use cce_ui::widget::input::{Dropdown, Toggle};
-use cce_ui::widget::{TextBox, WidgetHost};
+use cce_ui::widget::TextBox;
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent};
use crate::pages::AppPage;
/// config key, menu label — the browser maps the key onto a query URL.
@@ -282,53 +282,44 @@ impl AppPage for BrowserState {
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section(&mut final_pc, "Browser Settings", sec_focused.first().copied().unwrap_or(false), |sec| {
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- let sec_w = stack.context.cw;
- let row_w = sec_w - 28.0;
-
- self.homepage_box.set_row_rect(stack.context.left + 14.0, row_w);
- stack.add_widget(&mut self.homepage_box, row_w, 44.0, ctx);
-
self.search_menu.selected = search_index(&self.search);
- self.search_menu.set_row_rect(stack.context.left + 14.0, row_w);
- stack.add_widget(&mut self.search_menu, row_w, 44.0, ctx);
-
self.bar_position_menu.selected = bar_position_index(&self.bar_position);
- self.bar_position_menu.set_row_rect(stack.context.left + 14.0, row_w);
- stack.add_widget(&mut self.bar_position_menu, row_w, 44.0, ctx);
-
self.color_scheme_menu.selected = color_scheme_index(&self.color_scheme);
- self.color_scheme_menu.set_row_rect(stack.context.left + 14.0, row_w);
- stack.add_widget(&mut self.color_scheme_menu, row_w, 44.0, ctx);
-
- self.download_dir_box.set_row_rect(stack.context.left + 14.0, row_w);
- stack.add_widget(&mut self.download_dir_box, row_w, 44.0, ctx);
-
self.history_toggle.set_toggled(self.history);
- stack.add_widget(&mut self.history_toggle, row_w, cce_ui::layout::toggle_height(), ctx);
-
// Needs a Raindrop token in the keyring (service=raindrop.io);
// the browser shows the sync's status on cce://bookmarks.
self.raindrop_toggle.set_toggled(self.raindrop);
- stack.add_widget(&mut self.raindrop_toggle, row_w, cce_ui::layout::toggle_height(), ctx);
-
self.vi_mode_toggle.set_toggled(self.vi_mode);
- stack.add_widget(&mut self.vi_mode_toggle, row_w, cce_ui::layout::toggle_height(), ctx);
- let btn_h = cce_ui::layout::button_height();
- stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
- ctx.button(
- "Apply",
- x,
- ctx.ay(),
- w,
- btn_h,
- [0.20, 0.40, 0.65, 1.0],
- [0.28, 0.50, 0.78, 1.0],
- [1.0, 1.0, 1.0, 1.0],
- AppAction::Browser(BrowserMessage::Apply),
- );
- });
+ let (field_h, menu_h, toggle_h) = (
+ cce_ui::layout::textbox_height(),
+ cce_ui::layout::dropdown_height(),
+ cce_ui::layout::toggle_height(),
+ );
+ let mut form = sec.form();
+ form.column()
+ .widget(&mut self.homepage_box, field_h)
+ .widget(&mut self.search_menu, menu_h)
+ .widget(&mut self.bar_position_menu, menu_h)
+ .widget(&mut self.color_scheme_menu, menu_h)
+ .widget(&mut self.download_dir_box, field_h)
+ .widget(&mut self.history_toggle, toggle_h)
+ .widget(&mut self.raindrop_toggle, toggle_h)
+ .widget(&mut self.vi_mode_toggle, toggle_h)
+ .draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
+ pc.button(
+ "Apply",
+ r.x,
+ r.y,
+ r.width,
+ r.height,
+ [0.20, 0.40, 0.65, 1.0],
+ [0.28, 0.50, 0.78, 1.0],
+ [1.0, 1.0, 1.0, 1.0],
+ AppAction::Browser(BrowserMessage::Apply),
+ );
+ });
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/default_apps.rs b/src/pages/default_apps.rs
index 139975f..39b9b11 100644
--- a/src/pages/default_apps.rs
+++ b/src/pages/default_apps.rs
@@ -13,7 +13,7 @@
use crate::app::{AppAction, PageContent};
use cce_ui::widget::Owned;
use cce_ui::layout::{PageLayoutBuilder, PageFlow};
-use cce_ui::widget::{Dropdown, WidgetHost};
+use cce_ui::widget::Dropdown;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -130,16 +130,16 @@ pub fn view(state: &mut DefaultAppsState, cx: f32, cy: f32, cw: f32, ch: f32, _r
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
- let sec_w = sec.cw;
+ let mut form = sec.form();
+ let mut col = form.column();
if !state.loaded {
- sec.text("Scanning installed applications...", 12.0, 0.0, 12.0, TEXT_DIM);
+ col.text("Scanning installed applications...", 12.0, TEXT_DIM);
} else {
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
for entry in state.categories.iter_mut() {
- entry.dropdown.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
- stack.add_widget(&mut entry.dropdown, sec_w - 28.0, 44.0, ctx);
+ col.widget(&mut entry.dropdown, cce_ui::layout::dropdown_height());
}
}
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/network.rs b/src/pages/network.rs
index 9535080..2ce21b2 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -157,6 +157,8 @@ fn wifi_toggle(enable: bool) {
const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+/// The Wi-Fi list's own height: a window of rows that scrolls.
+const WIFI_LIST_H: f32 = 160.0;
const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
const NET_BTN: [f32; 4] = [0.13, 0.20, 0.27, 1.0];
@@ -170,98 +172,90 @@ 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 padding = sec.padding();
- let row_gap = cce_ui::layout::label_margin();
- let margin = padding.max(12.0);
+ // The list's own inset of its rows inside its frame.
+ let margin = sec.padding().max(12.0);
+ let mut form = sec.form();
+ let mut col = form.column();
if !state.loaded {
- sec.text("Loading WiFi interfaces...", margin, 0.0, 12.0, TEXT_DIM);
+ col.text("Loading WiFi interfaces...", 12.0, TEXT_DIM);
} else {
let wifi_btn_w = if sec_w < 200.0 { 40.0 } else { 60.0 };
-
state.wifi_toggle.set_toggled(state.wifi_enabled);
state.wifi_toggle.set_label(if state.wifi_enabled { "ON" } else { "OFF" });
- // Hand-placed at its real width — sec.widget grid-places at full
- // column width (the old wide "ON" plate).
- let yt = sec.ay();
- let tx = sec.ax(margin);
- let toggle_h = cce_ui::layout::toggle_height();
- cce_ui::layout::render_widget(sec.pc, &mut state.wifi_toggle, tx, yt, wifi_btn_w, toggle_h, ctx);
- sec.content_y = yt + toggle_h + row_gap;
+ // At its own width, not the row's: the old wide "ON" plate.
+ col.row(|r| {
+ r.widget_w(&mut state.wifi_toggle, wifi_btn_w, cce_ui::layout::toggle_height());
+ });
if state.wifi_enabled {
- // Flowing text rows — sec.text advances content_y itself.
- if !state.connected_ssid.is_empty() {
- let ssid_max_chars = ((sec_w - 2.0 * margin) / 7.0) as usize;
+ let text_w = col.form_width();
+ col.block(|b| {
+ if state.connected_ssid.is_empty() {
+ b.text("Not connected", 12.0, TEXT_DIM);
+ return;
+ }
+ let ssid_max_chars = (text_w / 7.0) as usize;
let ssid_truncated = if state.connected_ssid.len() > ssid_max_chars {
format!("{}...", &state.connected_ssid[..ssid_max_chars.saturating_sub(3)])
} else {
state.connected_ssid.clone()
};
- sec.text(&format!("Connected: {}", ssid_truncated), margin, 0.0, 13.0, ACCENT);
-
+ b.text(format!("Connected: {}", ssid_truncated), 13.0, ACCENT);
if sec_w < 220.0 {
- sec.text(&format!("Signal: {}%", state.signal_strength), margin, 0.0, 12.0, TEXT_DIM);
+ b.text(format!("Signal: {}%", state.signal_strength), 12.0, TEXT_DIM);
} else {
- sec.text(&format!("Signal: {}% IP: {}", state.signal_strength, state.ip_address),
- margin, 0.0, 12.0, TEXT_DIM);
+ b.text(format!("Signal: {}% IP: {}", state.signal_strength, state.ip_address), 12.0, TEXT_DIM);
}
- } else {
- sec.text("Not connected", margin, 0.0, 12.0, TEXT_DIM);
- }
- sec.content_y += row_gap;
+ });
}
if state.wifi_enabled && !state.available.is_empty() {
- // 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.content_width();
- let list_box_h = 160.0;
-
- // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
- state.wifi_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- state.wifi_list.update_bounds(state.available.len(), list_box_y, list_box_h);
- state.wifi_list.push_prims(sec.pc);
-
- let btn_w = list_box_w - 2.0 * margin;
- let max_chars = ((btn_w / 6.5) as usize).saturating_sub(10).max(5);
-
- sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- for (idx, net) in state.available.iter().enumerate() {
- if let Some(draw_y) = state.wifi_list.get_item_draw_y(idx, 4.0) {
- let ssid_truncated = if net.ssid.len() > max_chars {
- format!("{}...", &net.ssid[..max_chars.saturating_sub(3)])
- } else {
- net.ssid.clone()
- };
- let label = format!("{} ({}%)", ssid_truncated, net.signal);
- let active = net.in_use;
- let row_x = list_box_x + margin;
- let row_h = 26.0;
- sec.pc.button(&label, row_x, draw_y, btn_w, row_h,
- if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
- if active { ACCENT } else { TEXT_FG },
- AppAction::Network(NetworkMessage::ConnectWifi(net.ssid.clone())));
- // The network in use wears a `check` glyph at the
- // row's left (it was a ">" in the label), in the
- // plain text colour: ACCENT is the row's own green
- // and a glyph in it all but vanishes there.
- if active {
- let g = 12.0;
- sec.pc.icon("check", row_x + cce_ui::layout::CONTROL_TEXT_INSET,
- draw_y + (row_h - g) / 2.0, g, g, TEXT_FG);
+ let list = &mut state.wifi_list;
+ let available = &state.available;
+ col.draw(0.0, WIFI_LIST_H, false, move |pc, r, _| {
+ // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+ list.set_rect(r.x, r.y, r.width, r.height);
+ list.update_bounds(available.len(), r.y, r.height);
+ list.push_prims(pc);
+
+ let btn_w = r.width - 2.0 * margin;
+ let max_chars = ((btn_w / 6.5) as usize).saturating_sub(10).max(5);
+
+ pc.push_clip_rect(r.x, r.y, r.width, r.height);
+ for (idx, net) in available.iter().enumerate() {
+ if let Some(draw_y) = list.get_item_draw_y(idx, 4.0) {
+ let ssid_truncated = if net.ssid.len() > max_chars {
+ format!("{}...", &net.ssid[..max_chars.saturating_sub(3)])
+ } else {
+ net.ssid.clone()
+ };
+ let label = format!("{} ({}%)", ssid_truncated, net.signal);
+ let active = net.in_use;
+ let row_x = r.x + margin;
+ let row_h = 26.0;
+ pc.button(&label, row_x, draw_y, btn_w, row_h,
+ if active { ACT_BTN } else { NET_BTN }, BTN_HOVER,
+ if active { ACCENT } else { TEXT_FG },
+ AppAction::Network(NetworkMessage::ConnectWifi(net.ssid.clone())));
+ // The network in use wears a `check` glyph at the
+ // row's left (it was a ">" in the label), in the
+ // plain text colour: ACCENT is the row's own green
+ // and a glyph in it all but vanishes there.
+ if active {
+ let g = 12.0;
+ pc.icon("check", row_x + cce_ui::layout::CONTROL_TEXT_INSET,
+ draw_y + (row_h - g) / 2.0, g, g, TEXT_FG);
+ }
}
}
- }
- sec.pc.pop_clip_rect();
- // The scrollbar's fore copy, over the rows at the raise's fade.
- state.wifi_list.push_scrollbar_fore(sec.pc);
- sec.content_y += list_box_h + row_gap;
+ pc.pop_clip_rect();
+ // The scrollbar's fore copy, over the rows at the raise's fade.
+ list.push_scrollbar_fore(pc);
+ });
}
}
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/notifications.rs b/src/pages/notifications.rs
index 302a95d..31c7fd6 100644
--- a/src/pages/notifications.rs
+++ b/src/pages/notifications.rs
@@ -2,9 +2,8 @@ use std::fs;
use std::io::Write;
use cce_ui::widget::Owned;
use cce_ui::widget::input::{Toggle, Dropdown, Spinbox};
-use cce_ui::widget::WidgetHost;
use cce_ui::layout::{PageLayoutBuilder, PageFlow};
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{AppAction, PageContent};
use crate::pages::AppPage;
@@ -213,45 +212,35 @@ impl AppPage for NotificationsState {
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section(&mut final_pc, "Notifications Settings", sec_focused.first().copied().unwrap_or(false), |sec| {
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- let sec_w = stack.context.cw;
self.enable_toggle.set_toggled(self.enable);
- stack.add_widget(&mut self.enable_toggle, sec_w - 28.0, cce_ui::layout::toggle_height(), ctx);
-
- let selected_idx = match self.bell.as_str() {
- "none" => 0,
+ self.bell_menu.selected = match self.bell.as_str() {
"bell" => 1,
"dialog" => 2,
"message" => 3,
_ => 0,
};
- self.bell_menu.selected = selected_idx;
- self.bell_menu.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
- stack.add_widget(&mut self.bell_menu, sec_w - 28.0, 44.0, ctx);
-
self.duration_spinbox.value = self.duration;
self.duration_spinbox.set_label("Notification Duration");
- self.duration_spinbox.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
- stack.add_widget(&mut self.duration_spinbox, sec_w - 28.0, 44.0, ctx);
-
- let btn_h = cce_ui::layout::button_height();
- let white_color = [1.0, 1.0, 1.0, 1.0];
- let btn_bg = [0.20, 0.40, 0.65, 1.0];
- let btn_hover = [0.28, 0.50, 0.78, 1.0];
-
- stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
- ctx.button(
- "Send Test Notification",
- x,
- ctx.ay(),
- w,
- btn_h,
- btn_bg,
- btn_hover,
- white_color,
- AppAction::Notifications(NotificationsMessage::SendTestNotification),
- );
- });
+
+ let mut form = sec.form();
+ form.column()
+ .widget(&mut self.enable_toggle, cce_ui::layout::toggle_height())
+ .widget(&mut self.bell_menu, cce_ui::layout::dropdown_height())
+ .widget(&mut self.duration_spinbox, cce_ui::layout::spinbox_height())
+ .draw(0.0, cce_ui::layout::button_height(), false, |pc, r, _| {
+ pc.button(
+ "Send Test Notification",
+ r.x,
+ r.y,
+ r.width,
+ r.height,
+ [0.20, 0.40, 0.65, 1.0],
+ [0.28, 0.50, 0.78, 1.0],
+ [1.0, 1.0, 1.0, 1.0],
+ AppAction::Notifications(NotificationsMessage::SendTestNotification),
+ );
+ });
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index b61594b..8208c1b 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -1,8 +1,8 @@
-use crate::app::{AppAction, PageContent, SectionContextExt, section_divider};
+use crate::app::{button_need, form_button, form_divider, wrap_to_width, AppAction, PageContent};
use cce_ui::widget::Owned;
use cce_ui::widget::ScrollRegion;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, SectionContext, RenderTarget};
-use cce_ui::widget::{WidgetHost, TextBox, InteractiveListItem};
+use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
+use cce_ui::widget::{TextBox, InteractiveListItem};
#[derive(Debug, Clone, Default)]
pub struct PackageInfo {
@@ -365,11 +365,6 @@ pub fn human_size(bytes: u64) -> String {
}
}
-/// A button wide enough for its label in the (monospace) button font.
-fn label_button_w(label: &str) -> f32 {
- label.chars().count() as f32 * 9.5 + 28.0
-}
-
/// "3 packages" / "1 package".
fn count_noun(n: usize) -> String {
if n == 1 { "1 package".to_string() } else { format!("{n} packages") }
@@ -523,6 +518,8 @@ const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
const RED: [f32; 4] = [0.85, 0.25, 0.25, 1.0];
+/// The least room the list keeps on a short window.
+const LIST_MIN_H: f32 = 120.0;
pub fn view(
state: &mut PackagesState,
@@ -534,65 +531,65 @@ pub fn view(
layout: &mut PageFlow,
ctx: &mut cce_ui::context::UiContext,
) -> PageContent {
- let m = crate::app::section_margin();
let mut final_pc = PageContent::new();
let sec_w = 260.0f32;
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
- let sec_w = sec.cw;
+ let mut form = sec.form();
if !state.loaded {
- sec.text("Loading package lists...", 12.0, 0.0, 12.0, TEXT_DIM);
+ form.column().text("Loading package lists...", 12.0, TEXT_DIM);
+ sec.place(form, ctx);
return;
}
+ // The list fills the page like the services list: the well's floor lands at the
+ // page's bottom.
+ form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
+ let wrap_w = form.width();
- // ── Tabs (with counts), filter, compact update row ──
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- let tab_h = cce_ui::layout::button_height();
let active_bg = [0.20, 0.40, 0.65, 0.4];
let inactive_bg = [0.10, 0.10, 0.16, 0.3];
let hover_bg = [0.20, 0.20, 0.25, 0.15];
+ let text_on = [0.90, 0.90, 0.95, 1.0];
+ let busy = state.busy();
+
+ let query = if state.search_box.editing {
+ state.search_box.edit_buffer.to_lowercase()
+ } else {
+ state.search_box.text.to_lowercase()
+ };
+ // What the list shows, worked out before the widgets are lent to the form.
+ let visible_installed = state.visible_installed();
+ if state.installed_items.len() != visible_installed.len() {
+ state.installed_items.clear();
+ for _ in 0..visible_installed.len() {
+ state.installed_items.push(Owned::new(InteractiveListItem::new("")));
+ }
+ }
+ let filtered_updates: Vec<&UpdateInfo> = state.updates.iter().filter(|p| p.name.to_lowercase().contains(&query)).collect();
+ if state.updates_items.len() != filtered_updates.len() {
+ state.updates_items.clear();
+ for _ in 0..filtered_updates.len() {
+ state.updates_items.push(Owned::new(InteractiveListItem::new("")));
+ }
+ }
+
+ let mut col = form.column();
+ // ── Tabs (with counts) ──
let label1 = format!("Installed ({})", state.installed.len());
let label2 = format!("Updates ({})", state.updates.len());
-
- stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
- if i == 0 {
- ctx.button(
- &label1,
- x,
- ctx.ay(),
- w,
- tab_h,
- if state.active_tab == PackageTab::Installed { active_bg } else { inactive_bg },
- hover_bg,
- [0.90, 0.90, 0.95, 1.0],
- AppAction::Packages(PackagesMessage::SetTab(PackageTab::Installed)),
- );
- } else {
- ctx.button(
- &label2,
- x,
- ctx.ay(),
- w,
- tab_h,
- if state.active_tab == PackageTab::Updates { active_bg } else { inactive_bg },
- hover_bg,
- [0.90, 0.90, 0.95, 1.0],
- AppAction::Packages(PackagesMessage::SetTab(PackageTab::Updates)),
- );
- }
+ let tab = state.active_tab;
+ col.row(|r| {
+ form_button(r, label1, 0.0, (if tab == PackageTab::Installed { active_bg } else { inactive_bg }, hover_bg, text_on),
+ AppAction::Packages(PackagesMessage::SetTab(PackageTab::Installed)));
+ form_button(r, label2, 0.0, (if tab == PackageTab::Updates { active_bg } else { inactive_bg }, hover_bg, text_on),
+ AppAction::Packages(PackagesMessage::SetTab(PackageTab::Updates)));
});
- stack.context.spacing(4.0);
-
- let search_w = sec_w - 2.0 * m;
- let search_h = 46.0;
- state.search_box.set_row_rect(stack.context.left + m, search_w);
- stack.add_widget(&mut state.search_box, search_w, search_h, ctx);
- stack.context.spacing(4.0);
+ col.widget(&mut state.search_box, cce_ui::layout::textbox_height());
- // Update System: compact button + status text on one row.
+ // ── Update System: a button as wide as its label, its status beside it ──
let status_line = if state.updating {
"Updating system...".to_string()
} else if state.updates.is_empty() {
@@ -606,30 +603,25 @@ pub fn view(
} else {
("Update System", [0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0])
};
- stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
- if i == 0 {
- c.button(btn_lbl, x, c.ay(), w, tab_h, bg, hover, [0.90, 0.90, 0.95, 1.0],
- AppAction::Packages(PackagesMessage::StartUpdate));
- } else if i == 1 {
- let y = c.ay();
- c.pc.text(&status_line, x, y + (tab_h - 14.0) / 2.0, 12.0, status_color);
- }
+ col.row(|r| {
+ form_button(r, btn_lbl, button_need(btn_lbl), (bg, hover, text_on), AppAction::Packages(PackagesMessage::StartUpdate));
+ r.text_fill(status_line, 12.0, status_color);
});
if let Some(ref res) = state.last_update_res {
match res {
- Ok(_) => stack.context.text("Last update succeeded", 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]),
+ Ok(_) => {
+ col.text("Last update succeeded", 12.0, [0.56, 0.83, 0.56, 1.0]);
+ }
Err(err) => {
- stack.context.text("Last update failed:", 12.0, 0.0, 12.0, RED);
- stack.context.text(err, 12.0, 0.0, 11.0, RED);
+ col.block(|b| {
+ b.text("Last update failed:", 12.0, RED);
+ b.lines(wrap_to_width(err, wrap_w, 11.0), 11.0, RED);
+ });
}
}
}
- // Characters per line for wrapped status text at 11px.
- let wrap_chars = (((sec_w - 2.0 * m - 24.0) / 6.5) as usize).max(20);
- let busy = state.busy();
-
// ── Filter row: All / Explicit / Orphans, and the select-mode switch ──
if state.active_tab == PackageTab::Installed {
let n_explicit = state.installed.iter().filter(|p| p.explicit).count();
@@ -642,55 +634,48 @@ pub fn view(
let select_label = if state.select_mode { "Done Selecting" } else { "Select..." };
let filter = state.filter;
let select_mode = state.select_mode;
- stack.add_row(4, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
- if let Some((f, label)) = filters.get(i) {
- let bg = if filter == *f { active_bg } else { inactive_bg };
- c.button(label, x, c.ay(), w, tab_h, bg, hover_bg, [0.90, 0.90, 0.95, 1.0],
- AppAction::Packages(PackagesMessage::SetFilter(*f)));
- } else {
- let bg = if select_mode { active_bg } else { inactive_bg };
- c.button(select_label, x, c.ay(), w, tab_h, bg, hover_bg, [0.90, 0.90, 0.95, 1.0],
- AppAction::Packages(PackagesMessage::ToggleSelectMode));
+ col.row(|r| {
+ for (f, label) in filters {
+ let face = if filter == f { active_bg } else { inactive_bg };
+ form_button(r, label, 0.0, (face, hover_bg, text_on), AppAction::Packages(PackagesMessage::SetFilter(f)));
}
+ let face = if select_mode { active_bg } else { inactive_bg };
+ form_button(r, select_label, 0.0, (face, hover_bg, text_on), AppAction::Packages(PackagesMessage::ToggleSelectMode));
});
// ── Bulk actions over the checked rows ──
if state.select_mode {
let n = state.checked.len();
- let summary = if n == 0 {
- "Click rows to select".to_string()
- } else {
- format!("{} selected", count_noun(n))
- };
+ let summary = if n == 0 { "Click rows to select".to_string() } else { format!("{} selected", count_noun(n)) };
let targets: Vec<String> = state.checked.iter().cloned().collect();
let can_act = n > 0 && !busy;
- let (act_bg, act_text) = if can_act { (TOGGLE_OFF, TEXT_FG) } else { (TOGGLE_OFF, TEXT_DIM) };
- stack.add_row(5, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
- let y = c.ay();
- match i {
- 0 => c.pc.text(&summary, x, y + (tab_h - 14.0) / 2.0, 12.0, if n > 0 { ACCENT } else { TEXT_DIM }),
- 1 => c.button("Select Visible", x, y, w, tab_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG,
- AppAction::Packages(PackagesMessage::CheckAllVisible)),
- 2 => c.button("Clear", x, y, w, tab_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG,
- AppAction::Packages(PackagesMessage::ClearChecked)),
- 3 => c.button(if state.marking { "Marking..." } else { "Mark Explicit" }, x, y, w, tab_h,
- act_bg, if can_act { BTN_HOVER } else { act_bg }, act_text,
- AppAction::Packages(PackagesMessage::SetInstallReason(targets.clone(), true))),
- _ => c.button("Remove...", x, y, w, tab_h,
+ let act_text = if can_act { TEXT_FG } else { TEXT_DIM };
+ let mark_label = if state.marking { "Marking..." } else { "Mark Explicit" };
+ col.row(|r| {
+ r.text_fill(summary, 12.0, if n > 0 { ACCENT } else { TEXT_DIM });
+ form_button(r, "Select Visible", button_need("Select Visible"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
+ AppAction::Packages(PackagesMessage::CheckAllVisible));
+ form_button(r, "Clear", button_need("Clear"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
+ AppAction::Packages(PackagesMessage::ClearChecked));
+ form_button(r, mark_label, button_need(mark_label),
+ (TOGGLE_OFF, if can_act { BTN_HOVER } else { TOGGLE_OFF }, act_text),
+ AppAction::Packages(PackagesMessage::SetInstallReason(targets.clone(), true)));
+ form_button(r, "Remove...", button_need("Remove..."),
+ (
if can_act { [0.25, 0.14, 0.14, 1.0] } else { TOGGLE_OFF },
if can_act { [0.40, 0.20, 0.20, 1.0] } else { TOGGLE_OFF },
if can_act { [0.95, 0.55, 0.55, 1.0] } else { TEXT_DIM },
- AppAction::Packages(PackagesMessage::PreviewRemoval(targets.clone()))),
- }
+ ),
+ AppAction::Packages(PackagesMessage::PreviewRemoval(targets)));
});
}
}
// ── Removal confirmation: what pacman says it would take ──
if state.previewing {
- stack.context.text("Checking what would be removed...", 12.0, 0.0, 12.0, TEXT_DIM);
+ col.text("Checking what would be removed...", 12.0, TEXT_DIM);
} else if let Some(plan) = state.removal.clone() {
- section_divider(stack.context);
+ form_divider(&mut col);
match &plan.outcome {
Ok(pkgs) => {
let total: u64 = pkgs.iter().map(|(_, s)| *s).sum();
@@ -702,40 +687,36 @@ pub fn view(
} else {
format!("Remove {} ({}):", count_noun(pkgs.len()), human_size(total))
};
- stack.context.text(&head, 12.0, 0.0, 12.0, [0.95, 0.75, 0.55, 1.0]);
let names = pkgs.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ");
- let lines = wrap_text(&names, wrap_chars);
+ let lines = wrap_to_width(&names, wrap_w, 11.0);
const MAX_LINES: usize = 6;
- for line in lines.iter().take(MAX_LINES) {
- stack.context.text(line, 12.0, 0.0, 11.0, TEXT_FG);
- }
- if lines.len() > MAX_LINES {
- let shown: usize = lines.iter().take(MAX_LINES).map(|l| l.split_whitespace().count()).sum();
- stack.context.text(&format!("... and {} more", pkgs.len().saturating_sub(shown)), 12.0, 0.0, 11.0, TEXT_DIM);
- }
+ col.block(|b| {
+ b.text(head, 12.0, [0.95, 0.75, 0.55, 1.0]);
+ b.lines(lines.iter().take(MAX_LINES).cloned().collect(), 11.0, TEXT_FG);
+ if lines.len() > MAX_LINES {
+ let shown: usize = lines.iter().take(MAX_LINES).map(|l| l.split_whitespace().count()).sum();
+ b.text(format!("... and {} more", pkgs.len().saturating_sub(shown)), 11.0, TEXT_DIM);
+ }
+ });
let confirm = if state.uninstalling { "Removing...".to_string() } else { format!("Remove {}", count_noun(pkgs.len())) };
let targets = plan.targets.clone();
- stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
- let y = c.ay();
- if i == 0 {
- c.button(&confirm, x, y, w, tab_h, [0.30, 0.14, 0.14, 1.0], [0.45, 0.20, 0.20, 1.0], [0.98, 0.65, 0.65, 1.0],
- AppAction::Packages(PackagesMessage::StartUninstall(targets.clone())));
- } else if i == 1 {
- c.button("Cancel", x, y, w, tab_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG,
- AppAction::Packages(PackagesMessage::CancelRemoval));
- }
+ col.row(|r| {
+ let need = button_need(&confirm);
+ form_button(r, confirm, need, ([0.30, 0.14, 0.14, 1.0], [0.45, 0.20, 0.20, 1.0], [0.98, 0.65, 0.65, 1.0]),
+ AppAction::Packages(PackagesMessage::StartUninstall(targets)));
+ form_button(r, "Cancel", button_need("Cancel"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
+ AppAction::Packages(PackagesMessage::CancelRemoval));
});
}
Err(err) => {
- stack.context.text(&format!("Can't remove {}:", plan.targets.join(", ")), 12.0, 0.0, 12.0, RED);
- for line in err.lines().flat_map(|l| wrap_text(l, wrap_chars)).take(8) {
- stack.context.text(&line, 12.0, 0.0, 11.0, [0.95, 0.55, 0.55, 1.0]);
- }
- stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
- if i == 0 {
- c.button("Dismiss", x, c.ay(), w, tab_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG,
- AppAction::Packages(PackagesMessage::CancelRemoval));
- }
+ let lines: Vec<String> = err.lines().flat_map(|l| wrap_to_width(l, wrap_w, 11.0)).take(8).collect();
+ col.block(|b| {
+ b.text(format!("Can't remove {}:", plan.targets.join(", ")), 12.0, RED);
+ b.lines(lines, 11.0, [0.95, 0.55, 0.55, 1.0]);
+ });
+ col.row(|r| {
+ form_button(r, "Dismiss", button_need("Dismiss"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
+ AppAction::Packages(PackagesMessage::CancelRemoval));
});
}
}
@@ -744,11 +725,12 @@ pub fn view(
// ── Outcome of the last remove / mark ──
if let Some(ref res) = state.last_action {
match res {
- Ok(msg) => stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]),
+ Ok(msg) => {
+ col.text(msg.clone(), 12.0, [0.56, 0.83, 0.56, 1.0]);
+ }
Err(err) => {
- for (i, line) in err.lines().flat_map(|l| wrap_text(l, wrap_chars)).take(8).enumerate() {
- stack.context.text(&line, 12.0, 0.0, if i == 0 { 12.0 } else { 11.0 }, RED);
- }
+ let lines: Vec<String> = err.lines().flat_map(|l| wrap_to_width(l, wrap_w, 11.0)).take(8).collect();
+ col.lines(lines, 11.0, RED);
}
}
}
@@ -758,12 +740,12 @@ pub fn view(
// package, and both together push the list off the page.
let confirming = state.previewing || state.removal.is_some();
if !confirming && (state.loading_info || state.selected_package.is_some()) {
- section_divider(stack.context);
+ form_divider(&mut col);
}
if confirming {
// Nothing: the confirmation above stands in for the details.
} else if state.loading_info {
- stack.context.text("Loading package details...", 12.0, 0.0, 12.0, TEXT_DIM);
+ col.text("Loading package details...", 12.0, TEXT_DIM);
} else if let Some(pkg_name) = state.selected_package.clone() {
if let Some(info_raw) = state.selected_package_info.clone() {
let mut parsed = parse_package_info(&info_raw);
@@ -771,235 +753,163 @@ pub fn view(
parsed.name = pkg_name.clone();
}
- // Heading row: name left, Uninstall (compact, quiet-red) right.
- {
- let sc = &mut *stack.context;
- let mut y = sc.content_y;
- if y > sc.content_start_y {
- y += sc.row_gap;
- }
- let lx = sc.ax(m);
- sc.pc.text(&parsed.name, lx, y, 14.0, [0.35, 0.65, 0.90, 1.0]);
- sc.content_y = y + 20.0;
- for h in &mut sc.grid.col_heights {
- *h = sc.content_y;
- }
- let is_installed = state.installed.iter().find(|p| p.name == pkg_name);
- if let (PackageTab::Installed, Some(info)) = (state.active_tab, is_installed) {
- // Right-aligned to the box every button row lays out in
- // (`row_layout`); a width derived from cw by hand ran
- // a few px past the well and cut the button's edge.
- let right = sc.row_layout(1, 0.0).first().map(|&(x, w)| x + w).unwrap_or(lx);
- let bw = label_button_w("Uninstall");
- let bx = right - bw;
- let (btn_lbl, bg, hover, text_col) = if busy {
- ("Uninstall", TOGGLE_OFF, TOGGLE_OFF, TEXT_DIM)
+ // Heading row: the name, then the install-reason flip and Uninstall at the
+ // row's end. An explicit package can be handed back as a dependency (so it
+ // goes when nothing needs it) and a dependency kept for good.
+ let installed = state.installed.iter().find(|p| p.name == pkg_name).map(|p| p.explicit);
+ let on_installed = state.active_tab == PackageTab::Installed;
+ col.row(|r| {
+ r.text_fill(parsed.name.clone(), 14.0, [0.35, 0.65, 0.90, 1.0]);
+ if let (true, Some(explicit)) = (on_installed, installed) {
+ let (mark_lbl, to_explicit) = if explicit { ("Mark as Dependency", false) } else { ("Mark Explicit", true) };
+ form_button(r, mark_lbl, button_need(mark_lbl),
+ (TOGGLE_OFF, if busy { TOGGLE_OFF } else { BTN_HOVER }, if busy { TEXT_DIM } else { TEXT_FG }),
+ AppAction::Packages(PackagesMessage::SetInstallReason(vec![pkg_name.clone()], to_explicit)));
+ let colors = if busy {
+ (TOGGLE_OFF, TOGGLE_OFF, TEXT_DIM)
} else {
- ("Uninstall", [0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0], [0.95, 0.55, 0.55, 1.0])
+ ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0], [0.95, 0.55, 0.55, 1.0])
};
- // Centred on the 14px name's line.
- let bh = cce_ui::layout::button_height();
- let by = y + (17.0 - bh) / 2.0;
- sc.button(btn_lbl, bx, by, bw, bh, bg, hover, text_col,
+ form_button(r, "Uninstall", button_need("Uninstall"), colors,
AppAction::Packages(PackagesMessage::PreviewRemoval(vec![pkg_name.clone()])));
- // The install-reason flip beside it: an explicit package can
- // be handed back as a dependency (so it goes when nothing
- // needs it) and a dependency kept for good.
- let (mark_lbl, to_explicit) = if info.explicit {
- ("Mark as Dependency", false)
- } else {
- ("Mark Explicit", true)
- };
- let mw = label_button_w(mark_lbl);
- let text_col = if busy { TEXT_DIM } else { TEXT_FG };
- sc.button(mark_lbl, bx - 4.0 - mw, by, mw, bh, TOGGLE_OFF, if busy { TOGGLE_OFF } else { BTN_HOVER }, text_col,
- AppAction::Packages(PackagesMessage::SetInstallReason(vec![pkg_name.clone()], to_explicit)));
- }
- }
-
- // Same-line kv rows, values wrapping at the value column.
- let kv_wrap = |sc: &mut SectionContext<'_, PageContent>, key: &str, val: &str| {
- if val.is_empty() {
- return;
- }
- let mut y = sc.content_y + 4.0;
- let lx = sc.ax(m);
- let vx = lx + 118.0;
- let usable_w = sc.cw - 118.0 - 2.0 * (sc.padding() + m);
- let max_chars = ((usable_w / 6.0) as usize).max(15);
- sc.pc.text(key, lx, y, 11.0, TEXT_DIM);
- let lines = wrap_text(val, max_chars);
- for line in &lines {
- sc.pc.text(line, vx, y, 11.0, TEXT_FG);
- y += 15.0;
- }
- if lines.is_empty() {
- y += 15.0;
}
- sc.content_y = y + 1.0;
- for h in &mut sc.grid.col_heights {
- *h = sc.content_y;
- }
- };
+ });
- kv_wrap(stack.context, "Version", &parsed.version);
let orphan = state.installed.iter().any(|p| p.name == pkg_name && p.orphan);
let reason = if orphan {
format!("{} (orphan: nothing requires it now)", parsed.install_reason)
} else {
parsed.install_reason.clone()
};
- kv_wrap(stack.context, "Install Reason", &reason);
- kv_wrap(stack.context, "Size", &parsed.size);
- kv_wrap(stack.context, "Licenses", &parsed.licenses);
- kv_wrap(stack.context, "Website", &parsed.website);
- kv_wrap(stack.context, "Packager", &parsed.packager);
- kv_wrap(stack.context, "Built", &parsed.build_date);
- kv_wrap(stack.context, "Description", &parsed.description);
- kv_wrap(stack.context, "Commands", &parsed.commands);
+ let pairs = [
+ ("Version", parsed.version.clone()),
+ ("Install Reason", reason),
+ ("Size", parsed.size.clone()),
+ ("Licenses", parsed.licenses.clone()),
+ ("Website", parsed.website.clone()),
+ ("Packager", parsed.packager.clone()),
+ ("Built", parsed.build_date.clone()),
+ ("Description", parsed.description.clone()),
+ ("Commands", parsed.commands.clone()),
+ ]
+ .into_iter()
+ .filter(|(_, v)| !v.is_empty())
+ .map(|(k, v)| (k.to_string(), TEXT_DIM, v, TEXT_FG))
+ .collect();
+ crate::app::form_pairs(&mut col, 11.0, pairs);
if !parsed.required_by.is_empty() && parsed.required_by != "None" {
- stack.context.text("Required By", 12.0, 0.0, 11.0, TEXT_DIM);
-
- let reqs: Vec<&str> = parsed.required_by.split_whitespace().collect();
- let cols_count = 4;
- let gap = 4.0;
- let btn_h = cce_ui::layout::button_height();
+ let reqs: Vec<String> = parsed.required_by.split_whitespace().map(str::to_string).collect();
+ const COLUMNS: usize = 4;
// A core library is required by hundreds (glibc: ~300). Every
// row of buttons pushes the list down, and past the page's
// bottom the list loses its box; a few rows say enough.
const MAX_REQ_ROWS: usize = 4;
- let shown = reqs.len().min(cols_count * MAX_REQ_ROWS);
-
- for chunk in reqs[..shown].chunks(cols_count) {
- let btn_y = stack.context.ay();
- let cols = stack.context.row_layout(cols_count, gap);
- for (i, &pkg) in chunk.iter().enumerate() {
- if let Some(&(x, w)) = cols.get(i) {
- let action = AppAction::Packages(PackagesMessage::SelectAndScrollPackage(pkg.to_string()));
- stack.context.button(pkg, x, btn_y, w, btn_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG, action);
+ let shown = reqs.len().min(COLUMNS * MAX_REQ_ROWS);
+ col.text("Required By", 11.0, TEXT_DIM);
+ for chunk in reqs[..shown].chunks(COLUMNS) {
+ col.row(|r| {
+ for pkg in chunk {
+ let action = AppAction::Packages(PackagesMessage::SelectAndScrollPackage(pkg.clone()));
+ form_button(r, pkg.clone(), 0.0, (TOGGLE_OFF, BTN_HOVER, TEXT_FG), action);
}
- }
+ // A short last row keeps the columns of the rows above it.
+ for _ in chunk.len()..COLUMNS {
+ r.space(0.0, true);
+ }
+ });
}
if reqs.len() > shown {
- stack.context.text(&format!("... and {} more", reqs.len() - shown), 12.0, 0.0, 11.0, TEXT_DIM);
+ col.text(format!("... and {} more", reqs.len() - shown), 11.0, TEXT_DIM);
}
}
} else {
- stack.context.text("No details available.", 12.0, 0.0, 12.0, TEXT_DIM);
+ col.text("No details available.", 12.0, TEXT_DIM);
}
}
- section_divider(stack.context);
-
- // ── 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.content_width();
- let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
+ form_divider(&mut col);
- let query = if state.search_box.editing {
- state.search_box.edit_buffer.to_lowercase()
+ // ── The list fills the rest of the page ──
+ let tab = state.active_tab;
+ let empty_msg = if state.filter == InstalledFilter::Orphans && query.is_empty() {
+ "No orphaned packages"
} else {
- state.search_box.text.to_lowercase()
+ "No packages match the query"
};
-
- match state.active_tab {
- PackageTab::Installed => {
- let visible = state.visible_installed();
- let installed = &state.installed;
- let filtered: Vec<&PackageInfo> = visible.iter().map(|&i| &installed[i]).collect();
-
- // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
- state.installed_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- state.installed_list.update_bounds(filtered.len(), list_box_y, list_box_h);
- state.installed_list.push_prims(sec.pc);
- let item_h = state.installed_list.item_height;
-
- if state.installed_items.len() != filtered.len() {
- state.installed_items.clear();
- for _ in 0..filtered.len() {
- state.installed_items.push(Owned::new(InteractiveListItem::new("")));
+ let select_mode = state.select_mode;
+ let checked = &state.checked;
+ let selected = state.selected_package.clone();
+ let installed = &state.installed;
+ let (installed_list, installed_items) = (&mut state.installed_list, &mut state.installed_items);
+ let (updates_list, updates_items) = (&mut state.updates_list, &mut state.updates_items);
+ col.fill(LIST_MIN_H, move |pc, rect, ctx| {
+ let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
+ let inset = cce_ui::layout::plate_padding();
+ match tab {
+ PackageTab::Installed => {
+ let filtered: Vec<&PackageInfo> = visible_installed.iter().map(|&i| &installed[i]).collect();
+ // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+ installed_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ installed_list.update_bounds(filtered.len(), list_box_y, list_box_h);
+ installed_list.push_prims(pc);
+ let item_h = installed_list.item_height;
+
+ pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ for (idx, pkg) in filtered.iter().enumerate() {
+ if let Some(draw_y) = installed_list.get_item_draw_y(idx, 4.0) {
+ // Rows dispatch as extra roots (the dissolved list is no parent).
+ let item = &mut installed_items[idx];
+ item.title = pkg.name.clone();
+ let reason = match (pkg.explicit, pkg.orphan) {
+ (true, _) => "explicit",
+ (false, true) => "orphan",
+ (false, false) => "dependency",
+ };
+ item.subtitle = Some(format!("{} · {}", pkg.version, reason));
+ item.selected = if select_mode {
+ checked.contains(&pkg.name)
+ } else {
+ Some(&pkg.name) == selected.as_ref()
+ };
+ render_widget(pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+ }
}
- }
-
- sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- for (idx, pkg) in filtered.iter().enumerate() {
- if let Some(draw_y) = state.installed_list.get_item_draw_y(idx, 4.0) {
- // Rows dispatch as extra roots (the dissolved list is no parent).
- let item = &mut state.installed_items[idx];
- item.title = pkg.name.clone();
- let reason = match (pkg.explicit, pkg.orphan) {
- (true, _) => "explicit",
- (false, true) => "orphan",
- (false, false) => "dependency",
- };
- item.subtitle = Some(format!("{} · {}", pkg.version, reason));
- item.selected = if state.select_mode {
- state.checked.contains(&pkg.name)
- } else {
- Some(&pkg.name) == state.selected_package.as_ref()
- };
- render_widget(sec.pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+ pc.pop_clip_rect();
+ // The scrollbar's fore copy, over the rows at the raise's fade.
+ installed_list.push_scrollbar_fore(pc);
+ if filtered.is_empty() {
+ pc.text(empty_msg, list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
}
}
- sec.pc.pop_clip_rect();
- // The scrollbar's fore copy, over the rows at the raise's fade.
- state.installed_list.push_scrollbar_fore(sec.pc);
-
- if filtered.is_empty() {
- let msg = if state.filter == InstalledFilter::Orphans && query.is_empty() {
- "No orphaned packages"
- } else {
- "No packages match the query"
- };
- sec.pc.text(msg, list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
- }
- }
- PackageTab::Updates => {
- let filtered: Vec<&UpdateInfo> = state.updates.iter()
- .filter(|p| p.name.to_lowercase().contains(&query))
- .collect();
-
- // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
- state.updates_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- state.updates_list.update_bounds(filtered.len(), list_box_y, list_box_h);
- state.updates_list.push_prims(sec.pc);
- let item_h = state.updates_list.item_height;
-
- if state.updates_items.len() != filtered.len() {
- state.updates_items.clear();
- for _ in 0..filtered.len() {
- state.updates_items.push(Owned::new(InteractiveListItem::new("")));
+ PackageTab::Updates => {
+ // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+ updates_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ updates_list.update_bounds(filtered_updates.len(), list_box_y, list_box_h);
+ updates_list.push_prims(pc);
+ let item_h = updates_list.item_height;
+
+ pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ for (idx, pkg) in filtered_updates.iter().enumerate() {
+ if let Some(draw_y) = updates_list.get_item_draw_y(idx, 4.0) {
+ // Rows dispatch as extra roots (the dissolved list is no parent).
+ let item = &mut updates_items[idx];
+ item.title = pkg.name.clone();
+ item.subtitle = Some(format!("{} -> {}", pkg.old_version, pkg.new_version));
+ item.selected = Some(&pkg.name) == selected.as_ref();
+ render_widget(pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+ }
}
- }
-
- sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- for (idx, pkg) in filtered.iter().enumerate() {
- if let Some(draw_y) = state.updates_list.get_item_draw_y(idx, 4.0) {
- // Rows dispatch as extra roots (the dissolved list is no parent).
- let item = &mut state.updates_items[idx];
- item.title = pkg.name.clone();
- item.subtitle = Some(format!("{} -> {}", pkg.old_version, pkg.new_version));
- item.selected = Some(&pkg.name) == state.selected_package.as_ref();
- render_widget(sec.pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+ pc.pop_clip_rect();
+ // The scrollbar's fore copy, over the rows at the raise's fade.
+ updates_list.push_scrollbar_fore(pc);
+ if filtered_updates.is_empty() {
+ pc.text("No updates match the query", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
}
}
- sec.pc.pop_clip_rect();
- // The scrollbar's fore copy, over the rows at the raise's fade.
- state.updates_list.push_scrollbar_fore(sec.pc);
-
- if filtered.is_empty() {
- sec.pc.text("No updates match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
- }
}
- }
-
- // End the section so the well's bottom wall sits one margin below
- // the list: finish() places the wall at content_y + padding + margin,
- // so the list's own bottom margin and that one cancel.
- sec.content_y = list_box_y + list_box_h - sec.padding();
+ });
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/power.rs b/src/pages/power.rs
index 262ee3a..9a2accb 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -46,7 +46,7 @@ use crate::app::{AppAction, PageContent};
use crate::power_plan::{self, Automation, ChargeLimit, Lever, Mode, PowerPlan, Source};
use cce_ui::widget::Owned;
use cce_ui::layout::{PageFlow, PageLayoutBuilder};
-use cce_ui::widget::{Adapted, Dropdown, WidgetHost};
+use cce_ui::widget::{Adapted, Dropdown};
use std::path::{Path, PathBuf};
const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
@@ -736,7 +736,9 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
if !state.loaded {
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section_spanned(&mut final_pc, "", 1, focused(0), |sec| {
- sec.text("Reading power interfaces...", 12.0, 0.0, 12.0, TEXT_DIM);
+ let mut form = sec.form();
+ form.column().text("Reading power interfaces...", 12.0, TEXT_DIM);
+ sec.place(form, ctx);
});
return final_pc;
}
@@ -747,12 +749,18 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
let show_assign = assignment_shown(facts);
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w)
.with_section_count(if show_assign { 3 } else { 2 });
+ let menu_h = cce_ui::layout::dropdown_height();
// ── Battery: facts, the charge limit, and whether switching is wired up ──
builder.add_section(&mut final_pc, "Battery", focused(0), |sec| {
- let sec_w = sec.cw;
let f = &*facts;
- if f.battery_present {
+ let mut form = sec.form();
+ let mut col = form.column();
+ col.block(|b| {
+ if !f.battery_present {
+ b.text("No battery detected", 12.0, TEXT_DIM);
+ return;
+ }
let status_color = match f.status.as_str() {
"Charging" | "Full" => GOOD,
"Discharging" => WARN,
@@ -765,7 +773,7 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
if let Some(true) = f.ac_online {
line.push_str(" · on AC");
}
- sec.text(&line, 12.0, 0.0, 12.0, status_color);
+ b.text(line, 12.0, status_color);
// Folded in from the System page's battery section, which showed
// the same pack in a second place until 2026-08-23.
@@ -781,52 +789,38 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
detail.push_str(&format!("{} {}", humanize_secs(secs), what));
}
if !detail.is_empty() {
- sec.text(&detail, 12.0, 0.0, 12.0, TEXT_DIM);
+ b.text(detail, 12.0, TEXT_DIM);
}
-
if let Some(h) = f.health_pct {
- sec.text(
- &format!("Health: {}% of design capacity", h),
- 12.0,
- 0.0,
- 12.0,
- if h >= 80 { TEXT_DIM } else { WARN },
- );
+ b.text(format!("Health: {}% of design capacity", h), 12.0, if h >= 80 { TEXT_DIM } else { WARN });
}
let pack = format!("{} {}", f.vendor, f.model);
if !pack.trim().is_empty() {
- sec.text(pack.trim(), 11.0, 0.0, 11.0, TEXT_DIM);
+ b.text(pack.trim().to_string(), 11.0, TEXT_DIM);
}
- } else {
- sec.text("No battery detected", 12.0, 0.0, 12.0, TEXT_DIM);
- }
+ });
if f.charge_limit.is_some() {
- sec.spacing(10.0);
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- // TODO(style): the dropdown's 14px row inset is this page's own,
- // two wider than the well margin its neighbours sit on.
- dd_limit.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
- stack.add_widget(dd_limit, sec_w - 28.0, 44.0, ctx);
+ col.widget(&mut *dd_limit, menu_h);
}
if f.battery_present {
- sec.spacing(10.0);
- match f.automation {
+ col.block(|b| match f.automation {
Automation::Ready => {
- sec.text("Switches automatically on plug and unplug.", 12.0, 0.0, 11.0, TEXT_DIM);
+ b.text("Switches automatically on plug and unplug.", 11.0, TEXT_DIM);
}
Automation::Missing => {
- sec.text("Automatic switching is not installed:", 12.0, 0.0, 11.0, WARN);
- sec.text("System › System Files installs it.", 12.0, 0.0, 11.0, WARN);
+ b.text("Automatic switching is not installed:", 11.0, WARN);
+ b.text("System › System Files installs it.", 11.0, WARN);
}
Automation::Stale => {
- sec.text("Automatic switching is out of date and", 12.0, 0.0, 11.0, WARN);
- sec.text("applies nothing on plug or unplug.", 12.0, 0.0, 11.0, WARN);
- sec.text("Run: ccebuild install-system", 12.0, 0.0, 11.0, WARN);
+ b.text("Automatic switching is out of date and", 11.0, WARN);
+ b.text("applies nothing on plug or unplug.", 11.0, WARN);
+ b.text("Run: ccebuild install-system", 11.0, WARN);
}
- }
+ });
}
+ sec.place(form, ctx);
});
// ── The edited mode's levers, behind the picker that chooses it ──
@@ -839,30 +833,25 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
.map(|s| when_text(*s))
.collect();
builder.add_section(&mut final_pc, "Power Mode", focused(1), |sec| {
- let sec_w = sec.cw;
- {
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- dd_mode.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
- stack.add_widget(dd_mode, sec_w - 28.0, 44.0, ctx);
- }
- sec.spacing(4.0);
- if running {
- sec.text("Running now — picks apply immediately.", 12.0, 0.0, 11.0, GOOD);
- } else if applies_when.is_empty() {
- sec.text("Assigned to no adapter state.", 12.0, 0.0, 11.0, TEXT_DIM);
- } else {
- sec.text(&format!("Applied when {}.", applies_when.join(" and ")), 12.0, 0.0, 11.0, TEXT_DIM);
- }
- sec.text("Not set leaves a lever alone.", 12.0, 0.0, 11.0, TEXT_DIM);
- sec.spacing(6.0);
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- for i in 0..Lever::ALL.len() {
- if levers.rows[i].is_empty() {
- continue;
+ let mut form = sec.form();
+ let mut col = form.column();
+ col.widget(&mut *dd_mode, menu_h);
+ col.block(|b| {
+ if running {
+ b.text("Running now — picks apply immediately.", 11.0, GOOD);
+ } else if applies_when.is_empty() {
+ b.text("Assigned to no adapter state.", 11.0, TEXT_DIM);
+ } else {
+ b.text(format!("Applied when {}.", applies_when.join(" and ")), 11.0, TEXT_DIM);
+ }
+ b.text("Not set leaves a lever alone.", 11.0, TEXT_DIM);
+ });
+ for (row, dd) in levers.rows.iter().zip(levers.dds.iter_mut()) {
+ if !row.is_empty() {
+ col.widget(dd, menu_h);
}
- levers.dds[i].set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
- stack.add_widget(&mut levers.dds[i], sec_w - 28.0, 44.0, ctx);
}
+ sec.place(form, ctx);
});
}
@@ -870,16 +859,19 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
if show_assign {
let f = &*facts;
builder.add_section(&mut final_pc, "Mode Assignment", focused(2), |sec| {
- let sec_w = sec.cw;
- sec.text("Which mode runs in each adapter state.", 12.0, 0.0, 11.0, TEXT_DIM);
- sec.text(&format!("{} right now.", f.source.label()), 12.0, 0.0, 11.0, GOOD);
- sec.spacing(6.0);
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- for source in sources.iter().copied() {
- let dd = &mut dd_assign[source_index(source)];
- dd.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
- stack.add_widget(dd, sec_w - 28.0, 44.0, ctx);
+ let mut form = sec.form();
+ let mut col = form.column();
+ col.block(|b| {
+ b.text("Which mode runs in each adapter state.", 11.0, TEXT_DIM);
+ b.text(format!("{} right now.", f.source.label()), 11.0, GOOD);
+ });
+ let wanted: Vec<usize> = sources.iter().map(|s| source_index(*s)).collect();
+ for (i, dd) in dd_assign.iter_mut().enumerate() {
+ if wanted.contains(&i) {
+ col.widget(dd, menu_h);
+ }
}
+ sec.place(form, ctx);
});
}
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 898d468..20c1199 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -245,188 +245,185 @@ pub async fn fetch_processes_state() -> ProcessesState {
const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+/// The least room the table keeps on a short window.
+const LIST_MIN_H: f32 = 120.0;
-pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, _ctx: &mut cce_ui::context::UiContext) -> PageContent {
+pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
let mut final_pc = PageContent::new();
let sec_w = 320.0f32;
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
// ── Processes Section (label-less well) ──
builder.add_section_spanned(&mut final_pc, "", 1, root_focused || sec_focused.first().copied().unwrap_or(false), |sec| {
+ let mut form = sec.form();
if !state.loaded {
- sec.text("Loading processes...", 12.0, 0.0, 12.0, TEXT_FG);
- } else {
- // The list fills the section's content box across — the box the
- // section clips everything it draws to. Laid out at `left + 12`
- // instead, it started 2 * padding outside that clip, which cut
- // the first letter off the summary line and the left edge off
- // every row's hover wash. Top and bottom keep the well margin.
- let inset = crate::app::section_margin();
- let list_box_x = sec.content_left();
- let list_box_w = sec.content_width();
- // Power summary line above the list; the list starts below it.
- let summary_h = 18.0;
- sec.pc.text(&summary_line(&state.power), list_box_x, sec.well_top() + inset + 2.0, 11.0, TEXT_DIM);
- let list_box_y = sec.well_top() + inset + summary_h;
- // Fill the page: the well's bottom wall lands at the page bottom,
- // the list keeps its even inset inside the well.
- let list_box_h = ((cy + ch) - inset - list_box_y).max(120.0);
-
- // 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.
- // Sized so the default 820px window shows the whole table without
- // the h-bar: COMMAND gives up the 20px the content box's inset
- // took.
- const COL_PID: f32 = 12.0;
- const COL_COMMAND: f32 = 80.0;
- const COL_RSS: f32 = 380.0;
- const COL_MEM: f32 = 460.0;
- const COL_CPU: f32 = 525.0;
- const COL_WATTS: f32 = 585.0;
- const COL_WAKE: f32 = 635.0;
- const COL_KILL: f32 = 700.0;
- const CONTENT_W: f32 = 725.0;
-
- // TODO(style): the column offsets, header hit-target nudges and
- // in-row text centring below are this table's own layout.
- 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.set_content_w(CONTENT_W);
- // No band for the horizontal scrollbar: it rides the rows'
- // centre line, crossing the vertical one, behind the plate until
- // a scroll raises it.
- let rows_h = list_box_h - header_h - 6.0;
- state.cpu_list.update_bounds(state.processes.len(), list_box_y + header_h, rows_h);
- 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
-
- // Header labels pan with the columns, clipped to the box. The
- // sortable ones (MEM, MEM %, CPU %) are buttons: the active key
- // shows brighter with a `chevron-down` glyph after it (the sort
- // is always descending). Both memory headers toggle the same
- // Mem sort — one bigger target, no distinction to learn.
- let active = |k: ProcSort| state.sort == k;
- let hdr = |on: bool| if on { [0.78, 0.78, 0.85, 1.0] } else { TEXT_DIM };
- const HDR_SIZE: f32 = 11.0;
- const HDR_Y: f32 = 5.0;
- const CHEVRON: f32 = 9.0;
- sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
- let header = |pc: &mut PageContent, label: &str, col: f32, on: bool| {
- let x = list_box_x + col - ox;
- pc.text(label, x, list_box_y + HDR_Y, HDR_SIZE, hdr(on));
- if on {
- let gx = x + crate::app::text_width(label, HDR_SIZE, None) + 3.0;
- let gy = list_box_y + (header_h - CHEVRON) / 2.0;
- pc.icon("chevron-down", gx, gy, CHEVRON, CHEVRON, hdr(on));
- }
- };
- sec.pc.text("PID", list_box_x + COL_PID - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
- sec.pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
- header(sec.pc, "MEM", COL_RSS, active(ProcSort::Mem));
- sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + HDR_Y, HDR_SIZE, hdr(active(ProcSort::Mem)));
- header(sec.pc, "CPU %", COL_CPU, active(ProcSort::Cpu));
- header(sec.pc, "W", COL_WATTS, active(ProcSort::Power));
- header(sec.pc, "WAKE/s", COL_WAKE, active(ProcSort::Wakeups));
-
- // Header hit targets, list-row style like the rows (plateless,
- // washed on hover), inside the header clip so they pan and cut
- // with the labels. They share no rect with the row buttons, so
- // emission order is free here.
- sec.pc.list_row(list_box_x + COL_RSS - ox - 4.0, list_box_y, 52.0, header_h - 2.0,
- AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
- sec.pc.list_row(list_box_x + COL_MEM - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
- AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
- sec.pc.list_row(list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
- AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
- sec.pc.list_row(list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
- AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
- sec.pc.list_row(list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
- AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
- sec.pc.pop_clip_rect();
-
- let row_h = 24.0;
-
- // Visible process rows, clipped to the rows' viewport — not on
- // into the reserve band below it, where the scroll region still
- // hands back a row starting there and its Kill button stood alone
- // under the list.
- sec.pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, rows_h);
- for (idx, p) in state.processes.iter().enumerate() {
- if let Some(draw_y) = state.cpu_list.get_item_draw_y(idx, 4.0) {
- // The row itself, in the DE's list style: plateless,
- // washed on hover. A transparent plain button here wore a
- // control plate per row — the whole list read as a stack
- // of carved rings. Viewport-fixed on purpose: the hover
- // band spans the visible row whatever the horizontal pan.
- sec.pc.list_row(
- list_box_x + 2.0,
- draw_y,
- list_box_w - 16.0,
- row_h,
- AppAction::Processes(ProcessesMessage::None),
- );
-
- // A pending kill dims the row until the next refresh
- // settles it (gone, or alive again = the kill didn't take).
- let dim = state.killing.contains(&p.pid);
- let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
- let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
- let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
- let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
-
- sec.pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
- sec.pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
- sec.pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
- sec.pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
- sec.pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
- sec.pc.text(&format_watts(p.watts), list_box_x + COL_WATTS - ox, draw_y + 6.0, 12.0, watt_fg);
- sec.pc.text(&format_wakeups(p.wakeups), list_box_x + COL_WAKE - ox, draw_y + 6.0, 12.0, fg);
-
- // Kill button, in content space like the columns. Emitted
- // AFTER the row button on purpose: overlapping page
- // buttons all see the click and the LAST take_click wins
- // the dispatched action (input_handler's collect loop), so
- // the Kill button beats the row's no-op exactly because it comes later.
- if !dim {
- // The `x` glyph in the danger tint; "Kill" only if
- // the icon set is missing.
- let kill_h = cce_ui::layout::button_height();
- sec.pc.button_icon_tinted(
- "x",
- "Kill",
- list_box_x + COL_KILL - ox,
- draw_y + (row_h - kill_h) / 2.0,
- kill_h,
- kill_h,
- [0.0, 0.0, 0.0, 0.0],
- [0.75, 0.30, 0.30, 0.45],
- [0.85, 0.55, 0.55, 1.0],
- AppAction::Processes(ProcessesMessage::Kill(p.pid.clone())),
+ form.column().text("Loading processes...", 12.0, TEXT_FG);
+ sec.place(form, ctx);
+ return;
+ }
+ // The table fills the page: the well's floor lands at the page's bottom.
+ form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
+ let inset = crate::app::section_margin();
+ let cpu_list = &mut state.cpu_list;
+ let processes = &state.processes;
+ let killing = &state.killing;
+ let sort = state.sort;
+ let mut col = form.column();
+ // Power summary line above the table.
+ col.text(summary_line(&state.power), 11.0, TEXT_DIM);
+ col.fill(LIST_MIN_H, move |pc, r, _| {
+ let (list_box_x, list_box_y, list_box_w, list_box_h) = (r.x, r.y, r.width, r.height);
+ // 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.
+ // Sized so the default 820px window shows the whole table without
+ // the h-bar: COMMAND gives up the 20px the content box's inset
+ // took.
+ const COL_PID: f32 = 12.0;
+ const COL_COMMAND: f32 = 80.0;
+ const COL_RSS: f32 = 380.0;
+ const COL_MEM: f32 = 460.0;
+ const COL_CPU: f32 = 525.0;
+ const COL_WATTS: f32 = 585.0;
+ const COL_WAKE: f32 = 635.0;
+ const COL_KILL: f32 = 700.0;
+ const CONTENT_W: f32 = 725.0;
+
+ // TODO(style): the column offsets, header hit-target nudges and
+ // in-row text centring below are this table's own layout.
+ let header_h = 22.0;
+ cpu_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ cpu_list.set_content_w(CONTENT_W);
+ // No band for the horizontal scrollbar: it rides the rows'
+ // centre line, crossing the vertical one, behind the plate until
+ // a scroll raises it.
+ let rows_h = list_box_h - header_h - 6.0;
+ cpu_list.update_bounds(processes.len(), list_box_y + header_h, rows_h);
+ cpu_list.push_prims(pc);
+
+ let ox = cpu_list.scroll_x;
+
+ // Header row is background-less (the well shows through); only the
+ // divider separates it from the rows.
+ 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
+
+ // Header labels pan with the columns, clipped to the box. The
+ // sortable ones (MEM, MEM %, CPU %) are buttons: the active key
+ // shows brighter with a `chevron-down` glyph after it (the sort
+ // is always descending). Both memory headers toggle the same
+ // Mem sort — one bigger target, no distinction to learn.
+ let active = |k: ProcSort| sort == k;
+ let hdr = |on: bool| if on { [0.78, 0.78, 0.85, 1.0] } else { TEXT_DIM };
+ const HDR_SIZE: f32 = 11.0;
+ const HDR_Y: f32 = 5.0;
+ const CHEVRON: f32 = 9.0;
+ pc.push_clip_rect(list_box_x, list_box_y, list_box_w, header_h);
+ let header = |pc: &mut PageContent, label: &str, col: f32, on: bool| {
+ let x = list_box_x + col - ox;
+ pc.text(label, x, list_box_y + HDR_Y, HDR_SIZE, hdr(on));
+ if on {
+ let gx = x + crate::app::text_width(label, HDR_SIZE, None) + 3.0;
+ let gy = list_box_y + (header_h - CHEVRON) / 2.0;
+ pc.icon("chevron-down", gx, gy, CHEVRON, CHEVRON, hdr(on));
+ }
+ };
+ pc.text("PID", list_box_x + COL_PID - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
+ pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + HDR_Y, HDR_SIZE, TEXT_DIM);
+ header(pc, "MEM", COL_RSS, active(ProcSort::Mem));
+ pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + HDR_Y, HDR_SIZE, hdr(active(ProcSort::Mem)));
+ header(pc, "CPU %", COL_CPU, active(ProcSort::Cpu));
+ header(pc, "W", COL_WATTS, active(ProcSort::Power));
+ header(pc, "WAKE/s", COL_WAKE, active(ProcSort::Wakeups));
+
+ // Header hit targets, list-row style like the rows (plateless,
+ // washed on hover), inside the header clip so they pan and cut
+ // with the labels. They share no rect with the row buttons, so
+ // emission order is free here.
+ pc.list_row(list_box_x + COL_RSS - ox - 4.0, list_box_y, 52.0, header_h - 2.0,
+ AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
+ pc.list_row(list_box_x + COL_MEM - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
+ AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
+ pc.list_row(list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
+ AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
+ pc.list_row(list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
+ AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
+ pc.list_row(list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
+ AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
+ pc.pop_clip_rect();
+
+ let row_h = 24.0;
+
+ // Visible process rows, clipped to the rows' viewport — not on
+ // into the reserve band below it, where the scroll region still
+ // hands back a row starting there and its Kill button stood alone
+ // under the list.
+ pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, rows_h);
+ for (idx, p) in processes.iter().enumerate() {
+ if let Some(draw_y) = cpu_list.get_item_draw_y(idx, 4.0) {
+ // The row itself, in the DE's list style: plateless,
+ // washed on hover. A transparent plain button here wore a
+ // control plate per row — the whole list read as a stack
+ // of carved rings. Viewport-fixed on purpose: the hover
+ // band spans the visible row whatever the horizontal pan.
+ pc.list_row(
+ list_box_x + 2.0,
+ draw_y,
+ list_box_w - 16.0,
+ row_h,
+ AppAction::Processes(ProcessesMessage::None),
);
+
+ // A pending kill dims the row until the next refresh
+ // settles it (gone, or alive again = the kill didn't take).
+ let dim = killing.contains(&p.pid);
+ let fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.80, 0.80, 0.85, 1.0] };
+ let mem_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.62, 0.72, 0.88, 1.0] };
+ let cpu_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.56, 0.83, 0.56, 1.0] };
+ let watt_fg = if dim { [0.45, 0.45, 0.50, 1.0] } else { [0.90, 0.75, 0.45, 1.0] };
+
+ pc.text(&p.pid, list_box_x + COL_PID - ox, draw_y + 6.0, 12.0, fg);
+ pc.text(&p.command, list_box_x + COL_COMMAND - ox, draw_y + 6.0, 12.0, fg);
+ pc.text(&format_rss(p.rss_kb), list_box_x + COL_RSS - ox, draw_y + 6.0, 12.0, mem_fg);
+ pc.text(&format!("{}%", p.mem_pct), list_box_x + COL_MEM - ox, draw_y + 6.0, 12.0, mem_fg);
+ pc.text(&format!("{}%", p.cpu), list_box_x + COL_CPU - ox, draw_y + 6.0, 12.0, cpu_fg);
+ pc.text(&format_watts(p.watts), list_box_x + COL_WATTS - ox, draw_y + 6.0, 12.0, watt_fg);
+ pc.text(&format_wakeups(p.wakeups), list_box_x + COL_WAKE - ox, draw_y + 6.0, 12.0, fg);
+
+ // Kill button, in content space like the columns. Emitted
+ // AFTER the row button on purpose: overlapping page
+ // buttons all see the click and the LAST take_click wins
+ // the dispatched action (input_handler's collect loop), so
+ // the Kill button beats the row's no-op exactly because it comes later.
+ if !dim {
+ // The `x` glyph in the danger tint; "Kill" only if
+ // the icon set is missing.
+ let kill_h = cce_ui::layout::button_height();
+ pc.button_icon_tinted(
+ "x",
+ "Kill",
+ list_box_x + COL_KILL - ox,
+ draw_y + (row_h - kill_h) / 2.0,
+ kill_h,
+ kill_h,
+ [0.0, 0.0, 0.0, 0.0],
+ [0.75, 0.30, 0.30, 0.45],
+ [0.85, 0.55, 0.55, 1.0],
+ AppAction::Processes(ProcessesMessage::Kill(p.pid.clone())),
+ );
+ }
}
}
- }
- sec.pc.pop_clip_rect();
- // The scrollbar's fore copy, over the rows at the raise's fade.
- state.cpu_list.push_scrollbar_fore(sec.pc);
+ pc.pop_clip_rect();
+ // The scrollbar's fore copy, over the rows at the raise's fade.
+ cpu_list.push_scrollbar_fore(pc);
- if state.processes.is_empty() {
- sec.pc.text("No active processes", list_box_x + inset, list_box_y + header_h + 16.0, 12.0, TEXT_DIM);
- }
-
- // End the section so the well's bottom wall sits one margin below
- // the list: finish() places the wall at content_y + padding +
- // margin, so the list's own bottom margin and that one cancel.
- sec.content_y = list_box_y + list_box_h - sec.padding();
- }
+ if processes.is_empty() {
+ pc.text("No active processes", list_box_x + inset, list_box_y + header_h + 16.0, 12.0, TEXT_DIM);
+ }
+ });
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 15abe59..328a02a 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -1,8 +1,8 @@
-use crate::app::{PageContent, SectionContextExt};
+use crate::app::{form_button, AppAction, PageContent};
use cce_ui::widget::Owned;
use cce_ui::widget::ScrollRegion;
use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
-use cce_ui::widget::{TextBox, InteractiveListItem, WidgetHost};
+use cce_ui::widget::{TextBox, InteractiveListItem};
#[derive(Debug, Clone)]
pub struct ServiceInfo {
@@ -58,239 +58,202 @@ pub enum ServicesMessage {
}
const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+/// The least room the list keeps on a short window.
+const LIST_MIN_H: f32 = 120.0;
pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
- let m = crate::app::section_margin();
let mut final_pc = PageContent::new();
let sec_w = 320.0f32;
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
let sec_w = sec.cw;
+ let mut form = sec.form();
if !state.loaded {
- sec.text("Loading systemd services...", 12.0, 0.0, 12.0, TEXT_DIM);
+ form.column().text("Loading systemd services...", 12.0, TEXT_DIM);
+ sec.place(form, ctx);
+ return;
+ }
+ // The list fills the page: the well's floor lands at the page's bottom.
+ form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
+
+ let tab_colors = |on: bool| {
+ let face = if on { [0.20, 0.40, 0.65, 0.4] } else { [0.10, 0.10, 0.16, 0.3] };
+ (face, [0.20, 0.20, 0.25, 0.15], [0.90, 0.90, 0.95, 1.0])
+ };
+ let (label1, label2) = if sec_w < 250.0 { ("System", "User") } else { ("System Services", "User Services") };
+
+ // Filter services
+ let query = if state.search_box.editing {
+ state.search_box.edit_buffer.to_lowercase()
} else {
- // Tab header buttons: System Services, User Services
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- let tab_h = cce_ui::layout::button_height();
- let active_bg = [0.20, 0.40, 0.65, 0.4];
- let inactive_bg = [0.10, 0.10, 0.16, 0.3];
- let hover_bg = [0.20, 0.20, 0.25, 0.15];
-
- let label1 = if stack.context.cw < 250.0 { "System" } else { "System Services" };
- let label2 = if stack.context.cw < 250.0 { "User" } else { "User Services" };
-
- stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
- if i == 0 {
- ctx.button(
- label1,
- x,
- ctx.ay(),
- w,
- tab_h,
- if state.active_tab == ServiceTab::System { active_bg } else { inactive_bg },
- hover_bg,
- [0.90, 0.90, 0.95, 1.0],
- crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::System)),
- );
- } else {
- ctx.button(
- label2,
- x,
- ctx.ay(),
- w,
- tab_h,
- if state.active_tab == ServiceTab::User { active_bg } else { inactive_bg },
- hover_bg,
- [0.90, 0.90, 0.95, 1.0],
- crate::app::AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)),
- );
- }
- });
-
- stack.context.spacing(4.0);
-
- // Search textbox
- let search_w = sec_w - 2.0 * m;
- let search_h = 46.0;
-
- state.search_box.set_row_rect(stack.context.left + m, search_w);
- stack.add_widget(&mut state.search_box, search_w, search_h, ctx);
- stack.context.spacing(cce_ui::layout::plate_gap());
-
- // 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.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);
-
- // Filter services
- let query = if state.search_box.editing {
- state.search_box.edit_buffer.to_lowercase()
- } else {
- state.search_box.text.to_lowercase()
- };
- let filtered_services: Vec<&ServiceInfo> = state.services.iter()
- .filter(|s| s.is_system == (state.active_tab == ServiceTab::System))
- .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
- .collect();
-
- // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
- state.list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- state.list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
- state.list.push_prims(sec.pc);
-
- let item_h = state.list.item_height;
-
- if state.items.len() != filtered_services.len() {
- state.items.clear();
- for _ in 0..filtered_services.len() {
- state.items.push(Owned::new(InteractiveListItem::new("")));
- }
+ state.search_box.text.to_lowercase()
+ };
+ let filtered_services: Vec<&ServiceInfo> = state.services.iter()
+ .filter(|s| s.is_system == (state.active_tab == ServiceTab::System))
+ .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
+ .collect();
+ if state.items.len() != filtered_services.len() {
+ state.items.clear();
+ for _ in 0..filtered_services.len() {
+ state.items.push(Owned::new(InteractiveListItem::new("")));
}
+ }
- sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- for (idx, service) in filtered_services.iter().enumerate() {
- if let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) {
- // Transport + Restart, on the LEFT where the status dot
- // used to be: the dot was reporting what the transport icon
- // already says (play = stopped, stop = running), so the
- // controls take the column it was using and the name/
- // description run from there to the row's right inset.
- //
- // Sized on whether the icon set is actually THERE:
- // `button_icon` falls back to the labels when it isn't, and
- // a square button doesn't clip a label so much as replace it
- // — the text centers, so both ends cut and "Restart" reads
- // "sta". `upload_icon` caches per (name, px), so asking
- // every row costs one hash lookup.
- let icons_ok = cce_ui::upload_icon("play", 32).is_some();
- let is_small = sec_w < 350.0;
- let btn_h = cce_ui::layout::button_height();
- let (btn_w, r_btn_w) = if icons_ok { (btn_h, btn_h) } else { (46.0, 54.0) };
- let btn_gap = if is_small { 4.0 } else { 6.0 };
-
- // TODO(style): the row's control run and text column
- // below are this list row's own layout.
- let toggle_x = list_box_x + 10.0;
- let restart_x = toggle_x + btn_w + btn_gap;
- // The row's text starts after the controls and ends the
- // controls' inset short of the right wall: no gutter for
- // the scrollbar, which rides the list's centre line.
- let item_x = restart_x + r_btn_w + 10.0;
- let item_w = (list_box_x + list_box_w - 10.0) - item_x;
-
- let btn_y = draw_y + (item_h - btn_h) / 2.0;
-
- // Service description, truncated to the room the row's text
- // column actually has (the item insets its labels by 8px).
- let text_max_w = item_w - 16.0;
- let max_chars = ((text_max_w / 6.0) as usize).max(10);
- // `failed` was the status dot's third state and has nowhere
- // else to show: a failed unit offers the same play button a
- // cleanly stopped one does, so without this the row gives no
- // sign it died. It leads the line, and it goes in BEFORE the
- // truncation so a long description can't be what pushes the
- // marker off the end.
- let failed = service.active_state == "failed" || service.sub_state == "failed";
- // Char-boundary safe: the byte slice this replaced would
- // panic whenever the cut landed inside a multi-byte
- // character, and unit descriptions are free text.
- let desc_truncated = cce_ui::widget::display::truncate_tail(
- &description_line(failed, &service.description),
- max_chars,
- );
-
- // Render InteractiveListItem background and text labels
- // Rows dispatch as extra roots (the dissolved list is no parent).
- let item_btn = &mut state.items[idx];
- item_btn.title = service.name.clone();
- item_btn.subtitle = Some(desc_truncated);
- render_widget(sec.pc, item_btn, item_x, draw_y, item_w, item_h, ctx);
-
- let active_txt = [0.90, 0.90, 0.95, 1.0];
- // No per-action tints: both controls wear the DE's themed
- // button face, and what they DO is carried by the icon.
- // (The toolkit picks these itself for a `Button` with no
- // override; this host paints buttons from its own collected
- // colours, so it has to ask for them.)
- let face = cce_ui::colors::button_background_color();
- let face_hover = cce_ui::colors::button_hover_color();
-
- // ONE transport button, showing the action it will take:
- // play on a stopped service, stop on a running one. The two
- // dimmed half-buttons this replaced were never both live —
- // exactly one of them did anything on any given row.
- //
- // `running` is NOT the `is_active` the status dot reads.
- // `update` sets a transitional state the instant the button
- // is clicked (activating/starting, deactivating/stopping),
- // and counting those as the state they are heading for is
- // what flips this icon under the pointer instead of leaving
- // it stale until the next refresh lands. The dot keeps
- // reporting the confirmed state: the button says what it
- // will do, the dot says what is true.
- let running = transport_running(&service.active_state, &service.sub_state);
-
- // Fallback labels only — an icon face never draws them.
- // Without the icons a row says it in WORDS, narrow or
- // not: a symbol drawn as a character is exactly what the
- // icon set exists to replace, so the buttons keep the
- // width a word needs instead.
- let (start_lbl, stop_lbl, restart_lbl) = ("Start", "Stop", "Restart");
-
- // Start/Stop, collapsed
- sec.pc.button_icon(
- if running { "stop" } else { "play" },
- if running { stop_lbl } else { start_lbl },
- toggle_x,
- btn_y,
- btn_w,
- btn_h,
- face,
- face_hover,
- active_txt,
- 1.0,
- crate::app::AppAction::Services(if running {
- ServicesMessage::Stop(service.name.clone(), service.is_system)
- } else {
- ServicesMessage::Start(service.name.clone(), service.is_system)
- }),
- );
-
- // Restart button
- sec.pc.button_icon(
- "refresh",
- restart_lbl,
- restart_x,
- btn_y,
- r_btn_w,
- btn_h,
- face,
- face_hover,
- active_txt,
- 1.0,
- crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
- );
+ let active_tab = state.active_tab;
+ let list = &mut state.list;
+ let items = &mut state.items;
+ let mut col = form.column();
+ col.row(|r| {
+ form_button(r, label1, 0.0, tab_colors(active_tab == ServiceTab::System),
+ AppAction::Services(ServicesMessage::SetTab(ServiceTab::System)));
+ form_button(r, label2, 0.0, tab_colors(active_tab == ServiceTab::User),
+ AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)));
+ });
+ col.widget(&mut state.search_box, cce_ui::layout::textbox_height());
+ col.fill(LIST_MIN_H, move |pc, rect, ctx| {
+ let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
+ // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
+ list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
+ list.push_prims(pc);
+ let item_h = list.item_height;
+
+ pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ for (idx, service) in filtered_services.iter().enumerate() {
+ if let Some(draw_y) = list.get_item_draw_y(idx, 4.0) {
+ // Transport + Restart, on the LEFT where the status dot
+ // used to be: the dot was reporting what the transport icon
+ // already says (play = stopped, stop = running), so the
+ // controls take the column it was using and the name/
+ // description run from there to the row's right inset.
+ //
+ // Sized on whether the icon set is actually THERE:
+ // `button_icon` falls back to the labels when it isn't, and
+ // a square button doesn't clip a label so much as replace it
+ // — the text centers, so both ends cut and "Restart" reads
+ // "sta". `upload_icon` caches per (name, px), so asking
+ // every row costs one hash lookup.
+ let icons_ok = cce_ui::upload_icon("play", 32).is_some();
+ let is_small = sec_w < 350.0;
+ let btn_h = cce_ui::layout::button_height();
+ let (btn_w, r_btn_w) = if icons_ok { (btn_h, btn_h) } else { (46.0, 54.0) };
+ let btn_gap = if is_small { 4.0 } else { 6.0 };
+
+ // TODO(style): the row's control run and text column
+ // below are this list row's own layout.
+ let toggle_x = list_box_x + 10.0;
+ let restart_x = toggle_x + btn_w + btn_gap;
+ // The row's text starts after the controls and ends the
+ // controls' inset short of the right wall: no gutter for
+ // the scrollbar, which rides the list's centre line.
+ let item_x = restart_x + r_btn_w + 10.0;
+ let item_w = (list_box_x + list_box_w - 10.0) - item_x;
+
+ let btn_y = draw_y + (item_h - btn_h) / 2.0;
+
+ // Service description, truncated to the room the row's text
+ // column actually has (the item insets its labels by 8px).
+ let text_max_w = item_w - 16.0;
+ let max_chars = ((text_max_w / 6.0) as usize).max(10);
+ // `failed` was the status dot's third state and has nowhere
+ // else to show: a failed unit offers the same play button a
+ // cleanly stopped one does, so without this the row gives no
+ // sign it died. It leads the line, and it goes in BEFORE the
+ // truncation so a long description can't be what pushes the
+ // marker off the end.
+ let failed = service.active_state == "failed" || service.sub_state == "failed";
+ // Char-boundary safe: the byte slice this replaced would
+ // panic whenever the cut landed inside a multi-byte
+ // character, and unit descriptions are free text.
+ let desc_truncated = cce_ui::widget::display::truncate_tail(
+ &description_line(failed, &service.description),
+ max_chars,
+ );
+
+ // Render InteractiveListItem background and text labels
+ // Rows dispatch as extra roots (the dissolved list is no parent).
+ let item_btn = &mut items[idx];
+ item_btn.title = service.name.clone();
+ item_btn.subtitle = Some(desc_truncated);
+ render_widget(pc, item_btn, item_x, draw_y, item_w, item_h, ctx);
+
+ let active_txt = [0.90, 0.90, 0.95, 1.0];
+ // No per-action tints: both controls wear the DE's themed
+ // button face, and what they DO is carried by the icon.
+ // (The toolkit picks these itself for a `Button` with no
+ // override; this host paints buttons from its own collected
+ // colours, so it has to ask for them.)
+ let face = cce_ui::colors::button_background_color();
+ let face_hover = cce_ui::colors::button_hover_color();
+
+ // ONE transport button, showing the action it will take:
+ // play on a stopped service, stop on a running one. The two
+ // dimmed half-buttons this replaced were never both live —
+ // exactly one of them did anything on any given row.
+ //
+ // `running` is NOT the `is_active` the status dot reads.
+ // `update` sets a transitional state the instant the button
+ // is clicked (activating/starting, deactivating/stopping),
+ // and counting those as the state they are heading for is
+ // what flips this icon under the pointer instead of leaving
+ // it stale until the next refresh lands. The dot keeps
+ // reporting the confirmed state: the button says what it
+ // will do, the dot says what is true.
+ let running = transport_running(&service.active_state, &service.sub_state);
+
+ // Fallback labels only — an icon face never draws them.
+ // Without the icons a row says it in WORDS, narrow or
+ // not: a symbol drawn as a character is exactly what the
+ // icon set exists to replace, so the buttons keep the
+ // width a word needs instead.
+ let (start_lbl, stop_lbl, restart_lbl) = ("Start", "Stop", "Restart");
+
+ // Start/Stop, collapsed
+ pc.button_icon(
+ if running { "stop" } else { "play" },
+ if running { stop_lbl } else { start_lbl },
+ toggle_x,
+ btn_y,
+ btn_w,
+ btn_h,
+ face,
+ face_hover,
+ active_txt,
+ 1.0,
+ crate::app::AppAction::Services(if running {
+ ServicesMessage::Stop(service.name.clone(), service.is_system)
+ } else {
+ ServicesMessage::Start(service.name.clone(), service.is_system)
+ }),
+ );
+
+ // Restart button
+ pc.button_icon(
+ "refresh",
+ restart_lbl,
+ restart_x,
+ btn_y,
+ r_btn_w,
+ btn_h,
+ face,
+ face_hover,
+ active_txt,
+ 1.0,
+ crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
+ );
+ }
}
- }
- sec.pc.pop_clip_rect();
+ pc.pop_clip_rect();
// The scrollbar's fore copy, over the rows at the raise's fade.
- state.list.push_scrollbar_fore(sec.pc);
+ list.push_scrollbar_fore(pc);
if filtered_services.is_empty() {
- sec.pc.text("No services match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
+ let inset = cce_ui::layout::plate_padding();
+ pc.text("No services match the query", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
}
-
- // End the section so the well's bottom wall sits one margin below
- // the list: finish() places the wall at content_y + padding +
- // margin, so the list's own bottom margin and that one cancel.
- sec.content_y = list_box_y + list_box_h - sec.padding();
- }
+ });
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/storage.rs b/src/pages/storage.rs
index c82b735..3f1c147 100644
--- a/src/pages/storage.rs
+++ b/src/pages/storage.rs
@@ -1,6 +1,6 @@
use crate::app::{AppAction, PageContent};
use cce_ui::widget::Owned;
-use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow};
+use cce_ui::layout::{PageLayoutBuilder, PageFlow};
use std::fs;
/// What the background poll produces — the fetched numbers only, never the
@@ -223,6 +223,8 @@ const BTN_BG: [f32; 4] = [0.20, 0.40, 0.65, 1.0];
const BTN_HOVER: [f32; 4] = [0.28, 0.50, 0.78, 1.0];
const BTN_DISABLED: [f32; 4] = [0.15, 0.18, 0.22, 1.0];
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
+/// A usage bar's own height: a thin band under its figures.
+const USAGE_BAR_H: f32 = 8.0;
pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
let mut final_pc = PageContent::new();
@@ -231,113 +233,77 @@ pub fn view(state: &mut StorageState, cx: f32, cy: f32, cw: f32, ch: f32, sec_fo
// Section 1: Local Storage
builder.add_section(&mut final_pc, "Local Storage", sec_focused.first().copied().unwrap_or(false), |sec| {
- let sec_w = sec.cw;
+ let disk_pct = if state.disk_total > 0.0 { state.disk_used / state.disk_total * 100.0 } else { 0.0 };
+ let mut disk_bar = Owned::new(
+ cce_ui::widget::UsageBar::new((disk_pct as f32 / 100.0).min(1.0))
+ .with_colors([0.36, 0.60, 0.36, 1.0], [0.15, 0.15, 0.25, 1.0]),
+ );
+ let mut form = sec.form();
+ let mut col = form.column();
if !state.loaded {
- sec.text("Loading storage usage...", 12.0, 0.0, 12.0, TEXT_FG);
+ col.text("Loading storage usage...", 12.0, TEXT_FG);
} else {
- let disk_pct = if state.disk_total > 0.0 {
- state.disk_used / state.disk_total * 100.0
- } else {
- 0.0
- };
-
- sec.text("Disk", 12.0, 0.0, 12.0, LABEL_FG);
- sec.text(
- &format!("{:.0} / {:.0} GiB ({:.0}%)", state.disk_used, state.disk_total, disk_pct),
- 100.0, 0.0, 12.0, TEXT_FG,
- );
-
- let bar_w = sec_w - 2.0 * crate::app::section_margin();
- let yt = sec.ay();
- let disk_bar_x = sec.ax(crate::app::section_margin());
- let mut disk_bar = Owned::new(cce_ui::widget::UsageBar::new((disk_pct as f32 / 100.0).min(1.0))
- .with_colors([0.36, 0.60, 0.36, 1.0], [0.15, 0.15, 0.25, 1.0]));
- render_widget(sec.pc, &mut disk_bar, disk_bar_x, yt, bar_w, 8.0, ctx);
+ let usage = format!("{:.0} / {:.0} GiB ({:.0}%)", state.disk_used, state.disk_total, disk_pct);
+ crate::app::form_pairs(&mut col, 12.0, vec![("Disk".into(), LABEL_FG, usage, TEXT_FG)]);
+ col.widget(&mut disk_bar, USAGE_BAR_H);
}
+ sec.place(form, ctx);
});
// Section 2: Memory
builder.add_section(&mut final_pc, "Memory", sec_focused.get(1).copied().unwrap_or(false), |sec| {
- let sec_w = sec.cw;
+ let ram_pct = if state.ram_total > 0.0 { state.ram_used / state.ram_total * 100.0 } else { 0.0 };
+ let mut ram_bar = Owned::new(
+ cce_ui::widget::UsageBar::new((ram_pct as f32 / 100.0).min(1.0))
+ .with_colors([0.50, 0.50, 0.65, 1.0], [0.15, 0.15, 0.25, 1.0]),
+ );
+ let mut form = sec.form();
+ let mut col = form.column();
if !state.loaded {
- sec.text("Loading memory usage...", 12.0, 0.0, 12.0, TEXT_FG);
+ col.text("Loading memory usage...", 12.0, TEXT_FG);
} else {
- let ram_pct = if state.ram_total > 0.0 {
- state.ram_used / state.ram_total * 100.0
- } else {
- 0.0
- };
-
- sec.text("RAM", 12.0, 0.0, 12.0, LABEL_FG);
- sec.text(
- &format!("{:.1} / {:.1} GiB ({:.0}%)", state.ram_used, state.ram_total, ram_pct),
- 100.0, 0.0, 12.0, TEXT_FG,
- );
-
- let bar_w = sec_w - 2.0 * crate::app::section_margin();
- let yt = sec.ay();
- let ram_bar_x = sec.ax(crate::app::section_margin());
- let mut ram_bar = Owned::new(cce_ui::widget::UsageBar::new((ram_pct as f32 / 100.0).min(1.0))
- .with_colors([0.50, 0.50, 0.65, 1.0], [0.15, 0.15, 0.25, 1.0]));
- render_widget(sec.pc, &mut ram_bar, ram_bar_x, yt, bar_w, 8.0, ctx);
+ let usage = format!("{:.1} / {:.1} GiB ({:.0}%)", state.ram_used, state.ram_total, ram_pct);
+ crate::app::form_pairs(&mut col, 12.0, vec![("RAM".into(), LABEL_FG, usage, TEXT_FG)]);
+ col.widget(&mut ram_bar, USAGE_BAR_H);
}
+ sec.place(form, ctx);
});
- // Section 2: Full System Backup
+ // Section 3: Full System Backup
builder.add_section(&mut final_pc, "Full System Backup", sec_focused.get(2).copied().unwrap_or(false), |sec| {
- if !state.backup_loaded {
- sec.text("Loading backup state...", 12.0, 0.0, 12.0, TEXT_DIM);
+ // Retained widget rather than an immediate button, so it can hold keyboard focus. Its
+ // label/colours are re-synced each frame from the backup state, the way cce-mail drives
+ // its retained btn_unread.
+ let (btn_label, bg, hover) = if state.backup_in_progress {
+ ("Backing up...", BTN_DISABLED, BTN_DISABLED)
} else {
- // Status Row
- sec.text("Backup Status", 12.0, 0.0, 12.0, LABEL_FG);
- let status_text = if state.backup_in_progress { "Backing up..." } else { "Idle" };
- let status_color = if state.backup_in_progress { GREEN } else { TEXT_FG };
- sec.text(status_text, 120.0, 0.0, 12.0, status_color);
-
- // Last Backup Row
- sec.text("Last Backup", 12.0, 0.0, 12.0, LABEL_FG);
- sec.text(&state.last_backup_time, 120.0, 0.0, 12.0, TEXT_FG);
-
- // Backup Size Row
- sec.text("Archive Size", 12.0, 0.0, 12.0, LABEL_FG);
- sec.text(&state.backup_size, 120.0, 0.0, 12.0, TEXT_FG);
-
- // Target Directories Row
- sec.text("Backup Targets", 12.0, 0.0, 12.0, LABEL_FG);
- sec.text("Entire Filesystem (/) [Preserving attributes]", 120.0, 0.0, 12.0, TEXT_DIM);
-
- // Destination Archive Row
- sec.text("Destination", 12.0, 0.0, 12.0, LABEL_FG);
- sec.text("USB Drive (/mnt/usb or /run/media/...)", 120.0, 0.0, 12.0, TEXT_DIM);
+ ("Run Backup", BTN_BG, BTN_HOVER)
+ };
+ state.backup_button.set_label(btn_label);
+ state.backup_button.bg = Some(bg);
+ state.backup_button.hover_bg = Some(hover);
- // Error message if present
+ let mut form = sec.form();
+ let mut col = form.column();
+ if !state.backup_loaded {
+ col.text("Loading backup state...", 12.0, TEXT_DIM);
+ } else {
+ let (status_text, status_color) =
+ if state.backup_in_progress { ("Backing up...", GREEN) } else { ("Idle", TEXT_FG) };
+ let mut pairs = vec![
+ ("Backup Status".into(), LABEL_FG, status_text.into(), status_color),
+ ("Last Backup".into(), LABEL_FG, state.last_backup_time.clone(), TEXT_FG),
+ ("Archive Size".into(), LABEL_FG, state.backup_size.clone(), TEXT_FG),
+ ("Backup Targets".into(), LABEL_FG, "Entire Filesystem (/) [Preserving attributes]".into(), TEXT_DIM),
+ ("Destination".into(), LABEL_FG, "USB Drive (/mnt/usb or /run/media/...)".into(), TEXT_DIM),
+ ];
if let Some(ref err) = state.error_message {
- sec.text("Error:", 12.0, 0.0, 12.0, RED);
- sec.text(err, 60.0, 0.0, 11.0, RED);
+ pairs.push(("Error:".into(), RED, err.clone(), RED));
}
-
- // Action Button
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- let btn_h = cce_ui::layout::button_height();
-
- // Retained widget rather than an immediate `sec.button`, so it can hold
- // keyboard focus. Its label/colours are re-synced each frame from the
- // backup state, the way cce-mail drives its retained btn_unread.
- let (btn_label, bg, hover) = if state.backup_in_progress {
- ("Backing up...", BTN_DISABLED, BTN_DISABLED)
- } else {
- ("Run Backup", BTN_BG, BTN_HOVER)
- };
- state.backup_button.set_label(btn_label);
- state.backup_button.bg = Some(bg);
- state.backup_button.hover_bg = Some(hover);
-
- let btn = &mut state.backup_button;
- stack.add_row(1, 0.0, btn_h, |sctx, _, x, w| {
- let y = sctx.ay();
- render_widget(sctx.pc, btn, x, y, w, btn_h, ctx);
- });
+ crate::app::form_pairs(&mut col, 12.0, pairs);
+ col.widget(&mut state.backup_button, cce_ui::layout::button_height());
}
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/system_info.rs b/src/pages/system_info.rs
index 3b8dcea..e331056 100644
--- a/src/pages/system_info.rs
+++ b/src/pages/system_info.rs
@@ -1,7 +1,7 @@
-use crate::app::{AppAction, PageContent, SectionContextExt};
+use crate::app::{button_need, form_button, form_button_fit, wrap_to_width, AppAction, PageContent};
use cce_ui::widget::Owned;
use cce_ui::layout::{PageLayoutBuilder, PageFlow};
-use cce_ui::widget::{Label, Button, WidgetHostExt};
+use cce_ui::widget::{Label, WidgetHostExt};
#[derive(Debug, Clone)]
@@ -250,79 +250,11 @@ where
});
}
-/// Width a label needs on a button plate. Feeds `add_row_for`, so a row of
-/// buttons is divided by what is written on them rather than into equal
-/// slices — "Reboot" and "Hibernate" are not the same size and a row that
-/// pretends otherwise clips one and pads the other.
-///
-/// Asks the Button itself (`intrinsic_size`: its label shaped in the button
-/// font, plus its 8px inset each side) rather than measuring here. This used
-/// `measure_text_width` in the control-label font, an inked extent that runs
-/// ~20% short of the shaped run under Berkeley Mono, so the row handed every
-/// button less than its label and "Hibernate" / "Power Off" were cut off.
-fn button_need(label: &str) -> f32 {
- Button::new(0.0, 0.0, 0.0, 0.0)
- .with_label(label)
- .intrinsic_size()
- .map_or(0.0, |s| s.width)
-}
-
-/// `text` broken into lines no wider than `width` at `size`, measured as the
-/// renderer shapes it (the default UI face, as `PageContent::text` draws).
-/// Breaks after a space or before a `/`, so a path splits on its separators;
-/// a run with neither is cut wherever it overflows. A section's text is
-/// clipped to its content box, so anything that does not fit has to wrap or
-/// it is simply lost — a GPU name, the CPU model, a pending install path.
-fn wrap_to_width(text: &str, width: f32, size: f32) -> Vec<String> {
- let offsets = cce_ui::geometry_font_system()
- .lock()
- .map(|mut fs| cce_ui::backend::text::shaped_cluster_offsets(&mut fs, text, size, None))
- .unwrap_or_default();
- if offsets.last().map_or(true, |&(_, total)| total <= width) {
- return vec![text.to_string()];
- }
- let mut lines = Vec::new();
- let (mut start, mut start_x) = (0usize, 0.0f32);
- // Where the next line would begin if this one broke at the last chance.
- let mut chance: Option<(usize, f32)> = None;
- for pair in offsets.windows(2) {
- let ((b, x), (next_b, next_x)) = (pair[0], pair[1]);
- if next_x - start_x > width && b > start {
- let (cut, cut_x) = chance.filter(|&(c, _)| c > start).unwrap_or((b, x));
- lines.push(text[start..cut].trim_end().to_string());
- (start, start_x, chance) = (cut, cut_x, None);
- }
- match &text[b..next_b] {
- " " => chance = Some((next_b, next_x)),
- "/" if b > start => chance = Some((b, x)),
- _ => {}
- }
- }
- lines.push(text[start..].trim_end().to_string());
- lines.retain(|l| !l.is_empty());
- lines
-}
-
-/// [`SectionContext::text`](cce_ui::layout::SectionContext::text), wrapped
-/// to the content box instead of clipped by it. Continuation lines follow at
-/// the line pitch `text` itself advances by, without the row gap, so one
-/// wrapped item still reads as one item.
-fn wrapped_text(sec: &mut cce_ui::layout::SectionContext<'_, PageContent>, text: &str, size: f32, color: [f32; 4]) {
- use cce_ui::layout::RenderTarget;
- let x = sec.content_left();
- let right = x + sec.content_width();
- let mut y = sec.content_y;
- if y > sec.content_start_y {
- y += sec.row_gap;
- }
- for line in wrap_to_width(text, sec.content_width(), size) {
- sec.pc.text_with_bounds(&line, x, y, size, color, Some([x, y - size, right, y + 2.0 * size]));
- y += size + 4.0;
- }
- sec.content_y = y;
- for h in &mut sec.grid.col_heights {
- *h = y;
- }
+/// A line of text wrapped to the form's width rather than cut at it: its lines one under the
+/// next, so one wrapped item still reads as one item.
+fn wrapped_text(g: &mut cce_ui::layout::FormGroup<'_, '_, PageContent>, text: &str, size: f32, color: [f32; 4]) {
+ let lines = wrap_to_width(text, g.form_width(), size);
+ g.lines(lines, size, color);
}
fn spawn_systemctl(action: &str) {
@@ -558,7 +490,7 @@ const DANGER_BG: [f32; 4] = [0.67, 0.20, 0.20, 1.0];
const SAFE_BG: [f32; 4] = [0.20, 0.33, 0.22, 1.0];
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
-pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, _ctx: &mut cce_ui::context::UiContext) -> PageContent {
+pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
let mut final_pc = PageContent::new();
let sec_w = 320.0f32;
// Six, matching the add_section calls below and the six groups
@@ -570,76 +502,75 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
// ── 1. System Section ──
builder.add_section(&mut final_pc, "System", sec_focused.first().copied().unwrap_or(false), |sec| {
- if !state.loaded {
- sec.text("Loading system information...", 12.0, 0.0, 14.0, TEXT_FG);
- } else {
- wrapped_text(sec, &format!("{} — Linux {}", state.hostname, state.kernel), 14.0, TEXT_FG);
- wrapped_text(sec, &format!("Uptime: {}", state.uptime), 12.0, TEXT_DIM);
- }
+ let mut form = sec.form();
+ form.column().block(|b| {
+ if !state.loaded {
+ b.text("Loading system information...", 14.0, TEXT_FG);
+ } else {
+ wrapped_text(b, &format!("{} — Linux {}", state.hostname, state.kernel), 14.0, TEXT_FG);
+ wrapped_text(b, &format!("Uptime: {}", state.uptime), 12.0, TEXT_DIM);
+ }
+ });
+ sec.place(form, ctx);
});
// ── 2. System Actions Section ──
builder.add_section(&mut final_pc, "System Actions", sec_focused.get(1).copied().unwrap_or(false), |sec| {
- let gap = cce_ui::layout::plate_gap();
- let mut stack = sec.vstack(gap);
- // The toolkit's button height, like every other cce-ui button — this
- // row was a hand-set 32px, a size no other control in the DE wears.
- let btn_h = cce_ui::layout::button_height();
-
- const ACTIONS: [&str; 4] = ["Suspend", "Hibernate", "Reboot", "Power Off"];
- let needs: Vec<f32> = ACTIONS.iter().map(|l| button_need(l)).collect();
- // All four on one row when their labels fit; otherwise the safe pair
- // over the destructive pair, rather than squeezing every plate below
- // its label (`row_layout_for` scales an overfull row down, which is
- // what clipped the labels).
- let one_row = needs.iter().sum::<f32>() + gap * (needs.len() - 1) as f32
- <= stack.context.content_width();
+ const ACTIONS: [(&str, [f32; 4], SystemMessage); 4] = [
+ ("Suspend", SAFE_BG, SystemMessage::Suspend),
+ ("Hibernate", SAFE_BG, SystemMessage::Hibernate),
+ ("Reboot", DANGER_BG, SystemMessage::Reboot),
+ ("Power Off", DANGER_BG, SystemMessage::PowerOff),
+ ];
+ let needs: Vec<f32> = ACTIONS.iter().map(|a| button_need(a.0)).collect();
+ let mut form = sec.form();
+ // All four on one row when their labels fit; otherwise the safe pair over the
+ // destructive pair, rather than squeezing every plate below its label.
+ let gap = cce_ui::layout::control_gap();
+ let one_row = needs.iter().sum::<f32>() + gap * (needs.len() - 1) as f32 <= form.width();
let rows: &[std::ops::Range<usize>] = if one_row { &[0..4] } else { &[0..2, 2..4] };
- for r in rows {
- let first = r.start;
- stack.add_row_for(&needs[r.clone()], gap, btn_h, |ctx, i, x, w| {
- let (label, bg, msg) = match first + i {
- 0 => ("Suspend", SAFE_BG, SystemMessage::Suspend),
- 1 => ("Hibernate", SAFE_BG, SystemMessage::Hibernate),
- 2 => ("Reboot", DANGER_BG, SystemMessage::Reboot),
- _ => ("Power Off", DANGER_BG, SystemMessage::PowerOff),
- };
- ctx.button(label, x, ctx.ay(), w, btn_h, bg, BTN_HOVER, WHITE, AppAction::SystemInfo(msg));
+ let mut col = form.column();
+ for range in rows {
+ col.row(|r| {
+ for i in range.clone() {
+ let (label, bg, msg) = ACTIONS[i].clone();
+ form_button_fit(r, label, needs[i], (bg, BTN_HOVER, WHITE), AppAction::SystemInfo(msg));
+ }
});
}
-
- // No trailing gap after the last row: the well's own floor inset is
- // the space below it, as it is beside and above.
- stack.spacing = 0.0;
- stack.add_row(1, 0.0, btn_h, |ctx, _, x, w| {
- ctx.button("Force Shutdown", x, ctx.ay(), w, btn_h,
- DANGER_BG, BTN_HOVER, WHITE, AppAction::SystemInfo(SystemMessage::ForceShutdown));
- });
+ form_button(&mut col, "Force Shutdown", 0.0, (DANGER_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::ForceShutdown));
+ sec.place(form, ctx);
});
// ── 3. CPU Section ──
builder.add_section(&mut final_pc, "CPU", sec_focused.get(2).copied().unwrap_or(false), |sec| {
- if !state.loaded {
- sec.text("Loading CPU model and utilization...", 12.0, 0.0, 12.0, TEXT_FG);
- } else {
- // Same strings the old Label widgets carried, stacked vertically (the
- // grid-column widget placement overlapped them at narrow widths).
- wrapped_text(sec, &format!("CPU {} ({} cores)", state.cpu_model, state.cpu_cores), 12.0, TEXT_FG);
- wrapped_text(sec, &format!("Usage {:.0}%", state.cpu_usage), 12.0, TEXT_FG);
- let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp {:.0}°C", t)).unwrap_or_else(|| "Temp N/A".to_string());
- wrapped_text(sec, &cpu_temp_text, 12.0, TEXT_FG);
- }
+ let mut form = sec.form();
+ form.column().block(|b| {
+ if !state.loaded {
+ b.text("Loading CPU model and utilization...", 12.0, TEXT_FG);
+ } else {
+ wrapped_text(b, &format!("CPU {} ({} cores)", state.cpu_model, state.cpu_cores), 12.0, TEXT_FG);
+ wrapped_text(b, &format!("Usage {:.0}%", state.cpu_usage), 12.0, TEXT_FG);
+ let cpu_temp_text = read_cpu_temp().map(|t| format!("Temp {:.0}°C", t)).unwrap_or_else(|| "Temp N/A".to_string());
+ wrapped_text(b, &cpu_temp_text, 12.0, TEXT_FG);
+ }
+ });
+ sec.place(form, ctx);
});
// ── 4. GPU Section ──
- builder.add_section(&mut final_pc, "GPU", sec_focused.get(3).copied().unwrap_or(false), |sec_gpu| {
- if !state.loaded {
- sec_gpu.text("Loading GPU models...", 12.0, 0.0, 12.0, TEXT_FG);
- } else {
- for gpu_text in state.gpu_strings.iter() {
- wrapped_text(sec_gpu, gpu_text, 12.0, TEXT_FG);
+ builder.add_section(&mut final_pc, "GPU", sec_focused.get(3).copied().unwrap_or(false), |sec| {
+ let mut form = sec.form();
+ form.column().block(|b| {
+ if !state.loaded {
+ b.text("Loading GPU models...", 12.0, TEXT_FG);
+ } else {
+ for gpu_text in state.gpu_strings.iter() {
+ wrapped_text(b, gpu_text, 12.0, TEXT_FG);
+ }
}
- }
+ });
+ sec.place(form, ctx);
});
// ── 5. System Files Section ──
@@ -650,8 +581,6 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
// the greeter out of a half-built tree.
builder.add_section(&mut final_pc, "System Files", sec_focused.get(4).copied().unwrap_or(false), |sec| {
let sf = &state.sysfiles;
- let mut stack = sec.vstack(6.0);
-
let (status, color) = if sf.busy {
("Checking…".to_string(), TEXT_DIM)
} else if !sf.scanned {
@@ -669,96 +598,76 @@ pub fn view(state: &mut SystemState, cx: f32, cy: f32, cw: f32, ch: f32, _root_f
TEXT_FG,
)
};
- stack.add_row(1, 0.0, 16.0, |c, _, x, _| {
- let y = c.ay();
- c.pc.text(&status, x, y + 4.0, 12.0, color);
- });
-
- // Name the files. "2 files differ" is not enough to authorize a root
- // install on — which one is the greeter matters.
- // A path pair is wider than the well, and the well clips: wrapped,
- // continuation lines indented past the first, so each file still
- // reads as one entry.
- let wrap_w = stack.context.content_width();
- for line in sf.pending.iter().take(8) {
- let lines = wrap_to_width(line, wrap_w - 8.0, 11.0);
- let (first, rest) = lines.split_first().map_or((String::new(), &[][..]), |(f, r)| (f.clone(), r));
- let rest: Vec<String> = rest.iter().flat_map(|l| wrap_to_width(l, wrap_w - 20.0, 11.0)).collect();
- stack.add_row(1, 0.0, 14.0 * (1 + rest.len()) as f32, move |c, _, x, _| {
- let y = c.ay();
- c.pc.text(&first, x + 8.0, y + 3.0, 11.0, TEXT_DIM);
- for (k, l) in rest.iter().enumerate() {
- c.pc.text(l, x + 20.0, y + 3.0 + 14.0 * (k + 1) as f32, 11.0, TEXT_DIM);
+ let mut form = sec.form();
+ let wrap_w = form.width();
+ let mut col = form.column();
+ col.block(|b| {
+ b.text(status, 12.0, color);
+ // Name the files. "2 files differ" is not enough to authorize a root
+ // install on — which one is the greeter matters. A path pair is wider
+ // than the well, and the well clips: wrapped, continuation lines
+ // indented past the first, so each file still reads as one entry.
+ let indent = cce_ui::layout::CONTROL_TEXT_INSET;
+ for line in sf.pending.iter().take(8) {
+ let lines = wrap_to_width(line, wrap_w, 11.0);
+ let (first, rest) = lines.split_first().map_or((String::new(), &[][..]), |(f, r)| (f.clone(), r));
+ let rest: Vec<String> = rest.iter().flat_map(|l| wrap_to_width(l, wrap_w - indent, 11.0)).collect();
+ b.text(first, 11.0, TEXT_DIM);
+ if !rest.is_empty() {
+ b.row(|r| {
+ r.space(indent, false);
+ r.lines(rest, 11.0, TEXT_DIM);
+ });
}
- });
- }
+ }
+ });
if let Some(ref res) = sf.result {
- let (msg, col) = match res {
+ let (msg, color) = match res {
Ok(()) => ("Installed — takes effect at next login".to_string(), TEXT_DIM),
Err(e) => (format!("Failed: {}", e), DANGER_BG),
};
- let lines = wrap_to_width(&msg, stack.context.content_width(), 11.0);
- stack.add_row(1, 0.0, 16.0 + 14.0 * (lines.len().max(1) - 1) as f32, move |c, _, x, _| {
- let y = c.ay();
- for (k, l) in lines.iter().enumerate() {
- c.pc.text(l, x, y + 4.0 + 14.0 * k as f32, 11.0, col);
- }
- });
+ wrapped_text(&mut col, &msg, 11.0, color);
}
- let btn_h = cce_ui::layout::button_height();
- // The buttons close the well: no trailing gap below them.
- stack.spacing = 0.0;
let has_work = sf.scanned && !sf.pending.is_empty();
let busy = sf.busy;
- let needs = [button_need("Check"), button_need("Install (root)")];
- stack.add_row_for(&needs, cce_ui::layout::plate_gap(), btn_h, move |c, i, x, w| {
- match i {
- 0 => {
- c.button("Check", x, c.ay(), w, btn_h,
- SAFE_BG, BTN_HOVER, WHITE,
- AppAction::SystemInfo(SystemMessage::ScanSystemFiles));
- }
- 1 => {
- // Only offered when there is something to install. The
- // apply re-plans anyway, so a stale-enabled button would
- // be a no-op rather than a hazard — but it would also put
- // up a root prompt for nothing.
- if has_work && !busy {
- c.button("Install (root)", x, c.ay(), w, btn_h,
- DANGER_BG, BTN_HOVER, WHITE,
- AppAction::SystemInfo(SystemMessage::InstallSystemFiles));
- }
- }
- _ => {}
+ let (check_need, install_need) = (button_need("Check"), button_need("Install (root)"));
+ col.row(|r| {
+ form_button_fit(r, "Check", check_need, (SAFE_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::ScanSystemFiles));
+ // Only offered when there is something to install. The apply re-plans anyway,
+ // so a stale-enabled button would be a no-op rather than a hazard — but it
+ // would also put up a root prompt for nothing. Its room is kept either way, so
+ // Check does not change width.
+ if has_work && !busy {
+ form_button_fit(r, "Install (root)", install_need, (DANGER_BG, BTN_HOVER, WHITE), AppAction::SystemInfo(SystemMessage::InstallSystemFiles));
+ } else {
+ r.space(install_need, true);
}
});
+ sec.place(form, ctx);
});
// ── 6. Builds Section ──
// How long each installed cce binary took to build, slowest first.
builder.add_section(&mut final_pc, "Builds", sec_focused.get(5).copied().unwrap_or(false), |sec| {
+ let mut form = sec.form();
+ let mut col = form.column();
if !state.loaded {
- sec.text("Loading installed builds...", 12.0, 0.0, 12.0, TEXT_FG);
- return;
- }
- if state.builds.is_empty() {
- sec.text("No cce binaries in ~/.local/bin", 12.0, 0.0, 12.0, TEXT_DIM);
- return;
- }
- let mut stack = sec.vstack(0.0);
- for b in &state.builds {
+ col.text("Loading installed builds...", 12.0, TEXT_FG);
+ } else if state.builds.is_empty() {
+ col.text("No cce binaries in ~/.local/bin", 12.0, TEXT_DIM);
+ } else {
// "—" is a binary no ccebuild build has timed (a bare `cargo
// build`, or one built before the timing was recorded).
- let took = b.seconds.map(format_build_duration).unwrap_or_else(|| "—".to_string());
- let name = b.name.clone();
- stack.add_row(1, 0.0, 16.0, move |c, _, x, _| {
- let y = c.ay();
- c.pc.text(&name, x, y + 3.0, 11.0, TEXT_FG);
- c.pc.text(&took, x + 140.0, y + 3.0, 11.0, TEXT_DIM);
- });
+ let pairs = state
+ .builds
+ .iter()
+ .map(|b| (b.name.clone(), TEXT_FG, b.seconds.map(format_build_duration).unwrap_or_else(|| "—".to_string()), TEXT_DIM))
+ .collect();
+ crate::app::form_pairs(&mut col, 11.0, pairs);
}
+ sec.place(form, ctx);
});
final_pc
diff --git a/src/pages/timers.rs b/src/pages/timers.rs
index fc3b665..b91fa50 100644
--- a/src/pages/timers.rs
+++ b/src/pages/timers.rs
@@ -2,11 +2,11 @@
//! schedules, trigger the activated service immediately, and enable/disable
//! timer units (system scope through pkexec).
-use crate::app::{PageContent, SectionContextExt};
+use crate::app::{button_need, form_button, AppAction, PageContent};
use cce_ui::widget::Owned;
use cce_ui::widget::ScrollRegion;
use cce_ui::layout::{render_widget, PageLayoutBuilder, PageFlow, RenderTarget};
-use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox, WidgetHost};
+use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimerTab {
@@ -82,6 +82,8 @@ pub enum TimersMessage {
}
const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
+/// The least room the list keeps on a short window.
+const LIST_MIN_H: f32 = 120.0;
/// "46min" / "3h" / "5d 3h" — coarse two-unit humanization.
fn humanize(secs: u64) -> String {
@@ -402,272 +404,227 @@ fn button_glyph(pc: &mut PageContent, compact: bool, icon: &str, word: &str, x:
}
pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
- let m = crate::app::section_margin();
let mut final_pc = PageContent::new();
let sec_w = 320.0f32;
let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
let sec_w = sec.cw;
+ let mut form = sec.form();
if !state.loaded {
- sec.text("Loading systemd timers...", 12.0, 0.0, 12.0, TEXT_DIM);
- } else {
- // Tab header buttons: System Timers, User Timers
- let mut stack = sec.vstack(cce_ui::layout::plate_gap());
- let tab_h = cce_ui::layout::button_height();
- let active_bg = [0.20, 0.40, 0.65, 0.4];
- let inactive_bg = [0.10, 0.10, 0.16, 0.3];
- let hover_bg = [0.20, 0.20, 0.25, 0.15];
-
- let label1 = if stack.context.cw < 250.0 { "System" } else { "System Timers" };
- let label2 = if stack.context.cw < 250.0 { "User" } else { "User Timers" };
-
- stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
- if i == 0 {
- ctx.button(
- label1,
- x,
- ctx.ay(),
- w,
- tab_h,
- if state.active_tab == TimerTab::System { active_bg } else { inactive_bg },
- hover_bg,
- [0.90, 0.90, 0.95, 1.0],
- crate::app::AppAction::Timers(TimersMessage::SetTab(TimerTab::System)),
- );
- } else {
- ctx.button(
- label2,
- x,
- ctx.ay(),
- w,
- tab_h,
- if state.active_tab == TimerTab::User { active_bg } else { inactive_bg },
- hover_bg,
- [0.90, 0.90, 0.95, 1.0],
- crate::app::AppAction::Timers(TimersMessage::SetTab(TimerTab::User)),
- );
- }
- });
-
- stack.context.spacing(4.0);
-
- // New Timer (user scope) — compact button; the form unfolds below.
- let new_bg = if state.creating { active_bg } else { [0.13, 0.18, 0.14, 1.0] };
- stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
- if i == 0 {
- c.button("New Timer", x, c.ay(), w, tab_h,
- new_bg, [0.25, 0.30, 0.26, 1.0], [0.90, 0.90, 0.95, 1.0],
- crate::app::AppAction::Timers(TimersMessage::CreateStart));
- }
- });
-
- if state.creating || state.editing.is_some() {
- let item_w = sec_w - 2.0 * (stack.context.padding() + m);
- let rx = stack.context.left;
- let widget_h = cce_ui::layout::spinbox_height();
-
- if let Some(base) = &state.editing {
- stack.context.text(&format!("Editing {}.timer \u{2014} command and schedule rewrite in place.", base), 12.0, 0.0, 11.0, TEXT_DIM);
- } else {
- stack.context.text("New user timer \u{2014} runs the command on the schedule.", 12.0, 0.0, 11.0, TEXT_DIM);
- }
-
- if state.creating {
- state.name_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.name_box, item_w, widget_h, ctx);
- }
- state.command_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.command_box, item_w, widget_h, ctx);
- state.schedule_box.set_row_rect(rx + m, item_w);
- stack.add_widget(&mut state.schedule_box, item_w, widget_h, ctx);
-
- stack.context.spacing(4.0);
- let save_label = if state.editing.is_some() { "Save" } else { "Create" };
- stack.add_row(3, cce_ui::layout::plate_gap(), tab_h, |c, i, x, w| {
- match i {
- 0 => c.button(save_label, x, c.ay(), w, tab_h,
- [0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0], [0.90, 0.90, 0.95, 1.0],
- crate::app::AppAction::Timers(TimersMessage::CreateSave)),
- 1 => c.button("Cancel", x, c.ay(), w, tab_h,
- [0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0], [0.90, 0.90, 0.95, 1.0],
- crate::app::AppAction::Timers(TimersMessage::CreateCancel)),
- _ => {}
- }
- });
- }
-
- if let Some(ref msg) = state.status_msg {
- stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
+ form.column().text("Loading systemd timers...", 12.0, TEXT_DIM);
+ sec.place(form, ctx);
+ return;
+ }
+ // The list fills the page like the services list: the well's floor lands at the
+ // page's bottom.
+ form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
+
+ let active_bg = [0.20, 0.40, 0.65, 0.4];
+ let tab_colors = |on: bool| {
+ let face = if on { active_bg } else { [0.10, 0.10, 0.16, 0.3] };
+ (face, [0.20, 0.20, 0.25, 0.15], [0.90, 0.90, 0.95, 1.0])
+ };
+ let (label1, label2) = if sec_w < 250.0 { ("System", "User") } else { ("System Timers", "User Timers") };
+ let text_on = [0.90, 0.90, 0.95, 1.0];
+
+ let now = now_usec();
+ let filtered: Vec<&TimerInfo> = state.timers.iter()
+ .filter(|t| t.is_system == (state.active_tab == TimerTab::System))
+ .collect();
+ if state.items.len() != filtered.len() {
+ state.items.clear();
+ for _ in 0..filtered.len() {
+ state.items.push(Owned::new(InteractiveListItem::new("")));
}
+ }
- stack.context.spacing(cce_ui::layout::plate_gap());
-
- // 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.content_width();
- let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
-
- let now = now_usec();
- let filtered: Vec<&TimerInfo> = state.timers.iter()
- .filter(|t| t.is_system == (state.active_tab == TimerTab::System))
- .collect();
-
- state.list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- state.list.update_bounds(filtered.len(), list_box_y, list_box_h);
- state.list.push_prims(sec.pc);
-
- let item_h = state.list.item_height;
-
- if state.items.len() != filtered.len() {
- state.items.clear();
- for _ in 0..filtered.len() {
- state.items.push(Owned::new(InteractiveListItem::new("")));
- }
+ let active_tab = state.active_tab;
+ let list = &mut state.list;
+ let items = &mut state.items;
+ let mut col = form.column();
+ col.row(|r| {
+ form_button(r, label1, 0.0, tab_colors(active_tab == TimerTab::System),
+ AppAction::Timers(TimersMessage::SetTab(TimerTab::System)));
+ form_button(r, label2, 0.0, tab_colors(active_tab == TimerTab::User),
+ AppAction::Timers(TimersMessage::SetTab(TimerTab::User)));
+ });
+
+ // New Timer (user scope) — a button as wide as its label; the form unfolds below.
+ let new_bg = if state.creating { active_bg } else { [0.13, 0.18, 0.14, 1.0] };
+ col.row(|r| {
+ form_button(r, "New Timer", button_need("New Timer"), (new_bg, [0.25, 0.30, 0.26, 1.0], text_on),
+ AppAction::Timers(TimersMessage::CreateStart));
+ });
+
+ if state.creating || state.editing.is_some() {
+ let field_h = cce_ui::layout::spinbox_height();
+ let note = match &state.editing {
+ Some(base) => format!("Editing {}.timer \u{2014} command and schedule rewrite in place.", base),
+ None => "New user timer \u{2014} runs the command on the schedule.".to_string(),
+ };
+ col.text(note, 11.0, TEXT_DIM);
+ if state.creating {
+ col.widget(&mut state.name_box, field_h);
}
+ col.widget(&mut state.command_box, field_h);
+ col.widget(&mut state.schedule_box, field_h);
+ let save_label = if state.editing.is_some() { "Save" } else { "Create" };
+ col.row(|r| {
+ form_button(r, save_label, button_need(save_label), ([0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0], text_on),
+ AppAction::Timers(TimersMessage::CreateSave));
+ form_button(r, "Cancel", button_need("Cancel"), ([0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0], text_on),
+ AppAction::Timers(TimersMessage::CreateCancel));
+ });
+ }
- sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
- for (idx, timer) in filtered.iter().enumerate() {
- if let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) {
- let is_small = sec_w < 350.0;
- // A narrow row's buttons are cce-icons glyphs (pencil,
- // play, stop, circle) — but only when the icon set is
- // THERE: without it they fall back to words, and a word
- // needs the wide button. `upload_icon` caches per
- // (name, px), so asking every row is one hash lookup.
- let compact = is_small && cce_ui::upload_icon("play", 32).is_some();
- // Glyph buttons are square at the control height.
- let btn_h = cce_ui::layout::button_height();
- let run_w = if compact { btn_h } else { 76.0 };
- let en_w = if compact { btn_h } else { 66.0 };
- let edit_w = if compact { btn_h } else { 50.0 };
- let btn_gap = if is_small { 4.0 } else { 6.0 };
- // TODO(style): the row's button run, dot and text
- // column below are this list row's own layout.
- let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
-
- let en_x = right_edge - en_w;
- let run_x = en_x - btn_gap - run_w;
- let edit_x = run_x - btn_gap - edit_w;
-
- let btn_y = draw_y + (item_h - btn_h) / 2.0;
-
- // Title + schedule subtitle (truncated to the space before the buttons).
- let text_left_edge = if timer.editable { edit_x } else { run_x };
- let text_max_w = (text_left_edge - 8.0) - (list_box_x + 32.0);
- let max_chars = ((text_max_w / 6.0) as usize).max(10);
- let subtitle_full = schedule_line(timer, now);
- let subtitle = if subtitle_full.len() > max_chars {
- format!("{}...", &subtitle_full[..subtitle_full.char_indices().take(max_chars.saturating_sub(3)).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(0)])
- } else {
- subtitle_full
- };
-
- let item_btn = &mut state.items[idx];
- item_btn.title = timer.unit.clone();
- item_btn.subtitle = Some(subtitle);
- render_widget(sec.pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
-
- let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
- let mut dot = Owned::new(StatusDot::new(dot_state));
- render_widget(sec.pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
-
- let active_txt = [0.90, 0.90, 0.95, 1.0];
-
- // Edit: only for user units living in ~/.config/systemd/user.
- if timer.editable {
- // `button_glyph` below: the glyph when compact,
- // else (or with no icon set) the word.
- button_glyph(
- sec.pc,
- compact,
- "pencil",
- "Edit",
- edit_x,
- btn_y,
- edit_w,
- btn_h,
- [0.15, 0.15, 0.20, 1.0],
- [0.22, 0.22, 0.28, 1.0],
- active_txt,
- crate::app::AppAction::Timers(TimersMessage::EditStart(timer.unit.clone())),
- );
- }
+ if let Some(ref msg) = state.status_msg {
+ col.text(msg.clone(), 12.0, [0.56, 0.83, 0.56, 1.0]);
+ }
- // Run Now: start the activated service immediately.
- button_glyph(
- sec.pc,
- compact,
- "play",
- if is_small { "Run" } else { "Run Now" },
- run_x,
- btn_y,
- run_w,
- btn_h,
- [0.16, 0.35, 0.18, 0.4],
- [0.22, 0.45, 0.25, 0.6],
- active_txt,
- crate::app::AppAction::Timers(TimersMessage::RunNow(timer.activates.clone(), timer.is_system)),
- );
-
- // Enable/Disable the timer unit; static units have no toggle.
- match timer.file_state.as_str() {
- "enabled" | "enabled-runtime" => {
+ col.fill(LIST_MIN_H, move |pc, rect, ctx| {
+ let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
+ list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ list.update_bounds(filtered.len(), list_box_y, list_box_h);
+ list.push_prims(pc);
+ let item_h = list.item_height;
+
+ pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
+ for (idx, timer) in filtered.iter().enumerate() {
+ if let Some(draw_y) = list.get_item_draw_y(idx, 4.0) {
+ let is_small = sec_w < 350.0;
+ // A narrow row's buttons are cce-icons glyphs (pencil,
+ // play, stop, circle) — but only when the icon set is
+ // THERE: without it they fall back to words, and a word
+ // needs the wide button. `upload_icon` caches per
+ // (name, px), so asking every row is one hash lookup.
+ let compact = is_small && cce_ui::upload_icon("play", 32).is_some();
+ // Glyph buttons are square at the control height.
+ let btn_h = cce_ui::layout::button_height();
+ let run_w = if compact { btn_h } else { 76.0 };
+ let en_w = if compact { btn_h } else { 66.0 };
+ let edit_w = if compact { btn_h } else { 50.0 };
+ let btn_gap = if is_small { 4.0 } else { 6.0 };
+ // TODO(style): the row's button run, dot and text
+ // column below are this list row's own layout.
+ let right_edge = list_box_x + list_box_w - 24.0 - 8.0;
+
+ let en_x = right_edge - en_w;
+ let run_x = en_x - btn_gap - run_w;
+ let edit_x = run_x - btn_gap - edit_w;
+
+ let btn_y = draw_y + (item_h - btn_h) / 2.0;
+
+ // Title + schedule subtitle (truncated to the space before the buttons).
+ let text_left_edge = if timer.editable { edit_x } else { run_x };
+ let text_max_w = (text_left_edge - 8.0) - (list_box_x + 32.0);
+ let max_chars = ((text_max_w / 6.0) as usize).max(10);
+ let subtitle_full = schedule_line(timer, now);
+ let subtitle = if subtitle_full.len() > max_chars {
+ format!("{}...", &subtitle_full[..subtitle_full.char_indices().take(max_chars.saturating_sub(3)).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(0)])
+ } else {
+ subtitle_full
+ };
+
+ let item_btn = &mut items[idx];
+ item_btn.title = timer.unit.clone();
+ item_btn.subtitle = Some(subtitle);
+ render_widget(pc, item_btn, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
+
+ let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
+ let mut dot = Owned::new(StatusDot::new(dot_state));
+ render_widget(pc, &mut dot, list_box_x + 10.0, draw_y + (item_h - 10.0) / 2.0, 10.0, 10.0, ctx);
+
+ let active_txt = [0.90, 0.90, 0.95, 1.0];
+
+ // Edit: only for user units living in ~/.config/systemd/user.
+ if timer.editable {
+ // `button_glyph` below: the glyph when compact,
+ // else (or with no icon set) the word.
button_glyph(
- sec.pc,
+ pc,
compact,
- "stop",
- "Disable",
- en_x,
+ "pencil",
+ "Edit",
+ edit_x,
btn_y,
- en_w,
- btn_h,
- [0.25, 0.14, 0.14, 1.0],
- [0.40, 0.20, 0.20, 1.0],
- [0.95, 0.55, 0.55, 1.0],
- crate::app::AppAction::Timers(TimersMessage::Disable(timer.unit.clone(), timer.is_system)),
- );
- }
- "disabled" => {
- // `circle`, the filled dot the narrow row
- // always used: `play` is Run Now's beside it.
- button_glyph(
- sec.pc,
- compact,
- "circle",
- "Enable",
- en_x,
- btn_y,
- en_w,
+ edit_w,
btn_h,
[0.15, 0.15, 0.20, 1.0],
[0.22, 0.22, 0.28, 1.0],
active_txt,
- crate::app::AppAction::Timers(TimersMessage::Enable(timer.unit.clone(), timer.is_system)),
+ crate::app::AppAction::Timers(TimersMessage::EditStart(timer.unit.clone())),
);
}
- _ => {
- sec.pc.text("static", en_x + 8.0, btn_y + (btn_h - 12.0) / 2.0, 11.0, TEXT_DIM);
+
+ // Run Now: start the activated service immediately.
+ button_glyph(
+ pc,
+ compact,
+ "play",
+ if is_small { "Run" } else { "Run Now" },
+ run_x,
+ btn_y,
+ run_w,
+ btn_h,
+ [0.16, 0.35, 0.18, 0.4],
+ [0.22, 0.45, 0.25, 0.6],
+ active_txt,
+ crate::app::AppAction::Timers(TimersMessage::RunNow(timer.activates.clone(), timer.is_system)),
+ );
+
+ // Enable/Disable the timer unit; static units have no toggle.
+ match timer.file_state.as_str() {
+ "enabled" | "enabled-runtime" => {
+ button_glyph(
+ pc,
+ compact,
+ "stop",
+ "Disable",
+ en_x,
+ btn_y,
+ en_w,
+ btn_h,
+ [0.25, 0.14, 0.14, 1.0],
+ [0.40, 0.20, 0.20, 1.0],
+ [0.95, 0.55, 0.55, 1.0],
+ crate::app::AppAction::Timers(TimersMessage::Disable(timer.unit.clone(), timer.is_system)),
+ );
+ }
+ "disabled" => {
+ // `circle`, the filled dot the narrow row
+ // always used: `play` is Run Now's beside it.
+ button_glyph(
+ pc,
+ compact,
+ "circle",
+ "Enable",
+ en_x,
+ btn_y,
+ en_w,
+ btn_h,
+ [0.15, 0.15, 0.20, 1.0],
+ [0.22, 0.22, 0.28, 1.0],
+ active_txt,
+ crate::app::AppAction::Timers(TimersMessage::Enable(timer.unit.clone(), timer.is_system)),
+ );
+ }
+ _ => {
+ pc.text("static", en_x + 8.0, btn_y + (btn_h - 12.0) / 2.0, 11.0, TEXT_DIM);
+ }
}
}
}
- }
- sec.pc.pop_clip_rect();
+ pc.pop_clip_rect();
// The scrollbar's fore copy, over the rows at the raise's fade.
- state.list.push_scrollbar_fore(sec.pc);
+ list.push_scrollbar_fore(pc);
if filtered.is_empty() {
- sec.pc.text("No timers in this scope", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
+ let inset = cce_ui::layout::plate_padding();
+ pc.text("No timers in this scope", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
}
-
- // End the section so the well's bottom wall sits one margin below
- // the list: finish() places the wall at content_y + padding +
- // margin, so the list's own bottom margin and that one cancel.
- sec.content_y = list_box_y + list_box_h - sec.padding();
- }
+ });
+ sec.place(form, ctx);
});
final_pc