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

src/main.rs (55.5K)

   1 use cce_ui::widget::Handle;
   2 use cce_ui::widget::hover_animation;
   3 use cce_ui::cosmic_text::{Buffer, FontSystem};
   4 
   5 use cce_settings::app::{AppAction, AppState, ControlCarve};
   6 use cce_settings::pages::{self, Page};
   7 mod input_handler;
   8 mod renderer;
   9 mod scroll_bar;
  10 
  11 fn make_text_buffer_with_font(
  12     fs: &mut FontSystem,
  13     text: &str,
  14     size: f32,
  15     font: Option<&str>,
  16     _sans_fallback: &str,
  17     _serif_fallback: &str,
  18     _mono_fallback: &str,
  19 ) -> Buffer {
  20     cce_ui::backend::get_text_buffer(fs, text, size, font)
  21 }
  22 
  23 #[allow(dead_code)]
  24 struct AppWidget {
  25     x: f32, y: f32, w: f32, h: f32,
  26     color: [f32; 4],
  27     hover_color: [f32; 4],
  28     hovering: bool,
  29     radius: f32,
  30     corners: (bool, bool, bool, bool),
  31 }
  32 
  33 /// A cce-icons glyph ready to draw: window coordinates (scroll already
  34 /// applied), the image's alpha, and the clip it was placed under
  35 /// (`[x, y, w, h]`) — an image has no geometry to trim, only a clip.
  36 #[derive(Clone, Copy, Debug)]
  37 pub(crate) struct PlacedIcon {
  38     pub image: u32,
  39     pub x: f32,
  40     pub y: f32,
  41     pub w: f32,
  42     pub h: f32,
  43     pub alpha: f32,
  44     pub clip: Option<[f32; 4]>,
  45 }
  46 
  47 fn draw_placed_icon(pc: &mut cce_ui::scene::paint::PaintCtx, icon: &PlacedIcon) {
  48     use cce_ui::scene::layout::Rect;
  49     let rect = Rect { x: icon.x, y: icon.y, width: icon.w, height: icon.h };
  50     match icon.clip {
  51         Some([x, y, w, h]) => pc.clip(Rect { x, y, width: w, height: h }, |pc| pc.image(icon.image, rect, icon.alpha)),
  52         None => pc.image(icon.image, rect, icon.alpha),
  53     }
  54 }
  55 
  56 static INITIAL_PAGE_INDEX: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
  57 
  58 struct SystemInterface {
  59     app: AppState,
  60     font_system: FontSystem,
  61     widgets: Vec<AppWidget>,
  62     // (content, font_size, x, y, color, font, bounds) — the frame's text, emitted as
  63     // display-list Text prims (scroll shift, search dim/highlight, and viewport clamps
  64     // already applied by rebuild_layout).
  65     texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
  66     // Popover + context-menu content, drawn INTO the frame on top of everything (Phase 6t —
  67     // no engine xdg popup). Separate from widgets/texts so the wheel fast-path never
  68     // scrolls them.
  69     popover_widgets: Vec<AppWidget>,
  70     popover_texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
  71     /// Popover-layer inset plates (window coords, scroll already applied) —
  72     /// the dropdown's grown-trigger surface via the inset_plate hook.
  73     popover_control_reliefs: Vec<ControlCarve>,
  74     /// Per popover carve, the index into `popover_widgets` it precedes
  75     /// (`PageContent::control_relief_marks`) — display_list slots each carve
  76     /// back between the rects the widget drew before and after it.
  77     popover_control_relief_marks: Vec<usize>,
  78     page_buttons: Vec<(Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>, AppAction)>,
  79 
  80     sidebar_width: f32,
  81     header_height: f32,
  82     status_height: f32,
  83 
  84     cursor_x: f32,
  85     cursor_y: f32,
  86 
  87     rx_audio: std::sync::mpsc::Receiver<pages::audio::AudioState>,
  88     rx_network: std::sync::mpsc::Receiver<pages::network::NetworkState>,
  89     rx_bluetooth: std::sync::mpsc::Receiver<pages::bluetooth::BluetoothState>,
  90     rx_power: std::sync::mpsc::Receiver<pages::power::PowerFacts>,
  91     pub rx_processes: std::sync::mpsc::Receiver<pages::processes::ProcessesState>,
  92     rx_system: std::sync::mpsc::Receiver<pages::system_info::SystemInfo>,
  93     rx_storage: std::sync::mpsc::Receiver<pages::storage::StorageInfo>,
  94     rx_notifications: std::sync::mpsc::Receiver<pages::notifications::NotificationsConfig>,
  95     rx_browser: std::sync::mpsc::Receiver<pages::browser::BrowserConfig>,
  96     rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
  97     rx_default_apps: std::sync::mpsc::Receiver<pages::default_apps::DefaultAppsInfo>,
  98     rx_timers: std::sync::mpsc::Receiver<Vec<pages::timers::TimerInfo>>,
  99     rx_accounts: std::sync::mpsc::Receiver<pages::accounts::AccountsSnapshot>,
 100     tx_backup: std::sync::mpsc::Sender<pages::storage::StorageMessage>,
 101     rx_backup: std::sync::mpsc::Receiver<pages::storage::StorageMessage>,
 102     rx_packages: std::sync::mpsc::Receiver<pages::packages::PackagesState>,
 103     tx_update: std::sync::mpsc::Sender<pages::packages::PackagesMessage>,
 104     rx_update: std::sync::mpsc::Receiver<pages::packages::PackagesMessage>,
 105 
 106 
 107     scale_factor: f64,
 108     width: u32,
 109     height: u32,
 110     needs_rebuild: bool,
 111     /// The page's DRAWN offset — `page_scroll_motion` glides it (wheel) or
 112     /// coasts it (trackpad flick) by shifting the cached geometry in place;
 113     /// direct writes (thumb drag, keyboard, search jump, clamp) are adopted
 114     /// by the motion on its next step.
 115     scroll_y: f32,
 116     page_scroll_motion: cce_ui::widget::ScrollMotion,
 117     max_scroll_y: f32,
 118     scrollable_widgets_start_idx: usize,
 119     scrollable_text_items_start_idx: usize,
 120     scrollable_buttons_start_idx: usize,
 121     last_scroll_y: f32,
 122     // SectionContainer DISSOLVED (Phase 6w): section-level keyboard focus is this index
 123     // (single-slot with the global widget focus — descending clears it); the per-section
 124     // widget groups come from AppPage::section_widgets each time they're needed.
 125     focused_section: Option<usize>,
 126     // The page the last `rebuild_layout` actually laid out. Registration is a side effect
 127     // of the view pass (`render_widget`), and `clear_hierarchy` wipes the registry each
 128     // rebuild — so between a page switch and the next rebuild, the NEW page's
 129     // `section_widgets()` ids are not registered and every event to them is dropped with
 130     // a router warning. `dispatch_page_event` suppresses dispatch across that gap.
 131     laid_out_page: Option<Page>,
 132     /// The rect of the focused widget at the start of a view pass: the page's
 133     /// buttons are per-rebuild clones, so the focused id dies with every
 134     /// rebuild and the clone at the same rect takes the focus back (lit as it
 135     /// is collected, focused at the pass's tail).
 136     refocus_rect: Option<(f32, f32, f32, f32)>,
 137     /// What the last rebuild's widgets claimed for text input (an editing
 138     /// TextBox, `cce_ui::text_input::capture`). Claimed again every frame:
 139     /// a frame that only replays the cached layout paints no widget, and
 140     /// would otherwise read as the field closing.
 141     text_claim: Option<[f32; 4]>,
 142     page_dropdown: Handle<cce_ui::widget::Adapted<cce_ui::widget::input::Dropdown>>,
 143     // Switcher + Page DISSOLVED (Phase 6u): the current page is app.current_page, page
 144     // scroll is scroll_y/max_scroll_y, and the page scrollbar is this app-owned widget
 145     // (rendered into the window assembly, evented directly). content_h feeds it — the
 146     // window pass reads last frame's value, exactly as the legacy Page did.
 147     page_scroll_bar: Handle<cce_ui::widget::Adapted<crate::scroll_bar::ScrollBar>>,
 148     content_h: f32,
 149     // Section wells: body box + title tab (page coords, pre-scroll) — carved by
 150     // display_list.
 151     page_reliefs: Vec<((f32, f32, f32, f32), Option<(f32, f32, f32, f32)>)>,
 152     /// Per page carve, the index into `widgets` it precedes — see
 153     /// `popover_control_relief_marks`; the page layer interleaves the same way.
 154     page_control_relief_marks: Vec<usize>,
 155     /// Control troughs from `PageContent::control_reliefs` (page coords,
 156     /// pre-scroll) — each carved as a flush inset plate after the section
 157     /// wells — with the clip its control was emitted under (`[x, y, w, h]`,
 158     /// page coords, pre-scroll). A button's plate needs it: the renderer
 159     /// clamps the button's quad to its list, but a carve is drawn whole, so a
 160     /// row half-scrolled out of a list left its plate standing past the edge.
 161     page_control_reliefs: Vec<(ControlCarve, Option<[f32; 4]>)>,
 162     /// The page's glyphs, drawn under the page clip and each its own: a
 163     /// button's icon face, and the page's own (`PageContent::icon`: a sort
 164     /// chevron, an in-use check). A flat host draws no images at all
 165     /// otherwise: `all_quads` carries quads and the text list carries labels,
 166     /// and an icon is neither.
 167     page_icon_images: Vec<PlacedIcon>,
 168     /// Glyphs the popovers drew (the expanded Dropdown's chevron), drawn over
 169     /// the popover plates.
 170     popover_icon_images: Vec<PlacedIcon>,
 171     /// Glyphs of the window chrome (the page dropdown's arrow), window
 172     /// coordinates, drawn over the chrome outside the page clip.
 173     // root plate container + StatusBar DISSOLVED (Phase 6s): the window plate and the status
 174     // bar are emitted as tuples in rebuild_layout.
 175     sans_serif_family: String,
 176     serif_family: String,
 177     monospace_family: String,
 178     /// The page on screen, as the page workers see it (`watchers::PageWatch`).
 179     current_page_shared: tokio::sync::watch::Sender<u8>,
 180     /// A hash of the last snapshot each page worker sent, so an unchanged
 181     /// refresh is dropped instead of rebuilding the page (`fresh_snapshot`).
 182     seen_snapshots: std::collections::HashMap<&'static str, u64>,
 183     sender: calloop::channel::Sender<AppAction>,
 184     ui_context: cce_ui::context::UiContext,
 185     scroll_logs: Vec<String>,
 186     search_open: bool,
 187     search_query: String,
 188     search_box: Handle<cce_ui::widget::Adapted<cce_ui::widget::input::TextBox>>,
 189 
 190 }
 191 
 192 /// One collected control carve as the real relief prim it stands for.
 193 fn emit_control_carve(pc: &mut cce_ui::scene::paint::PaintCtx, carve: ControlCarve) {
 194     use cce_ui::scene::layout::Rect;
 195     match carve {
 196         ControlCarve::Plate { x, y, w, h, radius, depth, color, tint } => match tint {
 197             Some(t) => pc.inset_plate_tinted(
 198                 Rect { x, y, width: w, height: h },
 199                 (radius, radius, radius, radius),
 200                 cce_ui::scene::Material::face(color).as_ref(),
 201                 depth,
 202                 t,
 203             ),
 204             None => pc.inset_plate(
 205                 Rect { x, y, width: w, height: h },
 206                 (radius, radius, radius, radius),
 207                 cce_ui::scene::Material::face(color).as_ref(),
 208                 depth,
 209             ),
 210         },
 211         ControlCarve::Step(c) => pc.carve(&c),
 212     }
 213 }
 214 
 215 impl cce_ui::engine::Application for SystemInterface {
 216     type Message = AppAction;
 217 
 218     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 219         // The app keeps calloop's sender; `AppSender` converts into it.
 220         let sender: calloop::channel::Sender<Self::Message> = sender.into();
 221         cce_ui::scale::set_scale_factor(1.0);
 222         // The context owns the widgets; the app keeps their handles.
 223         let mut ui_context = cce_ui::context::UiContext::new();
 224         let app = AppState::new(&mut ui_context);
 225 
 226         // ── Background refresh channels ──
 227         let initial_page_idx = INITIAL_PAGE_INDEX.load(std::sync::atomic::Ordering::SeqCst);
 228         let (current_page_shared, page_rx) = tokio::sync::watch::channel(initial_page_idx as u8);
 229         let wake: cce_settings::watchers::Wake = {
 230             let sender = sender.clone();
 231             std::sync::Arc::new(move || {
 232                 let _ = sender.send(AppAction::Wake);
 233             })
 234         };
 235 
 236         let (watchers, tx_backup, rx_backup, tx_update, rx_update) =
 237             cce_settings::watchers::spawn_all(page_rx, wake);
 238 
 239         let (sans_family, serif_family, monospace_family, _) = cce_ui::layout::read_preferred_fonts();
 240 
 241         let pages_names = Page::ALL.iter().map(|p| p.label().to_string()).collect::<Vec<_>>();
 242         let page_dropdown = cce_ui::widget::input::Dropdown::new(pages_names, initial_page_idx)
 243             .with_open_upward(true)
 244             .with_auto_width(true)
 245             // The open menu is the page list alone, sat where the trigger was:
 246             // the current page already reads blue in the list, so the trigger
 247             // band repeating its title under the rows was noise.
 248             .with_menu_replaces_trigger(true);
 249         let sidebar_width = 0.0f32;
 250 
 251         let mut app_state = app;
 252         app_state.current_page = Page::ALL[initial_page_idx];
 253 
 254         let font_system = cce_ui::create_font_system();
 255 
 256         let search_box = ui_context.insert(
 257             cce_ui::widget::input::TextBox::new(String::new())
 258                 .with_placeholder("Search sections & parameters...")
 259                 .with_draw_bg_border(false),
 260         );
 261         let mut this = Self {
 262             app: app_state,
 263             font_system,
 264             widgets: Vec::new(),
 265             texts: Vec::new(),
 266             popover_widgets: Vec::new(),
 267             popover_texts: Vec::new(),
 268             popover_control_reliefs: Vec::new(),
 269             popover_control_relief_marks: Vec::new(),
 270             page_buttons: Vec::new(),
 271             sidebar_width,
 272             header_height: 0.0,
 273             // The page switcher at the toolkit's dropdown height, inset 3px
 274             // top and bottom: the bar grows with the control, not the reverse.
 275             status_height: cce_ui::layout::dropdown_height() + 6.0,
 276             cursor_x: 0.0,
 277             cursor_y: 0.0,
 278             rx_audio: watchers.rx_audio,
 279             rx_network: watchers.rx_network,
 280             rx_bluetooth: watchers.rx_bluetooth,
 281             rx_power: watchers.rx_power,
 282             rx_processes: watchers.rx_processes,
 283             rx_default_apps: watchers.rx_default_apps,
 284             rx_timers: watchers.rx_timers,
 285             rx_system: watchers.rx_system,
 286             rx_storage: watchers.rx_storage,
 287             rx_notifications: watchers.rx_notifications,
 288             rx_browser: watchers.rx_browser,
 289             rx_services: watchers.rx_services,
 290             rx_accounts: watchers.rx_accounts,
 291             tx_backup,
 292             rx_backup,
 293             rx_packages: watchers.rx_packages,
 294             tx_update,
 295             rx_update,
 296 
 297 
 298             scale_factor: 1.0,
 299             width: 820,
 300             height: 680,
 301             needs_rebuild: true,
 302             scroll_y: 0.0,
 303             page_scroll_motion: cce_ui::widget::ScrollMotion::new(),
 304             max_scroll_y: 0.0,
 305             scrollable_widgets_start_idx: 0,
 306             scrollable_text_items_start_idx: 0,
 307             scrollable_buttons_start_idx: 0,
 308             last_scroll_y: 0.0,
 309             focused_section: None,
 310             laid_out_page: None,
 311             refocus_rect: None,
 312             text_claim: None,
 313             page_dropdown: ui_context.insert(page_dropdown),
 314             page_scroll_bar: ui_context.insert(crate::scroll_bar::ScrollBar::new()),
 315             content_h: 0.0,
 316             page_reliefs: Vec::new(),
 317             page_control_reliefs: Vec::new(),
 318             page_control_relief_marks: Vec::new(),
 319             page_icon_images: Vec::new(),
 320             popover_icon_images: Vec::new(),
 321             sans_serif_family: sans_family,
 322             serif_family,
 323             monospace_family,
 324             current_page_shared,
 325             seen_snapshots: std::collections::HashMap::new(),
 326             sender,
 327             ui_context,
 328             scroll_logs: Vec::new(),
 329             search_open: false,
 330             search_query: String::new(),
 331             search_box,
 332         };
 333         this.app.system_info.sender = Some(this.sender.clone());
 334 
 335         this.rebuild_layout(820.0, 680.0);
 336         this.needs_rebuild = true;
 337         this
 338     }
 339 
 340     fn settings(&self) -> cce_ui::engine::WindowSettings {
 341         cce_ui::engine::WindowSettings {
 342             title: "CCE System Interface".to_string(),
 343             app_id: "cce-system-interface".to_string(),
 344             width: 820,
 345             height: 680,
 346             fullscreen: false,
 347             min_size: Some((400, 680)),
 348         }
 349     }
 350 
 351     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
 352         if let AppAction::Exit = msg {
 353             *exit = true;
 354             return;
 355         }
 356         // Wakes the loop so `tick` drains the workers' channels; whether
 357         // anything is redrawn is `poll_background_updates`' call.
 358         if let AppAction::Wake = msg {
 359             return;
 360         }
 361         self.handle_action(&msg);
 362         *needs_rebuild = true;
 363         self.needs_rebuild = true;
 364     }
 365 
 366     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
 367         if self.needs_rebuild {
 368             self.rebuild_layout(self.width as f32, self.height as f32);
 369         }
 370         self.poll_background_updates();
 371         if self.tick_internal(dt) {
 372             *needs_rebuild = true;
 373         }
 374         let mut actions = Vec::new();
 375         self.propagate_widget_changes(&mut actions);
 376         for action in actions {
 377             self.handle_action(&action);
 378         }
 379         if self.needs_rebuild || self.ui_context.is_dirty() {
 380             *needs_rebuild = true;
 381             self.needs_rebuild = true;
 382         }
 383     }
 384 
 385     fn display_list(&mut self, size: cce_ui::engine::LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
 386         // Phase 6 single paint path: the whole frame — geometry and text — is this one list.
 387         // rebuild_layout flattens the UI into self.widgets/self.texts (scroll shift, search
 388         // dim/highlight, and viewport clamps already applied).
 389         let (width, height) = (size.width, size.height);
 390         if self.needs_rebuild || self.ui_context.is_dirty() || self.width != width as u32 || self.height != height as u32 || self.scale_factor != scale {
 391             self.width = width as u32;
 392             self.height = height as u32;
 393             self.scale_factor = scale;
 394             cce_ui::scale::set_scale_factor(scale as f32);
 395             self.rebuild_layout(width, height);
 396         }
 397         use cce_ui::scene::layout::Rect;
 398         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 399 
 400         // The page scrollbar straddles the window plate (the designer
 401         // parameter-pane treatment) and is emitted fresh EVERY frame — never
 402         // baked into the rebuilt layout, so the thumb tracks the wheel fast
 403         // path's scrolls, which shift cached geometry without a rebuild and
 404         // used to leave the bar frozen until scrolling stopped. Sync first:
 405         // a thumb drag drives the page, anything else drives the thumb.
 406         if self.ui_context[self.page_scroll_bar].dragging {
 407             self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
 408         } else {
 409             self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
 410         }
 411         let page_bar = {
 412             use cce_ui::widget::WidgetHost;
 413             let (bx, by, bw, bh) = self.ui_context[self.page_scroll_bar].rect();
 414             self.ui_context[self.page_scroll_bar]
 415                 .layer_quads(Rect { x: bx, y: by, width: bw, height: bh })
 416         };
 417         // The idle copy: under the translucent window plate, every frame, so
 418         // the bar reads as sunk INTO the window rather than gone, and the
 419         // plate occludes it from input. Track and thumb are pills (the
 420         // designer look). The fore copy fades in over it, below.
 421         for &(r, c) in &page_bar {
 422             pc.rounded_rect(r, r.width.min(r.height) * 0.5, (true, true, true, true), c);
 423         }
 424         // The frameless inner lists' idle copies go under the plate too: they
 425         // sit on wells carved into it, so this is the plate behind them. In
 426         // page coordinates, so shifted by the page scroll and clipped to the
 427         // page view as the wells are. Their fore copies are the pages'.
 428         {
 429             let view = self.page_view(width, height);
 430             let scroll_y = self.scroll_y;
 431             for list in self.app.get_current_page().frameless_lists() {
 432                 let mut shown = list.clone();
 433                 shown.y -= scroll_y;
 434                 shown.viewport_y -= scroll_y;
 435                 pc.clip(view, |pc| shown.push_scrollbar_prims(pc));
 436             }
 437         }
 438 
 439         // One glass slab (data-editor's idiom): the root plate, with the status
 440         // bar carved into it as a step — everything else paints on top. The
 441         // standard spec (cce-ui `PlateSpec::window`) in this app's own dark
 442         // green tint at the DE root opacity: a deliberate deviation from the
 443         // DE root colour, and the one thing here that is not the standard.
 444         {
 445             let mut plate = cce_ui::color::to_linear([0x0a as f32 / 255.0, 0x1a as f32 / 255.0, 0x0e as f32 / 255.0, 1.0]);
 446             if plate[3] > 0.001 {
 447                 plate[3] = cce_ui::color::root_plate_opacity();
 448             }
 449             pc.plate_spec(
 450                 &cce_ui::scene::paint::PlateSpec::window(width, height)
 451                     .with_material(cce_ui::scene::Material::opaque(plate)),
 452             );
 453             let sb_h = self.status_height;
 454             let depth = cce_ui::layout::bar_wall_width().min(sb_h * 0.6);
 455             pc.recess_edges(
 456                 Rect { x: 0.0, y: height - sb_h, width, height: sb_h },
 457                 (0.0, 0.0, 0.0, 0.0),
 458                 depth,
 459                 (true, false, false, false),
 460             );
 461         }
 462 
 463         // The page's rects and the control carves the flat bridge offered
 464         // (a Dropdown's inset plate, a TextBox's well, a Toggle's steps, the
 465         // buttons' faces), replayed in the widgets' OWN order — each carve
 466         // just before the rect it was claimed ahead of, so a TextBox's
 467         // selection highlight and caret land on its well instead of under
 468         // its walls. Carves clip to the page viewport like the wells; the
 469         // rects were already clamped to it when collected.
 470         {
 471             let view = self.page_view(width, height);
 472             let scroll_y = self.scroll_y;
 473             let mut carves = self
 474                 .page_control_reliefs
 475                 .iter()
 476                 .copied()
 477                 .zip(self.page_control_relief_marks.iter().copied())
 478                 .peekable();
 479             let mut emit_pending = |pc: &mut cce_ui::scene::paint::PaintCtx, upto: usize| {
 480                 while carves.peek().map_or(false, |&(_, mark)| mark <= upto) {
 481                     let ((carve, clip), _) = carves.next().unwrap();
 482                     pc.clip(view, |pc| match clip {
 483                         Some([x, y, w, h]) => pc.clip(Rect { x, y: y - scroll_y, width: w, height: h }, |pc| {
 484                             emit_control_carve(pc, carve.shifted_y(-scroll_y))
 485                         }),
 486                         None => emit_control_carve(pc, carve.shifted_y(-scroll_y)),
 487                     });
 488                 }
 489             };
 490             for (i, w) in self.widgets.iter().enumerate() {
 491                 emit_pending(&mut pc, i);
 492                 let color = if w.hovering { w.hover_color } else { w.color };
 493                 let rect = Rect { x: w.x, y: w.y, width: w.w, height: w.h };
 494                 if w.radius > 0.1 {
 495                     pc.rounded_rect(rect, w.radius, w.corners, color);
 496                 } else {
 497                     pc.quad(rect, color);
 498                 }
 499             }
 500             emit_pending(&mut pc, usize::MAX);
 501         }
 502         // The section wells, carved after the page's flat quads so the walls shade
 503         // the fills they cross (the designer relief order), clipped to the page
 504         // viewport so a scrolled-off well can't shade the status bar or search row.
 505         if !self.page_reliefs.is_empty() {
 506             let view = self.page_view(width, height);
 507             let r = 20.0f32;
 508             let scroll_y = self.scroll_y;
 509             pc.clip(view, |pc| {
 510                 for &((cx, cy, cw, ch), tab) in &self.page_reliefs {
 511                     let cy = cy - scroll_y;
 512                     let depth = cce_ui::layout::bevel_width().min(ch * 0.2);
 513                     match tab {
 514                         Some((tx, ty, tw, th)) => {
 515                             // The designer union carve: the title tab bottom-open, one
 516                             // piece owning the whole right run so its corners are real
 517                             // turns, a left piece carrying the left wall — pieces
 518                             // extend past their interior seam by `depth` so the walls
 519                             // crossfade there instead of notching — and the throat's
 520                             // inside corner rounded by a concave fillet.
 521                             let ty = ty - scroll_y;
 522                             let rt = r.min(th * 0.45);
 523                             let throat_r = tx + tw;
 524                             let rho = 10.0f32; // designer SECTION_FILLET_R
 525                             let body_lr = |x_run: f32, pc: &mut cce_ui::scene::paint::PaintCtx| {
 526                                 pc.recess_edges(
 527                                     Rect { x: x_run, y: cy, width: cx + cw - x_run, height: ch },
 528                                     (0.0, r, r, 0.0),
 529                                     depth,
 530                                     (true, true, true, false),
 531                                 );
 532                                 pc.recess_edges(
 533                                     Rect { x: cx, y: cy, width: x_run + depth - cx, height: ch },
 534                                     (0.0, 0.0, 0.0, r),
 535                                     depth,
 536                                     (false, false, true, true),
 537                                 );
 538                             };
 539                             if cx + cw > throat_r + 2.0 * rho {
 540                                 // Filleted throat: the tab's right wall ends at the
 541                                 // fillet's vertical tangent, a left-only bridge
 542                                 // carries the left wall across the fillet span.
 543                                 pc.recess_edges(
 544                                     Rect { x: tx, y: ty, width: tw, height: (cy - rho) - ty + depth },
 545                                     (rt, rt, 0.0, 0.0),
 546                                     depth,
 547                                     (true, true, false, true),
 548                                 );
 549                                 pc.recess_edges(
 550                                     Rect { x: tx, y: cy - rho, width: tw, height: rho + depth },
 551                                     (0.0, 0.0, 0.0, 0.0),
 552                                     depth,
 553                                     (false, false, false, true),
 554                                 );
 555                                 body_lr(throat_r + rho - depth, pc);
 556                                 pc.concave_fillet(throat_r + rho, cy - rho, rho, depth, std::f32::consts::FRAC_PI_2, false);
 557                             } else if cx + cw > throat_r + 0.5 {
 558                                 // Too narrow for the fillet: the plain square throat.
 559                                 pc.recess_edges(
 560                                     Rect { x: tx, y: ty, width: tw, height: (cy - ty) + depth },
 561                                     (rt, rt, 0.0, 0.0),
 562                                     depth,
 563                                     (true, true, false, true),
 564                                 );
 565                                 body_lr(throat_r - depth, pc);
 566                             } else {
 567                                 // The tab spans the body: no top wall at all.
 568                                 pc.recess_edges(
 569                                     Rect { x: tx, y: ty, width: tw, height: (cy - ty) + depth },
 570                                     (rt, rt, 0.0, 0.0),
 571                                     depth,
 572                                     (true, true, false, true),
 573                                 );
 574                                 pc.recess_edges(
 575                                     Rect { x: cx, y: cy, width: cw, height: ch },
 576                                     (0.0, 0.0, r, r),
 577                                     depth,
 578                                     (false, true, true, true),
 579                                 );
 580                             }
 581                         }
 582                         None => {
 583                             pc.recess_edges(
 584                                 Rect { x: cx, y: cy, width: cw, height: ch },
 585                                 (r, r, r, r),
 586                                 depth,
 587                                 (true, true, true, true),
 588                             );
 589                         }
 590                     }
 591                 }
 592             });
 593         }
 594 
 595         // The page's glyphs — button icon faces and the page's own — over
 596         // its quads and carves, under the page viewport and each under the
 597         // clip it was placed with, which is what cuts a half-scrolled list
 598         // row's glyph at the list edge.
 599         if !self.page_icon_images.is_empty() {
 600             let view = self.page_view(width, height);
 601             pc.clip(view, |pc| {
 602                 for icon in &self.page_icon_images {
 603                     draw_placed_icon(pc, icon);
 604                 }
 605             });
 606         }
 607 
 608         // The window chrome, as it paints itself: the page dropdown in the status bar
 609         // and, while open, the search box in its band.
 610         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.page_dropdown], &mut pc);
 611         if self.search_open {
 612             cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.search_box], &mut pc);
 613         }
 614 
 615         // The page scrollbar's fore copy: over the page content at the
 616         // raise's fade, while a scroll or drag holds it up and as it sinks
 617         // (popovers still stack above it).
 618         let fade = self.ui_context[self.page_scroll_bar].fade();
 619         if fade > 0.001 {
 620             for &(r, mut c) in &page_bar {
 621                 c[3] *= fade;
 622                 pc.rounded_rect(r, r.width.min(r.height) * 0.5, (true, true, true, true), c);
 623             }
 624         }
 625 
 626         cce_ui::widget::hover_animation::post_render_check();
 627         if let Some((qx, qy, qw, qh, qc)) = cce_ui::widget::hover_animation::get_quad() {
 628             pc.quad(Rect { x: qx, y: qy - self.scroll_y, width: qw, height: qh }, qc);
 629         }
 630         // Popover rects and the surfaces claimed through the inset_plate hook
 631         // (the dropdown's menu plate), replayed in the widget's OWN order:
 632         // each carve goes out just before the rect it was claimed ahead of,
 633         // so the hovered-row highlight a Dropdown draws after its plate lands
 634         // on top of the frosted face instead of underneath it.
 635         {
 636             let mut carves = self
 637                 .popover_control_reliefs
 638                 .iter()
 639                 .copied()
 640                 .zip(self.popover_control_relief_marks.iter().copied())
 641                 .peekable();
 642             for (i, w) in self.popover_widgets.iter().enumerate() {
 643                 while carves.peek().map_or(false, |&(_, mark)| mark <= i) {
 644                     let (carve, _) = carves.next().unwrap();
 645                     emit_control_carve(&mut pc, carve);
 646                 }
 647                 let rect = Rect { x: w.x, y: w.y, width: w.w, height: w.h };
 648                 if w.radius > 0.1 {
 649                     pc.rounded_rect(rect, w.radius, w.corners, w.color);
 650                 } else {
 651                     pc.quad(rect, w.color);
 652                 }
 653             }
 654             for (carve, _) in carves {
 655                 emit_control_carve(&mut pc, carve);
 656             }
 657         }
 658         for icon in &self.popover_icon_images {
 659             draw_placed_icon(&mut pc, icon);
 660         }
 661         // The context menu last, over everything: the toolkit's lit plate,
 662         // its rows and its labels in the menu font, with the row marks and
 663         // page chevrons as cce-icons glyphs.
 664         cce_ui::widget::context_menu::paint_with_labels(&mut pc);
 665         for (text, font_size, x, y, col, font, bounds) in self.texts.iter().chain(self.popover_texts.iter()) {
 666             pc.text_with(
 667                 text.clone(),
 668                 *x,
 669                 *y,
 670                 *font_size,
 671                 [
 672                     (col[0] * 255.0) as u8,
 673                     (col[1] * 255.0) as u8,
 674                     (col[2] * 255.0) as u8,
 675                 ],
 676                 font.clone(),
 677                 *bounds,
 678             );
 679         }
 680         if let Some([x, y, w, h]) = self.text_claim {
 681             cce_ui::text_input::claim(x, y, w, h);
 682         }
 683         Some(pc.finish())
 684     }
 685 
 686     fn display_list_text(&self) -> bool {
 687         true
 688     }
 689 
 690     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
 691         Some(&self.ui_context)
 692     }
 693 
 694     // The engine ticks the exposed context each loop — this is what drives the
 695     // dropdown expand/contract animation frames.
 696     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
 697         Some(&mut self.ui_context)
 698     }
 699 
 700     // Page widgets are laid out unscrolled and drawn `scroll_y` higher (the
 701     // popover replay in `rebuild_layout` subtracts it); the chrome dropdown
 702     // is in window coords already.
 703     fn popover_offset(&self, id: cce_ui::widget::WidgetId) -> (f32, f32) {
 704         if id == self.page_dropdown.id() {
 705             (0.0, 0.0)
 706         } else {
 707             (0.0, -self.scroll_y)
 708         }
 709     }
 710 
 711     fn clear_color(&self) -> [f32; 4] {
 712         [0.039, 0.102, 0.055, 1.0]
 713     }
 714 
 715     fn handle_pointer_move(&mut self, pos: cce_ui::engine::LogicalPosition, needs_rebuild: &mut bool) {
 716         if self.handle_cursor_moved(pos.x, pos.y) {
 717             *needs_rebuild = true;
 718         }
 719     }
 720 
 721     fn handle_mouse_input(&mut self, button: cce_ui::widget::MouseButton, state: cce_ui::widget::ElementState, pos: cce_ui::engine::LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
 722         self.cursor_x = pos.x;
 723         self.cursor_y = pos.y;
 724         if self.handle_mouse_input_internal(button, state) {
 725             *needs_rebuild = true;
 726         }
 727         None
 728     }
 729 
 730     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, pos: cce_ui::engine::LogicalPosition, needs_rebuild: &mut bool) {
 731         if self.handle_mouse_wheel_internal(delta, pos.x, pos.y) {
 732             *needs_rebuild = true;
 733         }
 734     }
 735 
 736     /// Tab walks the page's plates and wells (cce-ui's navigation in plate
 737     /// terms); the section chords in input.kdl keep their own walk.
 738     fn plate_navigation(&self) -> bool {
 739         true
 740     }
 741 
 742     /// The geometry is cached until the next rebuild — a moved focus ring needs
 743     /// one. The view pass itself carries the focus across the button clones it
 744     /// makes (see `refocus_rect`), on every rebuild.
 745     fn focus_stepped(&mut self) {
 746         self.needs_rebuild = true;
 747     }
 748 
 749     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
 750         if self.handle_key_input_internal(event) {
 751             *needs_rebuild = true;
 752         }
 753         None
 754     }
 755 }
 756 
 757 impl SystemInterface {
 758     /// The page viewport in window coords: everything under the header and
 759     /// above the status bar (and the search row while it is open).
 760     fn page_view(&self, width: f32, height: f32) -> cce_ui::scene::layout::Rect {
 761         use cce_ui::scene::layout::Rect;
 762         Rect {
 763             x: self.sidebar_width,
 764             y: self.header_height,
 765             width: width - self.sidebar_width,
 766             height: (height - self.header_height - self.status_height
 767                 - if self.search_open { 42.0 } else { 0.0 }).max(0.0),
 768         }
 769     }
 770 
 771 
 772     fn tick_internal(&mut self, dt: f32) -> bool {
 773         let mut needs_redraw = false;
 774         if hover_animation::tick(dt) {
 775             needs_redraw = true;
 776         }
 777         // Raise/sink upkeep for the page scrollbar: true while the post-scroll
 778         // hold runs (keeps frames coming so the sink actually renders) and on
 779         // the raised flip itself. A redraw re-emits the bar at its new depth —
 780         // no layout rebuild needed, display_list draws it fresh each frame.
 781         if self.ui_context[self.page_scroll_bar].tick_activity(dt) {
 782             needs_redraw = true;
 783         }
 784         // The page's own wheel glide / flick coast: shifts the cached
 785         // geometry like the wheel fast path, no rebuild.
 786         if self.tick_page_scroll(dt) {
 787             needs_redraw = true;
 788         }
 789         // The current page's inner lists (their glide/coast lives in the
 790         // region's tick): a moved list re-lays the page out.
 791         if self.app.get_current_page_mut().tick(dt) {
 792             needs_redraw = true;
 793             self.needs_rebuild = true;
 794         }
 795         if self.ui_context.tick(dt) {
 796             needs_redraw = true;
 797             self.needs_rebuild = true;
 798         }
 799 
 800         needs_redraw
 801     }
 802 
 803     /// Advance the page's wheel glide / flick coast; true while the offset is
 804     /// moving, so the frame loop keeps drawing until it settles.
 805     fn tick_page_scroll(&mut self, dt: f32) -> bool {
 806         use cce_ui::widget::Bounds;
 807         self.page_scroll_motion.reconcile(0.0, self.scroll_y);
 808         if !self.page_scroll_motion.is_animating() {
 809             return false;
 810         }
 811         let moved = self.page_scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll_y));
 812         if moved {
 813             self.shift_page_to(self.page_scroll_motion.y.pos());
 814         }
 815         moved || self.page_scroll_motion.is_animating()
 816     }
 817 
 818     /// Whether a page worker's snapshot differs from the last one it sent.
 819     /// The workers resend the whole state on every period whether or not
 820     /// anything moved; applying an identical one changed nothing on screen
 821     /// but rebuilt the page and redrew the window — the settings app sat at
 822     /// ~3 fps at rest on its default page. Processes is not checked: its
 823     /// refresh also settles pending kills (`killing.clear()`), and its CPU
 824     /// column moves every time anyway.
 825     fn fresh_snapshot<T: std::fmt::Debug>(&mut self, key: &'static str, snapshot: &T) -> bool {
 826         use std::hash::{Hash, Hasher};
 827         let mut h = std::collections::hash_map::DefaultHasher::new();
 828         format!("{snapshot:?}").hash(&mut h);
 829         let hash = h.finish();
 830         self.seen_snapshots.insert(key, hash) != Some(hash)
 831     }
 832 
 833     fn poll_background_updates(&mut self) {
 834         use pages::*;
 835         while let Ok(s) = self.rx_audio.try_recv() {
 836             if !self.fresh_snapshot("audio", &s) {
 837                 continue;
 838             }
 839             audio::update(&mut self.app.audio, audio::AudioMessage::Refreshed(s), &mut self.ui_context);
 840             if self.app.current_page == Page::Audio {
 841                 self.needs_rebuild = true;
 842             }
 843         }
 844         while let Ok(s) = self.rx_network.try_recv() {
 845             if !self.fresh_snapshot("network", &s) {
 846                 continue;
 847             }
 848             network::update(&mut self.app.network, network::NetworkMessage::Refreshed(s), &mut self.ui_context);
 849             if self.app.current_page == Page::Network {
 850                 self.needs_rebuild = true;
 851             }
 852         }
 853         while let Ok(s) = self.rx_timers.try_recv() {
 854             if !self.fresh_snapshot("timers", &s) {
 855                 continue;
 856             }
 857             pages::timers::update(&mut self.app.timers, pages::timers::TimersMessage::Refreshed(s), &mut self.ui_context);
 858             if self.app.current_page == Page::Timers {
 859                 self.needs_rebuild = true;
 860             }
 861         }
 862         while let Ok(s) = self.rx_bluetooth.try_recv() {
 863             if !self.fresh_snapshot("bluetooth", &s) {
 864                 continue;
 865             }
 866             pages::bluetooth::update(&mut self.app.bluetooth, pages::bluetooth::BluetoothMessage::Refreshed(s), &mut self.ui_context);
 867             if self.app.current_page == Page::Bluetooth {
 868                 self.needs_rebuild = true;
 869             }
 870         }
 871         while let Ok(s) = self.rx_power.try_recv() {
 872             if !self.fresh_snapshot("power", &s) {
 873                 continue;
 874             }
 875             pages::power::update(&mut self.app.power, pages::power::PowerMessage::Refreshed(s), &mut self.ui_context);
 876             if self.app.current_page == Page::Power {
 877                 self.needs_rebuild = true;
 878             }
 879         }
 880         while let Ok(s) = self.rx_system.try_recv() {
 881             if !self.fresh_snapshot("system", &s) {
 882                 continue;
 883             }
 884             system_info::update(&mut self.app.system_info, system_info::SystemMessage::Refreshed(s), &mut self.ui_context);
 885             if self.app.current_page == Page::System {
 886                 self.needs_rebuild = true;
 887             }
 888         }
 889         while let Ok(s) = self.rx_processes.try_recv() {
 890             processes::update(&mut self.app.processes, processes::ProcessesMessage::Refreshed(s));
 891             if self.app.current_page == Page::Processes {
 892                 self.needs_rebuild = true;
 893             }
 894         }
 895         while let Ok(s) = self.rx_storage.try_recv() {
 896             if !self.fresh_snapshot("storage", &s) {
 897                 continue;
 898             }
 899             storage::update(&mut self.app.storage, storage::StorageMessage::Refreshed(s), &mut self.ui_context);
 900             if self.app.current_page == Page::Storage {
 901                 self.needs_rebuild = true;
 902             }
 903         }
 904         while let Ok(s) = self.rx_notifications.try_recv() {
 905             if !self.fresh_snapshot("notifications", &s) {
 906                 continue;
 907             }
 908             pages::notifications::update(&mut self.app.notifications, pages::notifications::NotificationsMessage::Refreshed(s), &mut self.ui_context);
 909             if self.app.current_page == Page::Notifications {
 910                 self.needs_rebuild = true;
 911             }
 912         }
 913         while let Ok(s) = self.rx_browser.try_recv() {
 914             if !self.fresh_snapshot("browser", &s) {
 915                 continue;
 916             }
 917             pages::browser::update(&mut self.app.browser, pages::browser::BrowserMessage::Refreshed(s), &mut self.ui_context);
 918             if self.app.current_page == Page::Browser {
 919                 self.needs_rebuild = true;
 920             }
 921         }
 922         while let Ok(s) = self.rx_default_apps.try_recv() {
 923             if !self.fresh_snapshot("default_apps", &s) {
 924                 continue;
 925             }
 926             pages::default_apps::update(&mut self.app.default_apps, pages::default_apps::DefaultAppsMessage::Refreshed(s), &mut self.ui_context);
 927             if self.app.current_page == Page::DefaultApps {
 928                 self.needs_rebuild = true;
 929             }
 930         }
 931         while let Ok(s) = self.rx_services.try_recv() {
 932             if !self.fresh_snapshot("services", &s) {
 933                 continue;
 934             }
 935             services::update(&mut self.app.services, services::ServicesMessage::Refreshed(s), &mut self.ui_context);
 936             if self.app.current_page == Page::Services {
 937                 self.needs_rebuild = true;
 938             }
 939         }
 940 
 941         while let Ok(s) = self.rx_accounts.try_recv() {
 942             if !self.fresh_snapshot("accounts", &s) {
 943                 continue;
 944             }
 945             accounts::update(&mut self.app.accounts, accounts::AccountsMessage::Refreshed(s), &mut self.ui_context);
 946             if self.app.current_page == Page::Accounts {
 947                 self.needs_rebuild = true;
 948             }
 949         }
 950         while let Ok(m) = self.rx_backup.try_recv() {
 951             self.handle_action(&AppAction::Storage(m));
 952             if self.app.current_page == Page::Storage {
 953                 self.needs_rebuild = true;
 954             }
 955         }
 956         while let Ok(s) = self.rx_packages.try_recv() {
 957             if !self.fresh_snapshot("packages", &s) {
 958                 continue;
 959             }
 960             pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::Refreshed(s), &mut self.ui_context);
 961             if self.app.current_page == Page::Packages {
 962                 self.needs_rebuild = true;
 963             }
 964         }
 965         while let Ok(m) = self.rx_update.try_recv() {
 966             self.handle_action(&AppAction::Packages(m));
 967             if self.app.current_page == Page::Packages {
 968                 self.needs_rebuild = true;
 969             }
 970         }
 971     }
 972 
 973     fn handle_action(&mut self, action: &AppAction) {
 974         use pages::*;
 975         // An action may change page state ahead of the system (a Wi-Fi
 976         // toggle flips `wifi_enabled` before nmcli answers). If it fails, the
 977         // next refresh equals the last one sent — and must still be applied
 978         // to put the page right, so forget what was seen.
 979         if !matches!(action, AppAction::Exit | AppAction::Wake) {
 980             self.seen_snapshots.clear();
 981         }
 982         if matches!(action, AppAction::Accounts(_)) {
 983             accounts::invalidate_keyring_probe();
 984         }
 985         match action {
 986             AppAction::Exit | AppAction::Wake => {}
 987             AppAction::Audio(m) => audio::update(&mut self.app.audio, m.clone(), &mut self.ui_context),
 988             AppAction::Network(m) => network::update(&mut self.app.network, m.clone(), &mut self.ui_context),
 989             AppAction::Bluetooth(m) => pages::bluetooth::update(&mut self.app.bluetooth, m.clone(), &mut self.ui_context),
 990             AppAction::Power(m) => pages::power::update(&mut self.app.power, m.clone(), &mut self.ui_context),
 991             AppAction::Timers(m) => pages::timers::update(&mut self.app.timers, m.clone(), &mut self.ui_context),
 992             AppAction::SystemInfo(m) => system_info::update(&mut self.app.system_info, m.clone(), &mut self.ui_context),
 993             AppAction::Processes(m) => processes::update(&mut self.app.processes, m.clone()),
 994             AppAction::Services(m) => services::update(&mut self.app.services, m.clone(), &mut self.ui_context),
 995             AppAction::DefaultApps(m) => pages::default_apps::update(&mut self.app.default_apps, m.clone(), &mut self.ui_context),
 996             AppAction::Notifications(m) => notifications::update(&mut self.app.notifications, m.clone(), &mut self.ui_context),
 997             AppAction::Browser(m) => pages::browser::update(&mut self.app.browser, m.clone(), &mut self.ui_context),
 998             AppAction::Storage(m) => match m {
 999                 pages::storage::StorageMessage::StartBackup => {
1000                     pages::storage::update(&mut self.app.storage, pages::storage::StorageMessage::StartBackup, &mut self.ui_context);
1001                     let tx = self.tx_backup.clone();
1002                     let wake = self.sender.clone();
1003                     tokio::spawn(async move {
1004                         let res = pages::storage::run_backup().await;
1005                         let _ = tx.send(pages::storage::StorageMessage::BackupFinished(res));
1006                         let _ = wake.send(AppAction::Wake);
1007                     });
1008                 }
1009                 _ => pages::storage::update(&mut self.app.storage, m.clone(), &mut self.ui_context),
1010             },
1011 
1012 
1013             AppAction::Accounts(m) => match m {
1014                 pages::accounts::AccountsMessage::GoogleLoginInit => {
1015                     // One listener at a time: port 36137 is fixed, so a second
1016                     // flow could only fail to bind and report it as a broken app
1017                     // rather than "you already have a login in the browser".
1018                     if self.app.accounts.oauth_listener_running {
1019                         pages::accounts::update(
1020                             &mut self.app.accounts,
1021                             pages::accounts::AccountsMessage::StatusMessage(
1022                                 "A Google sign-in is already waiting on the browser.".to_string(),
1023                             ),
1024                             &mut self.ui_context,
1025                         );
1026                     } else {
1027                         pages::accounts::update(&mut self.app.accounts, m.clone(), &mut self.ui_context);
1028                         let sender = self.sender.clone();
1029                         tokio::spawn(async move {
1030                             pages::accounts::run_google_login(sender).await;
1031                         });
1032                     }
1033                 }
1034                 _ => pages::accounts::update(&mut self.app.accounts, m.clone(), &mut self.ui_context),
1035             },
1036             AppAction::Packages(m) => match m {
1037                 pages::packages::PackagesMessage::StartUpdate => {
1038                     if self.app.packages.busy() {
1039                         return;
1040                     }
1041                     pages::packages::update(&mut self.app.packages, pages::packages::PackagesMessage::StartUpdate, &mut self.ui_context);
1042                     let tx = self.tx_update.clone();
1043                     let wake = self.sender.clone();
1044                     tokio::spawn(async move {
1045                         let res = pages::packages::run_update().await;
1046                         let _ = tx.send(pages::packages::PackagesMessage::UpdateFinished(res));
1047                         let _ = wake.send(AppAction::Wake);
1048                     });
1049                 }
1050                 pages::packages::PackagesMessage::UpdateFinished(res) => {
1051                     pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
1052                     if res.is_ok() {
1053                         let tx = self.tx_update.clone();
1054                         let wake = self.sender.clone();
1055                         tokio::spawn(async move {
1056                             let new_state = pages::packages::fetch_packages_state().await;
1057                             let _ = tx.send(pages::packages::PackagesMessage::Refreshed(new_state));
1058                             let _ = wake.send(AppAction::Wake);
1059                         });
1060                     }
1061                 }
1062                 pages::packages::PackagesMessage::SelectPackage(Some(ref name)) => {
1063                     let name_clone = name.clone();
1064                     let is_installed = self.app.packages.active_tab == pages::packages::PackageTab::Installed;
1065                     pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
1066                     let tx = self.tx_update.clone();
1067                     let wake = self.sender.clone();
1068                     tokio::spawn(async move {
1069                         let res = pages::packages::fetch_package_info(name_clone.clone(), is_installed).await;
1070                         let _ = tx.send(pages::packages::PackagesMessage::InfoFetched(name_clone, res));
1071                         let _ = wake.send(AppAction::Wake);
1072                     });
1073                 }
1074                 pages::packages::PackagesMessage::SelectAndScrollPackage(ref name) => {
1075                     let name_clone = name.clone();
1076                     pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
1077                     let tx = self.tx_update.clone();
1078                     let wake = self.sender.clone();
1079                     tokio::spawn(async move {
1080                         let res = pages::packages::fetch_package_info(name_clone.clone(), true).await;
1081                         let _ = tx.send(pages::packages::PackagesMessage::InfoFetched(name_clone, res));
1082                         let _ = wake.send(AppAction::Wake);
1083                     });
1084                 }
1085                 pages::packages::PackagesMessage::PreviewRemoval(ref targets) => {
1086                     if !targets.is_empty() && !self.app.packages.busy() {
1087                         pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
1088                         let targets = targets.clone();
1089                         let tx = self.tx_update.clone();
1090                         let wake = self.sender.clone();
1091                         tokio::spawn(async move {
1092                             let res = pages::packages::preview_removal(targets.clone()).await;
1093                             let _ = tx.send(pages::packages::PackagesMessage::RemovalPreviewed(targets, res));
1094                             let _ = wake.send(AppAction::Wake);
1095                         });
1096                     }
1097                 }
1098                 pages::packages::PackagesMessage::StartUninstall(ref targets) => {
1099                     if !targets.is_empty() && !self.app.packages.busy() {
1100                         pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
1101                         let targets = targets.clone();
1102                         let tx = self.tx_update.clone();
1103                         let wake = self.sender.clone();
1104                         tokio::spawn(async move {
1105                             let res = pages::packages::run_uninstall(targets.clone()).await;
1106                             let _ = tx.send(pages::packages::PackagesMessage::UninstallFinished(targets, res));
1107                             let _ = wake.send(AppAction::Wake);
1108                         });
1109                     }
1110                 }
1111                 pages::packages::PackagesMessage::SetInstallReason(ref targets, explicit) => {
1112                     if !targets.is_empty() && !self.app.packages.busy() {
1113                         pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
1114                         let (targets, explicit) = (targets.clone(), *explicit);
1115                         let tx = self.tx_update.clone();
1116                         let wake = self.sender.clone();
1117                         tokio::spawn(async move {
1118                             let res = pages::packages::run_set_install_reason(targets.clone(), explicit).await;
1119                             let _ = tx.send(pages::packages::PackagesMessage::InstallReasonSet(targets, explicit, res));
1120                             let _ = wake.send(AppAction::Wake);
1121                         });
1122                     }
1123                 }
1124                 // Both outcomes refresh, failure included: the list (and the
1125                 // orphan set, which either can change) is pacman's to report.
1126                 pages::packages::PackagesMessage::UninstallFinished(..)
1127                 | pages::packages::PackagesMessage::InstallReasonSet(..) => {
1128                     pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context);
1129                     let tx = self.tx_update.clone();
1130                     let wake = self.sender.clone();
1131                     // The details pane shows the install reason; re-read it.
1132                     let reread = self.app.packages.selected_package.clone();
1133                     tokio::spawn(async move {
1134                         let new_state = pages::packages::fetch_packages_state().await;
1135                         let _ = tx.send(pages::packages::PackagesMessage::Refreshed(new_state));
1136                         if let Some(name) = reread {
1137                             let res = pages::packages::fetch_package_info(name.clone(), true).await;
1138                             let _ = tx.send(pages::packages::PackagesMessage::InfoFetched(name, res));
1139                         }
1140                         let _ = wake.send(AppAction::Wake);
1141                     });
1142                 }
1143                 _ => pages::packages::update(&mut self.app.packages, m.clone(), &mut self.ui_context),
1144             },
1145         }
1146     }
1147 
1148 }
1149 
1150 fn main() {
1151     // cce-ui reports fatal event-loop errors through `log`; without a logger
1152     // installed they vanish (the cce-terminal connection-death lesson).
1153     env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
1154     let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
1155     let _guard = rt.enter();
1156 
1157     // One gap everywhere: the wells fill their grid allocations, so the grid
1158     // gap IS the visual section gap. The sections stand on the root plate,
1159     // so it is the root rung's sibling gap — `PageFlow::init` reads the
1160     // toolkit's grid_gap, hence the set here.
1161     cce_ui::layout::set_grid_gap(cce_ui::layout::root_plate_gap());
1162 
1163     let mut initial_page = Page::ALL[0];
1164 
1165 
1166 
1167     let args: Vec<String> = std::env::args().collect();
1168     if args.len() > 1 {
1169         let arg = args.last().unwrap().to_lowercase();
1170         for page in Page::ALL {
1171             if page.label().to_lowercase() == arg {
1172                 initial_page = page;
1173                 break;
1174             }
1175         }
1176     }
1177 
1178     let initial_page_idx = Page::ALL.iter().position(|&p| p == initial_page).unwrap_or(0);
1179     INITIAL_PAGE_INDEX.store(initial_page_idx, std::sync::atomic::Ordering::SeqCst);
1180 
1181     cce_ui::engine::run::<SystemInterface>();
1182 }
1183 
1184 impl Drop for SystemInterface {
1185     fn drop(&mut self) {
1186         if !self.scroll_logs.is_empty() {
1187             if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open("/tmp/cce-scroll-debug.log") {
1188                 use std::io::Write;
1189                 for log in &self.scroll_logs {
1190                     let _ = writeln!(file, "{}", log);
1191                 }
1192             }
1193         }
1194     }
1195 }