diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 03cf7df6..b344b35a 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,26 @@ 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, inline: 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 +4420,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 00000000..3079250c --- /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 b747fdf8..067f7ece 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 91bc04ee..b2118906 100644 --- a/crates/ui-egui/src/fonts/system.rs +++ b/crates/ui-egui/src/fonts/system.rs @@ -40,6 +40,15 @@ 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. + #[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); + } + } + pub fn families(&self) -> &[String] { &self.families } @@ -68,3 +77,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 d05aa98a..e5a9e867 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 8000de78..5aae0e6e 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"; @@ -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} pt")).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")); } @@ -3027,4 +3049,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/src/system_fonts.win.rs b/crates/ui-egui/src/system_fonts.win.rs index 51458f95..60e2ec89 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 552c3a1b..9f815e54 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); @@ -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,14 +6160,140 @@ 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 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 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.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] @@ -6449,7 +6575,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 +6603,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,9 +6675,9 @@ 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.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"); @@ -6668,9 +6794,9 @@ 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"); + 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] { @@ -6836,7 +6962,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 +7001,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 +7021,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 +7040,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 +7074,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 +7104,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 +7126,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 +7154,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 +7189,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}"); diff --git a/i18n/ar-XB.tsv b/i18n/ar-XB.tsv index 3791b7b1..9fb39f04 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 8964b627..adb16e8d 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 bbea87a7..d9e26e3d 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