system settings
git clone https://git.lucas.co/cce-system-interface.git
fix(icons): every symbol the settings app draws is a cce-icons glyph
The pages drew their symbols as characters: a "▼" after the active
sort column, a "×" on each process's Kill button, a ">" before the
wifi network and the bluetooth device in use, "• default" and
"• no password" after an account, and "▶ ■ ⟳" on a narrow services
row when the icon set was missing. Each is a glyph now: chevron-down,
x (tinted the Kill button's red), check, star and warning — and the
services fallback says it in words.
A flat host had no way to draw a glyph that was not a button face, so
PageContent grows `icon(name, rect, color)` (tinted like text, clipped
like everything else it records) and implements RenderTarget::icon,
which is also where cce-ui's render_widget now hands a widget's own
glyphs: the page dropdown's arrow, closed and expanded, draws again.
`text_width` and `label_y_in` place a glyph after a run of text as the
renderer shapes it. The page's glyphs ride the wheel fast path, as the
button icon faces now do too (they stood still until a rebuild).
The context menu is painted with context_menu::paint_with_labels
straight into the frame, the toolkit's lit plate with its marks and
chevrons as glyphs, where the text_labels() loop could draw neither.
Also: a wifi network seen through several access points is in use if
any of them is (the first line's flag lost the mark when nmcli listed
an idle AP first).
This commit also carries the List-styling session's work, by
agreement: PageContent::list_row in app.rs, its use for the process
rows and sort targets in processes.rs, and the renderer reading a
button's idle and hover faces from Paint::color.
Not converted here (another session's file): the Timers page's
narrow-layout buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/app.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++++++
src/input_handler.rs | 12 ++++++
src/main.rs | 51 +++++++++++++++++++++++-
src/pages/accounts.rs | 42 +++++++++++++++-----
src/pages/bluetooth.rs | 20 ++++++----
src/pages/network.rs | 33 +++++++++++++---
src/pages/processes.rs | 105 +++++++++++++++++++++++++++++--------------------
src/pages/services.rs | 18 +++------
src/renderer.rs | 92 +++++++++++++++++++++++++------------------
9 files changed, 362 insertions(+), 116 deletions(-)
diff --git a/src/app.rs b/src/app.rs
index fad2835..395e902 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -153,6 +153,48 @@ impl ControlCarve {
}
}
+/// A bundled cce-icons glyph a page placed on its own (a sort chevron, a
+/// row's in-use check) rather than as a button's face: the tinted upload's
+/// image id, its rect and alpha, and the innermost clip at emission time —
+/// all page coordinates, pre-scroll, like `texts`.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PageIcon {
+ pub image: u32,
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+ pub alpha: f32,
+ pub clip: Option<[f32; 4]>,
+}
+
+/// The width `text` takes at `size` in `font` (None = the default UI face,
+/// what `PageContent::text` draws in), shaped as the renderer shapes it —
+/// for placing a glyph right after a run of text.
+pub fn text_width(text: &str, size: f32, font: Option<&str>) -> f32 {
+ cce_ui::geometry_font_system()
+ .lock()
+ .ok()
+ .and_then(|mut fs| {
+ cce_ui::backend::text::shaped_cluster_offsets(&mut fs, text, size, font)
+ .last()
+ .map(|&(_, total)| total)
+ })
+ .unwrap_or(0.0)
+}
+
+/// The y a label of `size` in `font` takes to sit centred in a band of
+/// height `h` at `y` — the renderer's own centring of a button label, so a
+/// run placed beside one shares its line.
+pub fn label_y_in(y: f32, h: f32, size: f32, font: Option<&str>) -> f32 {
+ let lh = cce_ui::geometry_font_system()
+ .lock()
+ .map(|mut fs| cce_ui::backend::get_text_buffer(&mut fs, "Ag", size, font).metrics().line_height)
+ .unwrap_or(size * 1.2)
+ / cce_ui::scale::scale_factor();
+ y + (h - lh) / 2.0
+}
+
pub struct PageContent {
pub rects: Vec<([f32; 4], f32, f32, f32, f32, f32, (bool, bool, bool, bool))>,
pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
@@ -160,6 +202,9 @@ pub struct PageContent {
/// time (page coords) — the renderer clamps the drawn quad, label bounds,
/// and the dispatch clone's hit rect to it.
pub buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, AppAction, Option<[f32; 4]>)>,
+ /// Glyphs placed with [`PageContent::icon`] (and a widget's own, through
+ /// `RenderTarget::icon` — the expanded Dropdown's chevron).
+ pub icons: Vec<PageIcon>,
/// Section wells claimed via `RenderTarget::section_relief` — the body box
/// plus the title tab box, carved into the window plate by display_list as
/// recess prims (page coordinates, pre-scroll).
@@ -186,6 +231,7 @@ impl Default for PageContent {
rects: Vec::new(),
texts: Vec::new(),
buttons: Vec::new(),
+ icons: Vec::new(),
reliefs: Vec::new(),
control_reliefs: Vec::new(),
control_relief_marks: Vec::new(),
@@ -201,6 +247,7 @@ impl PageContent {
rects: Vec::new(),
texts: Vec::new(),
buttons: Vec::new(),
+ icons: Vec::new(),
reliefs: Vec::new(),
control_reliefs: Vec::new(),
control_relief_marks: Vec::new(),
@@ -264,6 +311,25 @@ impl PageContent {
self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), cb));
}
+ /// A bundled cce-icons glyph at (x, y, w, h), tinted `color` as a text
+ /// colour is (raw sRGB, alpha = the image's) — the ONE way a page draws a
+ /// symbol, never a character in whatever face the font falls back to.
+ /// Rasterized at twice the rect's longer side for a 2x output.
+ ///
+ /// `false` when the icon set lacks the glyph, so the caller can say it
+ /// in a WORD instead; a measuring pass answers without recording.
+ pub fn icon(&mut self, name: &str, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) -> bool {
+ let px = (w.max(h) * 2.0).ceil().max(1.0) as u32;
+ let Some((image, _, _)) = cce_ui::upload_icon_tinted(name, px, cce_ui::icon_tint(color)) else {
+ return false;
+ };
+ if !self.measure_only {
+ let clip = self.clip_stack.last().copied();
+ self.icons.push(PageIcon { image, x, y, w, h, alpha: color[3], clip });
+ }
+ true
+ }
+
pub 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) {
@@ -303,6 +369,41 @@ impl PageContent {
self.buttons.push((btn, action, clip));
}
+ /// A row of a list, in the toolkit's list style (`Button::new_list_row`,
+ /// what cce-mail, cce-fonts and cce-calendar rows wear): no plate,
+ /// transparent until hovered, the shared row wash. A plain [`button`]
+ /// with a transparent face is NOT that — under `control_relief` it still
+ /// gets a control plate, edges only, so every idle row of a list wore a
+ /// carved ring. No colours to pass: the renderer asks the row for its own.
+ ///
+ /// [`button`]: PageContent::button
+ pub fn list_row(&mut self, x: f32, y: f32, w: f32, h: f32, action: AppAction) {
+ if self.measure_only { return; }
+ let btn = cce_ui::widget::Button::new_list_row(x, y, w, h);
+ let clip = self.clip_stack.last().copied();
+ self.buttons.push((btn, action, clip));
+ }
+
+ /// [`button_icon`](Self::button_icon) with the glyph tinted
+ /// `label_color` (its alpha the glyph's), where the face's colour is part
+ /// of what the control says — the process list's red Kill. The white
+ /// glyph `button_icon` uploads cannot carry a colour: an image has none.
+ pub fn button_icon_tinted(&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],
+ action: AppAction) {
+ if self.measure_only { return; }
+ let mut btn = cce_ui::widget::Button::new(x, y, w, h)
+ .with_label(label)
+ .with_bg(bg)
+ .with_hover_bg(hover_bg)
+ .with_label_color(label_color);
+ if let Some((id, iw, ih)) = cce_ui::upload_icon_tinted(icon, 32, cce_ui::icon_tint(label_color)) {
+ btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(label_color[3]);
+ }
+ let clip = self.clip_stack.last().copied();
+ self.buttons.push((btn, action, clip));
+ }
+
pub 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) {
@@ -319,6 +420,10 @@ impl PageContent {
}
impl RenderTarget for PageContent {
+ fn icon(&mut self, name: &str, rect: cce_ui::scene::layout::Rect, color: [f32; 4]) {
+ PageContent::icon(self, name, rect.x, rect.y, rect.width, rect.height, color);
+ }
+
fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
if self.measure_only { return; }
if let Some((cx, cy, cw, ch)) = self.get_clipped_rect(x, y, w, h) {
diff --git a/src/input_handler.rs b/src/input_handler.rs
index 7c67798..ab12f31 100644
--- a/src/input_handler.rs
+++ b/src/input_handler.rs
@@ -343,6 +343,18 @@ impl SystemInterface {
for (btn, _) in &mut self.page_buttons[self.scrollable_buttons_start_idx..] {
btn.base_mut().y -= actual_dy;
}
+ // The glyphs ride with the page too — a button's icon face and the
+ // page's own icons — or they stand still while their rows scroll
+ // past them until something rebuilds.
+ for (_, _, y, _, _, _) in &mut self.page_button_images {
+ *y -= actual_dy;
+ }
+ for icon in &mut self.page_icon_images {
+ icon.y -= actual_dy;
+ if let Some(c) = icon.clip.as_mut() {
+ c[1] -= actual_dy;
+ }
+ }
self.last_scroll_y = self.scroll_y;
self.page_scroll_bar.scroll_y = self.scroll_y;
true
diff --git a/src/main.rs b/src/main.rs
index 9c3553d..0984b93 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -29,6 +29,29 @@ struct AppWidget {
corners: (bool, bool, bool, bool),
}
+/// A cce-icons glyph ready to draw: window coordinates (scroll already
+/// applied), the image's alpha, and the clip it was placed under
+/// (`[x, y, w, h]`) — an image has no geometry to trim, only a clip.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct PlacedIcon {
+ pub image: u32,
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+ pub alpha: f32,
+ pub clip: Option<[f32; 4]>,
+}
+
+fn draw_placed_icon(pc: &mut cce_ui::scene::paint::PaintCtx, icon: &PlacedIcon) {
+ use cce_ui::scene::layout::Rect;
+ let rect = Rect { x: icon.x, y: icon.y, width: icon.w, height: icon.h };
+ match icon.clip {
+ Some([x, y, w, h]) => pc.clip(Rect { x, y, width: w, height: h }, |pc| pc.image(icon.image, rect, icon.alpha)),
+ None => pc.image(icon.image, rect, icon.alpha),
+ }
+}
+
static INITIAL_PAGE_INDEX: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
struct SystemInterface {
@@ -136,6 +159,15 @@ struct SystemInterface {
/// images at all otherwise: `all_quads` carries quads and the text list
/// carries labels, and an icon is neither.
page_button_images: Vec<(u32, f32, f32, f32, f32, f32)>,
+ /// The page's own glyphs (`PageContent::icon`: a sort chevron, an in-use
+ /// check), drawn with the button faces under the page clip.
+ page_icon_images: Vec<PlacedIcon>,
+ /// Glyphs the popovers drew (the expanded Dropdown's chevron), drawn over
+ /// the popover plates.
+ popover_icon_images: Vec<PlacedIcon>,
+ /// Glyphs of the window chrome (the page dropdown's arrow), window
+ /// coordinates, drawn over the chrome outside the page clip.
+ window_icon_images: Vec<PlacedIcon>,
// root plate container + StatusBar DISSOLVED (Phase 6s): the window plate and the status
// bar are emitted as tuples in rebuild_layout.
sans_serif_family: String,
@@ -262,6 +294,9 @@ impl cce_ui::engine::Application for SystemInterface {
page_control_reliefs: Vec::new(),
page_control_relief_marks: Vec::new(),
page_button_images: Vec::new(),
+ page_icon_images: Vec::new(),
+ popover_icon_images: Vec::new(),
+ window_icon_images: Vec::new(),
sans_serif_family: sans_family,
serif_family,
monospace_family,
@@ -524,15 +559,22 @@ impl cce_ui::engine::Application for SystemInterface {
// Button icon faces, over the page's quads and its carves — clipped to
// the page viewport, which is what cuts a half-scrolled list row's icon
// at the list edge (an image has no geometry to trim, only a clip).
- if !self.page_button_images.is_empty() {
+ if !self.page_button_images.is_empty() || !self.page_icon_images.is_empty() {
let view = self.page_view(width, height);
pc.clip(view, |pc| {
for &(image, x, y, w, h, alpha) in &self.page_button_images {
pc.image(image, Rect { x, y, width: w, height: h }, alpha);
}
+ for icon in &self.page_icon_images {
+ draw_placed_icon(pc, icon);
+ }
});
}
+ for icon in &self.window_icon_images {
+ draw_placed_icon(&mut pc, icon);
+ }
+
// The page scrollbar's raised layer: over the page content while a
// scroll or drag holds it up (popovers still stack above it).
if self.page_scroll_bar.raised() {
@@ -573,6 +615,13 @@ impl cce_ui::engine::Application for SystemInterface {
emit_control_carve(&mut pc, carve);
}
}
+ for icon in &self.popover_icon_images {
+ draw_placed_icon(&mut pc, icon);
+ }
+ // The context menu last, over everything: the toolkit's lit plate,
+ // its rows and its labels in the menu font, with the row marks and
+ // page chevrons as cce-icons glyphs.
+ cce_ui::widget::context_menu::paint_with_labels(&mut pc);
for (text, font_size, x, y, col, font, bounds) in self.texts.iter().chain(self.popover_texts.iter()) {
pc.text_with(
text.clone(),
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 16bed84..5f7dbad 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -580,6 +580,8 @@ const BTN_DANGER: ([f32; 4], [f32; 4]) = ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20,
const ACCENT_BG: [f32; 4] = [0.20, 0.40, 0.65, 0.35];
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];
+/// The amber the keyring line warns in — the list's `warning` glyph too.
+const TEXT_WARN: [f32; 4] = [0.90, 0.75, 0.40, 1.0];
/// Gap between account rows. style: deliberate — list rows pack tighter
/// than the pane gap, like every list on this app's pages (the inset from
@@ -641,15 +643,8 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) else {
continue;
};
- let mut label = if acc.is_default {
- format!("{} \u{2022} default", acc.email)
- } else {
- acc.email.clone()
- };
// The stranded-vault tell, visible without selecting the row.
- if state.keyring.get(&acc.email) == Some(&KeyringStatus::Missing) {
- label.push_str(" \u{2022} no password");
- }
+ 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])
@@ -657,7 +652,7 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
([1.0, 1.0, 1.0, 0.04], [1.0, 1.0, 1.0, 0.10])
};
sec.pc.button_left(
- &label,
+ &acc.email,
list_x + m,
draw_y,
btn_w,
@@ -667,6 +662,33 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
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;
+ }
+ 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();
// Reserve the region's height through `spacing`, NOT `content_y +=`:
@@ -823,7 +845,7 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
let (text, color) = match status {
KeyringStatus::InKeyring => ("in keyring", TEXT_BTN),
KeyringStatus::OnDiskPlaintext => {
- ("on disk (plaintext) \u{2014} migrates to keyring", [0.90, 0.75, 0.40, 1.0])
+ ("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)
diff --git a/src/pages/bluetooth.rs b/src/pages/bluetooth.rs
index 1d6a736..4d2e772 100644
--- a/src/pages/bluetooth.rs
+++ b/src/pages/bluetooth.rs
@@ -208,7 +208,6 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
} else {
let item_h = 22.0;
for dev in &state.devices {
- let status = if dev.connected { ">" } else { " " };
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" }
@@ -216,15 +215,15 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
if bt_sec_w < 250.0 { "Conn" } else { "Connect" }
};
- let label_max_w = (bt_sec_w - btn_w - 2.0 * padding - margin - row_gap).max(20.0);
+ 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 {
- format!("{} {}", status, dev.mac)
+ dev.mac.clone()
} else {
- format!("{} Unknown Device ({})", status, dev.mac)
+ format!("Unknown Device ({})", dev.mac)
}
} else {
if bt_sec_w < 350.0 {
@@ -233,9 +232,9 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
} else {
dev.name.clone()
};
- format!("{} {}", status, name_truncated)
+ name_truncated
} else {
- let full_label = format!("{} {} ({})", status, dev.name, dev.mac);
+ 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 {
@@ -255,7 +254,14 @@ pub fn view(state: &mut BluetoothState, cx: f32, cy: f32, cw: f32, ch: f32, sec_
} else {
AppAction::Bluetooth(BluetoothMessage::Connect(dev.mac.clone()))
});
- sec.text(&label, text_x, text_y_offset, font_size, if dev.connected { ACCENT } else { TEXT_FG });
+ // 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;
}
}
diff --git a/src/pages/network.rs b/src/pages/network.rs
index d1512f3..0106072 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -118,12 +118,25 @@ async fn fetch_wifi_list() -> Vec<WifiNetwork> {
let parts: Vec<&str> = line.splitn(4, ':').collect();
if parts.len() >= 3 {
let ssid = parts[0].to_string();
- if ssid.is_empty() || ssid == "--" || seen.contains(&ssid) { continue; }
+ if ssid.is_empty() || ssid == "--" { continue; }
+ let in_use = parts.len() > 3 && parts[3] == "*";
+ if seen.contains(&ssid) {
+ // One SSID, several access points (a mesh, 2.4 + 5 GHz):
+ // the row is in use if ANY of them is. Keeping the first
+ // line's flag lost the mark whenever nmcli listed an idle
+ // AP of the network ahead of the connected one.
+ if in_use {
+ if let Some(n) = networks.iter_mut().find(|n: &&mut WifiNetwork| n.ssid == ssid) {
+ n.in_use = true;
+ }
+ }
+ continue;
+ }
seen.insert(ssid.clone());
networks.push(WifiNetwork {
ssid, signal: parts[1].parse::<u8>().unwrap_or(0),
secured: !parts[2].is_empty(),
- in_use: parts.len() > 3 && parts[3] == "*",
+ in_use,
});
}
}
@@ -215,18 +228,28 @@ pub fn view(state: &mut NetworkState, cx: f32, cy: f32, cw: f32, ch: f32, root_f
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 prefix = if net.in_use { ">" } else { " " };
let ssid_truncated = if net.ssid.len() > max_chars {
format!("{}...", &net.ssid[..max_chars.saturating_sub(3)])
} else {
net.ssid.clone()
};
- let label = format!("{} {} ({}%)", prefix, ssid_truncated, net.signal);
+ let label = format!("{} ({}%)", ssid_truncated, net.signal);
let active = net.in_use;
- sec.pc.button(&label, list_box_x + margin, draw_y, btn_w, 26.0,
+ 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);
+ }
}
}
sec.pc.pop_clip_rect();
diff --git a/src/pages/processes.rs b/src/pages/processes.rs
index 5ee5488..896a3bc 100644
--- a/src/pages/processes.rs
+++ b/src/pages/processes.rs
@@ -81,7 +81,7 @@ impl Default for ProcessesState {
#[derive(Debug, Clone)]
pub enum ProcessesMessage {
Refreshed(ProcessesState),
- /// The row's ✕ button: SIGTERM this pid.
+ /// The row's Kill button (the `x` glyph): SIGTERM this pid.
Kill(String),
/// A column header click. Mem headers toggle (Mem ⇄ back to Cpu); the
/// CPU % header always selects Cpu.
@@ -305,35 +305,46 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
// 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 ▾. Both memory headers toggle the same
+ // 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 };
- let mark = |label: &str, on: bool| {
- if on { format!("{} \u{25bc}", label) } else { label.to_string() }
- };
+ 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);
- sec.pc.text("PID", list_box_x + COL_PID - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
- sec.pc.text("COMMAND", list_box_x + COL_COMMAND - ox, list_box_y + 5.0, 11.0, TEXT_DIM);
- sec.pc.text(&mark("MEM", active(ProcSort::Mem)), list_box_x + COL_RSS - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
- sec.pc.text("MEM %", list_box_x + COL_MEM - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Mem)));
- sec.pc.text(&mark("CPU %", active(ProcSort::Cpu)), list_box_x + COL_CPU - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Cpu)));
- sec.pc.text(&mark("W", active(ProcSort::Power)), list_box_x + COL_WATTS - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Power)));
- sec.pc.text(&mark("WAKE/s", active(ProcSort::Wakeups)), list_box_x + COL_WAKE - ox, list_box_y + 5.0, 11.0, hdr(active(ProcSort::Wakeups)));
-
- // Invisible header hit targets (transparent, subtle 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.button("", list_box_x + COL_RSS - ox - 4.0, list_box_y, 52.0, header_h - 2.0,
- [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
- sec.pc.button("", list_box_x + COL_MEM - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
- [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Mem)));
- sec.pc.button("", list_box_x + COL_CPU - ox - 4.0, list_box_y, 58.0, header_h - 2.0,
- [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Cpu)));
- sec.pc.button("", list_box_x + COL_WATTS - ox - 4.0, list_box_y, 48.0, header_h - 2.0,
- [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Power)));
- sec.pc.button("", list_box_x + COL_WAKE - ox - 4.0, list_box_y, 62.0, header_h - 2.0,
- [0.0; 4], [1.0, 1.0, 1.0, 0.05], [0.0; 4], AppAction::Processes(ProcessesMessage::SortBy(ProcSort::Wakeups)));
+ 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;
@@ -342,18 +353,16 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
sec.pc.push_clip_rect(list_box_x, list_box_y + header_h, list_box_w, list_box_h - header_h);
for (idx, p) in state.processes.iter().enumerate() {
if let Some(draw_y) = state.cpu_list.get_item_draw_y(idx, 4.0) {
- // Standard row action button (transparent background, highlights
- // on hover). Viewport-fixed on purpose: the hover band spans the
- // visible row whatever the horizontal pan.
- sec.pc.button(
- "",
+ // 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,
- [0.0, 0.0, 0.0, 0.0],
- [1.0, 1.0, 1.0, 0.06],
- [0.0, 0.0, 0.0, 0.0],
AppAction::Processes(ProcessesMessage::None),
);
@@ -377,10 +386,13 @@ pub fn view(state: &mut ProcessesState, cx: f32, cy: f32, cw: f32, ch: f32, root
// 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
- // ✕ beats the row's no-op exactly because it comes later.
+ // the Kill button beats the row's no-op exactly because it comes later.
if !dim {
- sec.pc.button(
- "\u{00d7}",
+ // The `x` glyph in the danger tint; "Kill" only if
+ // the icon set is missing.
+ sec.pc.button_icon_tinted(
+ "x",
+ "Kill",
list_box_x + COL_KILL - ox,
draw_y + 3.0,
20.0,
@@ -432,7 +444,7 @@ pub fn update(state: &mut ProcessesState, msg: ProcessesMessage) {
}
ProcessesMessage::Kill(pid) => {
// Plain SIGTERM, same privileges as the app. No confirm dialog:
- // the target is a small ✕ the pointer has to mean. Failure needs
+ // the target is a small `x` glyph the pointer has to mean. Failure needs
// no channel — a survivor un-dims on the next 3s refresh.
// Parsed, not passed through: `kill 0` signals the whole process
// group (this app included), and negative pids kill groups too.
@@ -556,10 +568,10 @@ mod tests {
.filter(|(_, (_, a, _))| matches!(a, AppAction::Processes(ProcessesMessage::Kill(_))))
.map(|(i, _)| i)
.collect();
- // One ✕ per row except the pending one (pid 2).
+ // One Kill button per row except the pending one (pid 2).
assert_eq!(kills.len(), 2, "{:?}", pc.buttons.iter().map(|(_, a, _)| a).collect::<Vec<_>>());
- // Ordering invariant the dispatch relies on: each ✕ comes after its
- // row's hover button — last take_click wins, so ✕ must be later.
+ // Ordering invariant the dispatch relies on: each Kill comes after
+ // its row's hover button — last take_click wins, so Kill must be later.
let rows: Vec<usize> = pc
.buttons
.iter()
@@ -665,8 +677,15 @@ mod tests {
.collect();
// MEM + MEM % both toggle Mem; CPU % selects Cpu; then W and WAKE/s.
assert_eq!(sorts, [ProcSort::Mem, ProcSort::Mem, ProcSort::Cpu, ProcSort::Power, ProcSort::Wakeups]);
- // Active-sort indicator rides the CPU % header by default.
- assert!(pc.texts.iter().any(|t| t.0.starts_with("CPU %") && t.0.contains('\u{25bc}')));
+ // Active-sort indicator rides the CPU % header by default — as a
+ // `chevron-down` glyph after the header text, never a character in it.
+ assert!(pc.texts.iter().any(|t| t.0 == "CPU %"));
+ assert!(pc.texts.iter().all(|t| !t.0.contains('\u{25bc}')));
+ if cce_ui::upload_icon("chevron-down", 18).is_some() {
+ let cpu = pc.texts.iter().find(|t| t.0 == "CPU %").unwrap();
+ assert_eq!(pc.icons.len(), 1, "one sort chevron");
+ assert!(pc.icons[0].x > cpu.2, "the chevron follows the header text");
+ }
}
#[test]
diff --git a/src/pages/services.rs b/src/pages/services.rs
index 6274b7a..dc40362 100644
--- a/src/pages/services.rs
+++ b/src/pages/services.rs
@@ -167,11 +167,7 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
// 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_w, r_btn_w) = match (icons_ok, is_small) {
- (true, _) => (24.0, 24.0),
- (false, true) => (24.0, 24.0),
- (false, false) => (46.0, 54.0),
- };
+ let (btn_w, r_btn_w) = if icons_ok { (24.0, 24.0) } 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
@@ -238,13 +234,11 @@ pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root
let running = transport_running(&service.active_state, &service.sub_state);
// Fallback labels only — an icon face never draws them.
- // Without the icons a narrow row is back to needing the
- // one-glyph words it used before.
- let (start_lbl, stop_lbl, restart_lbl) = if icons_ok || !is_small {
- ("Start", "Stop", "Restart")
- } else {
- ("\u{25b6}", "\u{25a0}", "\u{27f3}")
- };
+ // 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(
diff --git a/src/renderer.rs b/src/renderer.rs
index 2d55fb8..d4d5db9 100644
--- a/src/renderer.rs
+++ b/src/renderer.rs
@@ -124,6 +124,7 @@ impl SystemInterface {
let mut texts = Vec::new();
let mut page_buttons = Vec::new();
let mut page_button_images: Vec<(u32, f32, f32, f32, f32, f32)> = Vec::new();
+ let mut page_icon_images: Vec<crate::PlacedIcon> = Vec::new();
cce_ui::widget::hover_animation::reset_frame_registration();
self.ui_context.clear_popovers();
@@ -170,6 +171,15 @@ impl SystemInterface {
// root plate container dissolved, hand it the plate frame for its concentric-corner cut.
self.page_dropdown.set_corner_frame(Some(((0.0, 0.0, logical_sw, logical_sh), 12.0, (true, true, true, true))));
cce_ui::layout::render_widget(&mut dummy_pc, &mut self.page_dropdown, dropdown_x, dropdown_y, dropdown_w, dropdown_h, &mut self.ui_context);
+ // The chrome is collected below through `all_quads` and the text walk,
+ // which carry no images — so the closed dropdown's `chevron-down`
+ // comes from this pass, which `render_widget` hands to
+ // `RenderTarget::icon`. Window coordinates: no scroll applies.
+ let window_icon_images: Vec<crate::PlacedIcon> = dummy_pc
+ .icons
+ .iter()
+ .map(|ic| crate::PlacedIcon { image: ic.image, x: ic.x, y: ic.y, w: ic.w, h: ic.h, alpha: ic.alpha, clip: ic.clip })
+ .collect();
let switcher_h = if self.search_open {
logical_sh - self.header_height - 42.0 - self.status_height
} else {
@@ -485,8 +495,14 @@ impl SystemInterface {
}
for (btn, action, clip) in &pc.buttons {
let base = btn.base();
- let bg = btn.bg.unwrap_or([0.16, 0.16, 0.24, 1.0]);
- let hover_bg = btn.hover_bg.unwrap_or([0.25, 0.30, 0.26, 1.0]);
+ // The button's own idle and hover faces: the page's colours when
+ // it passed them, else the toolkit's for the button's kind — a
+ // list row's transparent-until-hover wash (`PageContent::list_row`).
+ let mut probe = (**btn).clone();
+ probe.set_hovered(false);
+ let bg = cce_ui::widget::Paint::color(&probe);
+ probe.set_hovered(true);
+ let hover_bg = cce_ui::widget::Paint::color(&probe);
// Clamp to the emission-time clip rect (page coords) so a partially
// scrolled list row's button draws cut at the list edge, not bleeding.
let (mut px0, mut py0, mut px1, mut py1) =
@@ -656,6 +672,23 @@ impl SystemInterface {
page_buttons.push((btn_clone, action.clone()));
}
+ // The page's own glyphs (`PageContent::icon`): shifted by the scroll
+ // as the texts are, cut at the clip they were placed under (a list's
+ // box) by display_list, and dimmed with the text a search leaves
+ // unmatched.
+ let searching = self.search_open && !self.search_query.is_empty();
+ for ic in &pc.icons {
+ page_icon_images.push(crate::PlacedIcon {
+ image: ic.image,
+ x: ic.x,
+ y: ic.y - scroll_offset_y,
+ w: ic.w,
+ h: ic.h,
+ alpha: if searching { ic.alpha * 0.25 } else { ic.alpha },
+ clip: ic.clip.map(|[x, y, w, h]| [x, y - scroll_offset_y, w, h]),
+ });
+ }
+
@@ -713,47 +746,23 @@ impl SystemInterface {
let shifted = bounds.map(|[l, tb, rr, b]| [l, tb - self.scroll_y, rr, b - self.scroll_y]);
popover_pc.texts.push((t, size, x, y - self.scroll_y, tc, font, shifted));
}
+ for ic in page_pop_pc.icons {
+ popover_pc.icons.push(cce_settings::app::PageIcon {
+ y: ic.y - self.scroll_y,
+ clip: ic.clip.map(|[x, y, w, h]| [x, y - self.scroll_y, w, h]),
+ ..ic
+ });
+ }
for (carve, mark) in page_pop_pc.control_reliefs.into_iter().zip(page_pop_pc.control_relief_marks) {
popover_pc.control_reliefs.push(carve.shifted_y(-self.scroll_y));
popover_pc.control_relief_marks.push(rect_base + mark);
}
}
- if cce_ui::widget::context_menu::is_visible() {
- use cce_ui::layout::RenderTarget;
- let cx = cce_ui::widget::context_menu::x();
- let cy = cce_ui::widget::context_menu::y();
- let cw = cce_ui::widget::context_menu::w();
- let ch = cce_ui::widget::context_menu::h();
-
- // This target is the legacy rect/text one, not a PaintCtx, so the
- // plate cannot be the toolkit's lit one yet — but the rows and
- // labels are the toolkit's: PAD-aware `row_y` / `text_labels`
- // (the hand-rolled `idx * 24.0` painted every row 8px above
- // where `cursor_moved` hit-tested it) and the menu font's family.
- use cce_ui::widget::context_menu::{self, ROW_H};
- popover_pc.rect([0.22, 0.22, 0.28, 1.0], cx, cy, cw, ch);
- popover_pc.rect([0.06, 0.06, 0.09, 1.0], cx + 1.0, cy + 1.0, cw - 2.0, ch - 2.0);
-
- if let Some(h_idx) = context_menu::hovered_item() {
- let iy = context_menu::row_y(h_idx);
- popover_pc.rect([0.20, 0.40, 0.65, 0.6], cx + 2.0, iy + 2.0, cw - 4.0, ROW_H - 4.0);
- }
-
- let (family, _) = context_menu::label_font();
- for label in context_menu::text_labels() {
- let c = label.color;
- let color = [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0];
- popover_pc.text_with_font_and_bounds(
- &label.text,
- label.x,
- label.y,
- label.font_size,
- color,
- &family,
- Some([cx, cy, cx + cw, cy + ch]),
- );
- }
- }
+ // The context menu is NOT collected here: display_list paints it
+ // straight into the frame with `context_menu::paint_with_labels`,
+ // the toolkit's lit plate and its labels in the menu font — and its
+ // marks (✓ ● ○) and page chevrons as cce-icons glyphs, which the
+ // bare `text_labels()` this used to loop over cannot draw.
self.popover_widgets = popover_pc.rects.iter().map(|(c, x, y, w, h, r, corners)| AppWidget {
x: *x, y: *y, w: *w, h: *h,
color: *c, hover_color: *c,
@@ -762,6 +771,11 @@ impl SystemInterface {
corners: *corners,
}).collect();
self.popover_texts = popover_pc.texts;
+ self.popover_icon_images = popover_pc
+ .icons
+ .iter()
+ .map(|ic| crate::PlacedIcon { image: ic.image, x: ic.x, y: ic.y, w: ic.w, h: ic.h, alpha: ic.alpha, clip: ic.clip })
+ .collect();
self.popover_control_reliefs = popover_pc.control_reliefs;
self.popover_control_relief_marks = popover_pc.control_relief_marks;
@@ -769,6 +783,8 @@ impl SystemInterface {
self.texts = texts;
self.page_buttons = page_buttons;
self.page_button_images = page_button_images;
+ self.page_icon_images = page_icon_images;
+ self.window_icon_images = window_icon_images;
// The id-rooted router (`propagate_event(event, WidgetId)`) resolves roots
// through the registry, and `clear_hierarchy` above wiped it. The view pass