system settings
git clone https://git.lucas.co/cce-system-interface.git
src/renderer.rs (30.8K)
1 use cce_ui::widget::WidgetHostExt;
2 use crate::{SystemInterface, AppWidget, make_text_buffer_with_font};
3 use cce_settings::app::{ControlCarve, PageContent};
4 use cce_settings::pages::Page;
5 use cce_ui::widget::WidgetHost;
6
7
8 impl SystemInterface {
9
10 /// Lay the page out and flatten it into the cached frame, keeping what
11 /// its widgets claimed for text input (`text_claim`).
12 pub(crate) fn rebuild_layout(&mut self, sw: f32, sh: f32) {
13 let ((), claim) = cce_ui::text_input::capture(|| self.rebuild_layout_inner(sw, sh));
14 self.text_claim = claim;
15 }
16
17 fn rebuild_layout_inner(&mut self, sw: f32, sh: f32) {
18 if std::env::var("CCE_HOVER_DEBUG").is_ok() {
19 let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
20 eprintln!("[hover] t={} rebuild", t);
21 }
22 // Keyboard focus on a per-rebuild button clone dies with the registry
23 // wipe below — on EVERY rebuild, not just the one a Tab step triggers
24 // (the page polls and re-lays-out on its own). Remember where the
25 // focused widget sat: this pass lights the clone at that rect as it
26 // collects its plate, and the pass's tail hands it the focus. A
27 // persistent widget resolves to itself.
28 self.refocus_rect = self
29 .ui_context
30 .focused_widget
31 .and_then(|id| self.ui_context.get_widget(id))
32 .map(|w| w.rect());
33 // SectionContainer dissolved (Phase 6w): no per-rebuild section clones to
34 // relink — the page's widgets dispatch directly (registration happens in
35 // render_widget during the view pass below).
36 self.ui_context.clear_hierarchy();
37
38 self.sidebar_width = 0.0;
39 self.header_height = 0.0; // No CSD Titlebar
40 let mut widgets = Vec::new();
41 let mut texts = Vec::new();
42 let mut page_buttons = Vec::new();
43 let mut page_icon_images: Vec<crate::PlacedIcon> = Vec::new();
44
45 cce_ui::widget::hover_animation::reset_frame_registration();
46 self.ui_context.clear_popovers();
47 cce_ui::widget::hover_animation::set_scroll_offset(self.scroll_y);
48 cce_ui::widget::hover_animation::set_cursor_pos(self.cursor_x, self.cursor_y);
49
50 let s = 1.0f32;
51 let cursor_phys_x = self.cursor_x;
52 let cursor_phys_y = self.cursor_y;
53 let check_hover = |wx: f32, wy: f32, ww: f32, wh: f32| -> bool {
54 cursor_phys_x >= wx && cursor_phys_x <= wx + ww && cursor_phys_y >= wy && cursor_phys_y <= wy + wh
55 };
56 let logical_sw = sw;
57 let logical_sh = sh;
58
59 let lcx = self.sidebar_width;
60 let lcy = self.header_height;
61 let lcw = logical_sw - self.sidebar_width;
62 let mut lch = logical_sh - self.header_height - self.status_height;
63 if self.search_open {
64 lch -= 42.0;
65 }
66
67 let page_idx = Page::ALL.iter().position(|&p| p == self.app.current_page).unwrap_or(0);
68 self.ui_context[self.page_dropdown].selected = page_idx;
69
70 // root plate container DISSOLVED (Phase 6s): top-level widgets stay parentless
71 // (render_widget registers them); the window plate, the root aggregate's
72 // emission order, and the StatusBar's root plate container-coupled theming are all
73 // replicated by hand below.
74
75 // Position sidebar and switcher below the titlebar
76 let mut dummy_pc = PageContent::new();
77 let dropdown_h = cce_ui::layout::dropdown_height();
78 let size = self.ui_context[self.page_dropdown].measure(
79 cce_ui::widget::LayoutConstraints::new(0.0, 500.0, dropdown_h, dropdown_h),
80 &self.ui_context,
81 );
82 let dropdown_w = size.width;
83 let dropdown_gap = (self.status_height - dropdown_h) / 2.0;
84 let dropdown_x = logical_sw - dropdown_w - dropdown_gap;
85 let dropdown_y = logical_sh - self.status_height + dropdown_gap;
86 // The dropdown sits flush against the window's rounded bottom-right corner; with the
87 // root plate container dissolved, hand it the plate frame for its concentric-corner cut.
88 self.ui_context[self.page_dropdown].set_corner_frame(Some(((0.0, 0.0, logical_sw, logical_sh), 12.0, (true, true, true, true))));
89 cce_ui::layout::render_widget_h(&mut dummy_pc, self.page_dropdown, dropdown_x, dropdown_y, dropdown_w, dropdown_h, &mut self.ui_context);
90 // The dropdown is laid out by this pass and painted live in display_list
91 // (`paint_root_into`), chevron and all.
92 let switcher_h = if self.search_open {
93 logical_sh - self.header_height - 42.0 - self.status_height
94 } else {
95 logical_sh - self.header_height - self.status_height
96 };
97 // The page scrollbar, the DE's one design: it rides the page's
98 // CENTRE line, over the sections with no lane of its own, idling
99 // behind the root plate until a scroll raises it. Updated with LAST
100 // frame's content height — the legacy window pass also ran before
101 // this frame's content was measured.
102 let sb_w = crate::scroll_bar::ScrollBar::width();
103 // TODO(style): the bar's 4px vertical stand-off is the toolkit
104 // track's own end inset, not a rung of the ladder.
105 self.ui_context[self.page_scroll_bar].set_rect(
106 lcx + (lcw - sb_w) * 0.5,
107 self.header_height + 4.0,
108 sb_w,
109 switcher_h - 8.0,
110 );
111 if self.ui_context[self.page_scroll_bar].dragging {
112 self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
113 }
114 self.ui_context[self.page_scroll_bar].update(self.scroll_y, self.content_h, switcher_h);
115
116 // The window chrome — the page dropdown and, while open, the search box — is
117 // painted live in display_list, each as it paints itself (`paint_root_into`).
118 // Until 2026-10-08 it was flattened here through the legacy tuple views (plain
119 // quads, rounded quads, then the text walk) into the cached frame, which drew
120 // the dropdown without its relief and the search box twice.
121
122 let mut search_pc = PageContent::new();
123 if self.search_open {
124 search_pc.rects.push((
125 [0.08, 0.08, 0.12, 1.0],
126 self.sidebar_width,
127 sh - 42.0,
128 sw - self.sidebar_width,
129 42.0,
130 0.0,
131 (false, false, false, false),
132 ));
133 search_pc.rects.push((
134 [0.18, 0.18, 0.24, 1.0],
135 self.sidebar_width,
136 sh - 42.0,
137 sw - self.sidebar_width,
138 1.0,
139 0.0,
140 (false, false, false, false),
141 ));
142 // The search box stands on the root plate: the window-edge inset.
143 // The toolkit's textbox height, centred in the 42px band.
144 let inset = cce_ui::layout::root_plate_inset();
145 let box_h = cce_ui::layout::textbox_height();
146 // Laid out here; painted live in display_list.
147 let mut layout_only = PageContent::new();
148 cce_ui::layout::render_widget_h(
149 &mut layout_only,
150 self.search_box,
151 self.sidebar_width + inset,
152 sh - 42.0 + (42.0 - box_h) / 2.0,
153 sw - self.sidebar_width - 2.0 * inset,
154 box_h,
155 &mut self.ui_context,
156 );
157 }
158
159 // CSD Titlebar removed
160
161 self.scrollable_widgets_start_idx = widgets.len();
162 self.scrollable_text_items_start_idx = texts.len();
163 self.scrollable_buttons_start_idx = page_buttons.len();
164
165 // Page content in LOGICAL coordinates, then scale to physical
166 let pc = self.render_page_content(lcx, lcy, lcw, lch);
167 self.page_reliefs = pc.reliefs.clone();
168 self.page_control_reliefs = pc.control_reliefs.iter().map(|&c| (c, None)).collect();
169 self.page_control_relief_marks.clear();
170
171
172
173 if self.search_open && !self.search_query.is_empty() && !self.ui_context[self.page_scroll_bar].dragging {
174 let query_lower = self.search_query.to_lowercase();
175 let mut first_match_y = None;
176 for (t, _, _, y, _, _, _) in &pc.texts {
177 if t.to_lowercase().contains(&query_lower) {
178 first_match_y = Some(*y);
179 break;
180 }
181 }
182 if let Some(y) = first_match_y {
183 let mut max_y = 0.0f32;
184 for (_, _, y, _, h, _, _) in &pc.rects {
185 max_y = max_y.max(y + h);
186 }
187 for (_, size, _, y, _, _, _) in &pc.texts {
188 max_y = max_y.max(y + size);
189 }
190 for (btn, _, _) in &pc.buttons {
191 let base = btn.base();
192 max_y = max_y.max(base.y + base.h);
193 }
194 let local_max_scroll_y = (max_y - lch).max(0.0);
195 self.scroll_y = (y - 100.0).clamp(0.0, local_max_scroll_y);
196 }
197 }
198
199 let mut max_y = 0.0f32;
200 for (_, _, y, _, h, _, _) in &pc.rects {
201 max_y = max_y.max(y + h);
202 }
203 for (_, size, _, y, _, _, _) in &pc.texts {
204 max_y = max_y.max(y + size);
205 }
206 for (btn, _, _) in &pc.buttons {
207 let base = btn.base();
208 max_y = max_y.max(base.y + base.h);
209 }
210 self.max_scroll_y = (max_y - lch).max(0.0);
211 static mut FRAME_COUNT: usize = 0;
212 unsafe {
213 FRAME_COUNT += 1;
214 if FRAME_COUNT > 5 {
215 self.scroll_y = self.scroll_y.min(self.max_scroll_y);
216 }
217 }
218
219 if self.ui_context[self.page_scroll_bar].dragging {
220 self.scroll_y = self.ui_context[self.page_scroll_bar].scroll_y;
221 } else {
222 self.ui_context[self.page_scroll_bar].scroll_y = self.scroll_y;
223 }
224 self.content_h = max_y;
225 self.ui_context[self.page_scroll_bar].update(self.scroll_y, max_y, lch);
226
227 let scroll_offset_y = self.scroll_y;
228
229 // Where each page rect landed in `widgets` (culled rects collapse onto
230 // the next survivor), so a carve's mark — "the rect I was claimed
231 // before" — survives the cull. One extra entry for "after the last".
232 let mut rect_widget_index: Vec<usize> = Vec::with_capacity(pc.rects.len() + 1);
233 for (c, x, y, w, h, r, corners) in pc.rects.iter() {
234 rect_widget_index.push(widgets.len());
235 let wx = *x * s;
236 let mut wy = (*y - scroll_offset_y) * s;
237 let ww = *w * s;
238 let mut wh = *h * s;
239
240 let viewport_bottom = logical_sh - self.status_height;
241 if wy >= viewport_bottom || wy + wh <= 0.0 {
242 continue;
243 }
244 if wy < 0.0 {
245 let diff = 0.0 - wy;
246 wy = 0.0;
247 wh = (wh - diff).max(0.0);
248 }
249 if wy + wh > viewport_bottom {
250 wh = (viewport_bottom - wy).max(0.0);
251 }
252
253 widgets.push(AppWidget {
254 x: wx, y: wy, w: ww, h: wh,
255 color: *c, hover_color: *c,
256 hovering: check_hover(wx, wy, ww, wh),
257 radius: *r * s,
258 corners: *corners,
259 });
260 }
261 rect_widget_index.push(widgets.len());
262 for &mark in &pc.control_relief_marks {
263 self.page_control_relief_marks.push(rect_widget_index[mark.min(rect_widget_index.len() - 1)]);
264 }
265 for (t, size, x, y, tc, font_opt, bounds) in pc.texts.iter() {
266 let shifted_bounds = bounds.map(|[bl, bt, br, bb]| {
267 [bl, bt - scroll_offset_y, br, bb - scroll_offset_y]
268 });
269
270 let viewport_bottom = logical_sh - self.status_height;
271 let final_bounds = match shifted_bounds {
272 Some(b) => Some([
273 b[0],
274 b[1].max(0.0),
275 b[2],
276 b[3].min(viewport_bottom),
277 ]),
278 None => Some([
279 0.0,
280 0.0,
281 logical_sw,
282 viewport_bottom,
283 ]),
284 };
285
286 let matched = self.search_open && !self.search_query.is_empty() && t.to_lowercase().contains(&self.search_query.to_lowercase());
287
288 if matched {
289 let text_buf = make_text_buffer_with_font(
290 &mut self.font_system,
291 t,
292 *size,
293 font_opt.as_deref(),
294 &self.sans_serif_family,
295 &self.serif_family,
296 &self.monospace_family,
297 );
298 let scale = cce_ui::scale::scale_factor();
299 let text_w = text_buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / scale;
300
301 let pad_x = 4.0;
302 let pad_y = 2.0;
303 let rect_x = *x - pad_x;
304 let rect_y = *y - pad_y;
305 let rect_w = text_w + 2.0 * pad_x;
306 let rect_h = *size + 2.0 * pad_y;
307
308 let wx = rect_x * s;
309 let mut wy = (rect_y - scroll_offset_y) * s;
310 let ww = rect_w * s;
311 let mut wh = rect_h * s;
312
313 if wy < viewport_bottom && wy + wh > 0.0 {
314 if wy < 0.0 {
315 let diff = 0.0 - wy;
316 wy = 0.0;
317 wh = (wh - diff).max(0.0);
318 }
319 if wy + wh > viewport_bottom {
320 wh = (viewport_bottom - wy).max(0.0);
321 }
322 widgets.push(AppWidget {
323 x: wx,
324 y: wy,
325 w: ww,
326 h: wh,
327 color: [0.65, 0.45, 0.05, 0.4],
328 hover_color: [0.65, 0.45, 0.05, 0.4],
329 hovering: check_hover(wx, wy, ww, wh),
330 radius: 3.0 * s,
331 corners: (true, true, true, true),
332 });
333 }
334 }
335
336 let text_color = if self.search_open && !self.search_query.is_empty() {
337 if matched {
338 [1.0, 0.95, 0.80, 1.0]
339 } else {
340 [tc[0] * 0.25, tc[1] * 0.25, tc[2] * 0.25, tc[3] * 0.25]
341 }
342 } else {
343 *tc
344 };
345
346 texts.push((t.clone(), *size, *x, *y - scroll_offset_y, text_color, font_opt.clone(), final_bounds));
347 }
348 for (btn, action, clip) in &pc.buttons {
349 let base = btn.base();
350 // The button's own idle and hover faces: the page's colours when
351 // it passed them, else the toolkit's for the button's kind — a
352 // list row's transparent-until-hover wash (`PageContent::list_row`).
353 let mut probe = (**btn).clone();
354 probe.set_hovered(false);
355 let bg = cce_ui::widget::Paint::color(&probe);
356 probe.set_hovered(true);
357 let hover_bg = cce_ui::widget::Paint::color(&probe);
358 // Clamp to the emission-time clip rect (page coords) so a partially
359 // scrolled list row's button draws cut at the list edge, not bleeding.
360 let (mut px0, mut py0, mut px1, mut py1) =
361 (base.x, base.y, base.x + base.w, base.y + base.h);
362 if let Some(c) = clip {
363 px0 = px0.max(c[0]);
364 py0 = py0.max(c[1]);
365 px1 = px1.min(c[0] + c[2]);
366 py1 = py1.min(c[1] + c[3]);
367 if px0 >= px1 || py0 >= py1 {
368 continue;
369 }
370 }
371 let wx = px0 * s;
372 let mut wy = (py0 - scroll_offset_y) * s;
373 let ww = (px1 - px0) * s;
374 let mut wh = (py1 - py0) * s;
375
376 let viewport_bottom = logical_sh - self.status_height;
377 if wy >= viewport_bottom || wy + wh <= 0.0 {
378 continue;
379 }
380 if wy < 0.0 {
381 let diff = 0.0 - wy;
382 wy = 0.0;
383 wh = (wh - diff).max(0.0);
384 }
385 if wy + wh > viewport_bottom {
386 wh = (viewport_bottom - wy).max(0.0);
387 }
388
389 widgets.push(AppWidget {
390 x: wx, y: wy, w: ww, h: wh,
391 color: bg, hover_color: hover_bg,
392 hovering: check_hover(wx, wy, ww, wh),
393 radius: cce_ui::layout::button_corner_radius() * s,
394 corners: (true, true, true, true),
395 });
396 // Buttons never reach the toolkit's flat-path bridge here: this
397 // app collects them into `pc.buttons` and draws them itself, so
398 // `render_widget` — where a widget's carves are offered — never
399 // sees one. Ask each button for the inset face its own `paint`
400 // draws (`Button::inset_face`, the same source the drawn one
401 // reads) and carve it right after the button's quad, where its
402 // paint puts it — the mark is "before the next widget". Page
403 // coords, pre-scroll, like everything else in this list.
404 if cce_ui::layout::control_relief() {
405 let rect = cce_ui::scene::layout::Rect { x: base.x, y: base.y, width: base.w, height: base.h };
406 if let Some(mut plate) = btn.plate(rect) {
407 // This pass's clone of the button a Tab step focused (its
408 // registered rect, window coords, matches `refocus_rect`;
409 // the view-pass tail hands it the focus): light its rim
410 // now, since the carve is collected here, before that.
411 let near = |a: f32, b: f32| (a - b).abs() < 0.5;
412 let refocused = self
413 .refocus_rect
414 .is_some_and(|(fx, fy, fw, fh)| near(wx, fx) && near(wy, fy) && near(ww, fw) && near(wh, fh));
415 if refocused {
416 plate.tint = Some(cce_ui::widget::ControlPlate::focus_tint());
417 }
418 // Under the button's own clip, as its quad is clamped:
419 // drawn whole, a row half-scrolled out of a list kept
420 // its plate standing past the list's edge.
421 let carve = ControlCarve::Plate {
422 x: plate.rect.x,
423 y: plate.rect.y,
424 w: plate.rect.width,
425 h: plate.rect.height,
426 radius: plate.radii.0,
427 depth: plate.depth,
428 color: plate.face_fill(),
429 tint: plate.tint,
430 };
431 self.page_control_reliefs.push((carve, *clip));
432 self.page_control_relief_marks.push(widgets.len());
433 }
434 }
435 // An icon face replaces the label entirely (as it does in
436 // `Button::paint`). The rect comes from the button's own
437 // `icon_rect` so the glyph lands where the paint path would put
438 // it, and it keeps the button's clip: the page clip alone let a
439 // row half-scrolled out of a list show its whole glyph past the
440 // list's edge.
441 let has_icon = if let Some((image, irect, alpha)) =
442 btn.icon_rect(cce_ui::scene::layout::Rect {
443 x: base.x,
444 y: base.y - scroll_offset_y,
445 width: base.w,
446 height: base.h,
447 })
448 {
449 page_icon_images.push(crate::PlacedIcon {
450 image,
451 x: irect.x,
452 y: irect.y,
453 w: irect.width,
454 h: irect.height,
455 alpha,
456 clip: clip.map(|[x, y, w, h]| [x, y - scroll_offset_y, w, h]),
457 });
458 true
459 } else {
460 false
461 };
462
463 // The label is skipped for an icon face, but NOT the dispatch clone
464 // below it: an icon button still has to be clickable.
465 if !has_icon {
466 let label = base.label.as_deref().unwrap_or("");
467 let label_size = 12.0;
468 let buf = make_text_buffer_with_font(
469 &mut self.font_system,
470 label,
471 label_size,
472 btn.widget_font().as_deref(),
473 &self.sans_serif_family,
474 &self.serif_family,
475 &self.monospace_family,
476 );
477 let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
478 let lh = buf.metrics().line_height;
479 let mut left_align = btn.justify == cce_ui::widget::Justification::Left;
480
481 // Auto-detect if inside a list frame to apply left alignment by default
482 if !left_align && base.w >= 60.0 {
483 if self.app.current_page == Page::Processes {
484 let sb1 = &self.app.processes.cpu_list;
485 if base.x >= sb1.x - 1.0 && base.x + base.w <= sb1.x + sb1.w + 1.0
486 && base.y >= sb1.y - 1.0 && base.y + base.h <= sb1.y + sb1.h + 1.0 {
487 left_align = true;
488 }
489 }
490 if self.app.current_page == Page::Services {
491 let sb2 = &self.app.services.list;
492 if base.x >= sb2.x - 1.0 && base.x + base.w <= sb2.x + sb2.w + 1.0
493 && base.y >= sb2.y - 1.0 && base.y + base.h <= sb2.y + sb2.h + 1.0 {
494 left_align = true;
495 }
496 }
497 }
498
499 let scale_factor = cce_ui::scale::scale_factor();
500 let logical_tw = tw / scale_factor;
501 let logical_lh = lh / scale_factor;
502 let text_x = if left_align {
503 base.x + cce_ui::layout::CONTROL_TEXT_INSET
504 } else {
505 base.x + (base.w - logical_tw) / 2.0
506 };
507
508 let label_color = btn.label_color.unwrap_or([0.83, 0.83, 0.83, 1.0]);
509 // Label bounds: the button's own (clip-clamped) box — a label longer
510 // than its button truncates instead of spilling over the neighbor.
511 let button_bounds = Some([
512 wx,
513 wy.max(0.0),
514 (wx + ww).min(logical_sw),
515 (wy + wh).min(viewport_bottom),
516 ]);
517 texts.push((
518 label.to_string(),
519 label_size,
520 text_x,
521 (base.y - scroll_offset_y) + (base.h - logical_lh) / 2.0,
522 label_color,
523 btn.widget_font(),
524 button_bounds,
525 ));
526 }
527 let mut btn_clone = btn.clone();
528 {
529 // The dispatch clone hit-tests at the CLAMPED rect, so clicks in
530 // a row's clipped-away region fall through to what's visible there.
531 let base_mut = btn_clone.base_mut();
532 base_mut.x = wx;
533 base_mut.y = wy;
534 base_mut.w = ww;
535 base_mut.h = wh;
536 }
537 page_buttons.push((btn_clone, action.clone()));
538 }
539
540 // The page's own glyphs (`PageContent::icon`): shifted by the scroll
541 // as the texts are, cut at the clip they were placed under (a list's
542 // box) by display_list, and dimmed with the text a search leaves
543 // unmatched.
544 let searching = self.search_open && !self.search_query.is_empty();
545 for ic in &pc.icons {
546 page_icon_images.push(crate::PlacedIcon {
547 image: ic.image,
548 x: ic.x,
549 y: ic.y - scroll_offset_y,
550 w: ic.w,
551 h: ic.h,
552 alpha: if searching { ic.alpha * 0.25 } else { ic.alpha },
553 clip: ic.clip.map(|[x, y, w, h]| [x, y - scroll_offset_y, w, h]),
554 });
555 }
556
557
558
559
560
561
562
563
564 for (c, x, y, w, h, r, corners) in &search_pc.rects {
565 let wx = *x * s;
566 let wy = *y * s;
567 let ww = *w * s;
568 let wh = *h * s;
569 widgets.push(AppWidget {
570 x: wx, y: wy, w: ww, h: wh,
571 color: *c, hover_color: *c,
572 hovering: check_hover(wx, wy, ww, wh),
573 radius: *r * s,
574 corners: *corners,
575 });
576 }
577 for (t, size, x, y, tc, font_opt, bounds) in &search_pc.texts {
578 texts.push((t.clone(), *size, *x, *y, *tc, font_opt.clone(), *bounds));
579 }
580
581 // Popovers + context menu draw INTO the frame (Phase 6t; the engine xdg popup no
582 // longer exists) — emitted on top of the whole window, so the dropdown's
583 // open-upward popover renders where it hit-tests. Kept out of
584 // widgets/texts so the wheel fast-path can't scroll them; dl-text occlusion comes
585 // from the ui_context popover registration (and the engine's context-menu overlay
586 // rect).
587 let mut popover_pc = PageContent::new();
588 {
589 // Chrome popover (the page dropdown) is in window coords; page-widget popovers
590 // (notifications/fonts menus) are in page coords and shift with the viewport —
591 // the same scroll subtraction the old popup positioner applied at creation.
592 let chrome_id = self.page_dropdown.id();
593 let mut page_pop_pc = PageContent::new();
594 for &pop_id in &self.ui_context.active_popovers {
595 let Some(popover) = self.ui_context.get_widget(pop_id) else { continue };
596 if pop_id == chrome_id {
597 popover.render_popover(&mut popover_pc);
598 } else {
599 popover.render_popover(&mut page_pop_pc);
600 }
601 }
602 // The chrome popover's rects are already in place: the page
603 // carves' marks shift past them so the interleave stays true.
604 let rect_base = popover_pc.rects.len();
605 for (c, x, y, w, h, r, corners) in page_pop_pc.rects {
606 popover_pc.rects.push((c, x, y - self.scroll_y, w, h, r, corners));
607 }
608 for (t, size, x, y, tc, font, bounds) in page_pop_pc.texts {
609 let shifted = bounds.map(|[l, tb, rr, b]| [l, tb - self.scroll_y, rr, b - self.scroll_y]);
610 popover_pc.texts.push((t, size, x, y - self.scroll_y, tc, font, shifted));
611 }
612 for ic in page_pop_pc.icons {
613 popover_pc.icons.push(cce_settings::app::PageIcon {
614 y: ic.y - self.scroll_y,
615 clip: ic.clip.map(|[x, y, w, h]| [x, y - self.scroll_y, w, h]),
616 ..ic
617 });
618 }
619 for (carve, mark) in page_pop_pc.control_reliefs.into_iter().zip(page_pop_pc.control_relief_marks) {
620 popover_pc.control_reliefs.push(carve.shifted_y(-self.scroll_y));
621 popover_pc.control_relief_marks.push(rect_base + mark);
622 }
623 }
624 // The context menu is NOT collected here: display_list paints it
625 // straight into the frame with `context_menu::paint_with_labels`,
626 // the toolkit's lit plate and its labels in the menu font — and its
627 // marks (✓ ● ○) and page chevrons as cce-icons glyphs, which the
628 // bare `text_labels()` this used to loop over cannot draw.
629 self.popover_widgets = popover_pc.rects.iter().map(|(c, x, y, w, h, r, corners)| AppWidget {
630 x: *x, y: *y, w: *w, h: *h,
631 color: *c, hover_color: *c,
632 hovering: false,
633 radius: *r,
634 corners: *corners,
635 }).collect();
636 self.popover_texts = popover_pc.texts;
637 self.popover_icon_images = popover_pc
638 .icons
639 .iter()
640 .map(|ic| crate::PlacedIcon { image: ic.image, x: ic.x, y: ic.y, w: ic.w, h: ic.h, alpha: ic.alpha, clip: ic.clip })
641 .collect();
642 self.popover_control_reliefs = popover_pc.control_reliefs;
643 self.popover_control_relief_marks = popover_pc.control_relief_marks;
644
645 self.widgets = widgets;
646 self.texts = texts;
647 // The dispatch copies are the context's: last frame's go back, this frame's go in.
648 for (h, _) in std::mem::take(&mut self.page_buttons) {
649 self.ui_context.remove(h);
650 }
651 let ctx = &mut self.ui_context;
652 self.page_buttons = page_buttons.into_iter().map(|(b, a)| (ctx.insert(b), a)).collect();
653 self.page_icon_images = page_icon_images;
654
655 // A Tab step's focus, handed to the fresh clone at the same rect (the
656 // page buttons above are per-rebuild allocations; see `focus_stepped`).
657 if let Some((fx, fy, fw, fh)) = self.refocus_rect.take() {
658 let near = |a: f32, b: f32| (a - b).abs() < 0.5;
659 let heir = self.ui_context.widgets().find_map(|(id, w)| {
660 let (x, y, ww, hh) = w.rect();
661 (w.focus_role() != cce_ui::widget::FocusRole::None && near(x, fx) && near(y, fy) && near(ww, fw) && near(hh, fh))
662 .then_some(id)
663 });
664 if let Some(id) = heir {
665 self.ui_context.set_focused_id(id);
666 }
667 }
668 self.needs_rebuild = false;
669 self.laid_out_page = Some(self.app.current_page);
670 self.last_scroll_y = self.scroll_y;
671 self.ui_context.clear_dirty();
672 }
673
674 pub(crate) fn render_page_content(&mut self, cx: f32, cy: f32, cw: f32, ch: f32) -> PageContent {
675 use cce_ui::layout::PageFlow;
676 // The sections are wells carved straight into the root plate (no
677 // sidebar, no pane between), so the page's edge is the window's edge:
678 // the root rung's inset, and the grid's gap the root rung's gap (set
679 // once in main; `PageFlow::init` reads the toolkit's grid getters).
680 let margin = cce_ui::layout::root_plate_inset();
681 let cx = cx + margin;
682 let cy = cy + margin;
683 // No lane for the page scrollbar: it rides the page's centre line,
684 // over the sections, behind the root plate until a scroll raises it.
685 let cw = (cw - 2.0 * margin).max(1.0);
686 let ch = (ch - 2.0 * margin).max(1.0);
687 let mut layout = PageFlow::new();
688 // Page root dissolved (6u): the ctrl-nav entry focuses section 0, so root focus is
689 // permanently false; views that highlighted on it OR in their first section's bool.
690 // Section focus is the app-side index now (Phase 6w).
691 let n_sections = self.app.get_current_page_mut().section_widgets().len();
692 let sec_focused: Vec<bool> = (0..n_sections)
693 .map(|i| self.focused_section == Some(i))
694 .collect();
695 self.app.get_current_page_mut().view(cx, cy, cw, ch, false, &sec_focused, &mut layout, &mut self.ui_context)
696 }
697
698 }
699