font browser
feat: root Backplate + all three Plates DISSOLVED — cce-fonts is embedded-base-free but for ScrollBox/List (Phase 6p)
The 6m/6o recipes at full width: the window plate becomes a border prim
(bg at backplate opacity, config border/radius); the left/mid panel
Plates become plate prims (config plate color at plate opacity, config
border/radius) with children registered parentless and walked in the
legacy panel order; the select-mode bottom bar is laid out directly
(text-sized via Element::intrinsic_size, right-packed, 10px gap/inset,
vertically centered). Event routing is app-owned: a dispatch_widgets
list replicating the Plates' forwarding — popover-first press pass, the
unfocus-on-missed-press rule, drag_update forwarding for dragging
children, and panel-grouped ordering. Window dragging answers via
drag_allowed_at.
The A/B shows the mid panel ~22 units lighter: the legacy Plate
aggregated child plain-quads in paint_self while the walk painted the
child again, double-compositing the ScrollBox background — the
single-draw is the correct rendering. Verified live: family select,
style dropdown popover with occlusion, Oblique re-render, and the
select-mode bar (readout + Cancel exit) all exercised.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MjP3pGQEDLkJbV5WEmYBe
src/main.rs | 324 +++++++++++++++++++++++++++++++++++++-----------------------
1 file changed, 199 insertions(+), 125 deletions(-)
diff --git a/src/main.rs b/src/main.rs
index c0c400d..16f562e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,9 +6,7 @@ use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, Win
use cce_ui::widget::{
MouseButton, ElementState, MouseScrollDelta, KeyEvent, Element,
TextBox, Button, Key, NamedKey, List, ScrollBox, Dropdown, Spinbox,
- Backplate, Plate
};
-use cce_ui::widget::focus::link_parent_child;
@@ -78,61 +76,106 @@ struct TypefaceApp {
font_system: FontSystem,
needs_rebuild: bool,
- // Containers
- root_window: Backplate,
- left_panel: Plate,
- mid_panel: Plate,
+ // Containers (root Backplate + the three Plates are DISSOLVED: their plates are
+ // prims, their children are dispatched/walked directly, panel rects are computed)
mid_scroll: ScrollBox,
- bottom_bar: Plate,
+ widgets_registered: bool,
ui_context: cce_ui::context::UiContext,
}
impl TypefaceApp {
- fn rebuild_hierarchy(&mut self) {
- let ctx = &mut self.ui_context;
- self.root_window.clear_children(ctx);
- self.left_panel.clear_children(ctx);
- self.mid_panel.clear_children(ctx);
- self.bottom_bar.clear_children(ctx);
- self.mid_scroll.clear_children(ctx);
-
- // 1. Link active page-level containers to root
- link_parent_child(&mut self.root_window, &mut self.left_panel, ctx);
- link_parent_child(&mut self.root_window, &mut self.mid_panel, ctx);
-
+ /// Register the widgets (parentless — the panel Plates are dissolved). Static widgets
+ /// once; the font-list buttons every rebuild (they are recreated on search changes,
+ /// same cadence the old per-rebuild link_parent_child re-registration had).
+ fn register_widgets(&mut self) {
+ let self_ptr = self as *mut Self;
+ unsafe {
+ if !self.widgets_registered {
+ self.widgets_registered = true;
+ self.ui_context.register_widget(self.search_box.base().unwrap().id(), (*self_ptr).search_box.as_ptr_mut());
+ self.ui_context.register_widget(self.font_list.base().unwrap().id(), (*self_ptr).font_list.as_ptr_mut());
+ self.ui_context.register_widget(self.mid_scroll.base().unwrap().id(), (*self_ptr).mid_scroll.as_ptr_mut());
+ self.ui_context.register_widget(self.btn_open_folder.base().unwrap().id(), (*self_ptr).btn_open_folder.as_ptr_mut());
+ self.ui_context.register_widget(self.btn_remove_font.base().unwrap().id(), (*self_ptr).btn_remove_font.as_ptr_mut());
+ self.ui_context.register_widget(self.style_dropdown.base().unwrap().id(), (*self_ptr).style_dropdown.as_ptr_mut());
+ self.ui_context.register_widget(self.size_spinbox.base().unwrap().id(), (*self_ptr).size_spinbox.as_ptr_mut());
+ self.ui_context.register_widget(self.preview_box.base().unwrap().id(), (*self_ptr).preview_box.as_ptr_mut());
+ self.ui_context.register_widget(self.select_cancel_btn.base().unwrap().id(), (*self_ptr).select_cancel_btn.as_ptr_mut());
+ self.ui_context.register_widget(self.select_confirm_btn.base().unwrap().id(), (*self_ptr).select_confirm_btn.as_ptr_mut());
+ }
+ for btn in (*self_ptr).font_buttons.iter_mut() {
+ if btn.rect().0 > -9000.0 {
+ self.ui_context.register_widget(btn.base().unwrap().id(), btn.as_ptr_mut());
+ }
+ }
+ }
+ }
+
+ fn content_h(&self) -> f32 {
+ let h = self.height as f32;
if self.select_mode {
- link_parent_child(&mut self.root_window, &mut self.bottom_bar, ctx);
+ (h - 30.0 - 48.0).max(100.0)
+ } else {
+ (h - 20.0).max(100.0)
}
+ }
- // Left Panel (Browse list)
- link_parent_child(&mut self.left_panel, &mut self.search_box, ctx);
- link_parent_child(&mut self.left_panel, &mut self.font_list, ctx);
-
- // Scrolling list buttons
- for btn in &mut self.font_buttons {
- if btn.rect().0 > -9000.0 {
- link_parent_child(&mut self.left_panel, btn, ctx);
+ /// The dissolved mid panel's rect (was `mid_panel.rect()`).
+ fn mid_panel_rect(&self) -> (f32, f32, f32, f32) {
+ let left_panel_w = 270.0;
+ let mid_x = 10.0 + left_panel_w + 12.0;
+ (mid_x, 10.0, self.width as f32 - 10.0 - mid_x, self.content_h())
+ }
+
+ /// The dissolved Plates' visual (blur off, non-draggable): config plate color else
+ /// page-low, at plate opacity, with the config border and corner radius.
+ fn plate_prims(&self, rect: cce_ui::scene::layout::Rect, pc: &mut cce_ui::scene::paint::PaintCtx) {
+ let mut fill = cce_ui::colors::plate_color().unwrap_or_else(cce_ui::colors::page_low_color);
+ fill[3] *= cce_ui::layout::plate_opacity();
+ let radius = cce_ui::layout::plate_corner_radius();
+ let radii = (radius, radius, radius, radius);
+ if let Some(bc) = cce_ui::colors::plate_border_color() {
+ pc.border(rect, radii, fill, bc, cce_ui::colors::plate_border_thickness());
+ } else if fill[3].abs() > 0.001 {
+ if radius > 0.1 {
+ pc.rounded_rect(rect, radius, (true, true, true, true), fill);
+ } else {
+ pc.quad(rect, fill);
}
}
+ }
- // Middle Panel (Preview)
- if self.selected_family.is_some() {
- // Link mid_scroll to mid_panel
- link_parent_child(&mut self.mid_panel, &mut self.mid_scroll, ctx);
-
- // Link interactive elements to mid_panel (Plate) so it routes events to them
- link_parent_child(&mut self.mid_panel, &mut self.btn_open_folder, ctx);
- link_parent_child(&mut self.mid_panel, &mut self.btn_remove_font, ctx);
- link_parent_child(&mut self.mid_panel, &mut self.style_dropdown, ctx);
- link_parent_child(&mut self.mid_panel, &mut self.size_spinbox, ctx);
- link_parent_child(&mut self.mid_panel, &mut self.preview_box, ctx);
+ /// Event dispatch order of the dissolved panels: the flat child list, panel-grouped
+ /// (left: search/list/buttons; mid, when a family is selected; bottom bar in select
+ /// mode) — the same sets the Plates forwarded to.
+ fn dispatch_widgets(&mut self, forward: bool) -> Vec<*mut (dyn Element + 'static)> {
+ let self_ptr = self as *mut Self;
+ let mut v: Vec<*mut (dyn Element + 'static)> = Vec::new();
+ unsafe {
+ v.push((*self_ptr).search_box.as_ptr_mut());
+ v.push((*self_ptr).font_list.as_ptr_mut());
+ for btn in (*self_ptr).font_buttons.iter_mut() {
+ if btn.rect().0 > -9000.0 {
+ v.push(btn.as_ptr_mut());
+ }
+ }
+ if self.selected_family.is_some() {
+ v.push((*self_ptr).mid_scroll.as_ptr_mut());
+ v.push((*self_ptr).btn_open_folder.as_ptr_mut());
+ v.push((*self_ptr).btn_remove_font.as_ptr_mut());
+ v.push((*self_ptr).style_dropdown.as_ptr_mut());
+ v.push((*self_ptr).size_spinbox.as_ptr_mut());
+ v.push((*self_ptr).preview_box.as_ptr_mut());
+ }
+ if self.select_mode {
+ v.push((*self_ptr).select_cancel_btn.as_ptr_mut());
+ v.push((*self_ptr).select_confirm_btn.as_ptr_mut());
+ }
}
-
- // Bottom bar
- if self.select_mode {
- link_parent_child(&mut self.bottom_bar, &mut self.select_cancel_btn, ctx);
- link_parent_child(&mut self.bottom_bar, &mut self.select_confirm_btn, ctx);
+ if !forward {
+ v.reverse();
}
+ v
}
fn reload_fonts(&mut self) {
@@ -290,7 +333,7 @@ impl TypefaceApp {
}
}
- let (mid_panel_x, _, mid_panel_w, mid_panel_h) = self.mid_panel.rect();
+ let (mid_panel_x, _, mid_panel_w, mid_panel_h) = self.mid_panel_rect();
let preview_box_x = mid_panel_x + 10.0;
let preview_box_w = mid_panel_w - 20.0;
@@ -348,6 +391,11 @@ impl Application for TypefaceApp {
true
}
+ fn is_movable_backplate_at(&self, px: f32, py: f32) -> bool {
+ // Root Backplate dissolved: the surface itself is the movable plate.
+ self.ui_context.drag_allowed_at(px, py)
+ }
+
fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
cce_ui::scale::set_scale_factor(1.0);
// Parse command line arguments
@@ -404,27 +452,8 @@ impl Application for TypefaceApp {
scale_factor: 1.0,
font_system: cce_ui::create_font_system_with_system_fonts(),
needs_rebuild: true,
- root_window: {
- let win_color = cce_ui::colors::page_low_color();
- let win_radius = cce_ui::colors::backplate_corner_radius();
- Backplate::new(0.0, 0.0, if select_mode { 900.0 } else { 1200.0 }, if select_mode { 500.0 } else { 720.0 })
- .with_background(win_color)
- .with_border([0.22, 0.22, 0.28, 1.0], 1.5)
- .with_radius(win_radius)
- },
- left_panel: Plate::new(0.0, 0.0, 0.0, 0.0).with_blur(false).with_draggable(false),
- mid_panel: Plate::new(0.0, 0.0, 0.0, 0.0).with_blur(false).with_draggable(false),
mid_scroll: ScrollBox::new(),
- bottom_bar: Plate::new(0.0, 0.0, 0.0, 0.0).with_blur(false).with_draggable(false).with_engine_layout({
- // Right-aligned row of buttons, 10px gap, vertically centered, 10px right inset.
- // The buttons size to their text (Button::intrinsic_size) instead of a fixed 80px.
- let mut s = cce_ui::scene::layout::Style::row()
- .gap(10.0)
- .main_align(cce_ui::scene::layout::MainAlign::End)
- .cross_align(cce_ui::scene::layout::CrossAlign::Center);
- s.padding = cce_ui::scene::layout::Edges { left: 0.0, right: 10.0, top: 0.0, bottom: 0.0 };
- s
- }),
+ widgets_registered: false,
ui_context: cce_ui::context::UiContext::new(),
};
@@ -613,23 +642,14 @@ impl Application for TypefaceApp {
if self.needs_rebuild || size_changed {
cce_ui::scale::set_scale_factor(scale as f32);
- self.root_window.set_rect(0.0, 0.0, w_f32, h_f32);
+ self.register_widgets();
- // Position panel Plates
- self.left_panel.set_rect(left_panel_x, 10.0, left_panel_w, content_h);
- self.mid_panel.set_rect(mid_panel_x, 10.0, mid_panel_w, content_h);
self.mid_scroll.set_rect(mid_panel_x, 10.0, mid_panel_w, content_h);
let mid_content_h = if self.select_mode { 440.0 } else { 520.0 };
self.mid_scroll.update_bounds(mid_content_h, 10.0, content_h);
let bar_y = h_f32 - select_bar_h - 10.0;
- self.bottom_bar.set_rect(left_panel_x, bar_y, w_f32 - 20.0, select_bar_h);
-
- // Configure Plate visibility
- self.left_panel.visible = true;
- self.mid_panel.visible = true;
- self.bottom_bar.visible = self.select_mode;
// Search box
self.search_box.set_rect(left_panel_x + 10.0, 10.0, left_panel_w - 20.0, 26.0);
@@ -695,25 +715,23 @@ impl Application for TypefaceApp {
}
if self.select_mode {
- // Phase 2b: lay out the bottom bar's buttons via the scene layout engine. They
- // size to their text and right-align in the bar, instead of fixed 80px slots.
- let bar_ptr: *mut (dyn cce_ui::widget::Element + 'static) =
- &mut self.bottom_bar as *mut _;
- cce_ui::scene::bridge::layout_subtree(
- &self.ui_context,
- bar_ptr,
- cce_ui::scene::layout::Rect {
- x: left_panel_x,
- y: bar_y,
- width: w_f32 - 20.0,
- height: select_bar_h,
- },
- );
+ // The bottom bar's buttons, laid out directly (the layout Plate is
+ // DISSOLVED): text-sized via the bridge's Element::intrinsic_size, packed
+ // right with a 10px gap and inset, vertically centered — the old row style.
+ let cancel_sz = cce_ui::widget::Element::intrinsic_size(&self.select_cancel_btn)
+ .unwrap_or(cce_ui::scene::layout::Size::new(80.0, 28.0));
+ let confirm_sz = cce_ui::widget::Element::intrinsic_size(&self.select_confirm_btn)
+ .unwrap_or(cce_ui::scene::layout::Size::new(80.0, 28.0));
+ let bar_w = w_f32 - 20.0;
+ let mut x = left_panel_x + bar_w - 10.0 - confirm_sz.width;
+ self.select_confirm_btn.set_rect(x, bar_y + (select_bar_h - confirm_sz.height) / 2.0, confirm_sz.width, confirm_sz.height);
+ x -= 10.0 + cancel_sz.width;
+ self.select_cancel_btn.set_rect(x, bar_y + (select_bar_h - cancel_sz.height) / 2.0, cancel_sz.width, cancel_sz.height);
}
- self.rebuild_hierarchy();
self.refresh_widget_text();
self.needs_rebuild = false;
+ self.ui_context.rebuild_spatial_grid();
}
// Popover registration for the display-list text occlusion clamp. ui_context ONLY —
@@ -735,13 +753,56 @@ impl Application for TypefaceApp {
(base_low[2] * 1.17).min(1.0),
base_low[3],
];
- self.root_window.background_color = Some(bg_color);
-
- // 2. The widget tree walked into the list.
+ // 2. The dissolved root Backplate's plate (bg at backplate opacity, config border
+ // and radius), then the dissolved panels' plates and their children walked in the
+ // legacy panel order.
use cce_ui::scene::layout::Rect;
let mut pc = cce_ui::scene::paint::PaintCtx::new();
- let root: *mut (dyn cce_ui::widget::Element + 'static) = self.root_window.as_ptr_mut();
- cce_ui::scene::painter::paint_root_into(&self.ui_context, root, &mut pc);
+ {
+ let mut fill = bg_color;
+ if fill[3] > 0.001 {
+ fill[3] = cce_ui::color::active_backplate_opacity();
+ }
+ let radius = cce_ui::colors::backplate_corner_radius();
+ let radii = (radius, radius, radius, radius);
+ let rect = Rect { x: 0.0, y: 0.0, width: w_f32, height: h_f32 };
+ pc.border(rect, radii, fill, [0.22, 0.22, 0.28, 1.0], 1.5);
+ }
+ {
+ let self_ptr = self as *mut Self;
+ // Left panel plate + children.
+ self.plate_prims(Rect { x: left_panel_x, y: 10.0, width: left_panel_w, height: content_h }, &mut pc);
+ unsafe {
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).search_box.as_ptr_mut(), &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).font_list.as_ptr_mut(), &mut pc);
+ for btn in (*self_ptr).font_buttons.iter_mut() {
+ if btn.rect().0 > -9000.0 {
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, btn.as_ptr_mut(), &mut pc);
+ }
+ }
+ }
+ // Mid panel plate + children (when a family is selected, like the old links).
+ self.plate_prims(Rect { x: mid_panel_x, y: 10.0, width: mid_panel_w, height: content_h }, &mut pc);
+ if self.selected_family.is_some() {
+ unsafe {
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).mid_scroll.as_ptr_mut(), &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).btn_open_folder.as_ptr_mut(), &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).btn_remove_font.as_ptr_mut(), &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).style_dropdown.as_ptr_mut(), &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).size_spinbox.as_ptr_mut(), &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).preview_box.as_ptr_mut(), &mut pc);
+ }
+ }
+ // Bottom bar plate + children (select mode).
+ if self.select_mode {
+ let bar_y = h_f32 - select_bar_h - 10.0;
+ self.plate_prims(Rect { x: left_panel_x, y: bar_y, width: w_f32 - 20.0, height: select_bar_h }, &mut pc);
+ unsafe {
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).select_cancel_btn.as_ptr_mut(), &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, (*self_ptr).select_confirm_btn.as_ptr_mut(), &mut pc);
+ }
+ }
+ }
let quad = |x: f32, y: f32, w: f32, h: f32, c: [f32; 4], pc: &mut cce_ui::scene::paint::PaintCtx| {
pc.quad(Rect { x, y, width: w, height: h }, c);
@@ -764,7 +825,7 @@ impl Application for TypefaceApp {
// 4. Alphabet preview box + its text prims (family + style/weight attrs).
if self.selected_family.is_some() {
- let mid_panel_h = self.mid_panel.base.base.h;
+ let mid_panel_h = self.content_h();
let alphabet_virtual_y = if self.select_mode { 320.0 } else { 380.0 };
let alphabet_box_h = if self.select_mode { 102.0 } else { 120.0 };
let scroll_y = self.mid_scroll.scroll_y;
@@ -873,14 +934,17 @@ impl Application for TypefaceApp {
let old_size = self.preview_box.font_size;
- for &child_ptr in &self.root_window.children {
+ // The dissolved panels' cursor forwarding: dragging children get drag_update,
+ // everyone else cursor_moved (Plate::on_cursor_moved's inner loop, flattened).
+ for w_ptr in self.dispatch_widgets(true) {
unsafe {
- if let Some(child) = child_ptr.as_mut() {
- if child.visible() {
- if child.cursor_moved(px, py, &mut self.ui_context) {
- changed = true;
- }
+ let w = &mut *w_ptr;
+ if w.is_dragging() {
+ if w.drag_update(px, py) {
+ changed = true;
}
+ } else if w.cursor_moved(px, py, &mut self.ui_context) {
+ changed = true;
}
}
}
@@ -918,15 +982,33 @@ impl Application for TypefaceApp {
}
}
- // Route input to root_window children in reverse order
- for &child_ptr in self.root_window.children.iter().rev() {
+ // The dissolved panels' press routing, flattened: popover-first (an open dropdown
+ // must see the click before anything beneath), then reverse order with the
+ // Plates' unfocus-on-missed-press rule, stopping at the first handler.
+ let widgets = self.dispatch_widgets(false);
+ let mut input_handled = false;
+ for &w_ptr in &widgets {
unsafe {
- if let Some(child) = child_ptr.as_mut() {
- if child.visible() {
- if child.mouse_input(button, state, px, py, &mut self.ui_context) {
- changed = true;
- break;
- }
+ let w = &mut *w_ptr;
+ if w.popover_rect().is_some() {
+ if w.mouse_input(button, state, px, py, &mut self.ui_context) {
+ changed = true;
+ input_handled = true;
+ break;
+ }
+ }
+ }
+ }
+ if !input_handled {
+ for &w_ptr in &widgets {
+ unsafe {
+ let w = &mut *w_ptr;
+ if w.mouse_input(button, state, px, py, &mut self.ui_context) {
+ changed = true;
+ break;
+ }
+ if state == ElementState::Pressed && !w.hit_test(px, py, &self.ui_context) {
+ w.unfocus();
}
}
}
@@ -992,14 +1074,10 @@ impl Application for TypefaceApp {
let px = pos.x as f32;
let py = pos.y as f32;
- for &child_ptr in &self.root_window.children {
+ for w_ptr in self.dispatch_widgets(true) {
unsafe {
- if let Some(child) = child_ptr.as_mut() {
- if child.visible() {
- if child.mouse_wheel(delta, px, py, &mut self.ui_context) {
- changed = true;
- }
- }
+ if (*w_ptr).mouse_wheel(delta, px, py, &mut self.ui_context) {
+ changed = true;
}
}
}
@@ -1104,15 +1182,11 @@ impl Application for TypefaceApp {
if !handled {
let old_search_text = if self.search_box.editing { self.search_box.edit_buffer.clone() } else { self.search_box.text.clone() };
- for &child_ptr in &self.root_window.children {
+ for w_ptr in self.dispatch_widgets(true) {
unsafe {
- if let Some(child) = child_ptr.as_mut() {
- if child.visible() {
- if child.keyboard_input(event, &mut self.ui_context) {
- handled = true;
- break;
- }
- }
+ if (*w_ptr).keyboard_input(event, &mut self.ui_context) {
+ handled = true;
+ break;
}
}
}