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 }