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

src/renderer.rs (36.2K)

  1 use crate::{SystemInterface, AppWidget, make_text_buffer_with_font};
  2 use cce_settings::app::{ControlCarve, PageContent};
  3 use cce_settings::pages::Page;
  4 use cce_ui::widget::WidgetHost;
  5 
  6 type RectTuple = ([f32; 4], f32, f32, f32, f32, f32, (bool, bool, bool, bool));
  7 type TextTuple = (String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>);
  8 
  9 /// One child's contribution to the dissolved root's window assembly, replicating the
 10 /// legacy `render_widget(root plate container)` aggregate exactly: plain quads are skipped
 11 /// when they are a rounded child's own bg (the rounded pass carries it), clipped to
 12 /// the window, and corner-resolved against the root's rounded rect (a quad flush with
 13 /// a window corner picks up the plate radius there); rounded quads are clipped;
 14 /// text bounds are clamped to the window (unbounded labels become window-bounded).
 15 fn collect_window_child(
 16     w: &dyn WidgetHost,
 17     ctx: &cce_ui::context::UiContext,
 18     win_w: f32,
 19     win_h: f32,
 20     plate_radius: f32,
 21     plain: &mut Vec<RectTuple>,
 22     rounded: &mut Vec<RectTuple>,
 23     texts: &mut Vec<TextTuple>,
 24 ) {
 25     let (cx, cy, cw, ch) = w.rect();
 26     let child_rounded = w.corner_style().1 != (false, false, false, false);
 27     for (qx, qy, qw, qh, qc) in w.all_quads(ctx) {
 28         if child_rounded && (qx - cx).abs() < 0.1 && (qy - cy).abs() < 0.1 && (qw - cw).abs() < 0.1 && (qh - ch).abs() < 0.1 {
 29             continue;
 30         }
 31         let x0 = qx.max(0.0);
 32         let y0 = qy.max(0.0);
 33         let x1 = (qx + qw).min(win_w);
 34         let y1 = (qy + qh).min(win_h);
 35         if x1 <= x0 || y1 <= y0 {
 36             continue;
 37         }
 38         let corners = (
 39             x0 <= 1.5 && y0 <= 1.5,
 40             x1 >= win_w - 1.5 && y0 <= 1.5,
 41             x1 >= win_w - 1.5 && y1 >= win_h - 1.5,
 42             x0 <= 1.5 && y1 >= win_h - 1.5,
 43         );
 44         if corners == (false, false, false, false) {
 45             plain.push((qc, x0, y0, x1 - x0, y1 - y0, 0.0, (false, false, false, false)));
 46         } else {
 47             plain.push((qc, x0, y0, x1 - x0, y1 - y0, plate_radius, corners));
 48         }
 49     }
 50     for (qx, qy, qw, qh, qr, qc, qcorners) in w.all_rounded_quads(ctx) {
 51         let x0 = qx.max(0.0);
 52         let y0 = qy.max(0.0);
 53         let x1 = (qx + qw).min(win_w);
 54         let y1 = (qy + qh).min(win_h);
 55         if x1 <= x0 || y1 <= y0 {
 56             continue;
 57         }
 58         rounded.push((qc, x0, y0, x1 - x0, y1 - y0, qr, qcorners));
 59     }
 60     // Text via the paint walk (not the legacy text_labels* getters): same labels, with the
 61     // widget's content font and any container clip composed into the prim bounds; clamped
 62     // to the window exactly as before.
 63     let mut scratch = cce_ui::scene::paint::PaintCtx::new();
 64     cce_ui::scene::painter::append_widget_text(ctx, w, &mut scratch);
 65     for item in scratch.finish().items {
 66         if let cce_ui::scene::paint::Prim::Text { text, x, y, font_size, color, font, bounds, .. } = item.prim {
 67             let cb = match bounds {
 68                 Some(b) => {
 69                     let bx0 = b[0].max(0.0);
 70                     let by0 = b[1].max(0.0);
 71                     let bx1 = b[2].min(win_w);
 72                     let by1 = b[3].min(win_h);
 73                     if bx1 <= bx0 || by1 <= by0 {
 74                         continue;
 75                     }
 76                     Some([bx0, by0, bx1, by1])
 77                 }
 78                 None => Some([0.0, 0.0, win_w, win_h]),
 79             };
 80             let colorf = [
 81                 color[0] as f32 / 255.0,
 82                 color[1] as f32 / 255.0,
 83                 color[2] as f32 / 255.0,
 84                 1.0,
 85             ];
 86             texts.push((text, font_size, x, y, colorf, font, cb));
 87         }
 88     }
 89 }
 90 
 91 impl SystemInterface {
 92 
 93     pub(crate) fn rebuild_layout(&mut self, sw: f32, sh: f32) {
 94         if std::env::var("CCE_HOVER_DEBUG").is_ok() {
 95             let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
 96             eprintln!("[hover] t={} rebuild", t);
 97         }
 98         // Keyboard focus on a per-rebuild button clone dies with the registry
 99         // wipe below — on EVERY rebuild, not just the one a Tab step triggers
100         // (the page polls and re-lays-out on its own). Remember where the
101         // focused widget sat: this pass lights the clone at that rect as it
102         // collects its plate, and the pass's tail hands it the focus. A
103         // persistent widget resolves to itself.
104         self.refocus_rect = self
105             .ui_context
106             .focused_widget
107             .and_then(|id| self.ui_context.tree.get_ptr(id))
108             .map(|ptr| unsafe { (*ptr).rect() });
109         // SectionContainer dissolved (Phase 6w): no per-rebuild section clones to
110         // relink — the page's widgets dispatch directly (registration happens in
111         // render_widget during the view pass below).
112         self.ui_context.clear_hierarchy();
113 
114         self.sidebar_width = 0.0;
115         self.header_height = 0.0; // No CSD Titlebar
116         let mut widgets = Vec::new();
117         let mut texts = Vec::new();
118         let mut page_buttons = Vec::new();
119         let mut page_button_images: Vec<(u32, f32, f32, f32, f32, f32)> = Vec::new();
120 
121         cce_ui::widget::hover_animation::reset_frame_registration();
122         self.ui_context.clear_popovers();
123         cce_ui::widget::hover_animation::set_scroll_offset(self.scroll_y);
124         cce_ui::widget::hover_animation::set_cursor_pos(self.cursor_x, self.cursor_y);
125 
126         let s = 1.0f32;
127         let cursor_phys_x = self.cursor_x;
128         let cursor_phys_y = self.cursor_y;
129         let check_hover = |wx: f32, wy: f32, ww: f32, wh: f32| -> bool {
130             cursor_phys_x >= wx && cursor_phys_x <= wx + ww && cursor_phys_y >= wy && cursor_phys_y <= wy + wh
131         };
132         let logical_sw = sw;
133         let logical_sh = sh;
134 
135         let lcx = self.sidebar_width;
136         let lcy = self.header_height;
137         let lcw = logical_sw - self.sidebar_width;
138         let mut lch = logical_sh - self.header_height - self.status_height;
139         if self.search_open {
140             lch -= 42.0;
141         }
142 
143         let page_idx = Page::ALL.iter().position(|&p| p == self.app.current_page).unwrap_or(0);
144         self.page_dropdown.selected = page_idx;
145 
146         // root plate container DISSOLVED (Phase 6s): top-level widgets stay parentless
147         // (render_widget registers them); the window plate, the root aggregate's
148         // emission order, and the StatusBar's root plate container-coupled theming are all
149         // replicated by hand below.
150 
151         // Position sidebar and switcher below the titlebar
152         let mut dummy_pc = PageContent::new();
153         let dropdown_h = 18.0f32;
154         let size = self.page_dropdown.measure(
155             cce_ui::widget::LayoutConstraints::new(0.0, 500.0, dropdown_h, dropdown_h),
156             &self.ui_context,
157         );
158         let dropdown_w = size.width;
159         let dropdown_gap = (self.status_height - dropdown_h) / 2.0;
160         let dropdown_x = logical_sw - dropdown_w - dropdown_gap;
161         let dropdown_y = logical_sh - self.status_height + dropdown_gap;
162         // The dropdown sits flush against the window's rounded bottom-right corner; with the
163         // root plate container dissolved, hand it the plate frame for its concentric-corner cut.
164         self.page_dropdown.set_corner_frame(Some(((0.0, 0.0, logical_sw, logical_sh), 12.0, (true, true, true, true))));
165         cce_ui::layout::render_widget(&mut dummy_pc, &mut self.page_dropdown, dropdown_x, dropdown_y, dropdown_w, dropdown_h, &mut self.ui_context);
166         let switcher_h = if self.search_open {
167             logical_sh - self.header_height - 42.0 - self.status_height
168         } else {
169             logical_sh - self.header_height - self.status_height
170         };
171         // The page scrollbar, on the designer parameter-pane geometry: the DE
172         // width widened (the bar rides over page content and reads too slim at
173         // stock width), stood off the window's right edge by the configured
174         // inset instead of hugging it. Updated with LAST frame's content
175         // height — the legacy window pass also ran before this frame's content
176         // was measured.
177         let sb_w = cce_ui::layout::scrollbar_width() * 1.6;
178         // TODO(style): the bar's 4px vertical stand-off pairs with the
179         // toolkit's `scrollbar_inset()` knob, not a rung of the ladder.
180         self.page_scroll_bar.set_rect(
181             logical_sw - sb_w - cce_ui::layout::scrollbar_inset(),
182             self.header_height + 4.0,
183             sb_w,
184             switcher_h - 8.0,
185         );
186         if self.page_scroll_bar.dragging {
187             self.scroll_y = self.page_scroll_bar.scroll_y;
188         }
189         self.page_scroll_bar.update(self.scroll_y, self.content_h, switcher_h);
190 
191         // Assemble the window exactly as the legacy `render_widget(root plate container)`
192         // aggregate did: every child plain quad (clipped to the window, with the root's
193         // corner resolution against its rounded rect), then the translucent window
194         // plate, then every child rounded quad, then the root-clamped text — in the old
195         // child order [switcher, statusbar, dropdown, search box]. The StatusBar widget
196         // is dissolved outright: its theming was root plate container-parent-coupled (statusbar
197         // theme color falling back to STATUS_BG, bottom corners rounded at the root's
198         // radius, statusbar text color/font), replicated here as tuples.
199         let plate_radius = 12.0f32;
200         let mut window_pc = PageContent::new();
201         {
202             let mut plain: Vec<RectTuple> = Vec::new();
203             let mut rounded: Vec<RectTuple> = Vec::new();
204             let mut wtexts: Vec<TextTuple> = Vec::new();
205 
206             // The page scrollbar is NOT collected here: baked into the rebuilt
207             // layout, its thumb froze for every wheel tick the scroll fast path
208             // absorbed (the fast path shifts cached geometry without a rebuild,
209             // so the bar only moved once scrolling stopped and something else
210             // rebuilt). display_list emits it fresh each frame instead — under
211             // the window plate while sunk, over the page content while raised.
212 
213             // The status bar has no background of its own anymore: the beveled window
214             // plate shows through and display_list carves its recess (data-editor's
215             // with_recess idiom).
216 
217             collect_window_child(&self.page_dropdown, &self.ui_context, logical_sw, logical_sh, plate_radius, &mut plain, &mut rounded, &mut wtexts);
218             if self.search_open {
219                 collect_window_child(&self.search_box, &self.ui_context, logical_sw, logical_sh, plate_radius, &mut plain, &mut rounded, &mut wtexts);
220             }
221 
222             // The window plate itself is emitted by display_list as a beveled
223             // pc.plate() prim, under everything collected here.
224             window_pc.rects.extend(plain);
225             window_pc.rects.extend(rounded);
226             window_pc.texts.extend(wtexts);
227         }
228 
229         let mut search_pc = PageContent::new();
230         if self.search_open {
231             search_pc.rects.push((
232                 [0.08, 0.08, 0.12, 1.0],
233                 self.sidebar_width,
234                 sh - 42.0,
235                 sw - self.sidebar_width,
236                 42.0,
237                 0.0,
238                 (false, false, false, false),
239             ));
240             search_pc.rects.push((
241                 [0.18, 0.18, 0.24, 1.0],
242                 self.sidebar_width,
243                 sh - 42.0,
244                 sw - self.sidebar_width,
245                 1.0,
246                 0.0,
247                 (false, false, false, false),
248             ));
249             // The search box stands on the root plate: the window-edge inset.
250             let inset = cce_ui::layout::root_plate_inset();
251             cce_ui::layout::render_widget(
252                 &mut search_pc,
253                 &mut self.search_box,
254                 self.sidebar_width + inset,
255                 sh - 36.0,
256                 sw - self.sidebar_width - 2.0 * inset,
257                 30.0,
258                 &mut self.ui_context,
259             );
260         }
261 
262         // CSD Titlebar removed
263 
264         for pc_part in &[window_pc] {
265             for (c, x, y, w, h, r, corners) in pc_part.rects.iter() {
266                 let wx = *x * s;
267                 let wy = *y * s;
268                 let ww = *w * s;
269                 let wh = *h * s;
270                 widgets.push(AppWidget {
271                     x: wx, y: wy, w: ww, h: wh,
272                     color: *c, hover_color: *c,
273                     hovering: check_hover(wx, wy, ww, wh),
274                     radius: *r * s,
275                     corners: *corners,
276                 });
277             }
278             for (t, size, x, y, tc, font_opt, bounds) in pc_part.texts.iter() {
279                 texts.push((t.clone(), *size, *x, *y, *tc, font_opt.clone(), *bounds));
280             }
281         }
282 
283         self.scrollable_widgets_start_idx = widgets.len();
284         self.scrollable_text_items_start_idx = texts.len();
285         self.scrollable_buttons_start_idx = page_buttons.len();
286 
287         // Page content in LOGICAL coordinates, then scale to physical
288         let pc = self.render_page_content(lcx, lcy, lcw, lch);
289         self.page_reliefs = pc.reliefs.clone();
290         self.page_control_reliefs = pc.control_reliefs.clone();
291         self.page_control_relief_marks.clear();
292 
293 
294 
295         if self.search_open && !self.search_query.is_empty() && !self.page_scroll_bar.dragging {
296             let query_lower = self.search_query.to_lowercase();
297             let mut first_match_y = None;
298             for (t, _, _, y, _, _, _) in &pc.texts {
299                 if t.to_lowercase().contains(&query_lower) {
300                     first_match_y = Some(*y);
301                     break;
302                 }
303             }
304             if let Some(y) = first_match_y {
305                 let mut max_y = 0.0f32;
306                 for (_, _, y, _, h, _, _) in &pc.rects {
307                     max_y = max_y.max(y + h);
308                 }
309                 for (_, size, _, y, _, _, _) in &pc.texts {
310                     max_y = max_y.max(y + size);
311                 }
312                 for (btn, _, _) in &pc.buttons {
313                     let base = btn.base();
314                     max_y = max_y.max(base.y + base.h);
315                 }
316                 let local_max_scroll_y = (max_y - lch).max(0.0);
317                 self.scroll_y = (y - 100.0).clamp(0.0, local_max_scroll_y);
318             }
319         }
320 
321         let mut max_y = 0.0f32;
322         for (_, _, y, _, h, _, _) in &pc.rects {
323             max_y = max_y.max(y + h);
324         }
325         for (_, size, _, y, _, _, _) in &pc.texts {
326             max_y = max_y.max(y + size);
327         }
328         for (btn, _, _) in &pc.buttons {
329             let base = btn.base();
330             max_y = max_y.max(base.y + base.h);
331         }
332         self.max_scroll_y = (max_y - lch).max(0.0);
333         static mut FRAME_COUNT: usize = 0;
334         unsafe {
335             FRAME_COUNT += 1;
336             if FRAME_COUNT > 5 {
337                 self.scroll_y = self.scroll_y.min(self.max_scroll_y);
338             }
339         }
340 
341         if self.page_scroll_bar.dragging {
342             self.scroll_y = self.page_scroll_bar.scroll_y;
343         } else {
344             self.page_scroll_bar.scroll_y = self.scroll_y;
345         }
346         self.content_h = max_y;
347         self.page_scroll_bar.update(self.scroll_y, max_y, lch);
348 
349         let scroll_offset_y = self.scroll_y;
350 
351         // Where each page rect landed in `widgets` (culled rects collapse onto
352         // the next survivor), so a carve's mark — "the rect I was claimed
353         // before" — survives the cull. One extra entry for "after the last".
354         let mut rect_widget_index: Vec<usize> = Vec::with_capacity(pc.rects.len() + 1);
355         for (c, x, y, w, h, r, corners) in pc.rects.iter() {
356             rect_widget_index.push(widgets.len());
357             let wx = *x * s;
358             let mut wy = (*y - scroll_offset_y) * s;
359             let ww = *w * s;
360             let mut wh = *h * s;
361 
362             let viewport_bottom = logical_sh - self.status_height;
363             if wy >= viewport_bottom || wy + wh <= 0.0 {
364                 continue;
365             }
366             if wy < 0.0 {
367                 let diff = 0.0 - wy;
368                 wy = 0.0;
369                 wh = (wh - diff).max(0.0);
370             }
371             if wy + wh > viewport_bottom {
372                 wh = (viewport_bottom - wy).max(0.0);
373             }
374 
375             widgets.push(AppWidget {
376                 x: wx, y: wy, w: ww, h: wh,
377                 color: *c, hover_color: *c,
378                 hovering: check_hover(wx, wy, ww, wh),
379                 radius: *r * s,
380                 corners: *corners,
381             });
382         }
383         rect_widget_index.push(widgets.len());
384         for &mark in &pc.control_relief_marks {
385             self.page_control_relief_marks.push(rect_widget_index[mark.min(rect_widget_index.len() - 1)]);
386         }
387         for (t, size, x, y, tc, font_opt, bounds) in pc.texts.iter() {
388             let shifted_bounds = bounds.map(|[bl, bt, br, bb]| {
389                 [bl, bt - scroll_offset_y, br, bb - scroll_offset_y]
390             });
391 
392             let viewport_bottom = logical_sh - self.status_height;
393             let final_bounds = match shifted_bounds {
394                 Some(b) => Some([
395                     b[0],
396                     b[1].max(0.0),
397                     b[2],
398                     b[3].min(viewport_bottom),
399                 ]),
400                 None => Some([
401                     0.0,
402                     0.0,
403                     logical_sw,
404                     viewport_bottom,
405                 ]),
406             };
407 
408             let matched = self.search_open && !self.search_query.is_empty() && t.to_lowercase().contains(&self.search_query.to_lowercase());
409 
410             if matched {
411                 let text_buf = make_text_buffer_with_font(
412                     &mut self.font_system,
413                     t,
414                     *size,
415                     font_opt.as_deref(),
416                     &self.sans_serif_family,
417                     &self.serif_family,
418                     &self.monospace_family,
419                 );
420                 let scale = cce_ui::scale::scale_factor();
421                 let text_w = text_buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0) / scale;
422 
423                 let pad_x = 4.0;
424                 let pad_y = 2.0;
425                 let rect_x = *x - pad_x;
426                 let rect_y = *y - pad_y;
427                 let rect_w = text_w + 2.0 * pad_x;
428                 let rect_h = *size + 2.0 * pad_y;
429 
430                 let wx = rect_x * s;
431                 let mut wy = (rect_y - scroll_offset_y) * s;
432                 let ww = rect_w * s;
433                 let mut wh = rect_h * s;
434 
435                 if wy < viewport_bottom && wy + wh > 0.0 {
436                     if wy < 0.0 {
437                         let diff = 0.0 - wy;
438                         wy = 0.0;
439                         wh = (wh - diff).max(0.0);
440                     }
441                     if wy + wh > viewport_bottom {
442                         wh = (viewport_bottom - wy).max(0.0);
443                     }
444                     widgets.push(AppWidget {
445                         x: wx,
446                         y: wy,
447                         w: ww,
448                         h: wh,
449                         color: [0.65, 0.45, 0.05, 0.4],
450                         hover_color: [0.65, 0.45, 0.05, 0.4],
451                         hovering: check_hover(wx, wy, ww, wh),
452                         radius: 3.0 * s,
453                         corners: (true, true, true, true),
454                     });
455                 }
456             }
457 
458             let text_color = if self.search_open && !self.search_query.is_empty() {
459                 if matched {
460                     [1.0, 0.95, 0.80, 1.0]
461                 } else {
462                     [tc[0] * 0.25, tc[1] * 0.25, tc[2] * 0.25, tc[3] * 0.25]
463                 }
464             } else {
465                 *tc
466             };
467 
468             texts.push((t.clone(), *size, *x, *y - scroll_offset_y, text_color, font_opt.clone(), final_bounds));
469         }
470         for (btn, action, clip) in &pc.buttons {
471             let base = btn.base();
472             let bg = btn.bg.unwrap_or([0.16, 0.16, 0.24, 1.0]);
473             let hover_bg = btn.hover_bg.unwrap_or([0.25, 0.30, 0.26, 1.0]);
474             // Clamp to the emission-time clip rect (page coords) so a partially
475             // scrolled list row's button draws cut at the list edge, not bleeding.
476             let (mut px0, mut py0, mut px1, mut py1) =
477                 (base.x, base.y, base.x + base.w, base.y + base.h);
478             if let Some(c) = clip {
479                 px0 = px0.max(c[0]);
480                 py0 = py0.max(c[1]);
481                 px1 = px1.min(c[0] + c[2]);
482                 py1 = py1.min(c[1] + c[3]);
483                 if px0 >= px1 || py0 >= py1 {
484                     continue;
485                 }
486             }
487             let wx = px0 * s;
488             let mut wy = (py0 - scroll_offset_y) * s;
489             let ww = (px1 - px0) * s;
490             let mut wh = (py1 - py0) * s;
491 
492             let viewport_bottom = logical_sh - self.status_height;
493             if wy >= viewport_bottom || wy + wh <= 0.0 {
494                 continue;
495             }
496             if wy < 0.0 {
497                 let diff = 0.0 - wy;
498                 wy = 0.0;
499                 wh = (wh - diff).max(0.0);
500             }
501             if wy + wh > viewport_bottom {
502                 wh = (viewport_bottom - wy).max(0.0);
503             }
504 
505             widgets.push(AppWidget {
506                 x: wx, y: wy, w: ww, h: wh,
507                 color: bg, hover_color: hover_bg,
508                 hovering: check_hover(wx, wy, ww, wh),
509                 radius: cce_ui::layout::button_corner_radius() * s,
510                 corners: (true, true, true, true),
511             });
512             // Buttons never reach the toolkit's flat-path bridge here: this
513             // app collects them into `pc.buttons` and draws them itself, so
514             // `render_widget` — where a widget's carves are offered — never
515             // sees one. Ask each button for the inset face its own `paint`
516             // draws (`Button::inset_face`, the same source the drawn one
517             // reads) and carve it right after the button's quad, where its
518             // paint puts it — the mark is "before the next widget". Page
519             // coords, pre-scroll, like everything else in this list.
520             if cce_ui::layout::control_relief() {
521                 let rect = cce_ui::scene::layout::Rect { x: base.x, y: base.y, width: base.w, height: base.h };
522                 if let Some(mut plate) = btn.plate(rect) {
523                     // This pass's clone of the button a Tab step focused (its
524                     // registered rect, window coords, matches `refocus_rect`;
525                     // the view-pass tail hands it the focus): light its rim
526                     // now, since the carve is collected here, before that.
527                     let near = |a: f32, b: f32| (a - b).abs() < 0.5;
528                     let refocused = self
529                         .refocus_rect
530                         .is_some_and(|(fx, fy, fw, fh)| near(wx, fx) && near(wy, fy) && near(ww, fw) && near(wh, fh));
531                     if refocused {
532                         plate.tint = Some(cce_ui::widget::ControlPlate::focus_tint());
533                     }
534                     self.page_control_reliefs.push(ControlCarve::Plate {
535                         x: plate.rect.x,
536                         y: plate.rect.y,
537                         w: plate.rect.width,
538                         h: plate.rect.height,
539                         radius: plate.radii.0,
540                         depth: plate.depth,
541                         color: plate.face_fill(),
542                         tint: plate.tint,
543                     });
544                     self.page_control_relief_marks.push(widgets.len());
545                 }
546             }
547             // An icon face replaces the label entirely (as it does in
548             // `Button::paint`). The rect comes from the button's own
549             // `icon_rect` so the glyph lands where the paint path would put
550             // it; display_list draws these under the page clip, which is what
551             // cuts a half-scrolled row's icon at the list edge.
552             let has_icon = if let Some((image, irect, alpha)) =
553                 btn.icon_rect(cce_ui::scene::layout::Rect {
554                     x: base.x,
555                     y: base.y - scroll_offset_y,
556                     width: base.w,
557                     height: base.h,
558                 })
559             {
560                 page_button_images.push((image, irect.x, irect.y, irect.width, irect.height, alpha));
561                 true
562             } else {
563                 false
564             };
565 
566             // The label is skipped for an icon face, but NOT the dispatch clone
567             // below it: an icon button still has to be clickable.
568             if !has_icon {
569             let label = base.label.as_deref().unwrap_or("");
570             let label_size = 12.0;
571              let buf = make_text_buffer_with_font(
572                  &mut self.font_system,
573                  label,
574                  label_size,
575                  btn.widget_font().as_deref(),
576                  &self.sans_serif_family,
577                  &self.serif_family,
578                  &self.monospace_family,
579              );
580             let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
581             let lh = buf.metrics().line_height;
582             let mut left_align = btn.justify == cce_ui::widget::Justification::Left;
583 
584             // Auto-detect if inside a list frame to apply left alignment by default
585             if !left_align && base.w >= 60.0 {
586                 if self.app.current_page == Page::Processes {
587                     let sb1 = &self.app.processes.cpu_list;
588                     if base.x >= sb1.x - 1.0 && base.x + base.w <= sb1.x + sb1.w + 1.0
589                        && base.y >= sb1.y - 1.0 && base.y + base.h <= sb1.y + sb1.h + 1.0 {
590                         left_align = true;
591                     }
592                 }
593                 if self.app.current_page == Page::Services {
594                     let sb2 = &self.app.services.list;
595                     if base.x >= sb2.x - 1.0 && base.x + base.w <= sb2.x + sb2.w + 1.0
596                        && base.y >= sb2.y - 1.0 && base.y + base.h <= sb2.y + sb2.h + 1.0 {
597                         left_align = true;
598                     }
599                 }
600             }
601 
602             let scale_factor = cce_ui::scale::scale_factor();
603             let logical_tw = tw / scale_factor;
604             let logical_lh = lh / scale_factor;
605             let text_x = if left_align {
606                 base.x + cce_ui::layout::CONTROL_TEXT_INSET
607             } else {
608                 base.x + (base.w - logical_tw) / 2.0
609             };
610 
611             let label_color = btn.label_color.unwrap_or([0.83, 0.83, 0.83, 1.0]);
612             // Label bounds: the button's own (clip-clamped) box — a label longer
613             // than its button truncates instead of spilling over the neighbor.
614             let button_bounds = Some([
615                 wx,
616                 wy.max(0.0),
617                 (wx + ww).min(logical_sw),
618                 (wy + wh).min(viewport_bottom),
619             ]);
620             texts.push((
621                 label.to_string(),
622                 label_size,
623                 text_x,
624                 (base.y - scroll_offset_y) + (base.h - logical_lh) / 2.0,
625                 label_color,
626                 btn.widget_font(),
627                 button_bounds,
628             ));
629             }
630             let mut btn_clone = btn.clone();
631             {
632                 // The dispatch clone hit-tests at the CLAMPED rect, so clicks in
633                 // a row's clipped-away region fall through to what's visible there.
634                 let base_mut = btn_clone.base_mut();
635                 base_mut.x = wx;
636                 base_mut.y = wy;
637                 base_mut.w = ww;
638                 base_mut.h = wh;
639             }
640             page_buttons.push((btn_clone, action.clone()));
641         }
642 
643 
644 
645 
646 
647 
648 
649 
650         for (c, x, y, w, h, r, corners) in &search_pc.rects {
651             let wx = *x * s;
652             let wy = *y * s;
653             let ww = *w * s;
654             let wh = *h * s;
655             widgets.push(AppWidget {
656                 x: wx, y: wy, w: ww, h: wh,
657                 color: *c, hover_color: *c,
658                 hovering: check_hover(wx, wy, ww, wh),
659                 radius: *r * s,
660                 corners: *corners,
661             });
662         }
663         for (t, size, x, y, tc, font_opt, bounds) in &search_pc.texts {
664             texts.push((t.clone(), *size, *x, *y, *tc, font_opt.clone(), *bounds));
665         }
666 
667         // Popovers + context menu draw INTO the frame (Phase 6t; the engine xdg popup no
668         // longer exists) — emitted on top of the whole window, so the dropdown's
669         // open-upward popover renders where it hit-tests. Kept out of
670         // widgets/texts so the wheel fast-path can't scroll them; dl-text occlusion comes
671         // from the ui_context popover registration (and the engine's context-menu overlay
672         // rect).
673         let mut popover_pc = PageContent::new();
674         {
675             // Chrome popover (the page dropdown) is in window coords; page-widget popovers
676             // (notifications/fonts menus) are in page coords and shift with the viewport —
677             // the same scroll subtraction the old popup positioner applied at creation.
678             let chrome_id = self.page_dropdown.id();
679             let mut page_pop_pc = PageContent::new();
680             for &pop_id in &self.ui_context.active_popovers {
681                 let Some(pop_ptr) = self.ui_context.tree.get_ptr(pop_id) else { continue };
682                 unsafe {
683                     if pop_id == chrome_id {
684                         (*pop_ptr).render_popover(&mut popover_pc);
685                     } else {
686                         (*pop_ptr).render_popover(&mut page_pop_pc);
687                     }
688                 }
689             }
690             // The chrome popover's rects are already in place: the page
691             // carves' marks shift past them so the interleave stays true.
692             let rect_base = popover_pc.rects.len();
693             for (c, x, y, w, h, r, corners) in page_pop_pc.rects {
694                 popover_pc.rects.push((c, x, y - self.scroll_y, w, h, r, corners));
695             }
696             for (t, size, x, y, tc, font, bounds) in page_pop_pc.texts {
697                 let shifted = bounds.map(|[l, tb, rr, b]| [l, tb - self.scroll_y, rr, b - self.scroll_y]);
698                 popover_pc.texts.push((t, size, x, y - self.scroll_y, tc, font, shifted));
699             }
700             for (carve, mark) in page_pop_pc.control_reliefs.into_iter().zip(page_pop_pc.control_relief_marks) {
701                 popover_pc.control_reliefs.push(carve.shifted_y(-self.scroll_y));
702                 popover_pc.control_relief_marks.push(rect_base + mark);
703             }
704         }
705         if cce_ui::widget::context_menu::is_visible() {
706             use cce_ui::layout::RenderTarget;
707             let cx = cce_ui::widget::context_menu::x();
708             let cy = cce_ui::widget::context_menu::y();
709             let cw = cce_ui::widget::context_menu::w();
710             let ch = cce_ui::widget::context_menu::h();
711 
712             // This target is the legacy rect/text one, not a PaintCtx, so the
713             // plate cannot be the toolkit's lit one yet — but the rows and
714             // labels are the toolkit's: PAD-aware `row_y` / `text_labels`
715             // (the hand-rolled `idx * 24.0` painted every row 8px above
716             // where `cursor_moved` hit-tested it) and the menu font's family.
717             use cce_ui::widget::context_menu::{self, ROW_H};
718             popover_pc.rect([0.22, 0.22, 0.28, 1.0], cx, cy, cw, ch);
719             popover_pc.rect([0.06, 0.06, 0.09, 1.0], cx + 1.0, cy + 1.0, cw - 2.0, ch - 2.0);
720 
721             if let Some(h_idx) = context_menu::hovered_item() {
722                 let iy = context_menu::row_y(h_idx);
723                 popover_pc.rect([0.20, 0.40, 0.65, 0.6], cx + 2.0, iy + 2.0, cw - 4.0, ROW_H - 4.0);
724             }
725 
726             let (family, _) = context_menu::label_font();
727             for label in context_menu::text_labels() {
728                 let c = label.color;
729                 let color = [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0];
730                 popover_pc.text_with_font_and_bounds(
731                     &label.text,
732                     label.x,
733                     label.y,
734                     label.font_size,
735                     color,
736                     &family,
737                     Some([cx, cy, cx + cw, cy + ch]),
738                 );
739             }
740         }
741         self.popover_widgets = popover_pc.rects.iter().map(|(c, x, y, w, h, r, corners)| AppWidget {
742             x: *x, y: *y, w: *w, h: *h,
743             color: *c, hover_color: *c,
744             hovering: false,
745             radius: *r,
746             corners: *corners,
747         }).collect();
748         self.popover_texts = popover_pc.texts;
749         self.popover_control_reliefs = popover_pc.control_reliefs;
750         self.popover_control_relief_marks = popover_pc.control_relief_marks;
751 
752         self.widgets = widgets;
753         self.texts = texts;
754         self.page_buttons = page_buttons;
755         self.page_button_images = page_button_images;
756 
757         // The id-rooted router (`propagate_event(event, WidgetId)`) resolves roots
758         // through the registry, and `clear_hierarchy` above wiped it. The view pass
759         // re-registers page widgets through `render_widget`; the chrome dispatch roots
760         // never go through it, so re-register them here. The page buttons are
761         // per-rebuild clones — registration follows the fresh allocations.
762         {
763             let id = self.search_box.id();
764             let ptr = self.search_box.as_ptr_mut();
765             self.ui_context.register_widget(id, ptr);
766             let id = self.page_dropdown.id();
767             let ptr = self.page_dropdown.as_ptr_mut();
768             self.ui_context.register_widget(id, ptr);
769             let id = self.page_scroll_bar.id();
770             let ptr = self.page_scroll_bar.as_ptr_mut();
771             self.ui_context.register_widget(id, ptr);
772             for (btn, _) in self.page_buttons.iter_mut() {
773                 let (id, ptr) = (btn.id(), btn.as_ptr_mut());
774                 self.ui_context.register_widget(id, ptr);
775             }
776         }
777         // A Tab step's focus, handed to the fresh clone at the same rect (the
778         // page buttons above are per-rebuild allocations; see `focus_stepped`).
779         if let Some((fx, fy, fw, fh)) = self.refocus_rect.take() {
780             let near = |a: f32, b: f32| (a - b).abs() < 0.5;
781             let heir = self.ui_context.tree.iter_registered().find_map(|(id, ptr)| {
782                 if ptr.is_null() {
783                     return None;
784                 }
785                 let w = unsafe { &*ptr };
786                 let (x, y, ww, hh) = w.rect();
787                 (w.focus_role() != cce_ui::widget::FocusRole::None && near(x, fx) && near(y, fy) && near(ww, fw) && near(hh, fh))
788                     .then_some(id)
789             });
790             if let Some(id) = heir {
791                 self.ui_context.set_focused_id(id);
792             }
793         }
794         self.needs_rebuild = false;
795         self.laid_out_page = Some(self.app.current_page);
796         self.last_scroll_y = self.scroll_y;
797         self.ui_context.clear_dirty();
798     }
799 
800     pub(crate) fn render_page_content(&mut self, cx: f32, cy: f32, cw: f32, ch: f32) -> PageContent {
801         use cce_ui::layout::AdaptiveGrid;
802         // The sections are wells carved straight into the root plate (no
803         // sidebar, no pane between), so the page's edge is the window's edge:
804         // the root rung's inset, and the grid's gap the root rung's gap (set
805         // once in main; `AdaptiveGrid::init` reads the toolkit getters, the
806         // constructor args are the same numbers stated for the record).
807         let margin = cce_ui::layout::root_plate_inset();
808         let cx = cx + margin;
809         let cy = cy + margin;
810         let cw = (cw - 2.0 * margin).max(1.0);
811         let ch = (ch - 2.0 * margin).max(1.0);
812         let mut layout = AdaptiveGrid::new(cce_ui::layout::grid_min_col_width(), cce_ui::layout::root_plate_gap());
813         // Page root dissolved (6u): the ctrl-nav entry focuses section 0, so root focus is
814         // permanently false; views that highlighted on it OR in their first section's bool.
815         // Section focus is the app-side index now (Phase 6w).
816         let n_sections = self.app.get_current_page_mut().section_widgets().len();
817         let sec_focused: Vec<bool> = (0..n_sections)
818             .map(|i| self.focused_section == Some(i))
819             .collect();
820         self.app.get_current_page_mut().view(cx, cy, cw, ch, false, &sec_focused, &mut layout, &mut self.ui_context)
821     }
822 
823 }
824