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

src/main.rs (43.9K)

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