1use 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#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
70pub enum FontSpacingFilter {
71 #[default]
73 Any,
74 Monospaced,
76 Proportional,
78}
79
80#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
82pub enum FontPreviewMode {
83 #[default]
87 NameThenSample,
88 NameInOwnFont,
90 NameInSystemFont,
93}
94
95#[derive(Clone, Debug, Default)]
99pub struct FontMeta {
100 pub monospaced: bool,
102 pub writing_systems: WritingSystemSet,
105}
106
107pub struct FontPicker {
109 selected: Signal<Option<String>>,
112 families_override: Option<Vec<FontFamilyInfo>>,
114 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: 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 all: Rc<RefCell<Vec<FontFamilyInfo>>>,
145 meta: Rc<RefCell<HashMap<String, WritingSystemSet>>>,
147 meta_ready: Rc<Cell<bool>>,
149 model: teksilo_data::ListModel<String>,
152 last_names: Rc<RefCell<Vec<String>>>,
156 index_handle: Option<(
158 Arc<AtomicBool>,
159 Arc<Mutex<Option<HashMap<String, WritingSystemSet>>>>,
160 )>,
161 index_started: bool,
162 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 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 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 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 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 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 pub fn spacing_filter(mut self, filter: impl Into<Prop<FontSpacingFilter>>) -> Self {
263 self.spacing_filter = filter.into();
264 self
265 }
266
267 pub fn writing_system(mut self, ws: impl Into<Prop<Option<WritingSystem>>>) -> Self {
272 self.writing_system = ws.into();
273 self
274 }
275
276 pub fn preview_mode(mut self, mode: FontPreviewMode) -> Self {
279 self.preview_mode = mode;
280 self
281 }
282
283 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 pub fn sample_text(mut self, text: impl Into<String>) -> Self {
295 self.sample_global = Some(text.into());
296 self
297 }
298
299 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 pub fn sample_text_for_family(
308 mut self,
309 family: impl Into<String>,
310 text: impl Into<String>,
311 ) -> Self {
312 self.sample_by_family
314 .insert(family.into().to_lowercase(), text.into());
315 self
316 }
317
318 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 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
329 self.placeholder = Some(text.into());
330 self
331 }
332
333 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
335 self.label = Some(label.into());
336 self
337 }
338
339 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
341 self.enabled = enabled.into();
342 self
343 }
344
345 pub fn variant(mut self, variant: ComboBoxVariant) -> Self {
347 self.variant = variant;
348 self
349 }
350
351 pub fn style(mut self, style: impl ComboBoxStyle) -> Self {
353 self.style_override = Some(Rc::new(style));
354 self
355 }
356
357 pub fn max_visible_items(mut self, n: usize) -> Self {
359 self.max_visible_items = Some(n);
360 self
361 }
362
363 pub fn searchable(mut self, on: bool) -> Self {
365 self.searchable = on;
366 self
367 }
368
369 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 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 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 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 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 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 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 self.rev
448 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
449
450 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 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 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 if *last_names.borrow() != names {
504 *last_names.borrow_mut() = names.clone();
505 model.replace_all(names);
506 }
507 })
508 };
509 recompute();
510
511 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 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 self.frame_tick_sub = None;
554 }
555
556 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 {
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 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 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 }
674}
675
676fn 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 Some(ws) => meta
703 .get(&info.name.to_lowercase())
704 .is_some_and(|set| set.contains(ws)),
705 }
706}
707
708fn 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
727fn 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#[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
811struct 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 let arial = info("Arial", false);
888 let mut meta = HashMap::new();
889 meta.insert("arial".to_string(), ws(&[WritingSystem::Latin]));
890 assert!(passes(
892 &arial,
893 FontSpacingFilter::Any,
894 Some(WritingSystem::Arabic),
895 false,
896 &meta
897 ));
898 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 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 assert_eq!(
948 sample_for("Wingdings", &meta, true, &None, &by_ws, &by_family).as_deref(),
949 Some("★☂")
950 );
951 assert_eq!(
953 sample_for("NotoArabic", &meta, true, &None, &by_ws, &by_family).as_deref(),
954 Some("custom-ar")
955 );
956 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 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 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 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 spacing.set(FontSpacingFilter::Monospaced);
1059 tree.layout(SizeProposal::exact(300.0, 50.0));
1060 assert!(tree.bounds(id).width > 0.0);
1061 }
1062}