Skip to main content

teksilo_widgets/
font_picker.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! FontPicker — a drop-in font-family selector.
5//!
6//! A [`ComboBox`] preset that lists every installed font family and lets
7//! the user pick one, in the tradition of Qt's `QFontComboBox`, GTK's
8//! `FontChooser`, and UIKit's `UIFontPickerViewController`. It
9//!
10//! - **self-populates** from the app's shared typesetter
11//!   (`ctx.app_state::<SharedTypesetter>()` → `families()`), so no font
12//!   list is passed in;
13//! - **previews each font**: every row shows the family name in a legible
14//!   system font next to a tiny sample rendered *in that font*
15//!   ([`FontPreviewMode::NameThenSample`], the default), and the closed
16//!   trigger shows the selected family in its own typeface;
17//! - is **searchable** (type to filter hundreds of fonts) and
18//!   **filterable** by spacing ([`FontSpacingFilter`]) and by writing
19//!   system ([`WritingSystem`]);
20//! - binds the choice to a `Signal<Option<String>>` (the family name), which
21//!   plugs straight into `TextStyle.family` / `RichTextEditor::set_font_family`.
22//!
23//! ```ignore
24//! let family: Signal<Option<String>> = Signal::new(None);
25//! VStack::new()
26//!     .child(TextWidget::new(tr!(font())).style(TextStyleRole::BodyBold))
27//!     .child(FontPicker::new(family.clone())
28//!         .on_select(|name, _ctx| editor.set_font_family(name)));
29//! ```
30//!
31//! # Writing-system detection is off-thread
32//!
33//! Classifying which scripts a font covers parses its OS/2 table, i.e.
34//! reads the font file — hundreds of reads for a full system. The picker
35//! therefore builds the coverage index on a background thread the first
36//! time it mounts and polls readiness on the frame tick; until the index is
37//! ready the writing-system filter shows the unfiltered list and samples
38//! fall back to a Latin default. Spacing (monospaced / proportional)
39//! filtering is instant (it uses only font metadata, no bytes).
40//!
41//! Only family selection is offered, matching Qt's `QFontComboBox`. Face /
42//! weight / size selection belongs to a larger font *dialog* and is out of
43//! scope.
44
45use std::cell::{Cell, RefCell};
46use std::collections::HashMap;
47use std::rc::Rc;
48use std::sync::atomic::{AtomicBool, Ordering};
49use std::sync::{Arc, Mutex};
50
51use teksilo_canvas::{Rect, SizeProposal};
52use teksilo_core::accessibility::AccessNodeBuilder;
53use teksilo_core::binding::BindingLevel;
54use teksilo_core::build_context::BuildContext;
55use teksilo_core::frame_tick_scheduler::FrameTickSubscription;
56use teksilo_core::signal::{Prop, Signal};
57use teksilo_core::styles::{ComboBoxStyle, ComboBoxStyleConfig, SharedComboBoxStyle};
58use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
59use teksilo_core::widget_id::WidgetId;
60use teksilo_i18n::{LocalizedString, lit, tr_widget};
61use teksilo_text::{FontFamilyInfo, SharedTypesetter, WritingSystem, WritingSystemSet};
62use teksilo_tokens::{TextStyle, TextStyleRole};
63
64use crate::combo_box::{ComboBox, ComboBoxVariant};
65use crate::primitives::{HStack, Spacer, TextWidget};
66
67/// Spacing filter, mirroring the monospaced / proportional axis of Qt's
68/// `QFontComboBox::FontFilters`. Cheap — it reads only font metadata.
69#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
70pub enum FontSpacingFilter {
71    /// Show all fonts (default).
72    #[default]
73    Any,
74    /// Only monospaced fonts.
75    Monospaced,
76    /// Only proportional (non-monospaced) fonts.
77    Proportional,
78}
79
80/// How each row — and the closed trigger — previews a font.
81#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
82pub enum FontPreviewMode {
83    /// Family name in a legible system font, then a tiny sample rendered in
84    /// the font itself (the default). The sample text is chosen for the
85    /// font's writing system.
86    #[default]
87    NameThenSample,
88    /// Family name rendered in its own typeface (the Qt / UIKit default).
89    NameInOwnFont,
90    /// Family name in the system font, no in-font sample (UIKit
91    /// `displayUsingSystemFont`). Maximum legibility.
92    NameInSystemFont,
93}
94
95/// Per-family metadata for headless testing / restricted font sets via
96/// [`FontPicker::families_with_meta`]. In a real app this data comes from
97/// the shared typesetter instead.
98#[derive(Clone, Debug, Default)]
99pub struct FontMeta {
100    /// Whether the family is monospaced (drives [`FontSpacingFilter`]).
101    pub monospaced: bool,
102    /// The scripts the family covers (drives the writing-system filter and
103    /// the per-row sample text).
104    pub writing_systems: WritingSystemSet,
105}
106
107/// A font-family selector built on [`ComboBox`]. See the module docs.
108pub struct FontPicker {
109    /// The bound family name — the source of truth. Passes straight through
110    /// to the inner ComboBox.
111    selected: Signal<Option<String>>,
112    /// Explicit family list. `None` ⇒ enumerate from the shared typesetter.
113    families_override: Option<Vec<FontFamilyInfo>>,
114    /// Explicit writing-system coverage (from `families_with_meta`). `None`
115    /// ⇒ built on a background thread from the typesetter.
116    meta_override: Option<HashMap<String, WritingSystemSet>>,
117
118    spacing_filter: Prop<FontSpacingFilter>,
119    writing_system: Prop<Option<WritingSystem>>,
120    preview_mode: FontPreviewMode,
121    sample_global: Option<String>,
122    sample_by_ws: HashMap<WritingSystem, String>,
123    sample_by_family: HashMap<String, String>,
124    show_selected_in_own_font: bool,
125
126    placeholder: Option<LocalizedString>,
127    label: Option<LocalizedString>,
128    /// Enabled state, static or reactive; forwarded to the inner
129    /// [`ComboBox`] at build time.
130    enabled: Prop<bool>,
131    variant: ComboBoxVariant,
132    style_override: Option<SharedComboBoxStyle>,
133    max_visible_items: Option<usize>,
134    searchable: bool,
135    search_query: Option<Signal<String>>,
136    on_select: Option<Rc<dyn Fn(&str, &mut EventContext)>>,
137
138    tooltip_text: Option<LocalizedString>,
139    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
140    composite_tooltip_content: Option<Box<dyn Widget>>,
141
142    // ── Runtime state (persists across rebuilds) ──
143    /// The master family list, refreshed each build.
144    all: Rc<RefCell<Vec<FontFamilyInfo>>>,
145    /// Writing-system coverage map — empty until the index is ready.
146    meta: Rc<RefCell<HashMap<String, WritingSystemSet>>>,
147    /// Whether `meta` is authoritative (override present, or index built).
148    meta_ready: Rc<Cell<bool>>,
149    /// The filtered item source handed to the ComboBox. Mutated by
150    /// `replace_all` on every filter change (reactive — no combo rebuild).
151    model: teksilo_data::ListModel<String>,
152    /// The names last pushed into `model`, so an unrelated rebuild (locale /
153    /// theme / ancestor) that recomputes an identical list doesn't churn the
154    /// (possibly-open) dropdown with a redundant `replace_all`.
155    last_names: Rc<RefCell<Vec<String>>>,
156    /// In-flight background index: a readiness flag + the result slot.
157    index_handle: Option<(
158        Arc<AtomicBool>,
159        Arc<Mutex<Option<HashMap<String, WritingSystemSet>>>>,
160    )>,
161    index_started: bool,
162    /// Bumped once when the background index completes, to force a single
163    /// rebuild that re-applies the filter and stops the readiness poll.
164    rev: Signal<u64>,
165    frame_tick_sub: Option<FrameTickSubscription>,
166    root_child_id: Option<WidgetId>,
167}
168
169impl std::fmt::Debug for FontPicker {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("FontPicker")
172            .field("preview_mode", &self.preview_mode)
173            .field("searchable", &self.searchable)
174            .finish_non_exhaustive()
175    }
176}
177
178impl FontPicker {
179    /// Create a picker bound to `selected` (the chosen family name). The
180    /// list is enumerated from the app's shared typesetter at build time.
181    pub fn new(selected: Signal<Option<String>>) -> Self {
182        Self {
183            selected,
184            families_override: None,
185            meta_override: None,
186            spacing_filter: Prop::Static(FontSpacingFilter::Any),
187            writing_system: Prop::Static(None),
188            preview_mode: FontPreviewMode::default(),
189            sample_global: None,
190            sample_by_ws: HashMap::new(),
191            sample_by_family: HashMap::new(),
192            show_selected_in_own_font: true,
193            placeholder: None,
194            label: None,
195            enabled: Prop::Static(true),
196            variant: ComboBoxVariant::default(),
197            style_override: None,
198            max_visible_items: None,
199            searchable: true,
200            search_query: None,
201            on_select: None,
202            tooltip_text: None,
203            rich_tooltip_source: None,
204            composite_tooltip_content: None,
205            all: Rc::new(RefCell::new(Vec::new())),
206            meta: Rc::new(RefCell::new(HashMap::new())),
207            meta_ready: Rc::new(Cell::new(false)),
208            model: teksilo_data::ListModel::new(),
209            last_names: Rc::new(RefCell::new(Vec::new())),
210            index_handle: None,
211            index_started: false,
212            rev: Signal::new(0),
213            frame_tick_sub: None,
214            root_child_id: None,
215        }
216    }
217
218    /// Override the family list instead of enumerating from the typesetter.
219    /// Family names only — spacing is treated as proportional and
220    /// writing-system coverage is unknown (the writing-system filter shows
221    /// all). For deterministic filter tests, prefer
222    /// [`families_with_meta`](Self::families_with_meta).
223    pub fn families(mut self, families: impl IntoIterator<Item = impl Into<String>>) -> Self {
224        let mut list: Vec<FontFamilyInfo> = families
225            .into_iter()
226            .map(|n| FontFamilyInfo {
227                name: n.into(),
228                monospaced: false,
229            })
230            .collect();
231        // Present alphabetized, like the typesetter-backed path.
232        list.sort_by_key(|f| f.name.to_lowercase());
233        self.families_override = Some(list);
234        self.meta_override = None;
235        self
236    }
237
238    /// Override the family list *and* its metadata (monospaced + writing
239    /// systems). Enables headless testing of the spacing / writing-system
240    /// filters and the script-aware sample without a font backend.
241    pub fn families_with_meta(mut self, families: Vec<(String, FontMeta)>) -> Self {
242        let mut list = Vec::with_capacity(families.len());
243        let mut meta = HashMap::with_capacity(families.len());
244        for (name, m) in families {
245            // Coverage is keyed by the lowercased name — the same convention
246            // the typesetter-backed index uses — so lookups agree regardless
247            // of the display casing (see `passes` / `sample_for`).
248            meta.insert(name.to_lowercase(), m.writing_systems);
249            list.push(FontFamilyInfo {
250                name,
251                monospaced: m.monospaced,
252            });
253        }
254        list.sort_by_key(|f| f.name.to_lowercase());
255        self.families_override = Some(list);
256        self.meta_override = Some(meta);
257        self
258    }
259
260    /// Restrict the list by spacing (monospaced / proportional). Accepts a
261    /// static value or a `Signal` for a reactive filter toolbar.
262    pub fn spacing_filter(mut self, filter: impl Into<Prop<FontSpacingFilter>>) -> Self {
263        self.spacing_filter = filter.into();
264        self
265    }
266
267    /// Restrict the list to fonts covering a writing system. `None` shows
268    /// all. Accepts a static value or a `Signal`. The first time a
269    /// non-`None` value is applied, the coverage index is built off-thread;
270    /// until it is ready the list is unfiltered.
271    pub fn writing_system(mut self, ws: impl Into<Prop<Option<WritingSystem>>>) -> Self {
272        self.writing_system = ws.into();
273        self
274    }
275
276    /// Choose how rows (and the trigger) preview each font. Default
277    /// [`FontPreviewMode::NameThenSample`].
278    pub fn preview_mode(mut self, mode: FontPreviewMode) -> Self {
279        self.preview_mode = mode;
280        self
281    }
282
283    /// Convenience: `true` keeps the default preview; `false` switches to
284    /// [`FontPreviewMode::NameInSystemFont`] (UIKit `displayUsingSystemFont`).
285    pub fn preview_in_own_font(mut self, on: bool) -> Self {
286        if !on {
287            self.preview_mode = FontPreviewMode::NameInSystemFont;
288        }
289        self
290    }
291
292    /// Global sample text override (used when the font's writing system has
293    /// no more specific sample). Mirrors GTK's preview text.
294    pub fn sample_text(mut self, text: impl Into<String>) -> Self {
295        self.sample_global = Some(text.into());
296        self
297    }
298
299    /// Per-writing-system sample override (Qt `setSampleTextForSystem`).
300    pub fn sample_text_for(mut self, ws: WritingSystem, text: impl Into<String>) -> Self {
301        self.sample_by_ws.insert(ws, text.into());
302        self
303    }
304
305    /// Per-family sample override (Qt `setSampleTextForFont`) — for fonts
306    /// whose script the generic sample doesn't suit (icon fonts, etc.).
307    pub fn sample_text_for_family(
308        mut self,
309        family: impl Into<String>,
310        text: impl Into<String>,
311    ) -> Self {
312        // Keyed lowercase to match the family-name lookup in `sample_for`.
313        self.sample_by_family
314            .insert(family.into().to_lowercase(), text.into());
315        self
316    }
317
318    /// Whether the closed trigger renders the selected family in its own
319    /// typeface (default `true`; Qt behaviour). No effect in
320    /// [`FontPreviewMode::NameInSystemFont`].
321    pub fn show_selected_in_own_font(mut self, on: bool) -> Self {
322        self.show_selected_in_own_font = on;
323        self
324    }
325
326    /// Placeholder shown when nothing is selected. Defaults to a localized
327    /// "Select a font…".
328    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
329        self.placeholder = Some(text.into());
330        self
331    }
332
333    /// Accessible / control label. Defaults to a localized "Font".
334    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
335        self.label = Some(label.into());
336        self
337    }
338
339    /// Enable / disable the control, statically or reactively.
340    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
341        self.enabled = enabled.into();
342        self
343    }
344
345    /// Design-language variant, forwarded to the inner [`ComboBox`].
346    pub fn variant(mut self, variant: ComboBoxVariant) -> Self {
347        self.variant = variant;
348        self
349    }
350
351    /// Per-call [`ComboBoxStyle`] override, forwarded to the inner combo.
352    pub fn style(mut self, style: impl ComboBoxStyle) -> Self {
353        self.style_override = Some(Rc::new(style));
354        self
355    }
356
357    /// Maximum rows shown before the dropdown scrolls (default 8).
358    pub fn max_visible_items(mut self, n: usize) -> Self {
359        self.max_visible_items = Some(n);
360        self
361    }
362
363    /// Enable / disable the in-dropdown search field (default `true`).
364    pub fn searchable(mut self, on: bool) -> Self {
365        self.searchable = on;
366        self
367    }
368
369    /// Drive the search field from an external query signal (implies
370    /// `searchable`).
371    pub fn search_query(mut self, query: Signal<String>) -> Self {
372        self.search_query = Some(query);
373        self.searchable = true;
374        self
375    }
376
377    /// React to a commit with a live [`EventContext`] — the place to apply
378    /// the chosen font (e.g. `editor.set_font_family(name)`).
379    pub fn on_select(mut self, f: impl Fn(&str, &mut EventContext) + 'static) -> Self {
380        self.on_select = Some(Rc::new(f));
381        self
382    }
383
384    /// Attach a plain tooltip, forwarded to the inner [`ComboBox`].
385    /// Mutually exclusive with the rich / composite variants — last-call-wins.
386    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
387        self.tooltip_text = Some(text.into());
388        self.rich_tooltip_source = None;
389        self.composite_tooltip_content = None;
390        self
391    }
392
393    /// Attach a registry-keyed rich tooltip, forwarded to the inner combo.
394    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
395        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
396        self.tooltip_text = None;
397        self.composite_tooltip_content = None;
398        self
399    }
400
401    /// Attach an inline rich tooltip, forwarded to the inner combo.
402    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
403        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
404        self.tooltip_text = None;
405        self.composite_tooltip_content = None;
406        self
407    }
408
409    /// Attach a composite tooltip hosting an arbitrary widget tree,
410    /// forwarded to the inner combo.
411    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
412        self.composite_tooltip_content = Some(Box::new(content));
413        self.tooltip_text = None;
414        self.rich_tooltip_source = None;
415        self
416    }
417
418    /// Kick off the background writing-system coverage index (once), when a
419    /// typesetter is available and no explicit meta was supplied.
420    fn maybe_start_index(&mut self, ctx: &BuildContext) {
421        if self.index_started || self.meta_override.is_some() {
422            return;
423        }
424        let Some(ts) = ctx.app_state::<SharedTypesetter>() else {
425            return;
426        };
427        let builder = ts.bridge().borrow().writing_system_index_builder();
428        let ready = Arc::new(AtomicBool::new(false));
429        let result = Arc::new(Mutex::new(None));
430        let ready_t = ready.clone();
431        let result_t = result.clone();
432        std::thread::spawn(move || {
433            let map = builder.build();
434            if let Ok(mut slot) = result_t.lock() {
435                *slot = Some(map);
436            }
437            ready_t.store(true, Ordering::Release);
438        });
439        self.index_handle = Some((ready, result));
440        self.index_started = true;
441    }
442}
443
444impl Widget for FontPicker {
445    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
446        // Rebuild once when the background index reports ready (via `rev`).
447        self.rev
448            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
449
450        // Refresh the master family list + coverage from overrides or the
451        // shared typesetter.
452        let families = self
453            .families_override
454            .clone()
455            .or_else(|| {
456                ctx.app_state::<SharedTypesetter>()
457                    .map(|ts| ts.bridge().borrow().families())
458            })
459            .unwrap_or_default();
460        *self.all.borrow_mut() = families;
461
462        if let Some(meta) = &self.meta_override {
463            *self.meta.borrow_mut() = meta.clone();
464            self.meta_ready.set(true);
465        } else {
466            self.maybe_start_index(ctx);
467        }
468
469        // The reactive filter: recompute the visible names and push them
470        // into the model. `replace_all` bumps the model version, so an open
471        // dropdown re-filters live (no ComboBox rebuild).
472        let recompute: Rc<dyn Fn()> = {
473            let all = self.all.clone();
474            let meta = self.meta.clone();
475            let meta_ready = self.meta_ready.clone();
476            let spacing = self.spacing_filter.clone();
477            let ws = self.writing_system.clone();
478            let selected = self.selected.clone();
479            let model = self.model.clone();
480            let last_names = self.last_names.clone();
481            Rc::new(move || {
482                let all = all.borrow();
483                let meta = meta.borrow();
484                let ready = meta_ready.get();
485                let sp = spacing.get();
486                let w = ws.get();
487                let mut names: Vec<String> = all
488                    .iter()
489                    .filter(|info| passes(info, sp, w, ready, &meta))
490                    .map(|info| info.name.clone())
491                    .collect();
492                // Keep the current selection visible even if the filter
493                // would exclude it, so a filter change never silently clears
494                // the user's choice.
495                if let Some(sel) = selected.get()
496                    && !names.iter().any(|n| n == &sel)
497                {
498                    names.push(sel);
499                    names.sort_by_key(|n| n.to_lowercase());
500                }
501                // Only touch the model when the list actually changed, so an
502                // unrelated rebuild doesn't churn an open dropdown.
503                if *last_names.borrow() != names {
504                    *last_names.borrow_mut() = names.clone();
505                    model.replace_all(names);
506                }
507            })
508        };
509        recompute();
510
511        // Re-filter when a bound spacing / writing-system signal changes.
512        if let Prop::Bound(s) = &self.spacing_filter {
513            let rc = recompute.clone();
514            ctx.effect(s, move |_| rc());
515        }
516        if let Prop::Bound(s) = &self.writing_system {
517            let rc = recompute.clone();
518            ctx.effect(s, move |_| rc());
519        }
520
521        // Poll the background index on the frame tick; on ready, populate
522        // `meta`, re-filter, and bump `rev` to rebuild once (which drops the
523        // subscription and stops the poll).
524        let pending = self.index_handle.is_some() && !self.meta_ready.get();
525        if pending {
526            let handle = self.index_handle.clone();
527            let meta = self.meta.clone();
528            let meta_ready = self.meta_ready.clone();
529            let rev = self.rev.clone();
530            let rc = recompute.clone();
531            ctx.effect(&ctx.frame_tick(), move |_| {
532                if meta_ready.get() {
533                    return;
534                }
535                let Some((ready, result)) = &handle else {
536                    return;
537                };
538                if !ready.load(Ordering::Acquire) {
539                    return;
540                }
541                if let Ok(mut slot) = result.lock()
542                    && let Some(map) = slot.take()
543                {
544                    *meta.borrow_mut() = map;
545                    meta_ready.set(true);
546                    rc();
547                    rev.set(rev.get().wrapping_add(1));
548                }
549            });
550            self.frame_tick_sub = Some(ctx.subscribe_frame_tick());
551        } else {
552            // Index ready (or none): stop polling.
553            self.frame_tick_sub = None;
554        }
555
556        // Build the inner ComboBox over the reactive model.
557        let base_style = ctx.theme().typography.body.clone();
558        let mut combo =
559            ComboBox::from_model(self.model.clone(), self.selected.clone(), |s: &String| {
560                LocalizedString::literal(s.clone())
561            })
562            .variant(self.variant)
563            .searchable(self.searchable)
564            .enabled(self.enabled.clone())
565            .label(
566                self.label
567                    .clone()
568                    .unwrap_or_else(|| tr_widget!(font_picker_label())),
569            )
570            .placeholder(
571                self.placeholder
572                    .clone()
573                    .unwrap_or_else(|| tr_widget!(font_picker_placeholder())),
574            );
575
576        // Per-row preview.
577        {
578            let meta = self.meta.clone();
579            let meta_ready = self.meta_ready.clone();
580            let mode = self.preview_mode;
581            let global = self.sample_global.clone();
582            let by_ws = self.sample_by_ws.clone();
583            let by_family = self.sample_by_family.clone();
584            let base = base_style.clone();
585            combo = combo.render_item(move |name: &String, _selected: bool| {
586                build_font_row(
587                    name,
588                    &meta,
589                    meta_ready.get(),
590                    mode,
591                    &global,
592                    &by_ws,
593                    &by_family,
594                    &base,
595                )
596            });
597        }
598
599        // Trigger-in-own-font (Qt behaviour), unless system-font mode.
600        if self.show_selected_in_own_font && self.preview_mode != FontPreviewMode::NameInSystemFont
601        {
602            let base = base_style.clone();
603            combo = combo.render_selected(move |name: &String| {
604                Box::new(
605                    TextWidget::new(lit!(name.clone()))
606                        .style(TextStyle {
607                            family: name.clone(),
608                            ..base.clone()
609                        })
610                        .single_line(),
611                )
612            });
613        }
614
615        if let Some(n) = self.max_visible_items {
616            combo = combo.max_visible_items(n);
617        }
618        if let Some(q) = &self.search_query {
619            combo = combo.search_query(q.clone());
620        }
621        if let Some(style) = &self.style_override {
622            combo = combo.style(SharedStyleAdapter(style.clone()));
623        }
624        if let Some(cb) = &self.on_select {
625            let cb = cb.clone();
626            combo = combo.on_select(move |s: &String, ctx| cb(s.as_str(), ctx));
627        }
628
629        // Forward exactly one configured tooltip (last-call-wins upstream).
630        if let Some(content) = self.composite_tooltip_content.take() {
631            combo = combo.composite_tooltip_boxed(content);
632        } else if let Some(source) = self.rich_tooltip_source.clone() {
633            combo = match source {
634                crate::tooltip::RichTooltipSource::Key(k) => combo.rich_tooltip(k),
635                crate::tooltip::RichTooltipSource::Content(c) => combo.rich_tooltip_content(c),
636            };
637        } else if let Some(text) = self.tooltip_text.clone() {
638            combo = combo.tooltip(text);
639        }
640
641        let combo_id = ctx.add(combo);
642        self.root_child_id = Some(combo_id);
643        vec![combo_id]
644    }
645
646    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
647        self.root_child_id
648            .and_then(|id| ctx.child_size(id, proposal))
649            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
650            .into()
651    }
652
653    fn place_children(
654        &self,
655        bounds: Rect,
656        _proposal: SizeProposal,
657        children: &mut [WidgetPlacement],
658        _ctx: &LayoutContext,
659    ) {
660        for child in children.iter_mut() {
661            child.origin = bounds.origin();
662            child.size = bounds.size();
663        }
664    }
665
666    fn children(&self) -> Vec<WidgetId> {
667        self.root_child_id.into_iter().collect()
668    }
669
670    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
671        // The inner ComboBox carries the control role, label, value, and the
672        // listbox / option a11y for the dropdown.
673    }
674}
675
676/// Filter predicate: does this family pass the spacing + writing-system
677/// filters? While the coverage index is not ready, a writing-system filter
678/// is inert (shows all) rather than hiding fonts we can't yet classify.
679fn passes(
680    info: &FontFamilyInfo,
681    spacing: FontSpacingFilter,
682    ws: Option<WritingSystem>,
683    meta_ready: bool,
684    meta: &HashMap<String, WritingSystemSet>,
685) -> bool {
686    let spacing_ok = match spacing {
687        FontSpacingFilter::Any => true,
688        FontSpacingFilter::Monospaced => info.monospaced,
689        FontSpacingFilter::Proportional => !info.monospaced,
690    };
691    if !spacing_ok {
692        return false;
693    }
694    match ws {
695        None => true,
696        Some(ws) if !meta_ready => {
697            let _ = ws;
698            true
699        }
700        // Coverage map is keyed by lowercased family name (see
701        // `families_with_meta` / the typesetter index builder).
702        Some(ws) => meta
703            .get(&info.name.to_lowercase())
704            .is_some_and(|set| set.contains(ws)),
705    }
706}
707
708/// Pick the most illustrative writing system for a font's sample: prefer a
709/// non-Latin, non-Symbol script (more distinctive), else Latin, else
710/// whatever is present.
711fn representative_ws(set: WritingSystemSet) -> Option<WritingSystem> {
712    let mut has_latin = false;
713    for ws in set.iter() {
714        match ws {
715            WritingSystem::Latin => has_latin = true,
716            WritingSystem::Symbol => {}
717            other => return Some(other),
718        }
719    }
720    if has_latin {
721        Some(WritingSystem::Latin)
722    } else {
723        set.iter().next()
724    }
725}
726
727/// The sample string to render *in* a font for its row, or `None` for
728/// name-only. Order: per-family override → per-writing-system override /
729/// script default → global override → Latin default.
730fn sample_for(
731    name: &str,
732    meta: &Rc<RefCell<HashMap<String, WritingSystemSet>>>,
733    meta_ready: bool,
734    global: &Option<String>,
735    by_ws: &HashMap<WritingSystem, String>,
736    by_family: &HashMap<String, String>,
737) -> Option<String> {
738    if let Some(s) = by_family.get(&name.to_lowercase()) {
739        return Some(s.clone());
740    }
741    if meta_ready
742        && let Some(set) = meta.borrow().get(&name.to_lowercase()).copied()
743        && let Some(ws) = representative_ws(set)
744    {
745        if let Some(s) = by_ws.get(&ws) {
746            return Some(s.clone());
747        }
748        return Some(ws.sample_text().to_string());
749    }
750    if let Some(g) = global {
751        return Some(g.clone());
752    }
753    Some(WritingSystem::Latin.sample_text().to_string())
754}
755
756/// Build one dropdown row for a family. The family name renders in a
757/// legible system font (and is the a11y name via the ComboBox row wrapper);
758/// the sample renders in the font itself and is hidden from AT.
759#[allow(clippy::too_many_arguments)]
760fn build_font_row(
761    name: &str,
762    meta: &Rc<RefCell<HashMap<String, WritingSystemSet>>>,
763    meta_ready: bool,
764    mode: FontPreviewMode,
765    global: &Option<String>,
766    by_ws: &HashMap<WritingSystem, String>,
767    by_family: &HashMap<String, String>,
768    base: &TextStyle,
769) -> Box<dyn Widget> {
770    match mode {
771        FontPreviewMode::NameInOwnFont => Box::new(
772            TextWidget::new(lit!(name.to_string()))
773                .style(TextStyle {
774                    family: name.to_string(),
775                    ..base.clone()
776                })
777                .single_line()
778                .a11y_hidden(),
779        ),
780        FontPreviewMode::NameInSystemFont => Box::new(
781            TextWidget::new(lit!(name.to_string()))
782                .style(TextStyleRole::Body)
783                .single_line()
784                .a11y_hidden(),
785        ),
786        FontPreviewMode::NameThenSample => {
787            let name_w = TextWidget::new(lit!(name.to_string()))
788                .style(TextStyleRole::Body)
789                .single_line()
790                .a11y_hidden();
791            let mut row = HStack::new()
792                .spacing(12.0)
793                .child(name_w)
794                .child(Spacer::new());
795            if let Some(sample) = sample_for(name, meta, meta_ready, global, by_ws, by_family) {
796                row = row.child(
797                    TextWidget::new(lit!(sample))
798                        .style(TextStyle {
799                            family: name.to_string(),
800                            ..base.clone()
801                        })
802                        .single_line()
803                        .a11y_hidden(),
804                );
805            }
806            Box::new(row)
807        }
808    }
809}
810
811/// Adapts a stored `Rc<dyn ComboBoxStyle>` back into `impl ComboBoxStyle`
812/// so `FontPicker::style` can forward it to `ComboBox::style` (which takes
813/// the style by value).
814struct SharedStyleAdapter(SharedComboBoxStyle);
815
816impl ComboBoxStyle for SharedStyleAdapter {
817    fn make_body(&self, cfg: &ComboBoxStyleConfig, ctx: &mut BuildContext) -> WidgetId {
818        self.0.make_body(cfg, ctx)
819    }
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825    use teksilo_core::widget_tree::WidgetTree;
826
827    fn light_tree() -> WidgetTree {
828        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
829    }
830
831    fn ws(list: &[WritingSystem]) -> WritingSystemSet {
832        let mut s = WritingSystemSet::new();
833        for &w in list {
834            s.insert(w);
835        }
836        s
837    }
838
839    fn info(name: &str, mono: bool) -> FontFamilyInfo {
840        FontFamilyInfo {
841            name: name.to_string(),
842            monospaced: mono,
843        }
844    }
845
846    #[test]
847    fn passes_spacing_filter() {
848        let mono = info("Courier", true);
849        let prop = info("Arial", false);
850        let empty = HashMap::new();
851        assert!(passes(&mono, FontSpacingFilter::Any, None, false, &empty));
852        assert!(passes(&prop, FontSpacingFilter::Any, None, false, &empty));
853        assert!(passes(
854            &mono,
855            FontSpacingFilter::Monospaced,
856            None,
857            false,
858            &empty
859        ));
860        assert!(!passes(
861            &prop,
862            FontSpacingFilter::Monospaced,
863            None,
864            false,
865            &empty
866        ));
867        assert!(!passes(
868            &mono,
869            FontSpacingFilter::Proportional,
870            None,
871            false,
872            &empty
873        ));
874        assert!(passes(
875            &prop,
876            FontSpacingFilter::Proportional,
877            None,
878            false,
879            &empty
880        ));
881    }
882
883    #[test]
884    fn passes_writing_system_filter_respects_readiness() {
885        // Display name "Arial", coverage keyed lowercase "arial" — exercises
886        // the case-insensitive lookup in `passes`.
887        let arial = info("Arial", false);
888        let mut meta = HashMap::new();
889        meta.insert("arial".to_string(), ws(&[WritingSystem::Latin]));
890        // Index not ready → a writing-system filter is inert (shows all).
891        assert!(passes(
892            &arial,
893            FontSpacingFilter::Any,
894            Some(WritingSystem::Arabic),
895            false,
896            &meta
897        ));
898        // Ready → Latin font excluded by an Arabic filter, kept by a Latin one.
899        assert!(!passes(
900            &arial,
901            FontSpacingFilter::Any,
902            Some(WritingSystem::Arabic),
903            true,
904            &meta
905        ));
906        assert!(passes(
907            &arial,
908            FontSpacingFilter::Any,
909            Some(WritingSystem::Latin),
910            true,
911            &meta
912        ));
913    }
914
915    #[test]
916    fn representative_ws_prefers_non_latin() {
917        assert_eq!(
918            representative_ws(ws(&[WritingSystem::Latin])),
919            Some(WritingSystem::Latin)
920        );
921        assert_eq!(
922            representative_ws(ws(&[WritingSystem::Latin, WritingSystem::Arabic])),
923            Some(WritingSystem::Arabic)
924        );
925        assert_eq!(
926            representative_ws(ws(&[WritingSystem::Symbol])),
927            Some(WritingSystem::Symbol)
928        );
929        assert_eq!(representative_ws(WritingSystemSet::new()), None);
930    }
931
932    #[test]
933    fn sample_for_precedence() {
934        // Coverage + per-family samples are keyed lowercase; the lookups
935        // (display names "NotoArabic" / "Wingdings") are case-folded.
936        let meta = Rc::new(RefCell::new({
937            let mut m = HashMap::new();
938            m.insert("notoarabic".to_string(), ws(&[WritingSystem::Arabic]));
939            m
940        }));
941        let mut by_family = HashMap::new();
942        by_family.insert("wingdings".to_string(), "★☂".to_string());
943        let mut by_ws = HashMap::new();
944        by_ws.insert(WritingSystem::Arabic, "custom-ar".to_string());
945
946        // Per-family override wins.
947        assert_eq!(
948            sample_for("Wingdings", &meta, true, &None, &by_ws, &by_family).as_deref(),
949            Some("★☂")
950        );
951        // Per-writing-system override for the font's script.
952        assert_eq!(
953            sample_for("NotoArabic", &meta, true, &None, &by_ws, &by_family).as_deref(),
954            Some("custom-ar")
955        );
956        // Font whose only script is Arabic → the Arabic default sample.
957        assert_eq!(
958            sample_for(
959                "NotoArabic",
960                &meta,
961                true,
962                &None,
963                &HashMap::new(),
964                &HashMap::new()
965            ),
966            Some(WritingSystem::Arabic.sample_text().to_string())
967        );
968        // Unknown font, meta ready → global override if set.
969        assert_eq!(
970            sample_for(
971                "Mystery",
972                &meta,
973                true,
974                &Some("g".to_string()),
975                &HashMap::new(),
976                &HashMap::new()
977            )
978            .as_deref(),
979            Some("g")
980        );
981        // Meta not ready → Latin default.
982        assert_eq!(
983            sample_for(
984                "Arial",
985                &meta,
986                false,
987                &None,
988                &HashMap::new(),
989                &HashMap::new()
990            ),
991            Some(WritingSystem::Latin.sample_text().to_string())
992        );
993    }
994
995    #[test]
996    fn builds_and_lays_out_with_families() {
997        let mut tree = light_tree();
998        let sel = Signal::new(None::<String>);
999        let id = tree.add(FontPicker::new(sel).families(["Arial", "Courier", "Times"]));
1000        tree.layout(SizeProposal::exact(300.0, 50.0));
1001        assert!(tree.bounds(id).width > 0.0);
1002    }
1003
1004    #[test]
1005    fn empty_without_backend_or_override_still_builds() {
1006        let mut tree = light_tree();
1007        let sel = Signal::new(None::<String>);
1008        let id = tree.add(FontPicker::new(sel));
1009        tree.layout(SizeProposal::exact(300.0, 50.0));
1010        assert!(tree.bounds(id).width >= 0.0);
1011    }
1012
1013    #[test]
1014    fn accessibility_is_combobox_role() {
1015        let mut tree = light_tree();
1016        let sel = Signal::new(Some("Arial".to_string()));
1017        let id = tree.add(
1018            FontPicker::new(sel)
1019                .families(["Arial", "Courier"])
1020                .label(lit!("Font family")),
1021        );
1022        tree.layout(SizeProposal::exact(300.0, 50.0));
1023        // The inner ComboBox carries the role; find it under the picker.
1024        let combo = tree.children(id)[0];
1025        let node = tree.accessibility_node(combo);
1026        assert_eq!(node.role(), teksilo_core::accesskit::Role::ComboBox);
1027        assert_eq!(node.name(), Some("Font family"));
1028    }
1029
1030    #[test]
1031    fn reactive_spacing_filter_signal_drives_refilter_without_panic() {
1032        let mut tree = light_tree();
1033        let sel = Signal::new(None::<String>);
1034        let spacing = Signal::new(FontSpacingFilter::Any);
1035        let id = tree.add(
1036            FontPicker::new(sel)
1037                .families_with_meta(vec![
1038                    (
1039                        "Courier".to_string(),
1040                        FontMeta {
1041                            monospaced: true,
1042                            writing_systems: ws(&[WritingSystem::Latin]),
1043                        },
1044                    ),
1045                    (
1046                        "Arial".to_string(),
1047                        FontMeta {
1048                            monospaced: false,
1049                            writing_systems: ws(&[WritingSystem::Latin]),
1050                        },
1051                    ),
1052                ])
1053                .spacing_filter(spacing.clone()),
1054        );
1055        tree.layout(SizeProposal::exact(300.0, 50.0));
1056        // Flip the filter: the bound-signal effect fires + `replace_all`
1057        // runs; the widget must keep laying out.
1058        spacing.set(FontSpacingFilter::Monospaced);
1059        tree.layout(SizeProposal::exact(300.0, 50.0));
1060        assert!(tree.bounds(id).width > 0.0);
1061    }
1062}