#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 <echelon@gmail.com>
This commit is contained in:
Antoine Boxho
2026-10-18 04:02:57 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent f354e7fc9b
commit 04eadcb2ca
29 changed files with 500 additions and 22 deletions
+2
View File
@@ -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 `<blockquote>` 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) |
+2
View File
@@ -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 Всички шрифтове
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 841 and column 23.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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 すべてのフォント
Can't render this file because it contains an unexpected character in line 919 and column 23.
+2
View File
@@ -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 모든 글꼴
Can't render this file because it contains an unexpected character in line 925 and column 23.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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 Все шрифты
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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 Сви фонтови
Can't render this file because it contains an unexpected character in line 11 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 793 and column 23.
+2
View File
@@ -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 Усі шрифти
Can't render this file because it contains an unexpected character in line 6 and column 31.
+2
View File
@@ -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ữ
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -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 所有字体
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -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 所有字型
Can't render this file because it contains an unexpected character in line 920 and column 23.
+197
View File
@@ -241,6 +241,10 @@ pub struct UiState {
pub recent_symbols: Vec<dialogs_insert::RecentSymbol>,
/// Colours picked in the Colors dialog (More Colors…), most recent first (#606).
pub recent_colors: Vec<String>,
/// 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<String>,
/// 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<String>, 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<Item = &'a str>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut keys: Vec<String> = 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<Vec<String>, 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<String> {
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, &params);
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<String> = ["", " 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<String> = 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]
+2 -1
View File
@@ -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<String> = 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 {
+1
View File
@@ -228,6 +228,7 @@ impl Previews {
let key = format!("font:{name}");
let tex: Option<TextureHandle> = ui.ctx().data(|d| d.get_temp::<TextureHandle>(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);
+3 -2
View File
@@ -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<String> = 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 {
+247 -16
View File
@@ -492,7 +492,7 @@ pub fn combo(
items: &[String],
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
) -> Option<String> {
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<String> {
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<String> {
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<Vec<usize>>,
/// 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<Rect>,
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<String> {
@@ -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<String> {
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<Row<'a>> {
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<Row<'a>>,
highlight: Option<usize>,
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<String> {
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<String> {
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<String> = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec();
let recent: Vec<String> = ["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<String> = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec();
let recent: Vec<String> = ["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<String> = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec();
let recent: Vec<String> = ["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<String> = ["Arial", "Courier New"].map(String::from).to_vec();
let recent: Vec<String> = ["Georgia"].map(String::from).to_vec();
fn names<'a>(items: &'a [String], recent: &'a [String], n: usize) -> Vec<String> {
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
+4 -3
View File
@@ -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 |