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