From 04eadcb2ca3622fa4c215ed876c30a797d91dcc1 Mon Sep 17 00:00:00 2001 From: Antoine Boxho Date: Sun, 18 Oct 2026 09:02:57 +0100 Subject: [PATCH] #2068: Recently Used Fonts at the top of the font list (#2192) The font box (ribbon and mini toolbar) opens with a Recently Used Fonts section: the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, once each, ten at most, kept in ui.json. All Fonts follows. The sections show while nothing is typed; a query lists its matches only. Up/Down walk the recent entries then the list and Enter applies the highlighted one; a click on a heading keeps the list open. The typed list is sized again when its length changes, in a discarded pass so it never blanks; it used to stay one row tall after a one-match query. Names over 128 bytes are not kept, and reading a saved list stops at ten names. Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 197 +++++++++++++++++++++ crates/ui-egui/src/mini_toolbar.rs | 3 +- crates/ui-egui/src/previews.rs | 1 + crates/ui-egui/src/ribbon.rs | 5 +- crates/ui-egui/src/widgets.rs | 263 ++++++++++++++++++++++++++-- docs/ui-parity.md | 7 +- 29 files changed, 500 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3d8c90ba3..6692cceb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-15 | Font box: a Recently Used Fonts section at the top of the list (the fonts applied lately, most recent first, ten at most, kept between runs), then All Fonts, as in Word (#2068) | | 2026-10-15 | A missing Thai document font (TH Sarabun New, Angsana New, Cordia New, the UPC families…) is substituted by one Thai face for the whole run, Latin letters and digits included, as Word substitutes Cordia New; Thai no longer sits in one font next to larger Latin in another (#1549) | |---|---| | 2026-10-17 | Paste from LibreOffice: a paragraph indented on both sides (HTML `
` on the clipboard) pastes as an indented Normal paragraph with LibreOffice's margins, not centred and italic in the Quote style (#1270). | @@ -423,6 +424,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Progress log: Recently Used Fonts in the font box (#2068) | | 2026-10-15 | trivial | Progress log: one Thai face for a run in a missing Thai font (#1549) | | 2026-10-18 | trivial | Progress log: Different First Page headers reachable from page 1 (#2826) | | 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) | diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 047804f60..f68232f38 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2635,3 +2635,5 @@ Other: Друго: The number must be between 1 and {max}. Числото трябва да е между 1 и {max}. The measurement must be between {min} and {max}. Размерът трябва да е между {min} и {max}. + Recently Used Fonts Наскоро използвани шрифтове + All Fonts Всички шрифтове diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a2a7d8f8b..740f5a41a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2638,3 +2638,5 @@ preview Title Ukázka Other: Jiné: The number must be between 1 and {max}. Číslo musí být mezi 1 a {max}. The measurement must be between {min} and {max}. Míra musí být mezi {min} a {max}. + Recently Used Fonts Naposledy použitá písma + All Fonts Všechna písma diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fe4711242..87baf70e8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2246,3 +2246,5 @@ Other: Andere: The number must be between 1 and {max}. Die Zahl muss zwischen 1 und {max} liegen. The measurement must be between {min} and {max}. Das Maß muss zwischen {min} und {max} liegen. + Recently Used Fonts Zuletzt verwendete Schriftarten + All Fonts Alle Schriftarten diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 037f988b6..7156e4abd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2613,3 +2613,5 @@ preview Title Título Other: Otro: The number must be between 1 and {max}. El número debe estar entre 1 y {max}. The measurement must be between {min} and {max}. La medida debe estar entre {min} y {max}. + Recently Used Fonts Fuentes usadas recientemente + All Fonts Todas las fuentes diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 678fc05e5..8c54632af 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2281,3 +2281,5 @@ Other: Muu: The number must be between 1 and {max}. Arv peab olema vahemikus 1 kuni {max}. The measurement must be between {min} and {max}. Mõõt peab olema vahemikus {min} kuni {max}. + Recently Used Fonts Viimati kasutatud fondid + All Fonts Kõik fondid diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7b43de9c0..532c5902f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2254,3 +2254,5 @@ Other: Autre : The number must be between 1 and {max}. Le nombre doit être compris entre 1 et {max}. The measurement must be between {min} and {max}. La mesure doit être comprise entre {min} et {max}. + Recently Used Fonts Polices récemment utilisées + All Fonts Toutes les polices diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 0a28fdfaa..fb73a4394 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2646,3 +2646,5 @@ preview Title Naslov Other: Ostalo: The number must be between 1 and {max}. Broj mora biti između 1 i {max}. The measurement must be between {min} and {max}. Mjera mora biti između {min} i {max}. + Recently Used Fonts Nedavno korišteni fontovi + All Fonts Svi fontovi diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1a6e6c9bc..4a4b85164 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2642,3 +2642,5 @@ Other: Egyéb: The number must be between 1 and {max}. A számnak 1 és {max} között kell lennie. The measurement must be between {min} and {max}. A méretnek {min} és {max} között kell lennie. + Recently Used Fonts Legutóbb használt betűtípusok + All Fonts Összes betűtípus diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 094452921..3ca09c0fc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2655,3 +2655,5 @@ preview Title Esempio Other: Altro: The number must be between 1 and {max}. Il numero deve essere compreso tra 1 e {max}. The measurement must be between {min} and {max}. La misura deve essere compresa tra {min} e {max}. + Recently Used Fonts Caratteri usati di recente + All Fonts Tutti i caratteri diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b32fcbc34..5b649c0b5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2247,3 +2247,5 @@ Other: その他: The number must be between 1 and {max}. 1 から {max} までの数値を指定してください。 The measurement must be between {min} and {max}. {min} から {max} までの値を指定してください。 + Recently Used Fonts 最近使用したフォント + All Fonts すべてのフォント diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f3b547b98..ac3bca891 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2502,3 +2502,5 @@ preview Title 제목 Other: 기타: The number must be between 1 and {max}. 숫자는 1에서 {max} 사이여야 합니다. The measurement must be between {min} and {max}. 측정값은 {min}에서 {max} 사이여야 합니다. + Recently Used Fonts 최근 사용한 글꼴 + All Fonts 모든 글꼴 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c548ad784..7fc07fbc0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2636,3 +2636,5 @@ preview Title Tytuł Other: Inny: The number must be between 1 and {max}. Liczba musi być z zakresu od 1 do {max}. The measurement must be between {min} and {max}. Miara musi być z zakresu od {min} do {max}. + Recently Used Fonts Ostatnio używane czcionki + All Fonts Wszystkie czcionki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d09943ac3..4ab9294fb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2259,3 +2259,5 @@ Other: Outro: The number must be between 1 and {max}. O número deve estar entre 1 e {max}. The measurement must be between {min} and {max}. A medida deve estar entre {min} e {max}. + Recently Used Fonts Fontes usadas recentemente + All Fonts Todas as fontes diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 989ede1b9..b549ff6fb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2644,3 +2644,5 @@ preview Title Пример Other: Другой: The number must be between 1 and {max}. Число должно быть от 1 до {max}. The measurement must be between {min} and {max}. Размер должен быть от {min} до {max}. + Recently Used Fonts Недавно использованные шрифты + All Fonts Все шрифты diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 667269747..64654587b 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2257,3 +2257,5 @@ Other: Drugo: The number must be between 1 and {max}. Broj mora biti između 1 i {max}. The measurement must be between {min} and {max}. Mera mora biti između {min} i {max}. + Recently Used Fonts Nedavno korišćeni fontovi + All Fonts Svi fontovi diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e2c42abe6..e2aec0016 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2261,3 +2261,5 @@ Other: Друго: The number must be between 1 and {max}. Број мора бити између 1 и {max}. The measurement must be between {min} and {max}. Мера мора бити између {min} и {max}. + Recently Used Fonts Недавно коришћени фонтови + All Fonts Сви фонтови diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e3c6cf85a..adedea7f6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2661,3 +2661,5 @@ preview Title Titel Other: Annat: The number must be between 1 and {max}. Talet måste vara mellan 1 och {max}. The measurement must be between {min} and {max}. Måttet måste vara mellan {min} och {max}. + Recently Used Fonts Senast använda typsnitt + All Fonts Alla typsnitt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 01b35e7de..85de2ce6a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2643,3 +2643,5 @@ preview Title Örnek Other: Diğer: The number must be between 1 and {max}. Sayı 1 ile {max} arasında olmalıdır. The measurement must be between {min} and {max}. Ölçü {min} ile {max} arasında olmalıdır. + Recently Used Fonts Son Kullanılan Yazı Tipleri + All Fonts Tüm Yazı Tipleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6ead0ad94..33676227d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2259,3 +2259,5 @@ Other: Інше: The number must be between 1 and {max}. Число має бути від 1 до {max}. The measurement must be between {min} and {max}. Розмір має бути від {min} до {max}. + Recently Used Fonts Нещодавно використані шрифти + All Fonts Усі шрифти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f23a2ba8a..9f024b434 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2686,3 +2686,5 @@ template Letter Thư Other: Khác: The number must be between 1 and {max}. Số phải nằm trong khoảng từ 1 đến {max}. The measurement must be between {min} and {max}. Số đo phải nằm trong khoảng từ {min} đến {max}. + Recently Used Fonts Phông chữ dùng gần đây + All Fonts Tất cả phông chữ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index be03d290f..adf421d6b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2259,3 +2259,5 @@ Other: 其他字符: The number must be between 1 and {max}. 数字必须介于 1 和 {max} 之间。 The measurement must be between {min} and {max}. 度量值必须介于 {min} 和 {max} 之间。 + Recently Used Fonts 最近使用的字体 + All Fonts 所有字体 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cd1ec4dc5..4489c5119 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2248,3 +2248,5 @@ Other: 其他: The number must be between 1 and {max}. 數字必須介於 1 和 {max} 之間。 The measurement must be between {min} and {max}. 度量值必須介於 {min} 和 {max} 之間。 + Recently Used Fonts 最近使用的字型 + All Fonts 所有字型 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ded130a0e..9d8e10b99 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -241,6 +241,10 @@ pub struct UiState { pub recent_symbols: Vec, /// Colours picked in the Colors dialog (More Colors…), most recent first (#606). pub recent_colors: Vec, + /// Fonts applied from the font box, the mini toolbar or the Font dialog, most recent first: + /// the Recently Used Fonts section at the top of the font list (#2068). + #[serde(deserialize_with = "lenient_recent_fonts")] + pub recent_fonts: Vec, /// Interface theme: light, dark, or follow the OS appearance (#115). pub theme: theme::Appearance, /// Interface size (#475, #822): Auto (the display scale the system reports) or an absolute @@ -338,6 +342,10 @@ pub const DEFAULT_TAB: &str = "Home"; pub const MAX_RECENT: usize = 50; /// Most custom dictionary words read back from `ui.json`. pub const MAX_DICTIONARY: usize = 10_000; +/// Fonts kept under Recently Used Fonts, about as many as Word shows. +pub const MAX_RECENT_FONTS: usize = 10; +/// The longest family name Recently Used Fonts keeps, the longest `format.font` accepts. +pub const MAX_FONT_NAME_BYTES: usize = 128; impl Default for UiState { fn default() -> Self { @@ -350,6 +358,7 @@ impl Default for UiState { recent: Vec::new(), recent_symbols: Vec::new(), recent_colors: Vec::new(), + recent_fonts: Vec::new(), theme: theme::Appearance::default(), interface_size: theme::InterfaceSize::default(), legacy_interface_size: None, @@ -407,6 +416,62 @@ impl UiState { } } +/// Put `name` first in the Recently Used Fonts list, dropping an earlier entry for the same +/// family (ignoring case); the list keeps [`MAX_RECENT_FONTS`] names. A blank name, or one longer +/// than [`MAX_FONT_NAME_BYTES`], is not added. +pub fn push_recent_font(list: &mut Vec, name: &str) { + let name = name.trim(); + if name.is_empty() || name.len() > MAX_FONT_NAME_BYTES { + return; + } + let key = name.to_lowercase(); + list.retain(|f| f.to_lowercase() != key); + list.insert(0, name.to_string()); + list.truncate(MAX_RECENT_FONTS); +} + +/// A Recently Used Fonts list from saved `names`: trimmed, without blanks, names longer than +/// [`MAX_FONT_NAME_BYTES`] or repeats (ignoring case); the first [`MAX_RECENT_FONTS`] that remain. +pub fn recent_fonts_from<'a>(names: impl IntoIterator) -> Vec { + let mut out: Vec = Vec::new(); + let mut keys: Vec = Vec::new(); + for f in names { + if out.len() >= MAX_RECENT_FONTS { + break; + } + let f = f.trim(); + if f.is_empty() || f.len() > MAX_FONT_NAME_BYTES { + continue; + } + let key = f.to_lowercase(); + if !keys.contains(&key) { + keys.push(key); + out.push(f.to_string()); + } + } + out +} + +/// `recentFonts` as saved, through [`recent_fonts_from`]: an entry that isn't a name is dropped, +/// not the whole list. +fn lenient_recent_fonts<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = Value::deserialize(d)?; + Ok(v.as_array().map(|a| recent_fonts_from(a.iter().filter_map(Value::as_str))).unwrap_or_default()) +} + +/// The family a command applies: `format.font {"name"}` (the font box, the mini toolbar, command +/// search) or `format.set {"props": {"font"}}` (the Font dialog). It joins Recently Used Fonts +/// once the command succeeds. +fn applied_font(id: &str, params: &Value) -> Option { + let name = match id { + "format.font" => params.get("name"), + "format.set" => params.get("props").and_then(|p| p.get("font")), + _ => None, + }?; + let name = name.as_str()?.trim(); + (!name.is_empty()).then(|| name.to_string()) +} + /// Settings saved before #838 kept the measurement unit as `editing.units`, which defaulted to /// inches; it now lives in `editing.measurementUnits`, and no value there follows the region. A /// unit other than inches under the old key was the user's own choice, so it carries over (an @@ -700,6 +765,8 @@ impl WordApp { // Word opens on Home every launch, so the tab `ui.json` holds is not restored. Going File // and back to the editor within the run still returns to the tab that was showing. self.ui.tab = DEFAULT_TAB.into(); + let recent_fonts = recent_fonts_from(self.ui.recent_fonts.iter().map(String::as_str)); + self.ui.recent_fonts = recent_fonts; // A `ui.json` from before the theme setting: its dark-mode switch picks Dark or Light. if let Some(dark) = self.ui.legacy_dark.take() { self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light }; @@ -796,7 +863,11 @@ impl WordApp { { return r; } + let applied = applied_font(id, ¶ms); let r = self.execute_user(id, params); + if let (Ok(_), Some(name)) = (&r, applied) { + push_recent_font(&mut self.ui.recent_fonts, &name); + } if matches!(id, "insert.picture" | "insert.icon") { self.select_inserted_picture(&r); } @@ -4894,6 +4965,132 @@ mod tests { assert_eq!(body_text(h.state()), before, "typing went into the font size box, not the page"); } + /// #2068: fonts applied from the font box or the mini toolbar (`format.font`) and from the + /// Font dialog (`format.set`) join Recently Used Fonts, most recent first, once each, ten at + /// most; the list is kept in `ui.json` and read back tidied. + #[test] + fn applied_fonts_join_recently_used_fonts() { + let mut a = typed(); + assert!(a.ui.recent_fonts.is_empty()); + a.run("format.font", json!({"name": "Courier New"})).unwrap(); + a.run("format.font", json!({"name": "Georgia"})).unwrap(); + assert_eq!(a.ui.recent_fonts, ["Georgia", "Courier New"]); + // Applied again: to the front, once, as typed. + a.run("format.font", json!({"name": "courier new"})).unwrap(); + assert_eq!(a.ui.recent_fonts, ["courier new", "Georgia"]); + // The Font dialog applies through format.set. + a.run("format.set", json!({"props": {"font": "Verdana", "bold": true}})).unwrap(); + assert_eq!(a.ui.recent_fonts, ["Verdana", "courier new", "Georgia"]); + // A refused command, or one applying no Latin font, adds nothing. + assert!(a.run("format.font", json!({"name": " "})).is_err()); + a.run("format.font", json!({"eastAsia": "SimSun"})).unwrap(); + a.run("format.set", json!({"props": {"italic": true}})).unwrap(); + assert_eq!(a.ui.recent_fonts.len(), 3); + for i in 0..12 { + a.run("format.font", json!({"name": format!("Font {i}")})).unwrap(); + } + assert_eq!(a.ui.recent_fonts.len(), MAX_RECENT_FONTS); + assert_eq!(a.ui.recent_fonts.first().map(String::as_str), Some("Font 11")); + assert_eq!(a.ui.recent_fonts.last().map(String::as_str), Some("Font 2")); + // Kept between runs. + let saved = serde_json::to_string(&a.prefs()).unwrap(); + assert!(saved.contains(r#""recentFonts":["Font 11","Font 10""#), "{saved}"); + let mut b = app(); + b.apply_prefs(UiState::from_json(saved.as_bytes()).unwrap()); + assert_eq!(b.ui.recent_fonts, a.ui.recent_fonts); + // A ui.json from before the list has nothing recent; junk in the list is dropped and the + // rest kept once each, trimmed, ten at most; a value of the wrong type keeps the default. + assert!(UiState::from_json(br#"{"tab": "Insert"}"#).unwrap().recent_fonts.is_empty()); + let mut list: Vec = ["", " Arial ", "ARIAL", "Georgia"].map(String::from).to_vec(); + list.extend((0..12).map(|i| format!("F{i}"))); + let raw = json!({ "recentFonts": list }); + let tidy = UiState::from_json(raw.to_string().as_bytes()).unwrap().recent_fonts; + assert_eq!(tidy.len(), MAX_RECENT_FONTS); + assert_eq!(&tidy[..3], ["Arial", "Georgia", "F0"]); + assert!(UiState::from_json(br#"{"recentFonts": "Arial"}"#).unwrap().recent_fonts.is_empty()); + // One entry that isn't a name drops that entry, not the list. + let mixed = UiState::from_json(br#"{"recentFonts": ["Arial", 3, null, {"a": 1}, "Georgia"]}"#).unwrap(); + assert_eq!(mixed.recent_fonts, ["Arial", "Georgia"]); + } + + /// #2068 review: a hand-edited `ui.json` with a huge list or huge names read back slowly and + /// kept a multi-megabyte name, and the Font dialog applies names of any length; neither is + /// kept. Reading stops at ten names. + #[test] + fn recently_used_fonts_refuse_overlong_names_and_huge_lists() { + let long = "N".repeat(MAX_FONT_NAME_BYTES + 1); + let mut a = typed(); + a.run("format.font", json!({"name": "Georgia"})).unwrap(); + a.run("format.set", json!({"props": {"font": long}})).unwrap(); + assert_eq!(a.ui.recent_fonts, ["Georgia"], "the dialog's overlong name is applied but not kept"); + let mut list = vec!["Arial".to_string()]; + push_recent_font(&mut list, &long); + push_recent_font(&mut list, &"M".repeat(MAX_FONT_NAME_BYTES)); + assert_eq!(list.len(), 2, "a name of the longest length is kept"); + // A huge saved list: overlong names skipped, the first ten others kept, quickly. + let mut saved: Vec = vec![long.clone(), "x".repeat(4 << 20)]; + saved.extend((0..200_000).map(|i| format!("Family {i}"))); + let raw = json!({ "recentFonts": saved }).to_string(); + let started = std::time::Instant::now(); + let ui = UiState::from_json(raw.as_bytes()).unwrap(); + assert_eq!(ui.recent_fonts.len(), MAX_RECENT_FONTS); + assert_eq!(ui.recent_fonts.first().map(String::as_str), Some("Family 0")); + assert!(started.elapsed() < std::time::Duration::from_secs(20), "read in {:?}", started.elapsed()); + let mut b = app(); + b.apply_prefs(UiState { recent_fonts: vec![long, " Arial ".into(), "ARIAL".into()], ..UiState::default() }); + assert_eq!(b.ui.recent_fonts, ["Arial"]); + } + + /// #2068: the font box lists the fonts applied lately under Recently Used Fonts, above All + /// Fonts, while nothing is typed; a query lists its matches only; Down and Enter apply a + /// recent one. + #[test] + fn the_font_box_lists_recently_used_fonts_first() { + use egui::accesskit::Role; + use egui_kittest::kittest::{By, Queryable}; + let mut a = typed(); + a.run("format.font", json!({"name": "Recent Font Alpha"})).unwrap(); + a.run("format.font", json!({"name": "Recent Font Beta"})).unwrap(); + let mut h = harness(a); + h.run_steps(6); + h.get(By::new().role(Role::TextInput).value("Recent Font Beta")).focus(); + h.step(); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + h.event(key(egui::Key::A, egui::Modifiers::COMMAND)); + h.step(); + h.event(egui::Event::Text("x".into())); + h.run_steps(2); + assert!(h.query_by_label("Recently Used Fonts").is_none(), "a query lists its matches only"); + assert_eq!(h.query_all_by_label("Recent Font Alpha").count(), 0); + h.event(key(egui::Key::Backspace, egui::Modifiers::NONE)); + h.run_steps(2); + assert!(h.query_by_label("Recently Used Fonts").is_some(), "the section shows while nothing is typed"); + assert!(h.query_by_label("All Fonts").is_some()); + assert_eq!(h.query_all_by_label("Recent Font Beta").count(), 1); + assert_eq!(h.query_all_by_label("Recent Font Alpha").count(), 1); + // Down moves to the second recent font; Enter applies it. + h.event(key(egui::Key::ArrowDown, egui::Modifiers::NONE)); + h.step(); + h.event(key(egui::Key::Enter, egui::Modifiers::NONE)); + h.run_steps(2); + let st = h.state_mut().session.run("format.state", &json!({})).unwrap(); + assert_eq!(st.get("font").and_then(Value::as_str), Some("Recent Font Alpha")); + assert_eq!(h.state().ui.recent_fonts, ["Recent Font Alpha", "Recent Font Beta"]); + // The arrow's list has the sections too; a click on a heading leaves it open. + let field = h.get(By::new().role(Role::TextInput).value("Recent Font Alpha")).rect(); + let arrow = egui::pos2(field.max.x + 8.0, field.center().y); + for pressed in [true, false] { + h.hover_at(arrow); + h.event(egui::Event::PointerButton { pos: arrow, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx), "the arrow opened the list"); + h.get_by_label("All Fonts").click(); + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx), "a click on a heading keeps the list open"); + assert!(h.query_by_label("Recently Used Fonts").is_some()); + } + /// #330: Word's shortcuts work from the keyboard — Ctrl+Alt+1 (⌘⌥1) applies Heading 1, and /// an Alt shortcut doesn't also type the character its key would produce. #[test] diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index ea85eda71..77a6d7fb2 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -215,8 +215,9 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); let font = app.previews.font_shown(&font); let fams = app.previews.families(); + let recent: Vec = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect(); let prev = app.previews.font_preview_fn(); - if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) { + if let Some(f) = widgets::font_combo_with_recent(ui, "mini_font", COMBO_FONT_W, &font, &fams, &recent, Some(&*prev)) { let _ = if f == crate::widgets::ADD_FONT { app.run("font.add", json!({})) } else if f == crate::widgets::LOAD_SYSTEM_FONTS { diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index cb9468482..3d6502752 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -228,6 +228,7 @@ impl Previews { let key = format!("font:{name}"); let tex: Option = ui.ctx().data(|d| d.get_temp::(egui::Id::new(&key))); let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name)); let t = Tokens::get(ui.ctx()); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 25e58ba99..088378a5d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use crate::colors::{Remember, Target}; use crate::options::MeasureExt; use crate::theme::{Tokens, medium, regular, semibold}; -use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, combo, font_combo, group, menu_button, small, split}; +use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, combo, font_combo_with_recent, group, menu_button, small, split}; use crate::{WordApp, icons}; pub const TABS: [&str; 13] = wordcraft_engine::ribbon::TABS; @@ -927,8 +927,9 @@ fn home(app: &mut WordApp, ui: &mut Ui) { let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); let font = app.previews.font_shown(&font); let fams = app.previews.families(); + let recent: Vec = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect(); let prev = app.previews.font_preview_fn(); - if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) { + if let Some(f) = font_combo_with_recent(ui, "font", 150.0, &font, &fams, &recent, Some(&*prev)) { let _ = if f == crate::widgets::ADD_FONT { app.run("font.add", json!({})) } else if f == crate::widgets::LOAD_SYSTEM_FONTS { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e893c19c9..32013e0ae 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -492,7 +492,7 @@ pub fn combo( items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>, ) -> Option { - combo_impl(ui, id, width, current, items, preview, false) + combo_impl(ui, id, width, current, items, &[], preview, false) } /// What [`font_combo`] returns when its list's last entry, Add Font…, is chosen (#582): the @@ -516,7 +516,24 @@ pub fn font_combo( items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>, ) -> Option { - combo_impl(ui, id, width, current, items, preview, true) + combo_impl(ui, id, width, current, items, &[], preview, true) +} + +/// [`font_combo`] with the `recent` families (most recent first) in a Recently Used Fonts +/// section above the list, then All Fonts, as in Word (#2068). The sections show when nothing is +/// typed; a query lists its matches only. Up/Down walk the recent entries, then the list; the +/// headings are skipped. A recent font that is no longer installed stays listed and is applied as +/// typed. +pub fn font_combo_with_recent( + ui: &mut Ui, + id: &str, + width: f32, + current: &str, + items: &[String], + recent: &[String], + preview: Option<&dyn Fn(&mut Ui, &str) -> Response>, +) -> Option { + combo_impl(ui, id, width, current, items, recent, preview, true) } /// Indices of the `items` matching `query`, ignoring case: exact matches, then the names starting @@ -554,8 +571,12 @@ struct ComboSearch { query: String, items_len: usize, matches: std::sync::Arc>, - /// Index into `matches`. + /// Index among the entries: the recent families shown, then `matches`. highlight: usize, + /// Up or Down moved the highlight since the query last changed. + moved: bool, + /// Rows the list drew last frame; 0 while it is hidden. + rows: usize, /// Where the list was drawn last frame, its scroll offset and visible height. popup: Option, offset: f32, @@ -568,6 +589,7 @@ fn combo_impl( width: f32, current: &str, items: &[String], + recent: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>, search: bool, ) -> Option { @@ -612,8 +634,11 @@ fn combo_impl( if search && resp.changed() { s.open = true; s.highlight = 0; + s.moved = false; reveal = true; } + // The recent section shows while nothing is typed; a query lists its matches only. + let recent_shown: &[String] = if text.trim().is_empty() { recent } else { &[] }; let mut regain_focus = false; if s.open { if s.query != text || s.items_len != items.len() { @@ -621,7 +646,8 @@ fn combo_impl( s.query = text.clone(); s.items_len = items.len(); } - let last = s.matches.len().saturating_sub(1); + let last = recent_shown.len().saturating_add(s.matches.len()).saturating_sub(1); + s.moved |= step != 0; s.highlight = match step { 1.. => s.highlight.saturating_add(1), 0 => s.highlight, @@ -629,9 +655,12 @@ fn combo_impl( } .min(last); if enter { - let picked = s.matches.get(s.highlight).and_then(|&i| items.get(i)); + let picked = recent_shown + .get(s.highlight) + .or_else(|| s.highlight.checked_sub(recent_shown.len()).and_then(|k| s.matches.get(k)).and_then(|&i| items.get(i))); + // A cleared box commits as typed (nothing) unless Up or Down picked an entry. out = Some(match picked { - Some(name) if !text.trim().is_empty() => name.clone(), + Some(name) if !text.trim().is_empty() || s.moved => name.clone(), _ => text.clone(), }); s.open = false; @@ -659,8 +688,12 @@ fn combo_impl( ui.painter().set(arrow_bg, arrow_box); icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(DROPDOWN, DROPDOWN)), "dropdown", t.icon, t.accent); // The list drops from the box's left edge, as in Word, not from the arrow (#535). - egui::Popup::menu(&aresp).anchor(resp.rect.union(ar)).show(|ui| { - if let Some(v) = combo_list(ui, width, current, items, preview) { + // The font list closes on a chosen entry or a click outside it, not on its headings. + let close = if search { egui::PopupCloseBehavior::CloseOnClickOutside } else { egui::PopupCloseBehavior::CloseOnClick }; + egui::Popup::menu(&aresp).anchor(resp.rect.union(ar)).close_behavior(close).show(|ui| { + let item = |k: usize| items.get(k); + let row = |k: usize| section_row(k, recent, &item); + if let Some(v) = list_rows(ui, width, current, section_rows(recent.len(), items.len()), &row, None, None, preview).0 { out = Some(v); ui.close(); } @@ -677,14 +710,27 @@ fn combo_impl( } } }); - if s.open && !s.matches.is_empty() { + let n = section_rows(recent_shown.len(), s.matches.len()); + if s.open && n > 0 { let matches = s.matches.clone(); let scroll = reveal.then_some((s.offset, s.view_h)); - let shown = egui::Area::new(search_id.with("list")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom()).show(ui.ctx(), |ui| { + let lit = highlight_row(s.highlight, recent_shown.len()); + // A list of a new length is sized again (an area keeps its last size, so a list that + // once held one match stayed one row tall). That pass draws nothing, so it is + // discarded and the frame drawn again, as egui does for its own sizing passes. + let list_id = search_id.with("list"); + let resize = n != s.rows; + if resize || egui::AreaState::load(ui.ctx(), list_id).is_none() { + ui.ctx().request_discard("font list resized"); + } + s.rows = n; + let area = egui::Area::new(list_id).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom()).sizing_pass(resize); + let shown = area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()) .show(ui, |ui| { let item = |k: usize| matches.get(k).and_then(|&i| items.get(i)); - list_rows(ui, width, current, matches.len(), &item, Some(s.highlight), scroll, preview) + let row = |k: usize| section_row(k, recent_shown, &item); + list_rows(ui, width, current, n, &row, Some(lit), scroll, preview) }) .inner }); @@ -699,6 +745,7 @@ fn combo_impl( } } else { s.popup = None; + s.rows = 0; } if regain_focus && s.open { ui.memory_mut(|m| m.request_focus(te_id)); @@ -725,10 +772,67 @@ pub const COMBO_PREVIEW_ROW_H: f32 = 24.0; /// family and each preview loads that font, so building all rows loaded every font on the system /// (gigabytes on large font collections) and froze the app (#121). pub fn combo_list(ui: &mut Ui, width: f32, current: &str, items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>) -> Option { - list_rows(ui, width, current, items.len(), &|k| items.get(k), None, None, preview).0 + list_rows(ui, width, current, items.len(), &|k| items.get(k).map(Row::Item), None, None, preview).0 } -/// The rows of [`combo_list`], virtualised, for `n` items fetched by index. With a `highlight` +/// A row of a combo's list: a dim section heading nothing picks, or an entry. +enum Row<'a> { + Heading(&'a str), + Item(&'a String), +} + +/// Rows in a list of `n` entries headed by `recent` recent ones: the two headings and the recent +/// entries come on top when there are any. +fn section_rows(recent: usize, n: usize) -> usize { + if recent == 0 { n } else { n.saturating_add(recent).saturating_add(2) } +} + +/// Row `k` of a list headed by the `recent` families: Recently Used Fonts, those families, All +/// Fonts, then `item(0..)`. Without recent families the rows are the items. +fn section_row<'a>(k: usize, recent: &'a [String], item: &dyn Fn(usize) -> Option<&'a String>) -> Option> { + if recent.is_empty() { + return item(k).map(Row::Item); + } + if k == 0 { + return Some(Row::Heading(tl!("Recently Used Fonts"))); + } + if let Some(r) = recent.get(k.saturating_sub(1)) { + return Some(Row::Item(r)); + } + if k == recent.len().saturating_add(1) { + return Some(Row::Heading(tl!("All Fonts"))); + } + item(k.saturating_sub(recent.len()).saturating_sub(2)).map(Row::Item) +} + +/// The row of entry `entry` in a list headed by `recent` recent families ([`section_row`]). +fn highlight_row(entry: usize, recent: usize) -> usize { + if recent == 0 { + entry + } else if entry < recent { + entry.saturating_add(1) + } else { + entry.saturating_add(2) + } +} + +/// A section heading's row of `size`: dim text, a rule across the list above it after the +/// first row, nothing to click. The row asks for no more than `size`: the list's width is its +/// entries' (a sizing pass offers an unbounded width). +fn heading_row(ui: &mut Ui, text: &str, size: egui::Vec2, rule: bool) { + let weak = ui.visuals().weak_text_color(); + let line = ui.visuals().widgets.noninteractive.bg_stroke; + if rule { + ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top(), line); + } + ui.allocate_ui_with_layout(size, egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_size(size); + ui.add_space(6.0); + ui.add(egui::Label::new(egui::RichText::new(text).small().color(weak)).selectable(false)); + }); +} + +/// The rows of [`combo_list`], virtualised, for `n` rows fetched by index. With a `highlight` /// that row is marked instead of `current`; `scroll` (last frame's offset and visible height) /// scrolls the highlight into view. Returns the clicked item, the scroll offset and the height. fn list_rows<'a>( @@ -736,7 +840,7 @@ fn list_rows<'a>( width: f32, current: &str, n: usize, - item: &dyn Fn(usize) -> Option<&'a String>, + row: &dyn Fn(usize) -> Option>, highlight: Option, scroll: Option<(f32, f32)>, preview: Option<&dyn Fn(&mut Ui, &str) -> Response>, @@ -760,7 +864,14 @@ fn list_rows<'a>( let shown = area.show_rows(ui, row_h, n, |ui, range| { ui.set_min_width(width + 60.0); for k in range { - let Some(it) = item(k) else { continue }; + let it = match row(k) { + Some(Row::Item(it)) => it, + Some(Row::Heading(h)) => { + heading_row(ui, h, vec2(width + 60.0, row_h), k > 0); + continue; + } + None => continue, + }; let lit = highlight == Some(k); let clicked = match preview { Some(p) => { @@ -979,6 +1090,11 @@ mod tests { /// Focuses a fresh combo, types `typed`, presses `keys` (one per frame) and returns its value. fn type_into_combo(search: bool, items: &[String], typed: &str, keys: &[egui::Key]) -> Option { + type_into(search, items, &[], typed, keys) + } + + /// [`type_into_combo`] for a font box headed by the `recent` families. + fn type_into(search: bool, items: &[String], recent: &[String], typed: &str, keys: &[egui::Key]) -> Option { let ctx = egui::Context::default(); let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; let mut frames = vec![vec![], vec![egui::Event::Text(typed.into())]]; @@ -989,7 +1105,7 @@ mod tests { if n == 0 { ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f")))); } - let v = if search { font_combo(ui, "f", 150.0, "", items, None) } else { combo(ui, "f", 150.0, "", items, None) }; + let v = if search { font_combo_with_recent(ui, "f", 150.0, "", items, recent, None) } else { combo(ui, "f", 150.0, "", items, None) }; out = out.take().or(v); }) .drop_without_applying_deltas(); @@ -1012,6 +1128,121 @@ mod tests { assert_eq!(type_into_combo(false, &sizes, "1", &[Enter]).as_deref(), Some("1")); } + /// #2068: with nothing typed, the recent families head the list and Enter applies the one + /// highlighted; Down walks past them into All Fonts; a query lists its matches only. + #[test] + fn recent_fonts_head_the_typed_list() { + use egui::Key::{ArrowDown, ArrowUp, Backspace, Enter}; + let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); + let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); + // Typing, then clearing the box, leaves the list open on the recent fonts. Enter alone + // applies nothing the user didn't pick; Down picks the second recent font. + assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, Enter]).as_deref(), Some("")); + assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, ArrowUp, Enter]).as_deref(), Some("Georgia")); + assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, Enter]).as_deref(), Some("Courier New")); + // Past the recent entries comes All Fonts; the headings are skipped. + assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, ArrowDown, Enter]).as_deref(), Some("Arial")); + // A query lists the matching installed families only. + assert_eq!(type_into(true, &fonts, &recent, "cour", &[Enter]).as_deref(), Some("Courier New")); + assert_eq!(type_into(true, &fonts, &recent, "arial", &[ArrowDown, Enter]).as_deref(), Some("Arial Black")); + // Nothing recent, nothing typed and no key moved the highlight: the text is committed as before. + assert_eq!(type_into(true, &fonts, &[], "x", &[Backspace, Enter]).as_deref(), Some("")); + } + + /// The typed list once held one match and stayed one row tall when the query then matched + /// many: an area keeps its last size unless a changed query sizes it again. + #[test] + fn the_typed_list_grows_when_the_query_matches_more() { + let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); + let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); + let ctx = egui::Context::default(); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let erase = || vec![key(egui::Key::Backspace)]; + let frames = vec![vec![], vec![egui::Event::Text("cour".into())], vec![], erase(), erase(), erase(), erase(), vec![]]; + let mut area = None; + let mut heights = Vec::new(); + for (n, events) in frames.into_iter().enumerate() { + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + if n == 0 { + ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f")))); + } + area = Some(ui.id().with(("combo_search", "f")).with("list")); + font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); + }) + .drop_without_applying_deltas(); + heights.push(area.and_then(|id| egui::AreaState::load(&ctx, id)).and_then(|s| s.size).map(|s| s.y)); + } + let one = heights[2].expect("one match listed"); + let all = heights[7].expect("every font listed"); + assert!(all > 4.0 * one, "one match: {one} tall; two headings, two recent and four fonts: {all}"); + } + + /// The typed list was built invisibly (a sizing pass) on each keystroke that changed its + /// length, so it blinked out for a frame. It is drawn on the keystroke's own frame. + #[test] + fn the_typed_list_shows_on_the_frame_of_the_keystroke() { + fn has_text(shape: &egui::Shape, s: &str) -> bool { + match shape { + egui::Shape::Text(t) => t.galley.text() == s, + egui::Shape::Vec(v) => v.iter().any(|x| has_text(x, s)), + _ => false, + } + } + let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); + let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); + let ctx = egui::Context::default(); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let erase = || vec![key(egui::Key::Backspace)]; + // Each keystroke and a row the list must draw on that frame: one match, the four + // queries on the way back to a cleared box (whose list shows the recent fonts), then two + // matches. The field itself shows only the typed text. + let frames = [ + (vec![egui::Event::Text("cour".into())], "Courier New"), + (erase(), "Courier New"), + (erase(), "Courier New"), + (erase(), "Arial Black"), + (erase(), "Recently Used Fonts"), + (vec![egui::Event::Text("ar".into())], "Arial Black"), + ]; + ctx.run_ui(egui::RawInput::default(), |ui| { + ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f")))); + font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); + }) + .drop_without_applying_deltas(); + for (n, (events, row)) in frames.into_iter().enumerate() { + let out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); + }); + let drawn = out.shapes.iter().any(|c| has_text(&c.shape, row)); + out.drop_without_applying_deltas(); + assert!(drawn, "keystroke {n}: {row} is drawn"); + } + } + + /// The rows of a sectioned font list, and where the keyboard highlight lands among them. + #[test] + fn font_list_sections_skip_their_headings() { + let items: Vec = ["Arial", "Courier New"].map(String::from).to_vec(); + let recent: Vec = ["Georgia"].map(String::from).to_vec(); + fn names<'a>(items: &'a [String], recent: &'a [String], n: usize) -> Vec { + let item = |k: usize| items.get(k); + (0..n) + .map(|k| match section_row(k, recent, &item) { + Some(Row::Heading(h)) => format!("[{h}]"), + Some(Row::Item(i)) => i.clone(), + None => "-".into(), + }) + .collect() + } + assert_eq!(section_rows(1, 2), 5); + assert_eq!(names(&items, &recent, 6), ["[Recently Used Fonts]", "Georgia", "[All Fonts]", "Arial", "Courier New", "-"]); + assert_eq!([highlight_row(0, 1), highlight_row(1, 1), highlight_row(2, 1)], [1, 3, 4]); + // Nothing recent: no headings, the rows are the items. + assert_eq!(section_rows(0, 2), 2); + assert_eq!(names(&items, &[], 3), ["Arial", "Courier New", "-"]); + assert_eq!(highlight_row(1, 0), 1); + } + /// The field was rounded on all four corners and the arrow on none, so the two looked apart, /// and their borders stood side by side as a double-width divider. The field is rounded only /// on the left, the arrow only on the right, and the arrow's box lies under the field by one diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8f3bf7eba..99587b680 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -12,9 +12,9 @@ Document title opens Rename Document; the account badge opens Options › Genera | Area | WordCraft | Word | Parity | Hours | |---|---|---|---|---| -| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math. The app opens on Home every launch, as in Word: the active tab is not restored between runs, but going File and back to the editor returns to the tab that was showing, contextual tabs included | Same plus Draw | 85% | (Draw in features) | +| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math. The app opens on Home every launch, as in Word: the active tab is not restored between runs, but going File and back to the editor returns to the tab that was showing, contextual tabs included. Home › Font: the font box lists Recently Used Fonts (the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, ten at most, kept between runs) above All Fonts, as in Word; the sections show while nothing is typed (#2068) | Same plus Draw | 85% | (Draw in features) | | Keytips (Alt / F10) | Ribbon badges (#43) and numbered Quick Access Toolbar badges in the user's button order (#620); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone | ✅ | 85% | 1–2 | -| Mini-toolbar on selection | ✅ (#43, two rows #535) | ✅ | 80% | 1–2 | +| Mini-toolbar on selection | ✅ (#43, two rows #535; Recently Used Fonts in its font box #2068) | ✅ | 80% | 1–2 | | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | @@ -66,6 +66,7 @@ Find selects the Results tab, including when it prefills selected text. Editing | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Font box (ribbon and mini toolbar): a Recently Used Fonts section at the top of the list holds the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, once each, ten at most, kept between runs in `ui.json`; All Fonts follows; the sections show while nothing is typed and a query lists its matches only; Up/Down walk the recent entries then the list, Enter applies the highlighted one; a recent font no longer installed stays listed (#2068) | | 2026-10-16 | trivial | Web Layout text fills the window width and re-wraps once a resize settles or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876) | | 2026-10-18 | trivial | Different First Page: double-clicking page 1's header while editing another one goes into page 1's own; Insert › Header/Footer/Page Number and Edit Header/Footer use the header the caret's page shows (first, even or default) instead of always the default (#2826) | | 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |