git.lucas.co / cce-fonts
font browser

src/main.rs (67.6K)

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