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