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 }