git.lucas.co / cce-fonts
font browser

src/main.rs (61K)

   1 mod pages;
   2 
   3 use wayland_client::QueueHandle;
   4 use cce_ui::cosmic_text::FontSystem;
   5 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
   6 use cce_ui::widget::{
   7     MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost, Event,
   8     TextBox, Button, Key, NamedKey, Dropdown, Spinbox, ScrollRegion,
   9 };
  10 
  11 
  12 /// How long after a click on a family row a second click on the same row
  13 /// still counts as a double-click (and, in `--select` mode, confirms).
  14 const DOUBLE_CLICK: std::time::Duration = std::time::Duration::from_millis(350);
  15 
  16 /// The family list pane's width and the select-mode bar's height — sizes, not
  17 /// spacing; every inset and gap around them comes from the ladder.
  18 const LEFT_PANEL_W: f32 = 270.0;
  19 const SELECT_BAR_H: f32 = 48.0;
  20 /// The family list's row height (its rows are `list_item_h` tall, but
  21 /// `scroll_to_index` keeps the legacy 24 it always scrolled by).
  22 const LIST_ROW_H: f32 = 24.0;
  23 
  24 /// The family list's row gap, doubling as its inner inset.
  25 /// style: deliberate — the rows are 24px scan lines and the pane gap would
  26 /// triple their pitch; this is the toolkit `List::new(24, 4)` metric the
  27 /// list dissolved from, kept tight so a long family list stays scannable.
  28 fn list_row_gap() -> f32 {
  29     4.0
  30 }
  31 
  32 /// The mid pane's preview form, stacked by the ladder inside the pane's
  33 /// scroll content: each control's virtual y (from the pane's top rim) and
  34 /// height, and the content height that stack adds up to.
  35 struct PreviewForm {
  36     buttons_y: f32,
  37     dropdown_y: f32,
  38     dropdown_h: f32,
  39     spinbox_y: f32,
  40     spinbox_h: f32,
  41     preview_y: f32,
  42     preview_h: f32,
  43     alphabet_y: f32,
  44     alphabet_h: f32,
  45     content_h: f32,
  46 }
  47 
  48 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  49 pub enum BrowseNavigation {
  50     Up,
  51     Down,
  52 }
  53 
  54 #[derive(Debug, Clone)]
  55 #[allow(dead_code)]
  56 enum AppMessage {
  57     Exit,
  58     SelectFamily(usize),
  59     SelectStyle(usize),
  60     FontSizeChanged,
  61     SearchChanged,
  62     OpenFolder,
  63     RemoveFont,
  64     RefreshFonts,
  65 }
  66 
  67 /// The retired local ScrollRegion's flat bg fill (the toolkit
  68 /// `ScrollRegion::push_prims` draws the bordered frame + pill bars, which this
  69 /// app's panels never had). With the regions on sink-behind, `push_quads` also
  70 /// lays a sunk scrollbar UNDER the translucent fill; the raised layer is
  71 /// emitted separately, after the rows, by [`emit_region_bar`].
  72 fn emit_region_quads(r: &ScrollRegion, pc: &mut cce_ui::scene::paint::PaintCtx) {
  73     use cce_ui::scene::layout::Rect;
  74     let mut v = Vec::new();
  75     r.push_quads(&mut v);
  76     for (x, y, w, h, c) in v {
  77         pc.quad(Rect { x, y, width: w, height: h }, c);
  78     }
  79 }
  80 
  81 /// The raised scrollbar layer (quiet while the bar is sunk): emitted after the
  82 /// panel content so the bar rides over the rows, the designer treatment.
  83 fn emit_region_bar(r: &ScrollRegion, pc: &mut cce_ui::scene::paint::PaintCtx) {
  84     use cce_ui::scene::layout::Rect;
  85     let mut v = Vec::new();
  86     r.push_scrollbar_quads(&mut v);
  87     for (x, y, w, h, c) in v {
  88         pc.quad(Rect { x, y, width: w, height: h }, c);
  89     }
  90 }
  91 
  92 /// App shortcuts, resolved once at startup from input.kdl
  93 /// (`cce-fonts` domain → `cce-ui` domain), defaulting to the historical keys.
  94 struct FontsKeys {
  95     refresh: String,
  96     open_search: String,
  97     focus_preview: String,
  98     open_folder: String,
  99     remove_font: String,
 100 }
 101 
 102 impl FontsKeys {
 103     fn load() -> Self {
 104         let get = cce_ui::input::app_chord;
 105         Self {
 106             refresh: get("refresh", "ctrl+r"),
 107             open_search: get("open_search", "ctrl+f"),
 108             focus_preview: get("focus_preview", "ctrl+e"),
 109             open_folder: get("open_folder", "ctrl+o"),
 110             remove_font: get("remove_font", "delete"),
 111         }
 112     }
 113 }
 114 
 115 struct TypefaceApp {
 116     keys: FontsKeys,
 117 
 118     // Browse panel
 119     search_box: cce_ui::widget::Adapted<TextBox>,
 120     list_region: ScrollRegion,
 121     list_item_h: f32,
 122     font_buttons: Vec<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 123 
 124     // Preview panel
 125     style_dropdown: cce_ui::widget::Adapted<Dropdown>,
 126     size_spinbox: cce_ui::widget::Adapted<cce_ui::widget::Spinbox>,
 127     preview_box: cce_ui::widget::Adapted<TextBox>,
 128 
 129     // Details panel
 130     btn_open_folder: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 131     btn_remove_font: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 132 
 133     // Selection mode buttons
 134     select_cancel_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 135     select_confirm_btn: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 136     select_mode: bool,
 137     last_click_idx: Option<usize>,
 138     /// When that click landed. A wall clock, not a `dt` countdown: `dt` is
 139     /// animation time, clamped to one frame after an idle sleep, and the app
 140     /// idles between clicks — so the 0.35 s window stayed open for seconds
 141     /// and a leisurely second click confirmed as a double-click.
 142     last_click_at: Option<std::time::Instant>,
 143 
 144     // State
 145     all_fonts: Vec<pages::FontEntry>,
 146     families: Vec<String>,
 147     filtered: Vec<String>,
 148     selected_idx: Option<usize>,
 149     selected_family: Option<String>,
 150     selected_style: Option<String>,
 151     selected_file: Option<String>,
 152     family_styles: Vec<String>,
 153     family_files: Vec<String>,
 154     charset_count: usize,
 155     charset_str: String,
 156     is_user_font: bool,
 157 
 158     // UI metrics
 159     width: u32,
 160     height: u32,
 161     scale_factor: f64,
 162     // Shapes glyph advances (prepare_text) — load-bearing for cursor↔pixel mapping and label
 163     // measurement; all rendered text is display-list prims shaped by the engine. Must stay
 164     // create_font_system_with_system_fonts() so measurement sees the same faces the engine
 165     // renders (load_system_fonts).
 166     font_system: FontSystem,
 167     needs_rebuild: bool,
 168 
 169     // Containers (root plate container, the three Plates, and the ScrollBox/List are DISSOLVED:
 170     // plates and scroll frames are prims, scroll state lives in the ScrollRegions,
 171     // children are dispatched/walked directly, panel rects are computed)
 172     mid_region: ScrollRegion,
 173     widgets_registered: bool,
 174     ui_context: cce_ui::context::UiContext,
 175 }
 176 
 177 impl TypefaceApp {
 178     /// Register the widgets (parentless — the panel Plates are dissolved). Static widgets
 179     /// once; the font-list buttons every rebuild (they are recreated on search changes,
 180     /// same cadence the old per-rebuild link_parent_child re-registration had).
 181     fn register_widgets(&mut self) {
 182         let self_ptr = self as *mut Self;
 183         unsafe {
 184             if !self.widgets_registered {
 185                 self.widgets_registered = true;
 186                 self.ui_context.register_widget(self.search_box.base().id(), (*self_ptr).search_box.as_ptr_mut());
 187                 self.ui_context.register_widget(self.btn_open_folder.base().id(), (*self_ptr).btn_open_folder.as_ptr_mut());
 188                 self.ui_context.register_widget(self.btn_remove_font.base().id(), (*self_ptr).btn_remove_font.as_ptr_mut());
 189                 self.ui_context.register_widget(self.style_dropdown.base().id(), (*self_ptr).style_dropdown.as_ptr_mut());
 190                 self.ui_context.register_widget(self.size_spinbox.base().id(), (*self_ptr).size_spinbox.as_ptr_mut());
 191                 self.ui_context.register_widget(self.preview_box.base().id(), (*self_ptr).preview_box.as_ptr_mut());
 192                 self.ui_context.register_widget(self.select_cancel_btn.base().id(), (*self_ptr).select_cancel_btn.as_ptr_mut());
 193                 self.ui_context.register_widget(self.select_confirm_btn.base().id(), (*self_ptr).select_confirm_btn.as_ptr_mut());
 194             }
 195             for btn in (*self_ptr).font_buttons.iter_mut() {
 196                 if btn.rect().0 > -9000.0 {
 197                     self.ui_context.register_widget(btn.base().id(), btn.as_ptr_mut());
 198                 }
 199             }
 200         }
 201     }
 202 
 203     /// The panes' height: the window less the root inset at top and bottom,
 204     /// and in select mode the bar and the root gap that separates it.
 205     fn content_h(&self) -> f32 {
 206         let h = self.height as f32;
 207         let inset = cce_ui::layout::root_plate_inset();
 208         if self.select_mode {
 209             (h - 2.0 * inset - cce_ui::layout::root_plate_gap() - SELECT_BAR_H).max(100.0)
 210         } else {
 211             (h - 2.0 * inset).max(100.0)
 212         }
 213     }
 214 
 215     /// The dissolved mid panel's rect (was `mid_panel.rect()`): the root gap
 216     /// right of the family list pane, the root inset from the other edges.
 217     fn mid_panel_rect(&self) -> (f32, f32, f32, f32) {
 218         let inset = cce_ui::layout::root_plate_inset();
 219         let mid_x = inset + LEFT_PANEL_W + cce_ui::layout::root_plate_gap();
 220         (mid_x, inset, self.width as f32 - inset - mid_x, self.content_h())
 221     }
 222 
 223     /// The preview form's stack (see [`PreviewForm`]): the pane padding at
 224     /// the top, the control gap between the form's controls, the pane gap
 225     /// before the alphabet box, and the pane padding below it.
 226     fn preview_form(&self) -> PreviewForm {
 227         let pad = cce_ui::layout::plate_padding();
 228         let gap = cce_ui::layout::plate_gap();
 229         let control_gap = cce_ui::layout::control_gap();
 230         let dropdown_h = cce_ui::layout::dropdown_height() + self.style_dropdown.label_strip();
 231         let spinbox_h = cce_ui::layout::spinbox_height() + self.size_spinbox.label_strip();
 232         let preview_h = if self.select_mode { 120.0 } else { 180.0 };
 233         let alphabet_h = if self.select_mode { 102.0 } else { 120.0 };
 234         let buttons_y = pad;
 235         let dropdown_y = buttons_y + 28.0 + control_gap;
 236         let spinbox_y = dropdown_y + dropdown_h + control_gap;
 237         let preview_y = spinbox_y + spinbox_h + control_gap;
 238         let alphabet_y = preview_y + preview_h + gap;
 239         PreviewForm {
 240             buttons_y,
 241             dropdown_y,
 242             dropdown_h,
 243             spinbox_y,
 244             spinbox_h,
 245             preview_y,
 246             preview_h,
 247             alphabet_y,
 248             alphabet_h,
 249             content_h: alphabet_y + alphabet_h + pad,
 250         }
 251     }
 252 
 253     /// The dissolved Plates' visual (blur off, non-draggable): config plate color else
 254     /// page-low, at plate opacity, with the config border and corner radius.
 255     fn plate_prims(&self, rect: cce_ui::scene::layout::Rect, pc: &mut cce_ui::scene::paint::PaintCtx) {
 256         let mut fill = cce_ui::colors::plate_color().unwrap_or_else(cce_ui::colors::page_low_color);
 257         fill[3] *= cce_ui::layout::plate_opacity();
 258         let radius = cce_ui::layout::plate_corner_radius();
 259         let radii = (radius, radius, radius, radius);
 260         if let Some(bc) = cce_ui::colors::plate_border_color() {
 261             pc.border(rect, radii, fill, bc, cce_ui::colors::plate_border_thickness());
 262         } else if fill[3].abs() > 0.001 {
 263             if radius > 0.1 {
 264                 pc.rounded_rect(rect, radius, (true, true, true, true), fill);
 265             } else {
 266                 pc.quad(rect, fill);
 267             }
 268         }
 269     }
 270 
 271     /// Event dispatch order of the dissolved panels: the flat child list, panel-grouped
 272     /// (left: search/list/buttons; mid, when a family is selected; bottom bar in select
 273     /// mode) — the same sets the Plates forwarded to.
 274     fn dispatch_widgets(&mut self, forward: bool) -> Vec<cce_ui::widget::WidgetId> {
 275         let mut v: Vec<cce_ui::widget::WidgetId> = Vec::new();
 276         v.push(self.search_box.id());
 277         for btn in self.font_buttons.iter() {
 278             if btn.rect().0 > -9000.0 {
 279                 v.push(btn.id());
 280             }
 281         }
 282         if self.selected_family.is_some() {
 283             v.push(self.btn_open_folder.id());
 284             v.push(self.btn_remove_font.id());
 285             v.push(self.style_dropdown.id());
 286             v.push(self.size_spinbox.id());
 287             v.push(self.preview_box.id());
 288         }
 289         if self.select_mode {
 290             v.push(self.select_cancel_btn.id());
 291             v.push(self.select_confirm_btn.id());
 292         }
 293         if !forward {
 294             v.reverse();
 295         }
 296         v
 297     }
 298 
 299     fn reload_fonts(&mut self) {
 300         self.all_fonts = pages::fetch_fonts();
 301         self.families = self.extract_families(&self.all_fonts);
 302         let query = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
 303         self.filtered = self.filter_families(&self.families, query);
 304         // Ids are globally monotonic and never reused, so the fresh buttons register under
 305         // NEW ids; the outgoing ones would keep pointing into this Vec's freed buffer, and
 306         // the engine derefs the whole registry on every left press. Drop them first.
 307         let stale: Vec<_> = self.font_buttons.iter().map(|b| b.id()).collect();
 308         for id in stale {
 309             self.ui_context.unregister_widget(id);
 310         }
 311         self.font_buttons = self.filtered.iter().map(|f| Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f)).collect();
 312         // ...and register the replacements now. `dispatch_widgets()` reports every row whose
 313         // rect passes the parked-sentinel gate, and fresh rows are (0,0,0,0) so they pass it
 314         // immediately — but `register_widgets()` only runs in the next layout pass, so the
 315         // ids were reported unregistered until then and the router dropped those events.
 316         for btn in self.font_buttons.iter_mut() {
 317             let (id, ptr) = (btn.id(), btn.as_ptr_mut());
 318             self.ui_context.register_widget(id, ptr);
 319         }
 320 
 321         if let Some(sel) = self.selected_idx {
 322             if sel >= self.filtered.len() {
 323                 self.selected_idx = None;
 324                 self.selected_family = None;
 325                 self.selected_style = None;
 326                 self.selected_file = None;
 327                 self.family_styles.clear();
 328                 self.family_files.clear();
 329                 self.style_dropdown.options.clear();
 330                 self.style_dropdown.selected = 0;
 331                 self.charset_count = 0;
 332                 self.charset_str = String::from("0");
 333                 self.is_user_font = false;
 334             } else {
 335                 let family = self.filtered[sel].clone();
 336                 self.select_family(family);
 337             }
 338         }
 339         self.needs_rebuild = true;
 340     }
 341 
 342     fn extract_families(&self, fonts: &[pages::FontEntry]) -> Vec<String> {
 343         let mut seen = std::collections::BTreeSet::new();
 344         for f in fonts {
 345             seen.insert(f.family.clone());
 346         }
 347         seen.into_iter().collect()
 348     }
 349 
 350     fn filter_families(&self, families: &[String], query: &str) -> Vec<String> {
 351         if query.is_empty() {
 352             return families.to_vec();
 353         }
 354         let q = query.to_lowercase();
 355         families
 356             .iter()
 357             .filter(|f| f.to_lowercase().contains(&q))
 358             .cloned()
 359             .collect()
 360     }
 361 
 362     fn select_family(&mut self, family: String) {
 363         self.selected_family = Some(family.clone());
 364         self.preview_box.font_family = family.clone();
 365         
 366         // Find styles and files
 367         let entries: Vec<&pages::FontEntry> = self.all_fonts.iter().filter(|f| f.family == family).collect();
 368         
 369         let mut styles = Vec::new();
 370         let mut files = Vec::new();
 371         for e in entries {
 372             styles.push(e.style.clone());
 373             files.push(e.file.clone());
 374         }
 375         
 376         self.family_styles = styles.clone();
 377         self.family_files = files.clone();
 378         
 379         // Pick default style: "Regular" if available, else first
 380         let default_idx = styles.iter().position(|s| s == "Regular").unwrap_or(0);
 381         
 382         self.style_dropdown.options = styles;
 383         self.style_dropdown.selected = default_idx;
 384         
 385         if !self.family_styles.is_empty() {
 386             let style = self.family_styles[default_idx].clone();
 387             let file = self.family_files[default_idx].clone();
 388             self.selected_style = Some(style);
 389             self.selected_file = Some(file.clone());
 390             
 391             self.charset_count = pages::count_chars(&file);
 392             self.charset_str = self.charset_count.to_string();
 393             self.is_user_font = pages::is_user_font(&file);
 394         } else {
 395             self.selected_style = None;
 396             self.selected_file = None;
 397             self.charset_count = 0;
 398             self.charset_str = String::from("0");
 399             self.is_user_font = false;
 400         }
 401 
 402         // Highlight selected button
 403         if let Some(sel) = self.selected_idx {
 404             for (idx, btn) in self.font_buttons.iter_mut().enumerate() {
 405                 btn.selected = idx == sel;
 406             }
 407         }
 408         
 409         self.needs_rebuild = true;
 410     }
 411 
 412     fn scroll_to_index(&mut self, index: usize) {
 413         let item_height_full = LIST_ROW_H + list_row_gap();
 414         let top = index as f32 * item_height_full;
 415         let bottom = top + LIST_ROW_H;
 416         let viewport_top = self.list_region.scroll_y;
 417         let viewport_bottom = viewport_top + self.list_region.viewport_h;
 418 
 419         if bottom > viewport_bottom {
 420             self.list_region.scroll_y = (bottom - self.list_region.viewport_h).clamp(0.0, self.list_region.max_scroll());
 421         } else if top < viewport_top {
 422             self.list_region.scroll_y = top;
 423         }
 424     }
 425 
 426     /// Glyph-advance shaping for the interactive widgets — load-bearing for cursor↔pixel
 427     /// mapping and label measurement; all rendered text is display-list prims shaped by the
 428     /// engine (which also carries the system fonts via `load_system_fonts`).
 429     fn refresh_widget_text(&mut self) {
 430         let font_system = &mut self.font_system;
 431         self.search_box.prepare_text(font_system);
 432         for btn in &mut self.font_buttons {
 433             btn.prepare_text(font_system);
 434         }
 435         self.style_dropdown.prepare_text(font_system);
 436         self.size_spinbox.prepare_text(font_system);
 437         self.preview_box.prepare_text(font_system);
 438         self.btn_open_folder.prepare_text(font_system);
 439         self.btn_remove_font.prepare_text(font_system);
 440         self.select_cancel_btn.prepare_text(font_system);
 441         self.select_confirm_btn.prepare_text(font_system);
 442     }
 443 
 444     /// The alphabet preview as display-list text: one prim per line (the legacy single
 445     /// multi-line buffer becomes per-line prims at the same 1.4 line spacing), in the selected
 446     /// family with the style variant's italic/weight attrs (`TextAttrs` — the Phase 6 prim
 447     /// extension this app motivated), clipped to the preview box.
 448     fn push_alphabet_preview(&self, pc: &mut cce_ui::scene::paint::PaintCtx) {
 449         let Some(ref family) = self.selected_family else { return };
 450 
 451         let font_size = self.size_spinbox.value as f32;
 452         let mut attrs = cce_ui::scene::paint::TextAttrs::default();
 453         if let Some(ref style) = self.selected_style {
 454             let sl = style.to_lowercase();
 455             if sl.contains("italic") || sl.contains("oblique") {
 456                 attrs.italic = true;
 457             }
 458             if sl.contains("bold") {
 459                 attrs.weight = Some(700);
 460             } else if sl.contains("light") {
 461                 attrs.weight = Some(300);
 462             } else if sl.contains("medium") {
 463                 attrs.weight = Some(500);
 464             }
 465         }
 466 
 467         let (mid_panel_x, panel_y, mid_panel_w, mid_panel_h) = self.mid_panel_rect();
 468         let pad = cce_ui::layout::plate_padding();
 469         let preview_box_x = mid_panel_x + pad;
 470         let preview_box_w = mid_panel_w - 2.0 * pad;
 471 
 472         let form = self.preview_form();
 473         let alphabet_box_h = form.alphabet_h;
 474         let scroll_y = self.mid_region.scroll_y;
 475         let alphabet_draw_y = panel_y + form.alphabet_y - scroll_y;
 476 
 477         let viewport_top = panel_y;
 478         let viewport_bottom = panel_y + mid_panel_h;
 479         if alphabet_draw_y + alphabet_box_h < viewport_top || alphabet_draw_y > viewport_bottom {
 480             return;
 481         }
 482         let bounds_y_start = alphabet_draw_y.max(viewport_top);
 483         let bounds_y_end = (alphabet_draw_y + alphabet_box_h).min(viewport_bottom);
 484         let bounds = Some([preview_box_x, bounds_y_start, preview_box_x + preview_box_w, bounds_y_end]);
 485 
 486         let size_used = (font_size * 0.55).clamp(8.0, 36.0);
 487         let lines = [
 488             "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
 489             "abcdefghijklmnopqrstuvwxyz",
 490             "0123456789",
 491             "!@#$%^&*()_+-=[]{}|;':\",./<>",
 492         ];
 493         for (i, line) in lines.iter().enumerate() {
 494             pc.text_attrs(
 495                 *line,
 496                 preview_box_x + pad,
 497                 alphabet_draw_y + pad + i as f32 * size_used * 1.4,
 498                 size_used,
 499                 [0x88, 0x88, 0x99],
 500                 Some(family.clone()),
 501                 bounds,
 502                 attrs,
 503             );
 504         }
 505     }
 506 }
 507 
 508 impl Application for TypefaceApp {
 509     type Message = AppMessage;
 510 
 511     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
 512         Some(&self.ui_context)
 513     }
 514 
 515     // The engine ticks the exposed context each loop — this is what drives the
 516     // dropdown expand/contract animation frames.
 517     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
 518         Some(&mut self.ui_context)
 519     }
 520 
 521     /// The font picker previews arbitrary installed families: the engine's render FontSystem
 522     /// must contain the system fonts, or preview text asking for a system-only family is
 523     /// silently invisible.
 524     fn load_system_fonts(&self) -> bool {
 525         true
 526     }
 527 
 528     fn display_list_text(&self) -> bool {
 529         true
 530     }
 531 
 532     fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
 533         // root plate container dissolved: the surface itself is the movable plate.
 534         self.ui_context.drag_allowed_at(px, py)
 535     }
 536 
 537     fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
 538         cce_ui::scale::set_scale_factor(1.0);
 539         // Parse command line arguments
 540         let args: Vec<String> = std::env::args().collect();
 541         let select_mode = args.iter().any(|arg| arg == "--select");
 542 
 543         let mut search_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true);
 544         search_box.font_size = 12.0;
 545 
 546         // The dissolved List's item metrics (List::new(24, 4) adjusted item height to
 547         // max(24, list font size + 14)); scroll_to_index keeps its legacy hardcoded 24s.
 548         let list_item_h = 24.0f32.max(cce_ui::layout::list_font_parsed().1 + 14.0);
 549 
 550         let style_dropdown = Dropdown::new(Vec::new(), 0).with_label("Style:");
 551 
 552         let size_spinbox = Spinbox::new(32, 8, 120, 1).with_label("Size:");
 553 
 554         let mut preview_box = TextBox::new(String::from("The quick brown fox jumps over the lazy dog")).with_multiline(true).with_draw_bg_border(true).with_max_width(None);
 555         preview_box.font_size = 32.0;
 556 
 557         let btn_open_folder = Button::new(914.0, 200.0, 110.0, 28.0).with_label("Open Folder");
 558         let btn_remove_font = Button::new_reset(1034.0, 200.0, 110.0, 28.0).with_label("Remove Font");
 559 
 560         let select_cancel_btn = Button::new_reset(0.0, 0.0, 80.0, 28.0).with_label("Cancel");
 561         let select_confirm_btn = Button::new(0.0, 0.0, 80.0, 28.0).with_label("Select");
 562 
 563         let all_fonts = pages::fetch_fonts();
 564         let mut app = Self {
 565             keys: FontsKeys::load(),
 566             search_box,
 567             list_region: ScrollRegion::new(0.0, 4.0)
 568                 .with_sink_behind(true)
 569                 .with_edge_inset(cce_ui::layout::scrollbar_inset()),
 570             list_item_h,
 571             font_buttons: Vec::new(),
 572             style_dropdown,
 573             size_spinbox,
 574             preview_box,
 575             btn_open_folder,
 576             btn_remove_font,
 577             select_cancel_btn,
 578             select_confirm_btn,
 579             select_mode,
 580             last_click_idx: None,
 581             last_click_at: None,
 582             all_fonts: all_fonts.clone(),
 583             families: Vec::new(),
 584             filtered: Vec::new(),
 585             selected_idx: None,
 586             selected_family: None,
 587             selected_style: None,
 588             selected_file: None,
 589             family_styles: Vec::new(),
 590             family_files: Vec::new(),
 591             charset_count: 0,
 592             charset_str: String::from("0"),
 593             is_user_font: false,
 594             width: if select_mode { 900 } else { 1200 },
 595             height: if select_mode { 500 } else { 720 },
 596             scale_factor: 1.0,
 597             font_system: cce_ui::create_font_system_with_system_fonts(),
 598             needs_rebuild: true,
 599             mid_region: ScrollRegion::new(0.0, 4.0)
 600                 .with_sink_behind(true)
 601                 .with_edge_inset(cce_ui::layout::scrollbar_inset()),
 602             widgets_registered: false,
 603             ui_context: cce_ui::context::UiContext::new(),
 604         };
 605         
 606         app.families = app.extract_families(&app.all_fonts);
 607         app.filtered = app.filter_families(&app.families, "");
 608         app.font_buttons = app.filtered.iter().map(|f| Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f)).collect();
 609         
 610         // Parse preselected family and size from CLI args (passed by FontSelector)
 611         let mut preselected_family = None;
 612         let mut preselected_size = None;
 613         if select_mode {
 614             let other_args: Vec<&String> = args.iter().filter(|arg| *arg != "--select" && !arg.ends_with("cce-fonts")).collect();
 615             if let Some(arg) = other_args.first() {
 616                 let (fam, sz) = cce_ui::layout::parse_font_string(arg);
 617                 preselected_family = Some(fam);
 618                 preselected_size = sz;
 619             }
 620         }
 621 
 622         // Auto-select first font or preselected font at start if available
 623         if !app.filtered.is_empty() {
 624             let mut select_idx = 0;
 625             if let Some(ref pre_fam) = preselected_family {
 626                 if let Some(pos) = app.filtered.iter().position(|f| f.to_lowercase() == pre_fam.to_lowercase()) {
 627                     select_idx = pos;
 628                 }
 629             }
 630             app.selected_idx = Some(select_idx);
 631             let family = app.filtered[select_idx].clone();
 632             app.select_family(family);
 633         }
 634 
 635         if let Some(sz) = preselected_size {
 636             app.size_spinbox.value = sz as i32;
 637             if app.size_spinbox.editing {
 638                 app.size_spinbox.edit_buffer = app.size_spinbox.value.to_string();
 639             }
 640             app.preview_box.font_size = sz;
 641         }
 642 
 643         app
 644     }
 645 
 646     fn settings(&self) -> WindowSettings {
 647         if self.select_mode {
 648             WindowSettings {
 649                 title: "Select Font".to_string(),
 650                 app_id: "cce-fonts-select".to_string(),
 651                 width: 900,
 652                 height: 500,
 653                 fullscreen: false,
 654                 min_size: Some((800, 400)),
 655             }
 656         } else {
 657             WindowSettings {
 658                 title: "CCE Fonts".to_string(),
 659                 app_id: "cce-fonts".to_string(),
 660                 width: 1200,
 661                 height: 720,
 662                 fullscreen: false,
 663                 min_size: Some((800, 500)),
 664             }
 665         }
 666     }
 667 
 668     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
 669         match msg {
 670             AppMessage::Exit => {
 671                 *exit = true;
 672             }
 673             AppMessage::SelectFamily(idx) => {
 674                 if idx < self.filtered.len() {
 675                     self.selected_idx = Some(idx);
 676                     let family = self.filtered[idx].clone();
 677                     self.select_family(family);
 678                     self.scroll_to_index(idx);
 679                     *needs_rebuild = true;
 680                     self.needs_rebuild = true;
 681                 }
 682             }
 683             AppMessage::SelectStyle(idx) => {
 684                 if idx < self.family_styles.len() {
 685                     self.style_dropdown.selected = idx;
 686                     let style = self.family_styles[idx].clone();
 687                     let file = self.family_files[idx].clone();
 688                     self.selected_style = Some(style);
 689                     self.selected_file = Some(file.clone());
 690                     self.charset_count = pages::count_chars(&file);
 691                     self.charset_str = self.charset_count.to_string();
 692                     self.is_user_font = pages::is_user_font(&file);
 693                     *needs_rebuild = true;
 694                     self.needs_rebuild = true;
 695                 }
 696             }
 697             AppMessage::FontSizeChanged => {
 698                 self.preview_box.font_size = self.size_spinbox.value as f32;
 699                 *needs_rebuild = true;
 700                 self.needs_rebuild = true;
 701             }
 702             AppMessage::SearchChanged => {
 703                 let query = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
 704                 self.filtered = self.filter_families(&self.families, query);
 705                 // See the note in refresh(): monotonic ids mean the outgoing buttons must be
 706                 // unregistered or the registry keeps dangling pointers. This path runs on
 707                 // every keystroke in the filter box, so it is the hottest producer of them.
 708                 let stale: Vec<_> = self.font_buttons.iter().map(|b| b.id()).collect();
 709                 for id in stale {
 710                     self.ui_context.unregister_widget(id);
 711                 }
 712                 self.font_buttons = self.filtered.iter().map(|f| Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(f)).collect();
 713                 // Register the replacements now rather than waiting for the next layout pass —
 714                 // see refresh(). This is the path that produced the stale-root spam.
 715                 for btn in self.font_buttons.iter_mut() {
 716                     let (id, ptr) = (btn.id(), btn.as_ptr_mut());
 717                     self.ui_context.register_widget(id, ptr);
 718                 }
 719 
 720                 // reset or re-evaluate selection
 721                 self.selected_idx = None;
 722                 self.selected_family = None;
 723                 self.selected_style = None;
 724                 self.selected_file = None;
 725                 self.family_styles.clear();
 726                 self.family_files.clear();
 727                 self.style_dropdown.options.clear();
 728                 self.style_dropdown.selected = 0;
 729                 self.charset_count = 0;
 730                 self.charset_str = String::from("0");
 731                 self.is_user_font = false;
 732 
 733                 // Auto-select first match if available
 734                 if !self.filtered.is_empty() {
 735                     self.selected_idx = Some(0);
 736                     let family = self.filtered[0].clone();
 737                     self.select_family(family);
 738                 }
 739 
 740                 *needs_rebuild = true;
 741                 self.needs_rebuild = true;
 742             }
 743             AppMessage::OpenFolder => {
 744                 if let Some(ref file) = self.selected_file {
 745                     if let Some(parent) = std::path::Path::new(file).parent() {
 746                         let _ = std::process::Command::new("xdg-open").arg(parent).spawn();
 747                     }
 748                 }
 749             }
 750             AppMessage::RemoveFont => {
 751                 if let Some(ref file) = self.selected_file {
 752                     if self.is_user_font {
 753                         let _ = std::process::Command::new("gio").args(["trash", file]).spawn();
 754                         let _ = std::process::Command::new("fc-cache").arg("-f").spawn();
 755                         self.reload_fonts();
 756                         *needs_rebuild = true;
 757                         self.needs_rebuild = true;
 758                     }
 759                 }
 760             }
 761             AppMessage::RefreshFonts => {
 762                 self.reload_fonts();
 763                 *needs_rebuild = true;
 764                 self.needs_rebuild = true;
 765             }
 766         }
 767     }
 768 
 769     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
 770         // Pump the widget tick walk: animating widgets (the style dropdown's
 771         // expand/contract menu) register as tick receivers and report changed
 772         // until their transition lands — without this a closing menu freezes
 773         // fully open.
 774         if self.ui_context.tick(dt) {
 775             *needs_rebuild = true;
 776             self.needs_rebuild = true;
 777         }
 778 
 779         // Raise/sink upkeep for the panel scrollbars: true while the
 780         // post-scroll hold runs or on the depth flip, keeping frames coming
 781         // so the sink actually renders.
 782         if self.list_region.tick(dt) {
 783             *needs_rebuild = true;
 784             self.needs_rebuild = true;
 785         }
 786         if self.mid_region.tick(dt) {
 787             *needs_rebuild = true;
 788             self.needs_rebuild = true;
 789         }
 790 
 791     }
 792 
 793     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
 794         // Phase 6 single paint path: setup/relayout (the old view() body), then the whole
 795         // frame — the widget tree walked into one list, the panel borders and alphabet box
 796         // (lost since the Phase 3 adoption discarded view()'s quads), the alphabet preview
 797         // prims, and the style-dropdown popover on top — is built here.
 798         let size_changed = self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale;
 799         if self.needs_rebuild || size_changed {
 800             self.width = size.width as u32;
 801             self.height = size.height as u32;
 802             self.scale_factor = scale;
 803         }
 804 
 805         let w_f32 = self.width as f32;
 806         let h_f32 = self.height as f32;
 807 
 808         // The ladder: the window edge to a pane is the root inset; the two
 809         // panes (and the select bar below them) are siblings on the root plate,
 810         // a root gap apart; inside a pane, content stands off the rim by the
 811         // pane padding, and rows / grouped buttons sit a pane gap apart.
 812         let inset = cce_ui::layout::root_plate_inset();
 813         let pad = cce_ui::layout::plate_padding();
 814         let gap = cce_ui::layout::plate_gap();
 815         let row_gap = list_row_gap();
 816 
 817         let panel_y = inset;
 818         let left_panel_x = inset;
 819         let left_panel_w = LEFT_PANEL_W;
 820         let (mid_panel_x, _, mid_panel_w, _) = self.mid_panel_rect();
 821 
 822         let select_bar_h = SELECT_BAR_H;
 823         let content_h = self.content_h();
 824         let bar_y = h_f32 - select_bar_h - inset;
 825 
 826         if self.needs_rebuild || size_changed {
 827             cce_ui::scale::set_scale_factor(scale as f32);
 828             self.register_widgets();
 829 
 830             self.mid_region.set_rect(mid_panel_x, panel_y, mid_panel_w, content_h);
 831 
 832             let form = self.preview_form();
 833             self.mid_region.update_bounds_raw(form.content_h, panel_y, content_h);
 834 
 835             // Search box, inset from the pane's rim.
 836             let search_h = 26.0;
 837             self.search_box.set_rect(left_panel_x + pad, panel_y + pad, left_panel_w - 2.0 * pad, search_h);
 838 
 839             // Scrolling list: a pane gap below the search box, down to the
 840             // pane's padded bottom rim.
 841             let list_x = left_panel_x + pad;
 842             let list_y = panel_y + pad + search_h + gap;
 843             let list_w = left_panel_w - 2.0 * pad;
 844             let list_h = (panel_y + content_h - pad - list_y).max(100.0);
 845             self.list_region.set_rect(list_x, list_y, list_w, list_h);
 846             // The dissolved List's content math: count * (adjusted item height + row gap) + row gap.
 847             let item_full = self.list_item_h + row_gap;
 848             self.list_region.update_bounds_raw(self.filtered.len() as f32 * item_full + row_gap, list_y, list_h);
 849 
 850             // Layout list buttons, inset from the list by its row gap.
 851             for (idx, btn) in self.font_buttons.iter_mut().enumerate() {
 852                 if let Some(draw_y) = self.list_region.get_draw_y(idx as f32 * item_full, self.list_item_h) {
 853                     btn.set_rect(list_x + row_gap, draw_y, list_w - 2.0 * row_gap, LIST_ROW_H);
 854                 } else {
 855                     btn.set_rect(-9999.0, -9999.0, 0.0, 0.0);
 856                 }
 857             }
 858 
 859             // Preview panel widgets layout: the form's stack, scrolled.
 860             let scroll_y = self.mid_region.scroll_y;
 861             let viewport_top = panel_y;
 862             let viewport_bottom = panel_y + content_h;
 863             let control_x = mid_panel_x + pad;
 864             let control_w = mid_panel_w - 2.0 * pad;
 865 
 866             // Open Folder & Remove Font buttons (h = 28.0), a pane gap apart.
 867             let btn_draw_y = panel_y + form.buttons_y - scroll_y;
 868             if btn_draw_y + 28.0 >= viewport_top && btn_draw_y <= viewport_bottom {
 869                 self.btn_open_folder.set_rect(control_x, btn_draw_y, 110.0, 28.0);
 870                 self.btn_remove_font.set_rect(control_x + 110.0 + gap, btn_draw_y, 110.0, 28.0);
 871             } else {
 872                 self.btn_open_folder.set_rect(-9999.0, -9999.0, 0.0, 0.0);
 873                 self.btn_remove_font.set_rect(-9999.0, -9999.0, 0.0, 0.0);
 874             }
 875 
 876             // Style dropdown
 877             let dropdown_draw_y = panel_y + form.dropdown_y - scroll_y;
 878             if dropdown_draw_y + form.dropdown_h >= viewport_top && dropdown_draw_y <= viewport_bottom {
 879                 self.style_dropdown.set_rect(control_x, dropdown_draw_y, control_w, form.dropdown_h);
 880             } else {
 881                 self.style_dropdown.set_rect(-9999.0, -9999.0, 0.0, 0.0);
 882             }
 883 
 884             // Size spinbox
 885             let spinbox_draw_y = panel_y + form.spinbox_y - scroll_y;
 886             if spinbox_draw_y + form.spinbox_h >= viewport_top && spinbox_draw_y <= viewport_bottom {
 887                 self.size_spinbox.set_rect(control_x, spinbox_draw_y, control_w, form.spinbox_h);
 888             } else {
 889                 self.size_spinbox.set_rect(-9999.0, -9999.0, 0.0, 0.0);
 890             }
 891 
 892             // Preview box
 893             let preview_draw_y = panel_y + form.preview_y - scroll_y;
 894             if preview_draw_y + form.preview_h >= viewport_top && preview_draw_y <= viewport_bottom {
 895                 self.preview_box.set_rect(control_x, preview_draw_y, control_w, form.preview_h);
 896             } else {
 897                 self.preview_box.set_rect(-9999.0, -9999.0, 0.0, 0.0);
 898             }
 899 
 900             if self.select_mode {
 901                 // The bottom bar's buttons, laid out directly (the layout Plate is
 902                 // DISSOLVED): text-sized via the widgets' intrinsic size, packed
 903                 // right — the pane padding off the bar's rim, a pane gap apart —
 904                 // and vertically centered: the old row style.
 905                 let cancel_sz = self.select_cancel_btn.intrinsic_size()
 906                     .unwrap_or(cce_ui::scene::layout::Size::new(80.0, 28.0));
 907                 let confirm_sz = self.select_confirm_btn.intrinsic_size()
 908                     .unwrap_or(cce_ui::scene::layout::Size::new(80.0, 28.0));
 909                 let bar_w = w_f32 - 2.0 * inset;
 910                 let mut x = left_panel_x + bar_w - pad - confirm_sz.width;
 911                 self.select_confirm_btn.set_rect(x, bar_y + (select_bar_h - confirm_sz.height) / 2.0, confirm_sz.width, confirm_sz.height);
 912                 x -= gap + cancel_sz.width;
 913                 self.select_cancel_btn.set_rect(x, bar_y + (select_bar_h - cancel_sz.height) / 2.0, cancel_sz.width, cancel_sz.height);
 914             }
 915 
 916             self.refresh_widget_text();
 917             self.needs_rebuild = false;
 918             self.ui_context.rebuild_spatial_grid();
 919         }
 920 
 921         // Popover registration for the display-list text occlusion clamp. ui_context ONLY —
 922         // deliberately not the global popovers registry: this app draws its popover in the
 923         // display list below (not on an engine xdg popup), and a global registration would
 924         // spawn an empty popup surface (no render_popovers override here).
 925         self.ui_context.clear_popovers();
 926         if self.selected_family.is_some() && self.style_dropdown.popover_rect().is_some() {
 927             self.ui_context.register_popover(&mut self.style_dropdown);
 928         }
 929 
 930         let base_low = cce_ui::colors::page_low_color();
 931         let border_col = cce_ui::colors::color_borders_color();
 932 
 933         // 1. The window base, then the dissolved panels' plates and their children
 934         // walked in the legacy panel order.
 935         use cce_ui::scene::layout::Rect;
 936         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 937         // The standard root plate (cce-ui PlateSpec::window).
 938         pc.root_plate(w_f32, h_f32);
 939         {
 940             let self_ptr = self as *mut Self;
 941             // Left panel plate + children.
 942             self.plate_prims(Rect { x: left_panel_x, y: panel_y, width: left_panel_w, height: content_h }, &mut pc);
 943             unsafe {
 944                 cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).search_box, &mut pc);
 945                 emit_region_quads(&(*self_ptr).list_region, &mut pc);
 946                 // Rows under the list-viewport clip: `get_draw_y` returns
 947                 // PARTIALLY visible rows (toolkit ScrollRegion intersection
 948                 // contract), so an edge row renders cut instead of vanishing.
 949                 // (Its full rect can overlap the search box's hit area above;
 950                 // the router dispatches the search box first, so it wins.)
 951                 let lr = &(*self_ptr).list_region;
 952                 pc.push_clip(Rect { x: lr.x, y: lr.viewport_y, width: lr.w, height: lr.viewport_h });
 953                 for btn in (*self_ptr).font_buttons.iter_mut() {
 954                     if btn.rect().0 > -9000.0 {
 955                         cce_ui::scene::painter::paint_root_into(&self.ui_context, &*btn, &mut pc);
 956                     }
 957                 }
 958                 pc.pop_clip();
 959             }
 960             // Mid panel plate + children (when a family is selected, like the old links).
 961             self.plate_prims(Rect { x: mid_panel_x, y: panel_y, width: mid_panel_w, height: content_h }, &mut pc);
 962             if self.selected_family.is_some() {
 963                 unsafe {
 964                     emit_region_quads(&(*self_ptr).mid_region, &mut pc);
 965                     cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).btn_open_folder, &mut pc);
 966                     cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).btn_remove_font, &mut pc);
 967                     cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).style_dropdown, &mut pc);
 968                     cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).size_spinbox, &mut pc);
 969                     cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).preview_box, &mut pc);
 970                 }
 971             }
 972             // Bottom bar plate + children (select mode).
 973             if self.select_mode {
 974                 self.plate_prims(Rect { x: left_panel_x, y: bar_y, width: w_f32 - 2.0 * inset, height: select_bar_h }, &mut pc);
 975                 unsafe {
 976                     cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).select_cancel_btn, &mut pc);
 977                     cce_ui::scene::painter::paint_root_into(&self.ui_context, &(*self_ptr).select_confirm_btn, &mut pc);
 978                 }
 979             }
 980         }
 981 
 982         let quad = |x: f32, y: f32, w: f32, h: f32, c: [f32; 4], pc: &mut cce_ui::scene::paint::PaintCtx| {
 983             pc.quad(Rect { x, y, width: w, height: h }, c);
 984         };
 985 
 986         // 3. Page Content Outline Borders
 987         // Left Panel Borders
 988         quad(left_panel_x, panel_y, left_panel_w, 1.0, border_col, &mut pc);
 989         quad(left_panel_x, panel_y + content_h, left_panel_w, 1.0, border_col, &mut pc);
 990         quad(left_panel_x, panel_y, 1.0, content_h, border_col, &mut pc);
 991         quad(left_panel_x + left_panel_w, panel_y, 1.0, content_h, border_col, &mut pc);
 992 
 993         // Middle Panel Borders
 994         quad(mid_panel_x, panel_y, mid_panel_w, 1.0, border_col, &mut pc);
 995         quad(mid_panel_x, panel_y + content_h, mid_panel_w, 1.0, border_col, &mut pc);
 996         quad(mid_panel_x, panel_y, 1.0, content_h, border_col, &mut pc);
 997         quad(mid_panel_x + mid_panel_w, panel_y, 1.0, content_h, border_col, &mut pc);
 998 
 999         // The ScrollBox now automatically draws its own borders and scrollbar.
1000 
1001         // 4. Alphabet preview box + its text prims (family + style/weight attrs).
1002         if self.selected_family.is_some() {
1003             let form = self.preview_form();
1004             let alphabet_box_h = form.alphabet_h;
1005             let scroll_y = self.mid_region.scroll_y;
1006             let alphabet_draw_y = panel_y + form.alphabet_y - scroll_y;
1007 
1008             let viewport_top = panel_y;
1009             let viewport_bottom = panel_y + content_h;
1010 
1011             if alphabet_draw_y + alphabet_box_h >= viewport_top && alphabet_draw_y <= viewport_bottom {
1012                 let draw_y_start = alphabet_draw_y.max(viewport_top);
1013                 let draw_y_end = (alphabet_draw_y + alphabet_box_h).min(viewport_bottom);
1014                 let draw_h = draw_y_end - draw_y_start;
1015 
1016                 if draw_h > 0.0 {
1017                     let preview_box_x = mid_panel_x + pad;
1018                     let preview_box_w = mid_panel_w - 2.0 * pad;
1019                     let alphabet_bg = [
1020                         base_low[0] * 0.9,
1021                         base_low[1] * 0.9,
1022                         base_low[2] * 0.9,
1023                         1.0,
1024                     ];
1025                     let alphabet_border = [
1026                         border_col[0] * 0.85,
1027                         border_col[1] * 0.85,
1028                         border_col[2] * 0.85,
1029                         1.0,
1030                     ];
1031 
1032                     quad(preview_box_x, draw_y_start, preview_box_w, draw_h, alphabet_bg, &mut pc); // alphabet box bg
1033 
1034                     if alphabet_draw_y >= viewport_top {
1035                         quad(preview_box_x, alphabet_draw_y, preview_box_w, 1.0, alphabet_border, &mut pc); // Top border
1036                     }
1037                     if alphabet_draw_y + alphabet_box_h <= viewport_bottom {
1038                         quad(preview_box_x, alphabet_draw_y + alphabet_box_h, preview_box_w, 1.0, alphabet_border, &mut pc); // Bottom border
1039                     }
1040                     quad(preview_box_x, draw_y_start, 1.0, draw_h, alphabet_border, &mut pc); // Left border
1041                     quad(preview_box_x + preview_box_w, draw_y_start, 1.0, draw_h, alphabet_border, &mut pc); // Right border
1042                 }
1043             }
1044 
1045             self.push_alphabet_preview(&mut pc);
1046         }
1047 
1048         // Raised scrollbars ride over the panel content (the sunk layers went
1049         // under the region bg fills inside emit_region_quads); the style
1050         // dropdown's popover still stacks above them.
1051         emit_region_bar(&self.list_region, &mut pc);
1052         if self.selected_family.is_some() {
1053             emit_region_bar(&self.mid_region, &mut pc);
1054         }
1055 
1056         if self.select_mode {
1057             // Draw bottom bar separator, side borders, and bottom border
1058             quad(left_panel_x, bar_y, w_f32 - 2.0 * inset, 1.0, border_col, &mut pc);
1059             quad(left_panel_x, bar_y, 1.0, select_bar_h, border_col, &mut pc);
1060             quad(w_f32 - inset, bar_y, 1.0, select_bar_h, border_col, &mut pc);
1061             quad(left_panel_x, bar_y + select_bar_h, w_f32 - 2.0 * inset, 1.0, border_col, &mut pc);
1062 
1063             // Selected-font readout in the bottom bar: the pane padding off
1064             // its rim, the 12px text centered in the bar's height, the name
1065             // in a 100px column after the label.
1066             let readout_size = 12.0;
1067             let readout_y = bar_y + (select_bar_h - readout_size) / 2.0;
1068             pc.text_with(
1069                 "Selected Font:",
1070                 left_panel_x + pad,
1071                 readout_y,
1072                 readout_size,
1073                 [0x5c, 0x90, 0x60],
1074                 Some("monospace".to_string()),
1075                 None,
1076             );
1077             let font_name = self.selected_family.clone().unwrap_or_else(|| "None".to_string());
1078             pc.text_with(
1079                 font_name,
1080                 left_panel_x + pad + 100.0,
1081                 readout_y,
1082                 readout_size,
1083                 [0xdd, 0xdd, 0xe2],
1084                 Some("monospace".to_string()),
1085                 None,
1086             );
1087         }
1088 
1089         // 5. Style-dropdown popover — geometry and labels last, on top of everything. Its
1090         // labels carry bounds equal to the popover rect, which both clips them to the plate
1091         // and exempts them from the occlusion clamp (the is-overlay-text convention).
1092         if self.selected_family.is_some() && self.style_dropdown.open {
1093             // PaintCtx is a RenderTarget: the popover draws its real prims (the
1094             // expanded inset-plate surface) with its own per-label bounds.
1095             self.style_dropdown.render_popover(&mut pc);
1096         }
1097 
1098         Some(pc.finish())
1099     }
1100 
1101     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1102         let mut changed = false;
1103         let px = pos.x as f32;
1104         let py = pos.y as f32;
1105 
1106         let old_size = self.preview_box.font_size;
1107 
1108         // The dissolved scroll regions: scrollbar-thumb drags and hover tracking.
1109         // Toolkit cursor_moved = the old drag_move + manual hover tracking.
1110         if self.list_region.cursor_moved(px, py) {
1111             changed = true;
1112         }
1113         if self.mid_region.cursor_moved(px, py) {
1114             changed = true;
1115         }
1116 
1117         // Routed dispatch (6bd shrink): one PointerMove through the router per roster
1118         // root — hover bookkeeping plus the router's drag forwarding (replaces the
1119         // is_dragging -> drag_update pass; DragUpdate reaches the drag target off-rect).
1120         let ev = Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
1121         for root in self.dispatch_widgets(true) {
1122             if self.ui_context.propagate_event(&ev, root) {
1123                 changed = true;
1124             }
1125         }
1126 
1127         let new_size = self.size_spinbox.value as f32;
1128         if (new_size - old_size).abs() > 0.001 {
1129             self.preview_box.font_size = new_size;
1130             changed = true;
1131         }
1132 
1133         if changed {
1134             *needs_rebuild = true;
1135             self.needs_rebuild = true;
1136         }
1137     }
1138 
1139     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
1140         let mut changed = false;
1141         let mut msg_out = None;
1142         let px = pos.x as f32;
1143         let py = pos.y as f32;
1144 
1145         if state == ElementState::Pressed && button == MouseButton::Left {
1146             if !self.search_box.hit_test(px, py, &self.ui_context) {
1147                 self.search_box.unfocus();
1148                 changed = true;
1149             } else {
1150                 self.ui_context.set_focused(&mut self.search_box);
1151             }
1152             if !self.preview_box.hit_test(px, py, &self.ui_context) {
1153                 self.preview_box.unfocus();
1154                 changed = true;
1155             } else {
1156                 self.ui_context.set_focused(&mut self.preview_box);
1157             }
1158         }
1159 
1160         // The dissolved scroll regions' scrollbars (thumb grab / track jump / release).
1161         let mut region_handled = false;
1162         if button == MouseButton::Left {
1163             match state {
1164                 ElementState::Pressed => {
1165                     if self.list_region.press(px, py) || (self.selected_family.is_some() && self.mid_region.press(px, py)) {
1166                         changed = true;
1167                         region_handled = true;
1168                     }
1169                 }
1170                 ElementState::Released => {
1171                     self.list_region.release();
1172                     self.mid_region.release();
1173                 }
1174             }
1175         }
1176 
1177         // The dissolved panels' press routing, flattened and ROUTED (6bd shrink):
1178         // popover-first (an open dropdown must see the click before anything beneath),
1179         // then reverse order with the Plates' unfocus-on-missed-press rule, stopping at
1180         // the first handler. The router hit-gates presses and records the drag target.
1181         let ev = Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
1182         let widgets = self.dispatch_widgets(false);
1183         let mut input_handled = region_handled;
1184         for &root in &widgets {
1185             if input_handled {
1186                 break;
1187             }
1188             let has_popover = self.ui_context.get_widget(root).map_or(false, |w| w.popover_rect().is_some());
1189             if has_popover {
1190                 if self.ui_context.propagate_event(&ev, root) {
1191                     changed = true;
1192                     input_handled = true;
1193                     break;
1194                 }
1195             }
1196         }
1197         if !input_handled {
1198             for &root in &widgets {
1199                 if self.ui_context.propagate_event(&ev, root) {
1200                     changed = true;
1201                     break;
1202                 }
1203                 if state == ElementState::Pressed {
1204                     let missed = self.ui_context.get_widget(root).map_or(false, |w| !w.hit_test(px, py, &self.ui_context));
1205                     if missed {
1206                         if let Some(w) = self.ui_context.get_widget_mut(root) {
1207                             w.unfocus();
1208                         }
1209                     }
1210                 }
1211             }
1212         }
1213 
1214         if self.selected_family.is_some() && self.style_dropdown.take_change() {
1215             msg_out = Some(AppMessage::SelectStyle(self.style_dropdown.selected));
1216         }
1217 
1218         for (idx, btn) in self.font_buttons.iter_mut().enumerate() {
1219             if btn.rect().0 > -9000.0 && btn.take_click() {
1220                 let double = self.last_click_at.is_some_and(|t| t.elapsed() < DOUBLE_CLICK);
1221                 if self.select_mode && self.last_click_idx == Some(idx) && double {
1222                     let selected = self.filtered[idx].clone();
1223                     print!("{} {}", selected, self.size_spinbox.value);
1224                     use std::io::Write;
1225                     let _ = std::io::stdout().flush();
1226                     std::process::exit(0);
1227                 }
1228                 self.last_click_idx = Some(idx);
1229                 self.last_click_at = Some(std::time::Instant::now());
1230                 msg_out = Some(AppMessage::SelectFamily(idx));
1231                 break;
1232             }
1233         }
1234 
1235         let old_size = self.preview_box.font_size;
1236         let new_size = self.size_spinbox.value as f32;
1237         if (new_size - old_size).abs() > 0.001 {
1238             self.preview_box.font_size = new_size;
1239             msg_out = Some(AppMessage::FontSizeChanged);
1240         }
1241 
1242         if self.btn_open_folder.take_click() {
1243             msg_out = Some(AppMessage::OpenFolder);
1244         }
1245         if self.btn_remove_font.take_click() {
1246             msg_out = Some(AppMessage::RemoveFont);
1247         }
1248 
1249         if self.select_mode {
1250             if self.select_cancel_btn.take_click() {
1251                 std::process::exit(1);
1252             }
1253             if self.select_confirm_btn.take_click() {
1254                 let selected = self.selected_family.clone().unwrap_or_default();
1255                 print!("{} {}", selected, self.size_spinbox.value);
1256                 use std::io::Write;
1257                 let _ = std::io::stdout().flush();
1258                 std::process::exit(0);
1259             }
1260         }
1261 
1262         if changed {
1263             *needs_rebuild = true;
1264             self.needs_rebuild = true;
1265         }
1266 
1267         msg_out
1268     }
1269 
1270     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
1271         let mut changed = false;
1272         let px = pos.x as f32;
1273         let py = pos.y as f32;
1274 
1275         if self.list_region.wheel(delta, px, py) {
1276             changed = true;
1277         }
1278         if self.selected_family.is_some() && self.mid_region.wheel(delta, px, py) {
1279             changed = true;
1280         }
1281         // Routed (6bd shrink): every roster root sees the wheel, as the legacy
1282         // no-break loop did; each widget hit-gates internally.
1283         let ev = Event::MouseWheel { delta: *delta, x: px, y: py, local_x: px, local_y: py };
1284         for root in self.dispatch_widgets(true) {
1285             if self.ui_context.propagate_event(&ev, root) {
1286                 changed = true;
1287             }
1288         }
1289 
1290         if changed {
1291             *needs_rebuild = true;
1292             self.needs_rebuild = true;
1293         }
1294     }
1295 
1296     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
1297         let mut handled = false;
1298         let mut msg_out = None;
1299 
1300         // Custom keyboard shortcuts (input.kdl `cce-fonts` domain)
1301         if event.state == ElementState::Pressed {
1302             let m = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
1303             if m(&self.keys.refresh) {
1304                 msg_out = Some(AppMessage::RefreshFonts);
1305                 handled = true;
1306             } else if m(&self.keys.open_search) {
1307                 self.search_box.focus();
1308                 self.ui_context.set_focused(&mut self.search_box);
1309                 self.preview_box.unfocus();
1310                 handled = true;
1311             } else if m(&self.keys.focus_preview) {
1312                 self.preview_box.focus();
1313                 self.ui_context.set_focused(&mut self.preview_box);
1314                 self.search_box.unfocus();
1315                 handled = true;
1316             } else if m(&self.keys.open_folder) {
1317                 msg_out = Some(AppMessage::OpenFolder);
1318                 handled = true;
1319             } else if m(&self.keys.remove_font) {
1320                 msg_out = Some(AppMessage::RemoveFont);
1321                 handled = true;
1322             }
1323         }
1324 
1325         if !handled && event.state == ElementState::Pressed {
1326             match event.logical_key {
1327                 Key::Character(ref ch) if ch == "+" || ch == "=" => {
1328                     if self.selected_family.is_some() {
1329                         let (_, max) = self.size_spinbox.range();
1330                         let old_val = self.size_spinbox.value;
1331                         self.size_spinbox.value = (self.size_spinbox.value + 2).min(max);
1332                         if self.size_spinbox.value != old_val && self.size_spinbox.editing {
1333                             self.size_spinbox.edit_buffer = self.size_spinbox.value.to_string();
1334                         }
1335                         msg_out = Some(AppMessage::FontSizeChanged);
1336                         handled = true;
1337                     }
1338                 }
1339                 Key::Character(ref ch) if ch == "-" => {
1340                     if self.selected_family.is_some() {
1341                         let (min, _) = self.size_spinbox.range();
1342                         let old_val = self.size_spinbox.value;
1343                         self.size_spinbox.value = (self.size_spinbox.value - 2).max(min);
1344                         if self.size_spinbox.value != old_val && self.size_spinbox.editing {
1345                             self.size_spinbox.edit_buffer = self.size_spinbox.value.to_string();
1346                         }
1347                         msg_out = Some(AppMessage::FontSizeChanged);
1348                         handled = true;
1349                     }
1350                 }
1351                 _ => {}
1352             }
1353         }
1354 
1355         if !handled && !self.style_dropdown.open {
1356             // Arrow navigation — family list only while no dropdown is open:
1357             // an open style dropdown takes the arrows for its own hover (via
1358             // the propagate loop below), and consuming them here left it
1359             // keyboard-navigable in every way except the one that matters.
1360             let direction = match event.logical_key {
1361                 Key::Named(NamedKey::ArrowUp) => Some(BrowseNavigation::Up),
1362                 Key::Named(NamedKey::ArrowDown) => Some(BrowseNavigation::Down),
1363                 _ => None,
1364             };
1365 
1366             if let Some(dir) = direction {
1367                 if event.state == ElementState::Pressed && !self.filtered.is_empty() {
1368                     let len = self.filtered.len();
1369                     let next_idx = match (self.selected_idx, dir) {
1370                         (Some(idx), BrowseNavigation::Up) => idx.saturating_sub(1),
1371                         (Some(idx), BrowseNavigation::Down) => (idx + 1).min(len - 1),
1372                         (None, BrowseNavigation::Up) => len - 1,
1373                         (None, BrowseNavigation::Down) => 0,
1374                     };
1375                     msg_out = Some(AppMessage::SelectFamily(next_idx));
1376                     handled = true;
1377                 }
1378             }
1379         }
1380 
1381         // TextBox and other focused widgets input propagation
1382         if !handled {
1383             let old_search_text = if self.search_box.editing { self.search_box.edit_buffer.clone() } else { self.search_box.text.clone() };
1384             
1385             // Routed (6bd shrink); short-circuits on the first handler — the router
1386             // delivers KeyInput to the focused widget first on EVERY call (the 6ac trap).
1387             let ev = Event::KeyInput(event.clone());
1388             for root in self.dispatch_widgets(true) {
1389                 if self.ui_context.propagate_event(&ev, root) {
1390                     handled = true;
1391                     break;
1392                 }
1393             }
1394             if !handled && self.list_region.keyboard(event) {
1395                 handled = true;
1396             }
1397             if !handled && self.selected_family.is_some() && self.mid_region.keyboard(event) {
1398                 handled = true;
1399             }
1400 
1401             if handled {
1402                 let new_search_text = if self.search_box.editing { &self.search_box.edit_buffer } else { &self.search_box.text };
1403                 if old_search_text != *new_search_text {
1404                     msg_out = Some(AppMessage::SearchChanged);
1405                 }
1406             }
1407         }
1408 
1409         if handled {
1410             *needs_rebuild = true;
1411             self.needs_rebuild = true;
1412         }
1413 
1414         msg_out
1415     }
1416 }
1417 
1418 fn main() {
1419     let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
1420     let _guard = rt.enter();
1421     
1422     cce_ui::engine::run::<TypefaceApp>();
1423 }