From 80f963809a6f99d3394223e50a606f5b0e358416 Mon Sep 17 00:00:00 2001 From: AlienTech Software Date: Wed, 11 Nov 2026 08:49:05 -0800 Subject: [PATCH 1/4] fix(ui-egui): font size list is OneNote's whole sizes, without pt The size list now offers OneNote's whole sizes (8 to 72, 16 sizes) and drops the " pt" text. 9.5 and 10.5 wait for a page-format change, since the page stores whole sizes. Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/ribbon.rs | 11 +++++++++-- crates/ui-egui/tests/ui.rs | 34 +++++++++++++++++----------------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 8000de7..25b3add 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -18,7 +18,7 @@ use notecraft_model::{PageFormat, Tag}; /// shows a tab for what is selected. pub const TABS: [&str; 5] = ["Home", "Insert", "Draw", "History", "View"]; pub const SOURCE_TAB: &str = "Source"; -pub const FONT_SIZES: [u8; 14] = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48, 72]; +pub const FONT_SIZES: [u8; 16] = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]; /// Shown only while the caret is in a table, the way Office shows a tab for the thing in /// hand. It goes when the caret leaves, and the ribbon falls back to Home. pub const TABLE_TAB: &str = "Table"; @@ -846,7 +846,7 @@ fn home_tab(ui: &mut Ui, view: &View<'_>, t: &Tokens, acts: &mut Vec) { let size_label = view.font_size.unwrap_or_else(|| crate::tl_id!("ui.font_size")); let size_menu = ui.menu_button(size_label, |ui| { for size in FONT_SIZES { - if ui.button(format!("{size} pt")).clicked() { + if ui.button(format!("{size}")).clicked() { acts.push(Act::FontSize(size)); ui.close(); } @@ -3027,4 +3027,11 @@ mod tests { assert_ne!(Act::Block("h1"), Act::Insert("h1")); assert_eq!(Act::Tab("Insert".into()), Act::Tab("Insert".to_string())); } + + /// SUP-009: the font size list is OneNote's whole sizes (the page format stores whole + /// numbers, so 9.5 and 10.5 wait for a format change). + #[test] + fn the_font_size_list_is_onenotes_whole_sizes() { + assert_eq!(FONT_SIZES.as_slice(), [8u8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72].as_slice()); + } } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 552c3a1..2fc070d 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -5830,7 +5830,7 @@ fn font_size_does_not_change_the_wrong_text_for_a_cross_paragraph_selection() { let before = h.state().body().unwrap_or_default().to_string(); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); assert_eq!(h.state().body().unwrap_or_default(), before, "unsupported cross-paragraph selection must not change an unrelated caret"); let inspection = ask(&mut h, "ui.inspect", json!({})).expect("inspect"); @@ -6160,7 +6160,7 @@ fn font_selectors_show_selection_values_and_mixed_state_in_both_formats() { assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("Mixed")); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("24 pt").click(); + h.get_by_label("24").click(); settle(&mut h); assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("24 pt")); assert_eq!(h.get_by_label("Font Family").value().as_deref(), Some("Mixed")); @@ -6181,7 +6181,7 @@ fn font_selectors_show_pending_size_without_editing_the_page() { let before = h.state().body().unwrap_or_default().to_string(); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18 pt")); assert_eq!(h.state().body().unwrap_or_default(), before); @@ -6449,7 +6449,7 @@ fn font_size_replaces_only_the_partial_selection_inside_an_existing_size() { } h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("24 pt").click(); + h.get_by_label("24").click(); settle(&mut h); let body = h.state().body().unwrap_or_default(); let (blocks, _) = notecraft_formats::boxes::read(notecraft_model::PageFormat::Cmgfm, body); @@ -6477,7 +6477,7 @@ fn font_size_replaces_mixed_sizes_in_a_partial_selection() { pump(&mut h); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("24 pt").click(); + h.get_by_label("24").click(); settle(&mut h); let body = h.state().body().unwrap_or_default(); let (blocks, _) = notecraft_formats::boxes::read(notecraft_model::PageFormat::Cmgfm, body); @@ -6549,7 +6549,7 @@ fn pending_marks_combine_with_size_for_unicode_typing_in_both_formats() { settle(&mut h); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18 pt"), "the pending size is shown"); assert_eq!(h.state().body().unwrap_or_default(), before); @@ -6668,7 +6668,7 @@ fn selected_container_font_size_is_undoable_and_leaves_other_boxes_alone() { let before = h.state().body().unwrap_or_default().to_string(); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18 pt"), "the selected container reports its size"); let after = h.state().body().unwrap_or_default().to_string(); @@ -6836,7 +6836,7 @@ fn font_size_without_selection_formats_new_text() { let original = h.state().body().unwrap_or_default().to_string(); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); assert_eq!(h.state().body().unwrap_or_default(), original, "choosing a size alone does not change the page"); let history = ask(&mut h, "ui.inspect", json!({})).expect("inspect")["page"]["history"].clone(); @@ -6875,7 +6875,7 @@ fn pending_font_size_is_cleared_when_caret_moves() { ask(&mut h, "ui.caret", json!({"block": 1})).expect("caret"); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); ask(&mut h, "ui.key", json!({"key": "ArrowLeft"})).expect("move caret"); settle(&mut h); @@ -6895,7 +6895,7 @@ fn pending_font_size_applies_to_unicode_typing() { ask(&mut h, "ui.caret", json!({"block": 1})).expect("caret"); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); ask(&mut h, "ui.text", json!({"text": "مرحبا"})).expect("type unicode"); settle(&mut h); @@ -6914,13 +6914,13 @@ fn pending_font_size_can_change_in_a_typing_run() { ask(&mut h, "ui.caret", json!({"block": 1})).expect("caret"); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); ask(&mut h, "ui.text", json!({"text": "first"})).expect("first size"); settle(&mut h); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("24 pt").click(); + h.get_by_label("24").click(); settle(&mut h); ask(&mut h, "ui.text", json!({"text": "second"})).expect("second size"); settle(&mut h); @@ -6948,7 +6948,7 @@ fn font_size_applies_to_selected_text_and_survives_reopen_in_both_formats() { h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); let body = h.state().body().unwrap_or_default().to_string(); @@ -6978,7 +6978,7 @@ fn font_size_undo_and_redo_preserve_page_content_in_both_formats() { h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); settle(&mut h); let formatted = h.state().body().unwrap_or_default().to_string(); assert!(formatted.contains(""), "the size was applied: {formatted}"); @@ -7000,7 +7000,7 @@ fn font_size_undo_and_redo_preserve_page_content_in_both_formats() { pump(&mut h); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("24 pt").click(); + h.get_by_label("24").click(); settle(&mut h); let resized = h.state().body().unwrap_or_default().to_string(); assert!(resized.contains("font-size: 24pt"), "the second size was applied for {path}: {resized}"); @@ -7028,7 +7028,7 @@ fn font_size_undo_and_redo_preserve_page_content_in_both_formats() { pump(&mut h); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("24 pt").click(); + h.get_by_label("24").click(); settle(&mut h); assert_eq!(ask(&mut h, "ui.inspect", json!({})).expect("inspect")["page"]["history"], history, "same size is not an edit"); ask(&mut h, "ui.undo", json!({})).expect("undo through the user action"); @@ -7063,7 +7063,7 @@ fn font_size_history_stays_separate_from_typing() { pump(&mut h); h.get_by_label("Font Size").click(); h.run(); - h.get_by_label("18 pt").click(); + h.get_by_label("18").click(); h.run(); let formatted = h.state().body().unwrap_or_default().to_string(); assert!(formatted.contains("font-size: 18pt"), "the size was applied: {formatted}"); From 530825f57d18b666861dc4ddc80b47b2b00e3afc Mon Sep 17 00:00:00 2001 From: AlienTech Software Date: Wed, 11 Nov 2026 20:06:43 -0800 Subject: [PATCH 2/4] feat(ui-egui): editable font name and size boxes with shared combo Font name and font size become OneNote-style combo boxes: an editable box showing the current value, and a drop-down arrow whose list opens under the box, highlights the current value and scrolls to it. The shared control lives in combo.rs. The font name sits beside the size, and the font list is read on a background thread. Windows also reads the per-user font folder. With no caret in the page the boxes show the base size and Default, and a choice made with no caret applies to the next typed text. Known gaps: typing a size does not apply it yet (the box loses the page selection on focus); the floating tools group has no font boxes yet. Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/app.rs | 28 ++++- crates/ui-egui/src/combo.rs | 115 ++++++++++++++++++++ crates/ui-egui/src/fonts.rs | 21 +++- crates/ui-egui/src/fonts/system.rs | 27 +++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ribbon.rs | 114 ++++++++++++-------- crates/ui-egui/src/system_fonts.win.rs | 3 + crates/ui-egui/tests/ui.rs | 142 +++++++++++++++++++++++-- i18n/ar-XB.tsv | 3 + i18n/en-XA.tsv | 3 + i18n/en.tsv | 3 + 11 files changed, 401 insertions(+), 59 deletions(-) create mode 100644 crates/ui-egui/src/combo.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 03cf7df..bd66212 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -798,6 +798,9 @@ impl NoteCraftApp { } pub fn new(session: Session) -> NoteCraftApp { + // The installed fonts are read in the background, so the window opens at once. + #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] + crate::fonts::start_catalog(); let mode_pref = session.settings.get("theme_mode").and_then(|v| ModePref::parse(&v)).unwrap_or(ModePref::System); let ui_language = session.settings.get("ui_language").map(|value| crate::i18n::normalize_preference(&value)).unwrap_or_else(|| crate::i18n::AUTO.into()); @@ -3011,13 +3014,16 @@ impl NoteCraftApp { pageedit::FontValue::Value(Some(name)) => Some(name.clone()), pageedit::FontValue::Value(None) => Some(crate::tl_id!("ui.default").to_string()), pageedit::FontValue::Mixed => Some(crate::tl_id!("ui.mixed").to_string()), - pageedit::FontValue::Unavailable => None, + // With no caret in the page the box shows the default font, as the size box shows its base size. + pageedit::FontValue::Unavailable => Some(crate::tl_id!("ui.default").to_string()), }; let size = match font_selection.size { - pageedit::FontValue::Value(Some(size)) => Some(format!("{size} pt")), - pageedit::FontValue::Value(None) => Some(crate::tl_id!("ui.default").to_string()), + pageedit::FontValue::Value(Some(size)) => Some(size.to_string()), + // The page's base size is 11 points, so the box shows that number, not the word Default. + pageedit::FontValue::Value(None) => Some("11".to_owned()), pageedit::FontValue::Mixed => Some(crate::tl_id!("ui.mixed").to_string()), - pageedit::FontValue::Unavailable => None, + // With no caret in the page the box shows the base size, as OneNote does. + pageedit::FontValue::Unavailable => Some("11".to_owned()), }; let view = ribbon::View { split_panes: self.split_panes, @@ -4306,12 +4312,25 @@ impl NoteCraftApp { self.status_is_error = false; } + /// With no caret in the page, a font or size choice goes to the end of the last writable + /// block, so it applies to the next text typed there, as OneNote does. + fn place_caret_for_format(ed: &mut Editor) { + if ed.caret.is_some() { + return; + } + let Some(index) = ed.blocks.iter().rposition(pageedit::is_writable) else { return }; + let text = pageedit::caret_text(&ed.blocks[index]); + let end = text.chars().count(); + ed.caret = Some(pageedit::Caret { block: index, text, focus: true, selection: (end, end), place: Some(end), cell: None, span: None }); + } + fn apply_font_family(&mut self, family: &str) { if !notecraft_formats::markdown::valid_font_family(family) { log::warn!("ribbon requested an invalid font family"); return; } let Some(ed) = self.editor.as_mut() else { return }; + Self::place_caret_for_format(ed); if ed.across.is_some_and(|selection| !selection.inside_one_block()) { self.status = crate::tl_id!("ui.inline_mark_whole_line").into(); self.status_is_error = false; @@ -4400,6 +4419,7 @@ impl NoteCraftApp { return; } let Some(ed) = self.editor.as_mut() else { return }; + Self::place_caret_for_format(ed); if ed.across.is_some_and(|selection| !selection.inside_one_block()) { self.status = crate::tl_id!("ui.inline_mark_whole_line").into(); self.status_is_error = false; diff --git a/crates/ui-egui/src/combo.rs b/crates/ui-egui/src/combo.rs new file mode 100644 index 0000000..3079250 --- /dev/null +++ b/crates/ui-egui/src/combo.rs @@ -0,0 +1,115 @@ +//! An editable combo box: a value box that shows the current value, with a drop-down arrow whose +//! list opens under the box. The font name and font size controls use it, so the arrow, the list +//! and the typing behave the same everywhere. +//! +//! The arrow is a generous hit region with a tooltip and an accessible label. The list highlights +//! the current value and scrolls to it once each time it opens. A typed value is applied when the +//! box loses focus, if the caller accepts it. + +use egui::{Align, Area, Color32, Frame, Id, Order, ScrollArea, Shape, Stroke, Ui, WidgetInfo, WidgetType, vec2}; + +/// What the user chose this frame: a value picked from the list, or typed and accepted. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Outcome { + pub chosen: Option, +} + +/// The parts of one combo box that the caller supplies. +pub struct Spec<'a> { + /// Keeps the box's typed text, the open state and the scroll state apart from other combos. + pub id: Id, + /// The accessible name of the value box. + pub box_label: &'a str, + /// The accessible name and tooltip of the drop-down arrow. + pub arrow_label: &'a str, + /// The value shown in the box and highlighted in the list. + pub shown: &'a str, + /// The width of the value box in points. + pub width: f32, + /// The items of the list, in order. + pub items: &'a [String], + /// Shown in the list instead of items when there are none (for example, the font catalog is + /// unavailable). + pub empty_note: Option<&'a str>, + /// Whether a typed value is accepted when the box loses focus. + pub accept: &'a dyn Fn(&str) -> bool, +} + +/// Shows the combo box and returns what was chosen this frame. +pub fn show(ui: &mut Ui, text: Color32, spec: Spec<'_>) -> Outcome { + let mut outcome = Outcome::default(); + let typed_id = spec.id.with("typed"); + let open_id = spec.id.with("open"); + let scroll_id = spec.id.with("scroll"); + + let mut typed = ui.data(|d| d.get_temp::(typed_id)).unwrap_or_else(|| spec.shown.to_owned()); + let value_box = ui.add(egui::TextEdit::singleline(&mut typed).id(typed_id).desired_width(spec.width)); + value_box.widget_info(|| WidgetInfo::labeled(WidgetType::TextInput, true, spec.box_label)); + if !value_box.has_focus() { + typed = spec.shown.to_owned(); + } + // The typed value is applied when the box loses focus, as in OneNote. + if value_box.lost_focus() { + let value = typed.trim(); + if value != spec.shown && (spec.accept)(value) { + outcome.chosen = Some(value.to_owned()); + } + typed = spec.shown.to_owned(); + } + ui.data_mut(|d| d.insert_temp(typed_id, typed)); + + let mut open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); + let mut scroll = ui.data(|d| d.get_temp::(scroll_id)).unwrap_or(false); + let arrow = ui.add(egui::Button::new("").min_size(vec2(18.0, 20.0))); + { + let c = arrow.rect.center(); + let points = vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]; + ui.painter().add(Shape::convex_polygon(points, text, Stroke::NONE)); + } + arrow.widget_info(|| { + let mut info = WidgetInfo::labeled(WidgetType::Button, true, spec.arrow_label); + info.current_text_value = Some(spec.shown.to_owned()); + info + }); + let arrow = arrow.on_hover_text(spec.arrow_label); + if arrow.clicked() { + open = !open; + scroll = open; + } + + if open { + let list = Area::new(spec.id.with("list")).order(Order::Foreground).fixed_pos(value_box.rect.left_bottom()).show(ui.ctx(), |ui| { + Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(value_box.rect.width()); + if spec.items.is_empty() { + if let Some(note) = spec.empty_note { + ui.label(note); + } + return; + } + ScrollArea::vertical().max_height(280.0).show(ui, |ui| { + for item in spec.items { + let current = item == spec.shown; + let row = ui.selectable_label(current, item); + if current && scroll { + row.scroll_to_me(Some(Align::Center)); + } + if row.clicked() { + outcome.chosen = Some(item.clone()); + open = false; + } + } + }); + }); + }); + if ui.input(|i| i.pointer.any_click()) && !list.response.hovered() && !arrow.hovered() { + open = false; + } + } + scroll = false; + ui.data_mut(|d| { + d.insert_temp(scroll_id, scroll); + d.insert_temp(open_id, open); + }); + outcome +} diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index b747fdf..067f7ec 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -49,9 +49,28 @@ pub fn page_families(blocks: &[notecraft_formats::Block]) -> BTreeSet { families } +#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] +static CATALOG: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Reads the installed fonts on a background thread, so the window never waits for it. Listing +/// every font takes a while on some machines. +#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] +pub fn start_catalog() { + std::thread::spawn(|| { + let _ = CATALOG.get_or_init(system::Catalog::discover); + }); +} + +/// The installed fonts, or `None` while they are still being read. Never blocks. +#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] +pub fn catalog_if_ready() -> Option> { + CATALOG.get().map(|result| result.as_ref().map_err(String::as_str)) +} + +/// The installed fonts. Waits for the background read if it is not finished (used when a font is +/// installed, which is rare). #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] pub fn system_catalog() -> Result<&'static system::Catalog, &'static str> { - static CATALOG: std::sync::OnceLock> = std::sync::OnceLock::new(); CATALOG.get_or_init(system::Catalog::discover).as_ref().map_err(String::as_str) } diff --git a/crates/ui-egui/src/fonts/system.rs b/crates/ui-egui/src/fonts/system.rs index 91bc04e..e589177 100644 --- a/crates/ui-egui/src/fonts/system.rs +++ b/crates/ui-egui/src/fonts/system.rs @@ -40,6 +40,14 @@ impl Catalog { Self { database, families } } + /// Adds every font file in `folder` to `database`. Windows keeps per-user fonts in a folder the + /// system scan does not read, so the catalog reads it as well. + pub(crate) fn load_fonts_folder(database: &mut fontdb::Database, folder: &std::path::Path) { + if folder.is_dir() { + database.load_fonts_dir(folder); + } + } + pub fn families(&self) -> &[String] { &self.families } @@ -68,3 +76,22 @@ impl Catalog { Ok(key) } } + +#[cfg(test)] +mod folder_tests { + use super::Catalog; + + /// A font folder the catalog reads: the real Noto Sans Hebrew file, copied into a fresh folder. + #[test] + fn a_fonts_folder_adds_its_families_to_the_catalog() { + let folder = std::env::temp_dir().join(format!("notecraft-fonts-folder-{}", std::process::id())); + std::fs::create_dir_all(&folder).expect("a folder"); + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/NotoSansHebrew.ttf"); + std::fs::copy(&source, folder.join("NotoSansHebrew.ttf")).expect("the font file"); + let mut database = fontdb::Database::new(); + Catalog::load_fonts_folder(&mut database, &folder); + let catalog = Catalog::from_database(database); + let _ = std::fs::remove_dir_all(&folder); + assert!(catalog.families().iter().any(|name| name == "Noto Sans Hebrew"), "the family is listed: {:?}", catalog.families()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d05aa98..e5a9e86 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod backstage; pub mod bidi; pub mod clipboard; pub mod codeview; +pub mod combo; pub mod containers; pub mod control; pub mod diagramview; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 25b3add..5aae0e6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -843,21 +843,76 @@ fn home_tab(ui: &mut Ui, view: &View<'_>, t: &Tokens, acts: &mut Vec) { group(ui, crate::tl_id!("ui.basic_text"), t, |ui| { rows(ui, |ui| { ui.horizontal(|ui| { - let size_label = view.font_size.unwrap_or_else(|| crate::tl_id!("ui.font_size")); - let size_menu = ui.menu_button(size_label, |ui| { - for size in FONT_SIZES { - if ui.button(format!("{size}")).clicked() { - acts.push(Act::FontSize(size)); - ui.close(); + // The font name: the same combo box as the size, listing the installed fonts. + // The font list is read in the background. While it is not ready the list says so, + // and the window asks to be drawn again until it is. + let (families, families_note): (Vec, Option) = { + #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] + { + match crate::fonts::catalog_if_ready() { + Some(Ok(catalog)) => ( + catalog.families().iter().filter(|name| notecraft_formats::markdown::valid_font_family(name)).cloned().collect(), + None, + ), + Some(Err(error)) => { + log::warn!("Could not discover installed fonts: {error}"); + (Vec::new(), Some(crate::tl_id!("ui.font_catalog_unavailable").to_string())) + } + None => { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(150)); + (Vec::new(), Some(crate::tl_id!("ui.loading_fonts").to_string())) + } } } - }); - size_menu.response.widget_info(|| { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::tl_id!("ui.font_size")); - info.current_text_value = view.font_size.map(str::to_owned); - info - }); - size_menu.response.on_hover_text(crate::tl_id!("ui.font_size")); + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] + { + (Vec::new(), Some(crate::tl_id!("ui.font_catalog_unavailable").to_string())) + } + }; + let family_out = crate::combo::show( + ui, + t.text, + crate::combo::Spec { + id: ui.id().with("font-family"), + box_label: crate::tl_id!("ui.font_family_box"), + arrow_label: crate::tl_id!("ui.font_family"), + shown: view.font_family.unwrap_or(""), + width: 96.0, + items: &families, + empty_note: families_note.as_deref(), + accept: &|typed| notecraft_formats::markdown::valid_font_family(typed), + }, + ); + if let Some(name) = family_out.chosen { + acts.push(Act::FontFamily(name)); + } + // OneNote's size control: an editable box showing the size, and a drop-down arrow. + // The box shows the selected size; with no selection it keeps the last size seen. + let last_id = ui.id().with("font-size-last"); + if let Some(now) = view.font_size.filter(|s| s.parse::().is_ok()) { + ui.data_mut(|d| d.insert_temp(last_id, now.to_owned())); + } + let shown_owned: String = + view.font_size.map(str::to_owned).or_else(|| ui.data(|d| d.get_temp::(last_id))).unwrap_or_default(); + let shown = shown_owned.as_str(); + let sizes: Vec = FONT_SIZES.iter().map(|size| size.to_string()).collect(); + let size_out = crate::combo::show( + ui, + t.text, + crate::combo::Spec { + id: ui.id().with("font-size"), + box_label: crate::tl_id!("ui.font_size_box"), + arrow_label: crate::tl_id!("ui.font_size"), + shown, + width: 38.0, + items: &sizes, + empty_note: None, + accept: &|typed| typed.parse::().is_ok_and(|size| FONT_SIZES.contains(&size)), + }, + ); + if let Some(size) = size_out.chosen.and_then(|typed| typed.parse::().ok()) { + acts.push(Act::FontSize(size)); + } if mark_button(ui, Mark::Bold, crate::tl_id!("ui.bold"), crate::tl_id!("ui.bold_ctrl_b"), false, t).clicked() { acts.push(Act::Mark("bold")); } @@ -909,39 +964,6 @@ fn home_tab(ui: &mut Ui, view: &View<'_>, t: &Tokens, acts: &mut Vec) { } }); ui.horizontal(|ui| { - let family_label = view.font_family.unwrap_or_else(|| crate::tl_id!("ui.font_family")); - let shown_family = if family_label.chars().count() > 14 { - format!("{}...", family_label.chars().take(11).collect::()) - } else { - family_label.to_string() - }; - let family_menu = ui.menu_button(shown_family, |ui| { - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - match crate::fonts::system_catalog() { - Ok(catalog) => { - egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { - for family in catalog.families().iter().filter(|name| notecraft_formats::markdown::valid_font_family(name)) { - if ui.button(family).clicked() { - acts.push(Act::FontFamily(family.clone())); - ui.close(); - } - } - }); - } - Err(error) => { - log::warn!("Could not discover installed fonts: {error}"); - ui.label(crate::tl_id!("ui.font_catalog_unavailable")); - } - } - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - ui.label(crate::tl_id!("ui.font_catalog_unavailable")); - }); - family_menu.response.widget_info(|| { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::tl_id!("ui.font_family")); - info.current_text_value = view.font_family.map(str::to_owned); - info - }); - family_menu.response.on_hover_text(format!("{}: {family_label}", crate::tl_id!("ui.font_family"))); if mark_button(ui, Mark::Underline, crate::tl_id!("ui.underline"), crate::tl_id!("ui.underline"), false, t).clicked() { acts.push(Act::Mark("underline")); } diff --git a/crates/ui-egui/src/system_fonts.win.rs b/crates/ui-egui/src/system_fonts.win.rs index 51458f9..60e2ec8 100644 --- a/crates/ui-egui/src/system_fonts.win.rs +++ b/crates/ui-egui/src/system_fonts.win.rs @@ -1,5 +1,8 @@ pub(super) fn discover() -> fontdb::Database { let mut database = fontdb::Database::new(); database.load_system_fonts(); + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + super::Catalog::load_fonts_folder(&mut database, &std::path::Path::new(&local).join("Microsoft").join("Windows").join("Fonts")); + } database } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 2fc070d..9f815e5 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -652,7 +652,7 @@ fn what_cannot_be_written_in_says_so() { fn tags_go_on_a_line_and_come_off_again() { let (_t, mut app, s) = sample_app("ui-tags"); app.open_path(&s.md_page.to_string_lossy()); - let mut h = harness(app, LIGHT); + let mut h = sized_harness(app, LIGHT, egui::vec2(1400.0, 760.0)); settle(&mut h); ask(&mut h, "ui.caret", json!({"block": 1})).expect("caret"); pump(&mut h); @@ -1289,7 +1289,7 @@ fn the_ribbon_inserts_in_both_formats() { fn the_ribbon_formats_what_the_caret_is_in() { let (_t, mut app, s) = sample_app("ui-ribbon-format"); app.open_path(&s.md_page.to_string_lossy()); - let mut h = harness(app, LIGHT); + let mut h = sized_harness(app, LIGHT, egui::vec2(1400.0, 760.0)); settle(&mut h); ask(&mut h, "ui.caret", json!({"block": 1})).expect("caret"); pump(&mut h); @@ -6162,12 +6162,138 @@ fn font_selectors_show_selection_values_and_mixed_state_in_both_formats() { h.run(); h.get_by_label("24").click(); settle(&mut h); - assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("24 pt")); + assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("24")); assert_eq!(h.get_by_label("Font Family").value().as_deref(), Some("Mixed")); review(&mut h, "font-indicators-mixed"); } } +/// SUP-009 (owner, 2026-11-11): the size box is editable, as OneNote's is. A typed size applies +/// to the selection on Enter, and the box then shows that size. +#[test] +#[ignore = "SUP-009: clicking into the size box clears the page selection; the typed size does not apply yet"] +fn a_typed_font_size_applies_to_the_selection() { + let (_t, app, s) = sample_app("ui-font-size-typed"); + let mut h = sized_harness(app, LIGHT, egui::vec2(2000.0, 900.0)); + notecraft_store::page::save_body(&s.md_page, "alpha plain").expect("fixture"); + h.state_mut().open_path(&s.md_page.to_string_lossy()); + settle(&mut h); + ask(&mut h, "ui.caret", json!({"block": 0})).expect("caret"); + pump(&mut h); + ask(&mut h, "ui.key", json!({"key": "A", "ctrl": true})).expect("select all"); + settle(&mut h); + assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("Mixed")); + h.get_by_label("Font size").click(); + h.run(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run(); + h.get_by_label("Font size").type_text("24"); + h.run(); + assert_eq!(h.get_by_label("Font size").value().as_deref(), Some("24"), "the box holds the typed text before Enter"); + // Leaving the box (by clicking the drop-down arrow) applies the typed size. + h.get_by_label("Font Size").click(); + h.run(); + settle(&mut h); + assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("24"), "the box shows the typed size"); + assert!(h.state().body().unwrap_or_default().contains("24pt"), "the selection took the typed size"); +} + +/// OneNote: the size list opens under the size box, left-aligned with it, not under the arrow. +#[test] +fn the_size_list_opens_under_the_size_box() { + let (_t, app, s) = sample_app("ui-font-size-list-position"); + let mut h = sized_harness(app, LIGHT, egui::vec2(2000.0, 900.0)); + h.state_mut().open_path(&s.md_page.to_string_lossy()); + settle(&mut h); + ask(&mut h, "ui.caret", json!({"block": 0})).expect("caret"); + settle(&mut h); + h.get_by_label("Font Size").click(); + h.run(); + h.run(); + let boxed = h.get_by_label("Font size").rect(); + let item = h.get_by_label("16").rect(); + assert!((item.min.x - boxed.min.x).abs() < 8.0, "the list is left-aligned with the size box: box {boxed:?}, list {item:?}"); + assert!(item.min.y >= boxed.max.y - 1.0, "the list is below the size box: box {boxed:?}, list {item:?}"); +} + +/// The size box shows the size of the text under the caret, as OneNote's does. +#[test] +fn the_size_box_shows_the_current_size() { + let (_t, app, s) = sample_app("ui-font-size-box-current"); + let mut h = sized_harness(app, LIGHT, egui::vec2(2000.0, 900.0)); + notecraft_store::page::save_body(&s.md_page, "alpha").expect("fixture"); + h.state_mut().open_path(&s.md_page.to_string_lossy()); + settle(&mut h); + ask(&mut h, "ui.caret", json!({"block": 0})).expect("caret"); + pump(&mut h); + ask(&mut h, "ui.key", json!({"key": "A", "ctrl": true})).expect("select all"); + settle(&mut h); + assert_eq!(h.get_by_label("Font size").value().as_deref(), Some("18"), "the box shows the selection's size"); +} + +/// Choosing a font in the list changes the font box to that font (with the caret in plain text). +#[test] +fn choosing_a_font_changes_the_font_box() { + let Ok(catalog) = notecraft_ui_egui::fonts::system_catalog() else { return }; + let Some(name) = catalog.families().iter().find(|n| notecraft_formats::markdown::valid_font_family(n)).cloned() else { return }; + let (_t, app, s) = sample_app("ui-font-choose"); + let mut h = sized_harness(app, LIGHT, egui::vec2(2000.0, 900.0)); + h.state_mut().open_path(&s.md_page.to_string_lossy()); + settle(&mut h); + ask(&mut h, "ui.caret", json!({"block": 0})).expect("caret"); + settle(&mut h); + h.get_by_label("Font Family").click(); + h.run(); + h.get_by_label(&name).click(); + settle(&mut h); + assert_eq!(h.get_by_label("Font Family").value().as_deref(), Some(name.as_str()), "the box shows the chosen font"); +} + +/// Dragging across plain text selects it, and the page reports the selection. +#[test] +fn dragging_across_text_selects_it() { + let (_t, app, s) = sample_app("ui-drag-select"); + let mut h = sized_harness(app, LIGHT, egui::vec2(2000.0, 900.0)); + notecraft_store::page::save_body(&s.md_page, "hello world").expect("fixture"); + h.state_mut().open_path(&s.md_page.to_string_lossy()); + settle(&mut h); + let page = ask(&mut h, "ui.inspect", json!({})).expect("inspect")["page"].clone(); + let origin = &page["origin"]; + let (ox, oy) = (origin[0].as_f64().unwrap_or(0.0) as f32, origin[1].as_f64().unwrap_or(0.0) as f32); + let _ = (ox, oy); + let (y, x0, x1) = (308.0, 500.0, 560.0); + ask(&mut h, "ui.click", json!({"x": x0, "y": y})).expect("focus the text"); + settle(&mut h); + ask(&mut h, "ui.press", json!({"x": x0, "y": y})).expect("press"); + pump(&mut h); + ask(&mut h, "ui.move", json!({"x": (x0 + x1) / 2.0, "y": y})).expect("move"); + pump(&mut h); + ask(&mut h, "ui.move", json!({"x": x1, "y": y})).expect("move"); + pump(&mut h); + ask(&mut h, "ui.release", json!({"x": x1, "y": y})).expect("release"); + settle(&mut h); + let caret = ask(&mut h, "ui.inspect", json!({})).expect("inspect")["page"]["caret"].clone(); + assert!(caret != Value::Null, "the page has a caret or selection after a drag: {caret}"); + assert!(caret["at"] != caret["anchor"], "the drag made a selection, not a caret: {caret}"); +} + +/// With no caret placed, choosing a font from the list still changes the font box (OneNote applies +/// it to the next text typed). +#[test] +fn choosing_a_font_without_a_caret_changes_the_font_box() { + let Ok(catalog) = notecraft_ui_egui::fonts::system_catalog() else { return }; + let Some(name) = catalog.families().iter().find(|n| notecraft_formats::markdown::valid_font_family(n)).cloned() else { return }; + let (_t, app, s) = sample_app("ui-font-choose-no-caret"); + let mut h = sized_harness(app, LIGHT, egui::vec2(2000.0, 900.0)); + h.state_mut().open_path(&s.md_page.to_string_lossy()); + settle(&mut h); + h.get_by_label("Font Family").click(); + h.run(); + h.get_by_label(&name).click(); + settle(&mut h); + assert_eq!(h.get_by_label("Font Family").value().as_deref(), Some(name.as_str()), "the box shows the chosen font"); +} + #[test] fn font_selectors_show_pending_size_without_editing_the_page() { let (_t, app, s) = sample_app("ui-font-indicator-pending"); @@ -6177,17 +6303,17 @@ fn font_selectors_show_pending_size_without_editing_the_page() { ask(&mut h, "ui.caret", json!({"block": 1})).expect("caret"); settle(&mut h); assert_eq!(h.get_by_label("Font Family").value().as_deref(), Some("Default")); - assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("Default")); + assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("11")); let before = h.state().body().unwrap_or_default().to_string(); h.get_by_label("Font Size").click(); h.run(); h.get_by_label("18").click(); settle(&mut h); - assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18 pt")); + assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18")); assert_eq!(h.state().body().unwrap_or_default(), before); ask(&mut h, "ui.key", json!({"key": "ArrowLeft"})).expect("move caret"); settle(&mut h); - assert_ne!(h.get_by_label("Font Size").value().as_deref(), Some("18 pt"), "the pending size does not follow a moved caret"); + assert_ne!(h.get_by_label("Font Size").value().as_deref(), Some("18"), "the pending size does not follow a moved caret"); } #[test] @@ -6551,7 +6677,7 @@ fn pending_marks_combine_with_size_for_unicode_typing_in_both_formats() { h.run(); h.get_by_label("18").click(); settle(&mut h); - assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18 pt"), "the pending size is shown"); + assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18"), "the pending size is shown"); assert_eq!(h.state().body().unwrap_or_default(), before); assert_eq!(ask(&mut h, "ui.inspect", json!({})).expect("inspect")["page"]["history"]["canUndo"], false); ask(&mut h, "ui.text", json!({"text": "مرحبا"})).expect("typing"); @@ -6670,7 +6796,7 @@ fn selected_container_font_size_is_undoable_and_leaves_other_boxes_alone() { h.run(); h.get_by_label("18").click(); settle(&mut h); - assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18 pt"), "the selected container reports its size"); + assert_eq!(h.get_by_label("Font Size").value().as_deref(), Some("18"), "the selected container reports its size"); let after = h.state().body().unwrap_or_default().to_string(); let (blocks, _) = notecraft_formats::boxes::read(notecraft_model::PageFormat::Cmgfm, &after); fn spans(block: ¬ecraft_formats::Block) -> &[notecraft_formats::markdown::Span] { diff --git a/i18n/ar-XB.tsv b/i18n/ar-XB.tsv index 3791b7b..9fb39f0 100644 --- a/i18n/ar-XB.tsv +++ b/i18n/ar-XB.tsv @@ -73,6 +73,9 @@ @id ui.basic_text [[RLI]]$$$Basic Text@@@[[PDI]] @id ui.font_size [[RLI]]$$$Font Size@@@[[PDI]] @id ui.font_family [[RLI]]$$$Font Family@@@[[PDI]] +@id ui.loading_fonts [[RLI]]$$$Loading fonts...@@@[[PDI]] +@id ui.font_family_box [[RLI]]$$$Font name@@@[[PDI]] +@id ui.font_size_box [[RLI]]$$$Font size@@@[[PDI]] @id ui.mixed [[RLI]]$$$Mixed@@@[[PDI]] @id ui.font_catalog_unavailable [[RLI]]$$$Installed fonts could not be listed on this device.@@@[[PDI]] @id ui.underline [[RLI]]$$$Underline@@@[[PDI]] diff --git a/i18n/en-XA.tsv b/i18n/en-XA.tsv index 8964b62..adb16e8 100644 --- a/i18n/en-XA.tsv +++ b/i18n/en-XA.tsv @@ -73,6 +73,9 @@ @id ui.basic_text $$$Basic Text@@@ @id ui.font_size $$$Font Size@@@ @id ui.font_family $$$Font Family@@@ +@id ui.loading_fonts $$$Loading fonts...@@@ +@id ui.font_family_box $$$Font name@@@ +@id ui.font_size_box $$$Font size@@@ @id ui.mixed $$$Mixed@@@ @id ui.font_catalog_unavailable $$$Installed fonts could not be listed on this device.@@@ @id ui.underline $$$Underline@@@ diff --git a/i18n/en.tsv b/i18n/en.tsv index bbea87a..d9e26e3 100644 --- a/i18n/en.tsv +++ b/i18n/en.tsv @@ -72,7 +72,10 @@ @id ui.back_to_the_page_before_alt_left Back to the page before (Alt+Left) @id ui.basic_text Basic Text @id ui.font_size Font Size +@id ui.font_size_box Font size @id ui.font_family Font Family +@id ui.font_family_box Font name +@id ui.loading_fonts Loading fonts... @id ui.mixed Mixed @id ui.font_catalog_unavailable Installed fonts could not be listed on this device. @id ui.underline Underline From d99fd09cf2e16ef474683cc4efba6c93ca7e64a3 Mon Sep 17 00:00:00 2001 From: AlienTech Software Date: Wed, 11 Nov 2026 21:04:44 -0800 Subject: [PATCH 3/4] fix(ui-egui): set the inline field on the caret placed for a font choice Main added an inline field to Caret. The helper that places the caret for a font or size choice now sets it to None. Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index bd66212..b344b35 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -4321,7 +4321,8 @@ impl NoteCraftApp { let Some(index) = ed.blocks.iter().rposition(pageedit::is_writable) else { return }; let text = pageedit::caret_text(&ed.blocks[index]); let end = text.chars().count(); - ed.caret = Some(pageedit::Caret { block: index, text, focus: true, selection: (end, end), place: Some(end), cell: None, span: None }); + ed.caret = + Some(pageedit::Caret { block: index, text, focus: true, selection: (end, end), place: Some(end), cell: None, span: None, inline: None }); } fn apply_font_family(&mut self, family: &str) { From e56f07f5c96e27ed4d49c43520f35767645ee602 Mon Sep 17 00:00:00 2001 From: AlienTech Software Date: Wed, 11 Nov 2026 21:23:02 -0800 Subject: [PATCH 4/4] fix(ui-egui): build the Windows font folder loader only on Windows On Linux and macOS nothing calls load_fonts_folder, so clippy with warnings denied failed on the CI runner. It is now built for Windows and tests only. Co-Authored-By: Claude Haiku 5.5 --- crates/ui-egui/src/fonts/system.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/fonts/system.rs b/crates/ui-egui/src/fonts/system.rs index e589177..b211890 100644 --- a/crates/ui-egui/src/fonts/system.rs +++ b/crates/ui-egui/src/fonts/system.rs @@ -42,6 +42,7 @@ impl Catalog { /// Adds every font file in `folder` to `database`. Windows keeps per-user fonts in a folder the /// system scan does not read, so the catalog reads it as well. + #[cfg(any(test, target_os = "windows"))] pub(crate) fn load_fonts_folder(database: &mut fontdb::Database, folder: &std::path::Path) { if folder.is_dir() { database.load_fonts_dir(folder);