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

src/app.rs (25.8K)

  1 use cce_ui::layout::RenderTarget;
  2 
  3 use crate::pages::audio;
  4 use crate::pages::bluetooth;
  5 use crate::pages::power;
  6 use crate::pages::default_apps;
  7 use crate::pages::network;
  8 use crate::pages::processes;
  9 use crate::pages::services;
 10 use crate::pages::system_info;
 11 use crate::pages::timers;
 12 use crate::pages::storage;
 13 use crate::pages::accounts;
 14 use crate::pages::packages;
 15 use crate::pages::notifications;
 16 use crate::pages::browser;
 17 use crate::pages::Page;
 18 
 19 pub struct AppState {
 20     pub current_page: Page,
 21     pub audio: audio::AudioState,
 22     pub bluetooth: bluetooth::BluetoothState,
 23     pub power: power::PowerState,
 24     pub browser: browser::BrowserState,
 25     pub default_apps: default_apps::DefaultAppsState,
 26     pub network: network::NetworkState,
 27     pub processes: processes::ProcessesState,
 28     pub services: services::ServicesState,
 29     pub system_info: system_info::SystemState,
 30     pub timers: timers::TimersState,
 31     pub storage: storage::StorageState,
 32     pub accounts: accounts::AccountsState,
 33     pub packages: packages::PackagesState,
 34     pub notifications: notifications::NotificationsState,
 35 }
 36 
 37 impl AppState {
 38     /// Every page's state, their widgets inserted into `ctx`.
 39     pub fn new(ctx: &mut cce_ui::context::UiContext) -> Self {
 40         Self {
 41             current_page: Page::ALL[0],
 42             audio: audio::AudioState::default(),
 43             bluetooth: bluetooth::BluetoothState::new(ctx),
 44             power: power::PowerState::new(ctx),
 45             browser: browser::BrowserState::new(ctx),
 46             default_apps: default_apps::DefaultAppsState::new(ctx),
 47             network: network::NetworkState::new(ctx),
 48             processes: processes::ProcessesState::default(),
 49             services: services::ServicesState::new(ctx),
 50             system_info: system_info::SystemState::new(ctx),
 51             timers: timers::TimersState::new(ctx),
 52             storage: storage::StorageState::new(ctx),
 53             accounts: accounts::AccountsState::new(ctx),
 54             packages: packages::PackagesState::new(ctx),
 55             notifications: notifications::NotificationsState::new(ctx),
 56         }
 57     }
 58 }
 59 
 60 impl AppState {
 61     pub fn get_page(&self, page: Page) -> &dyn crate::pages::AppPage {
 62         match page {
 63             Page::Accounts => &self.accounts,
 64             Page::Audio => &self.audio,
 65             Page::Bluetooth => &self.bluetooth,
 66             Page::Power => &self.power,
 67             Page::Browser => &self.browser,
 68             Page::DefaultApps => &self.default_apps,
 69             Page::Packages => &self.packages,
 70             Page::Processes => &self.processes,
 71             Page::Services => &self.services,
 72             Page::Network => &self.network,
 73             Page::Storage => &self.storage,
 74             Page::System => &self.system_info,
 75             Page::Timers => &self.timers,
 76             Page::Notifications => &self.notifications,
 77         }
 78     }
 79 
 80     pub fn get_page_mut(&mut self, page: Page) -> &mut dyn crate::pages::AppPage {
 81         match page {
 82             Page::Accounts => &mut self.accounts,
 83             Page::Audio => &mut self.audio,
 84             Page::Bluetooth => &mut self.bluetooth,
 85             Page::Power => &mut self.power,
 86             Page::Browser => &mut self.browser,
 87             Page::DefaultApps => &mut self.default_apps,
 88             Page::Packages => &mut self.packages,
 89             Page::Processes => &mut self.processes,
 90             Page::Services => &mut self.services,
 91             Page::Network => &mut self.network,
 92             Page::Storage => &mut self.storage,
 93             Page::System => &mut self.system_info,
 94             Page::Timers => &mut self.timers,
 95             Page::Notifications => &mut self.notifications,
 96         }
 97     }
 98 
 99     pub fn get_current_page(&self) -> &dyn crate::pages::AppPage {
100         self.get_page(self.current_page)
101     }
102 
103     pub fn get_current_page_mut(&mut self) -> &mut dyn crate::pages::AppPage {
104         self.get_page_mut(self.current_page)
105     }
106 }
107 
108 #[derive(Debug, Clone)]
109 pub enum AppAction {
110     Exit,
111     /// A page worker sent a snapshot; the next tick drains it. Carries
112     /// nothing and rebuilds nothing by itself.
113     Wake,
114     Audio(audio::AudioMessage),
115     Browser(browser::BrowserMessage),
116     DefaultApps(default_apps::DefaultAppsMessage),
117     Network(network::NetworkMessage),
118     Bluetooth(bluetooth::BluetoothMessage),
119     Power(power::PowerMessage),
120     Processes(processes::ProcessesMessage),
121     Services(services::ServicesMessage),
122     SystemInfo(system_info::SystemMessage),
123     Timers(timers::TimersMessage),
124     Storage(storage::StorageMessage),
125     Accounts(accounts::AccountsMessage),
126     Packages(packages::PackagesMessage),
127     Notifications(notifications::NotificationsMessage),
128 }
129 
130 
131 /// A control carve bridged out of a widget's `paint` for the flat path — the
132 /// relief prims the toolkit's controls draw for themselves and the legacy
133 /// `all_quads` stream cannot carry. The page collects them and `display_list`
134 /// re-emits them as real prims into the window plate.
135 #[derive(Clone, Copy, Debug, PartialEq)]
136 pub enum ControlCarve {
137     /// `PaintCtx::inset_plate` — a flush inset face over a boundary seam
138     /// (Dropdown, Button). `color` fills the face; transparent leaves the
139     /// plate below.
140     Plate { x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32, color: [f32; 4], tint: Option<[f32; 3]> },
141     /// A step carve straight from the toolkit (TextBox well, a Toggle's well
142     /// and glider) — it already carries its own rect, per-corner radii,
143     /// depth and wall mask, so nothing here re-derives them.
144     Step(cce_ui::layout::ReliefCarve),
145 }
146 
147 impl ControlCarve {
148     /// This carve shifted vertically — the page-scroll adjustment the popover
149     /// collector applies when it lifts page carves to the popover layer.
150     pub fn shifted_y(self, dy: f32) -> Self {
151         match self {
152             Self::Plate { x, y, w, h, radius, depth, color, tint } => {
153                 Self::Plate { x, y: y + dy, w, h, radius, depth, color, tint }
154             }
155             Self::Step(c) => Self::Step(c.shifted_y(dy)),
156         }
157     }
158 }
159 
160 /// A bundled cce-icons glyph a page placed on its own (a sort chevron, a
161 /// row's in-use check) rather than as a button's face: the tinted upload's
162 /// image id, its rect and alpha, and the innermost clip at emission time —
163 /// all page coordinates, pre-scroll, like `texts`.
164 #[derive(Clone, Copy, Debug, PartialEq)]
165 pub struct PageIcon {
166     pub image: u32,
167     pub x: f32,
168     pub y: f32,
169     pub w: f32,
170     pub h: f32,
171     pub alpha: f32,
172     pub clip: Option<[f32; 4]>,
173 }
174 
175 /// The width `text` takes at `size` in `font` (None = the default UI face,
176 /// what `PageContent::text` draws in), shaped as the renderer shapes it —
177 /// for placing a glyph right after a run of text.
178 pub fn text_width(text: &str, size: f32, font: Option<&str>) -> f32 {
179     cce_ui::geometry_font_system()
180         .lock()
181         .ok()
182         .and_then(|mut fs| {
183             cce_ui::backend::text::shaped_cluster_offsets(&mut fs, text, size, font)
184                 .last()
185                 .map(|&(_, total)| total)
186         })
187         .unwrap_or(0.0)
188 }
189 
190 /// The y a label of `size` in `font` takes to sit centred in a band of
191 /// height `h` at `y` — the renderer's own centring of a button label, so a
192 /// run placed beside one shares its line.
193 pub fn label_y_in(y: f32, h: f32, size: f32, font: Option<&str>) -> f32 {
194     let lh = cce_ui::geometry_font_system()
195         .lock()
196         .map(|mut fs| cce_ui::backend::get_text_buffer(&mut fs, "Ag", size, font).metrics().line_height)
197         .unwrap_or(size * 1.2)
198         / cce_ui::scale::scale_factor();
199     y + (h - lh) / 2.0
200 }
201 
202 pub struct PageContent {
203     pub rects: Vec<([f32; 4], f32, f32, f32, f32, f32, (bool, bool, bool, bool))>,
204     pub texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
205     /// (button, action, clip): clip is the innermost push_clip_rect at emission
206     /// time (page coords) — the renderer clamps the drawn quad, label bounds,
207     /// and the dispatch clone's hit rect to it.
208     pub buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, AppAction, Option<[f32; 4]>)>,
209     /// Glyphs placed with [`PageContent::icon`] (and a widget's own, through
210     /// `RenderTarget::icon` — the expanded Dropdown's chevron).
211     pub icons: Vec<PageIcon>,
212     /// Section wells claimed via `RenderTarget::section_relief` — the body box
213     /// plus the title tab box, carved into the window plate by display_list as
214     /// recess prims (page coordinates, pre-scroll).
215     pub reliefs: Vec<((f32, f32, f32, f32), Option<(f32, f32, f32, f32)>)>,
216     /// Control carves claimed via the flat-path relief hooks
217     /// (`RenderTarget::inset_plate` for a Dropdown's flush inset chrome,
218     /// `RenderTarget::recess` for a TextBox's well) — (x, y, w, h, radius,
219     /// depth, carve), page coordinates, pre-scroll; display_list re-emits them
220     /// as real relief prims.
221     pub control_reliefs: Vec<ControlCarve>,
222     /// Per carve, `rects.len()` at the moment it was claimed — the carve's
223     /// place in the widget's own emission order. A Dropdown draws its
224     /// hovered-row highlight AFTER the inset plate it claims for the menu
225     /// face; replaying every rect and then every carve put the frosted plate
226     /// over the highlight. The popover layer interleaves on these marks.
227     pub control_relief_marks: Vec<usize>,
228     pub clip_stack: Vec<[f32; 4]>,
229 }
230 
231 impl PageContent {
232     pub fn new() -> Self {
233         Self {
234             rects: Vec::new(),
235             texts: Vec::new(),
236             buttons: Vec::new(),
237             icons: Vec::new(),
238             reliefs: Vec::new(),
239             control_reliefs: Vec::new(),
240             control_relief_marks: Vec::new(),
241             clip_stack: Vec::new(),
242         }
243     }
244 
245     fn get_clipped_rect(&self, x: f32, y: f32, w: f32, h: f32) -> Option<(f32, f32, f32, f32)> {
246         if let Some(&clip) = self.clip_stack.last() {
247             let cx = clip[0];
248             let cy = clip[1];
249             let cw = clip[2];
250             let ch = clip[3];
251             let rx1 = x.max(cx);
252             let ry1 = y.max(cy);
253             let rx2 = (x + w).min(cx + cw);
254             let ry2 = (y + h).min(cy + ch);
255             if rx1 < rx2 && ry1 < ry2 {
256                 Some((rx1, ry1, rx2 - rx1, ry2 - ry1))
257             } else {
258                 None
259             }
260         } else {
261             Some((x, y, w, h))
262         }
263     }
264 
265     fn get_clipped_bounds(&self, bounds: Option<[f32; 4]>) -> Option<[f32; 4]> {
266         if let Some(&clip) = self.clip_stack.last() {
267             if let Some(b) = bounds {
268                 let rx1 = b[0].max(clip[0]);
269                 let ry1 = b[1].max(clip[1]);
270                 let rx2 = b[2].min(clip[0] + clip[2]);
271                 let ry2 = b[3].min(clip[1] + clip[3]);
272                 Some([rx1, ry1, rx2.max(rx1), ry2.max(ry1)])
273             } else {
274                 Some([clip[0], clip[1], clip[0] + clip[2], clip[1] + clip[3]])
275             }
276         } else {
277             bounds
278         }
279     }
280 
281     pub fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
282         if let Some((cx, cy, cw, ch)) = self.get_clipped_rect(x, y, w, h) {
283             self.rects.push((color, cx, cy, cw, ch, 0.0, (true, true, true, true)));
284         }
285     }
286 
287     pub fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
288         let cb = self.get_clipped_bounds(None);
289         self.texts.push((content.to_string(), size, x, y, color, None, cb));
290     }
291 
292     pub fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
293         let cb = self.get_clipped_bounds(None);
294         self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), cb));
295     }
296 
297     /// A bundled cce-icons glyph at (x, y, w, h), tinted `color` as a text
298     /// colour is (raw sRGB, alpha = the image's) — the ONE way a page draws a
299     /// symbol, never a character in whatever face the font falls back to.
300     /// Rasterized at twice the rect's longer side for a 2x output.
301     ///
302     /// `false` when the icon set lacks the glyph, so the caller can say it
303     /// in a WORD instead.
304     pub fn icon(&mut self, name: &str, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) -> bool {
305         let px = (w.max(h) * 2.0).ceil().max(1.0) as u32;
306         let Some((image, _, _)) = cce_ui::upload_icon_tinted(name, px, cce_ui::icon_tint(color)) else {
307             return false;
308         };
309         let clip = self.clip_stack.last().copied();
310         self.icons.push(PageIcon { image, x, y, w, h, alpha: color[3], clip });
311         true
312     }
313 
314     pub fn button(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32,
315                   bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4],
316                   action: AppAction) {
317         let btn = cce_ui::widget::Button::new(x, y, w, h)
318             .with_label(label)
319             .with_bg(bg)
320             .with_hover_bg(hover_bg)
321             .with_label_color(label_color);
322         let clip = self.clip_stack.last().copied();
323         self.buttons.push((btn, action, clip));
324     }
325 
326     /// A button whose face is a bundled cce-icons glyph instead of a label.
327     ///
328     /// `label` stays as the FALLBACK: `upload_icon` returns `None` when the
329     /// icon set is missing or unparsable, and a control that silently loses
330     /// its face has no affordance left at all — so the button degrades to the
331     /// word rather than to an empty box. `alpha` dims the glyph for a disabled
332     /// control, which is the only state lever an icon has (images carry no
333     /// color).
334     pub fn button_icon(&mut self, icon: &str, label: &str, x: f32, y: f32, w: f32, h: f32,
335                        bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4],
336                        alpha: f32, action: AppAction) {
337         let mut btn = cce_ui::widget::Button::new(x, y, w, h)
338             .with_label(label)
339             .with_bg(bg)
340             .with_hover_bg(hover_bg)
341             .with_label_color(label_color);
342         // 32px on the longer side: the toolkit caches the upload per (name, px),
343         // so every row's Start button shares one texture.
344         if let Some((id, iw, ih)) = cce_ui::upload_icon(icon, 32) {
345             btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(alpha);
346         }
347         let clip = self.clip_stack.last().copied();
348         self.buttons.push((btn, action, clip));
349     }
350 
351     /// A row of a list, in the toolkit's list style (`Button::new_list_row`,
352     /// what cce-mail, cce-fonts and cce-calendar rows wear): no plate,
353     /// transparent until hovered, the shared row wash. A plain [`button`]
354     /// with a transparent face is NOT that — under `control_relief` it still
355     /// gets a control plate, edges only, so every idle row of a list wore a
356     /// carved ring. No colours to pass: the renderer asks the row for its own.
357     ///
358     /// [`button`]: PageContent::button
359     pub fn list_row(&mut self, x: f32, y: f32, w: f32, h: f32, action: AppAction) {
360         let btn = cce_ui::widget::Button::new_list_row(x, y, w, h);
361         let clip = self.clip_stack.last().copied();
362         self.buttons.push((btn, action, clip));
363     }
364 
365     /// [`button_icon`](Self::button_icon) with the glyph tinted
366     /// `label_color` (its alpha the glyph's), where the face's colour is part
367     /// of what the control says — the process list's red Kill. The white
368     /// glyph `button_icon` uploads cannot carry a colour: an image has none.
369     pub fn button_icon_tinted(&mut self, icon: &str, label: &str, x: f32, y: f32, w: f32, h: f32,
370                               bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4],
371                               action: AppAction) {
372         let mut btn = cce_ui::widget::Button::new(x, y, w, h)
373             .with_label(label)
374             .with_bg(bg)
375             .with_hover_bg(hover_bg)
376             .with_label_color(label_color);
377         if let Some((id, iw, ih)) = cce_ui::upload_icon_tinted(icon, 32, cce_ui::icon_tint(label_color)) {
378             btn = btn.with_icon(id, iw as f32, ih as f32).with_icon_alpha(label_color[3]);
379         }
380         let clip = self.clip_stack.last().copied();
381         self.buttons.push((btn, action, clip));
382     }
383 
384     pub fn button_left(&mut self, label: &str, x: f32, y: f32, w: f32, h: f32,
385                        bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4],
386                        action: AppAction) {
387         let btn = cce_ui::widget::Button::new(x, y, w, h)
388             .with_label(label)
389             .with_bg(bg)
390             .with_hover_bg(hover_bg)
391             .with_label_color(label_color)
392             .with_left_align(true);
393         let clip = self.clip_stack.last().copied();
394         self.buttons.push((btn, action, clip));
395     }
396 }
397 
398 impl RenderTarget for PageContent {
399     fn icon(&mut self, name: &str, rect: cce_ui::scene::layout::Rect, color: [f32; 4]) {
400         PageContent::icon(self, name, rect.x, rect.y, rect.width, rect.height, color);
401     }
402 
403     fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
404         if let Some((cx, cy, cw, ch)) = self.get_clipped_rect(x, y, w, h) {
405             self.rects.push((color, cx, cy, cw, ch, 0.0, (true, true, true, true)));
406         }
407     }
408 
409     fn rect_with_radius(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32) {
410         if let Some((cx, cy, cw, ch)) = self.get_clipped_rect(x, y, w, h) {
411             self.rects.push((color, cx, cy, cw, ch, radius, (true, true, true, true)));
412         }
413     }
414 
415     fn rect_with_radius_corners(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, corners: (bool, bool, bool, bool)) {
416         if let Some((cx, cy, cw, ch)) = self.get_clipped_rect(x, y, w, h) {
417             self.rects.push((color, cx, cy, cw, ch, radius, corners));
418         }
419     }
420 
421     fn text(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
422         let cb = self.get_clipped_bounds(None);
423         self.texts.push((content.to_string(), size, x, y, color, None, cb));
424     }
425 
426     fn text_with_font(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str) {
427         let cb = self.get_clipped_bounds(None);
428         self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), cb));
429     }
430 
431     fn text_with_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], bounds: Option<[f32; 4]>) {
432         let cb = self.get_clipped_bounds(bounds);
433         self.texts.push((content.to_string(), size, x, y, color, None, cb));
434     }
435 
436     fn text_with_font_and_bounds(&mut self, content: &str, x: f32, y: f32, size: f32, color: [f32; 4], font: &str, bounds: Option<[f32; 4]>) {
437         let cb = self.get_clipped_bounds(bounds);
438         self.texts.push((content.to_string(), size, x, y, color, Some(font.to_string()), cb));
439     }
440 
441     fn section_relief_style(&self) -> bool {
442         cce_ui::layout::control_relief()
443     }
444 
445     /// The flat-path bridge offers each Dropdown's flush inset trough here;
446     /// carve it for real (display_list turns these into inset plates).
447     fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32) {
448         self.control_relief_marks.push(self.rects.len());
449         self.control_reliefs.push(ControlCarve::Plate { x, y, w, h, radius, depth, color, tint: None });
450     }
451     fn inset_plate_tinted(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32, tint: [f32; 3]) {
452         self.control_reliefs.push(ControlCarve::Plate { x, y, w, h, radius, depth, color, tint: Some(tint) });
453     }
454 
455     /// The same bridge for the step carves — a DIFFERENT shape, not an inset
456     /// plate at another rect: a trough is a seam about the boundary with the
457     /// face left level, a well drops the whole interior, a boss raises it.
458     /// Collapsing them would give this app controls no other relief host has.
459     fn relief_carve(&mut self, carve: &cce_ui::layout::ReliefCarve) {
460         self.control_relief_marks.push(self.rects.len());
461         self.control_reliefs.push(ControlCarve::Step(*carve));
462     }
463 
464     fn section_relief(&mut self, f: &cce_ui::layout::SectionFrame) -> bool {
465         // Focused sections keep the legacy green outline (the ctrl-nav feedback);
466         // relief-off styling keeps the outline everywhere.
467         if f.focused || !cce_ui::layout::control_relief() {
468             return false;
469         }
470         self.reliefs.push(((f.x, f.y, f.w, f.h), f.tab));
471         true
472     }
473 
474     fn push_clip_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
475         let clip = if let Some(&parent_clip) = self.clip_stack.last() {
476             let cx = x.max(parent_clip[0]);
477             let cy = y.max(parent_clip[1]);
478             let cw = (x + w).min(parent_clip[0] + parent_clip[2]) - cx;
479             let ch = (y + h).min(parent_clip[1] + parent_clip[3]) - cy;
480             [cx, cy, cw.max(0.0), ch.max(0.0)]
481         } else {
482             [x, y, w, h]
483         };
484         self.clip_stack.push(clip);
485     }
486 
487     fn pop_clip_rect(&mut self) {
488         self.clip_stack.pop();
489     }
490 }
491 
492 
493 /// Width a label needs on a button plate. Feeds [`form_button_fit`], so a row of
494 /// buttons is divided by what is written on them rather than into equal
495 /// slices — "Reboot" and "Hibernate" are not the same size and a row that
496 /// pretends otherwise clips one and pads the other.
497 ///
498 /// Asks the Button itself (`intrinsic_size`: its label shaped in the button
499 /// font, plus its 8px inset each side) rather than measuring here. This used
500 /// `measure_text_width` in the control-label font, an inked extent that runs
501 /// ~20% short of the shaped run under Berkeley Mono, so the row handed every
502 /// button less than its label and "Hibernate" / "Power Off" were cut off.
503 pub fn button_need(label: &str) -> f32 {
504     cce_ui::widget::Button::new(0.0, 0.0, 0.0, 0.0)
505         .with_label(label)
506         .intrinsic_size()
507         .map_or(0.0, |s| s.width)
508 }
509 
510 /// A page button (`PageContent::button`) as a form piece, a button's height: `w` wide, or
511 /// the width of its column (and, in a row, the row's slack) when `w` is 0. `colors` are the
512 /// face, the hover face and the label.
513 pub fn form_button<'w>(
514     g: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
515     label: impl Into<String>,
516     w: f32,
517     colors: ([f32; 4], [f32; 4], [f32; 4]),
518     action: AppAction,
519 ) {
520     let label = label.into();
521     g.draw(w, cce_ui::layout::button_height(), w == 0.0, move |pc, r, _| {
522         pc.button(&label, r.x, r.y, r.width, r.height, colors.0, colors.1, colors.2, action);
523     });
524 }
525 
526 /// A page button as a row cell that asks for `need` (its label's own width) and shares the
527 /// row's slack with the other cells — a row of buttons sized to their labels.
528 pub fn form_button_fit<'w>(
529     g: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
530     label: impl Into<String>,
531     need: f32,
532     colors: ([f32; 4], [f32; 4], [f32; 4]),
533     action: AppAction,
534 ) {
535     let label = label.into();
536     g.draw(need, cce_ui::layout::button_height(), true, move |pc, r, _| {
537         pc.button(&label, r.x, r.y, r.width, r.height, colors.0, colors.1, colors.2, action);
538     });
539 }
540 
541 /// `text` broken into lines no wider than `width` at `size`, measured as the
542 /// renderer shapes it (the default UI face, as `PageContent::text` draws).
543 /// Breaks after a space or before a `/`, so a path splits on its separators;
544 /// a run with neither is cut wherever it overflows. A section's text is
545 /// clipped to its content box, so anything that does not fit has to wrap or
546 /// it is simply lost — a GPU name, the CPU model, a pending install path.
547 pub fn wrap_to_width(text: &str, width: f32, size: f32) -> Vec<String> {
548     let offsets = cce_ui::geometry_font_system()
549         .lock()
550         .map(|mut fs| cce_ui::backend::text::shaped_cluster_offsets(&mut fs, text, size, None))
551         .unwrap_or_default();
552     if offsets.last().map_or(true, |&(_, total)| total <= width) {
553         return vec![text.to_string()];
554     }
555     let mut lines = Vec::new();
556     let (mut start, mut start_x) = (0usize, 0.0f32);
557     // Where the next line would begin if this one broke at the last chance.
558     let mut chance: Option<(usize, f32)> = None;
559     for pair in offsets.windows(2) {
560         let ((b, x), (next_b, next_x)) = (pair[0], pair[1]);
561         if next_x - start_x > width && b > start {
562             let (cut, cut_x) = chance.filter(|&(c, _)| c > start).unwrap_or((b, x));
563             lines.push(text[start..cut].trim_end().to_string());
564             (start, start_x, chance) = (cut, cut_x, None);
565         }
566         match &text[b..next_b] {
567             " " => chance = Some((next_b, next_x)),
568             "/" if b > start => chance = Some((b, x)),
569             _ => {}
570         }
571     }
572     lines.push(text[start..].trim_end().to_string());
573     lines.retain(|l| !l.is_empty());
574     lines
575 }
576 
577 /// Label / value pairs as one block of text: the labels in a column as wide as the widest of
578 /// them, each value beside its label and wrapped to the room left of the content box, the
579 /// label on the value's first line. Each pair is (label, label colour, value, value colour).
580 pub fn form_pairs<'w>(
581     col: &mut cce_ui::layout::FormGroup<'_, 'w, PageContent>,
582     size: f32,
583     pairs: Vec<(String, [f32; 4], String, [f32; 4])>,
584 ) {
585     use cce_ui::scene::layout::{CrossAlign, Style};
586     let label_w = pairs.iter().map(|p| cce_ui::layout::form_text_width(&p.0, size)).fold(0.0, f32::max);
587     let line_h = cce_ui::layout::form_line_height(size);
588     let value_w = (col.form_width() - label_w - cce_ui::layout::control_gap()).max(1.0);
589     // Lines of text, not controls: they stand a line apart, with no control gap between.
590     col.group(Style::column().cross_align(CrossAlign::Stretch), false, |lines| {
591         for (label, label_color, value, value_color) in pairs {
592             lines.group(Style::controls_row(), true, |row| {
593                 row.draw(label_w, line_h, false, move |pc, r, _| pc.text(&label, r.x, r.y, size, label_color));
594                 row.lines(wrap_to_width(&value, value_w, size), size, value_color);
595             });
596         }
597     });
598 }
599 
600 /// The hairline that parts a well's zones, as a form piece.
601 pub fn form_divider(g: &mut cce_ui::layout::FormGroup<'_, '_, PageContent>) {
602     g.rule([1.0, 1.0, 1.0, 0.06]);
603 }
604