git.lucas.co / cce-system-interface
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