#1885: dialog close box fills red on hover, whole box clickable (#2090)

Dialogs, the Read Aloud player and the file browser used egui's small close glyph: the hit area was the glyph alone and hovering only changed its stroke. The close control is now a box at the right end of the title bar, the full title-bar height and 1.6 times as wide, as a Windows caption button on a Word dialog. The whole box closes the window. It fills red with a white cross while the pointer is over it and darker red while pressed and still under the pointer, with colours from the theme tokens in both themes. The box is pointer-only, so Tab never lands on it; dragging from it does not move the window and Escape closes dialogs as before. Its accessibility name is translated like its tooltip.

docs/parity-checklist.md is the regenerated checklist.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Antoine BoxhoandBrandon Thomas authored and GitHub committed 2026-10-18 04:04:12 -04:00
1 parent ab57696ba7
commit 72cea2daf4
8 files changed
+334 -86

No files matched your search

+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 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) |
| 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) |
|---|---|
@@ -632,3 +633,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
| 2026-10-15 | trivial | Progress log: dialog close box (#1885) |
+53 -6
View File
@@ -1236,7 +1236,6 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
ctx.data_mut(|d| d.remove::<(egui::Id, u8)>(dialog_top_key()));
return;
};
let mut open = true;
let mut close = false;
let title = match &d {
Dialog::RenameDocument { .. } => "Rename Document",
@@ -1292,17 +1291,19 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::Layout(d) => d.title(),
};
let id = egui::Id::new(("dialog", title));
let shown = place_dialog(ctx, id, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false)).open(&mut open).show(ctx, |ui| {
let window = crate::window_close::window(ctx, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false));
let shown = place_dialog(ctx, id, window).show(ctx, |ui| {
ui.set_min_width(340.0);
close = body(app, ui, &mut d);
});
if shown.is_some() {
if let Some(shown) = &shown {
count_dialog_frame(ctx, id);
close |= crate::window_close::show(ctx, &shown.response, &Tokens::get(ctx));
}
if open && !close {
app.dialog = Some(d);
} else {
if close {
app.canvas.want_focus = true;
} else {
app.dialog = Some(d);
}
}
@@ -4208,6 +4209,52 @@ mod tests {
assert!((after.min.y - (before.min.y + 120.0)).abs() <= 1.0, "moved down: {before:?} -> {after:?}");
}
/// #1885: the close control is the whole box at the title bar's right end, as on a Windows
/// dialog, not only the ✕. A click 6 px from the window's right edge, mid title bar, closes
/// the dialog; one just under the title bar leaves it open; a drag that starts on the box
/// doesn't move the dialog. The box spans the title bar egui lays out.
#[test]
fn the_title_bar_close_box_is_the_whole_corner() {
fn press(h: &mut egui_kittest::Harness<'_, WordApp>, from: egui::Pos2, to: egui::Pos2) {
let button =
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
h.event(egui::Event::PointerMoved(from));
h.step();
h.event(button(from, true));
h.step();
if to != from {
h.event(egui::Event::PointerMoved(to));
h.step();
}
h.event(button(to, false));
for _ in 0..4 {
h.step();
}
}
let mut app = app_with("Some text");
app.dialog = Dialog::open("wordCount", &mut app);
let mut h = app_harness(app);
for _ in 0..4 {
h.step();
}
let id = egui::Id::new(("dialog", "Word Count"));
let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open");
let bx = crate::window_close::close_rect(before, 1.0, crate::window_close::geometry(&h.ctx));
let title = h.ctx.read_response(id.with("__title_click")).expect("egui's title bar").rect;
assert!((bx.top() - before.top()).abs() <= 1.5 && (bx.bottom() - title.bottom()).abs() <= 1.5, "box {bx:?} spans the title bar {title:?}");
assert!(bx.width() >= 1.5 * bx.height() && bx.right() + 1.5 >= before.right(), "a wide box at the right end: {bx:?} in {before:?}");
let under = egui::pos2(before.right() - 6.0, bx.bottom() + 4.0);
press(&mut h, under, under);
assert!(h.state().dialog.is_some(), "a click under the title bar keeps the dialog");
let at = egui::pos2(before.right() - 6.0, bx.center().y);
press(&mut h, at, at + vec2(-150.0, 90.0));
assert!(h.state().dialog.is_some(), "a drag from the box isn't a click");
let after = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is still open");
assert!((after.min - before.min).length() <= 1.0, "the box isn't a drag handle: {before:?} -> {after:?}");
press(&mut h, at, at);
assert!(h.state().dialog.is_none(), "a click in the box, away from the X, closes the dialog");
}
fn app_harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> {
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
+7 -9
View File
@@ -302,19 +302,17 @@ impl WordApp {
pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
let Some(mut b) = app.file_browser.open.take() else { return };
let t = Tokens::get(ctx);
let mut open = true;
let mut done: Option<Option<PathBuf>> = None;
egui::Window::new(tl!(b.title()))
let window = egui::Window::new(tl!(b.title()))
.id(egui::Id::new("builtin_file_browser"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0))
.open(&mut open)
.show(ctx, |ui| {
ui.set_width(620.0);
done = body(ui, &mut b, &t);
});
if !open {
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0));
let shown = crate::window_close::window(ctx, window).show(ctx, |ui| {
ui.set_width(620.0);
done = body(ui, &mut b, &t);
});
if shown.is_some_and(|s| crate::window_close::show(ctx, &s.response, &t)) {
done = Some(None);
}
app.file_browser.open = Some(b);
+1
View File
@@ -80,6 +80,7 @@ pub mod table_resize;
pub mod table_ruler;
pub mod theme;
pub mod widgets;
pub mod window_close;
pub mod window_geometry;
pub mod wordart_menus;
pub mod zotero;
+67 -69
View File
@@ -57,84 +57,82 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let state = app.session.read_aloud.state();
let (index, count) = app.session.read_aloud.progress();
let mut open = true;
let mut run: Option<(&str, serde_json::Value)> = None;
let right_top = app.canvas.canvas_rect.map_or(ctx.content_rect().right_top() + vec2(-24.0, 160.0), |r| r.right_top() + vec2(-24.0, 16.0));
egui::Window::new(tl!("Read Aloud"))
let window = egui::Window::new(tl!("Read Aloud"))
.id(egui::Id::new("read-aloud-player"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.pivot(egui::Align2::RIGHT_TOP)
.default_pos(right_top)
.show(ctx, |ui| {
ui.set_width(236.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
if button(ui, &t, "mediaPrev", tl!("Previous sentence"), count > 0 && index > 0) {
run = Some(("readAloud.previous", json!({})));
}
let (icon, tip) = if state == State::Playing { ("mediaPause", tl!("Pause")) } else { ("mediaPlay", tl!("Play")) };
if button(ui, &t, icon, tip, true) {
run = Some(("readAloud.playPause", json!({})));
}
if button(ui, &t, "mediaNext", tl!("Next sentence"), index + 1 < count) {
run = Some(("readAloud.next", json!({})));
}
if button(ui, &t, "mediaStop", tl!("Stop"), true) {
run = Some(("readAloud.stop", json!({})));
}
ui.add_space(4.0);
let what = match state {
State::Playing => format!("{} {} / {}", tl!("Sentence"), (index + 1).min(count), count),
State::Paused => tl!("Paused").to_string(),
State::Stopped if count > 0 && index >= count => tl!("Finished").to_string(),
State::Stopped => tl!("Stopped").to_string(),
};
ui.label(egui::RichText::new(what).color(t.text_dim).small());
});
ui.add_space(6.0);
// Speed: applied when the slider is let go, so dragging doesn't restart the sentence
// on every frame.
let id = egui::Id::new("read-aloud-rate");
let mut rate = ui.data(|d| d.get_temp::<f32>(id)).unwrap_or_else(|| app.session.read_aloud.rate());
ui.horizontal(|ui| {
ui.label(tl!("Speed"));
ui.spacing_mut().slider_width = 110.0;
let r = ui.add(
egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE)
.step_by(0.05)
.trailing_fill(true)
.custom_formatter(|v, _| format!("{v:.2}×"))
.custom_parser(|s| s.trim().trim_end_matches(['×', 'x']).trim().parse().ok()),
);
if r.dragged() {
ui.data_mut(|d| d.insert_temp(id, rate));
} else {
ui.data_mut(|d| d.remove::<f32>(id));
if r.changed() || r.drag_stopped() {
run = Some(("readAloud.speed", json!({"value": rate})));
}
}
if ui.small_button("1×").on_hover_text(tl!("Normal speed")).clicked() {
run = Some(("readAloud.speed", json!({"value": 1.0})));
}
});
let mut skip = app.session.read_aloud.skip_citations;
if ui
.checkbox(&mut skip, tl!("Skip citations & bibliography"))
.on_hover_text(tl!("Don't read Zotero, Mendeley or EndNote citations and bibliographies"))
.changed()
{
run = Some(("readAloud.skipCitations", json!({"value": skip})));
.default_pos(right_top);
let shown = crate::window_close::window(ctx, window).show(ctx, |ui| {
ui.set_width(236.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
if button(ui, &t, "mediaPrev", tl!("Previous sentence"), count > 0 && index > 0) {
run = Some(("readAloud.previous", json!({})));
}
// The error `poll` saw this frame, not the worker's latest: the player thread can set
// one between the two, and the window would show a message the status bar hasn't.
if let Some(e) = app.read_aloud_error.as_ref().filter(|e| app.session.read_aloud.error().as_ref() == Some(*e)) {
ui.label(egui::RichText::new(e.as_str()).color(t.red).small());
let (icon, tip) = if state == State::Playing { ("mediaPause", tl!("Pause")) } else { ("mediaPlay", tl!("Play")) };
if button(ui, &t, icon, tip, true) {
run = Some(("readAloud.playPause", json!({})));
}
if button(ui, &t, "mediaNext", tl!("Next sentence"), index + 1 < count) {
run = Some(("readAloud.next", json!({})));
}
if button(ui, &t, "mediaStop", tl!("Stop"), true) {
run = Some(("readAloud.stop", json!({})));
}
ui.add_space(4.0);
let what = match state {
State::Playing => format!("{} {} / {}", tl!("Sentence"), (index + 1).min(count), count),
State::Paused => tl!("Paused").to_string(),
State::Stopped if count > 0 && index >= count => tl!("Finished").to_string(),
State::Stopped => tl!("Stopped").to_string(),
};
ui.label(egui::RichText::new(what).color(t.text_dim).small());
});
ui.add_space(6.0);
// Speed: applied when the slider is let go, so dragging doesn't restart the sentence
// on every frame.
let id = egui::Id::new("read-aloud-rate");
let mut rate = ui.data(|d| d.get_temp::<f32>(id)).unwrap_or_else(|| app.session.read_aloud.rate());
ui.horizontal(|ui| {
ui.label(tl!("Speed"));
ui.spacing_mut().slider_width = 110.0;
let r = ui.add(
egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE)
.step_by(0.05)
.trailing_fill(true)
.custom_formatter(|v, _| format!("{v:.2}×"))
.custom_parser(|s| s.trim().trim_end_matches(['×', 'x']).trim().parse().ok()),
);
if r.dragged() {
ui.data_mut(|d| d.insert_temp(id, rate));
} else {
ui.data_mut(|d| d.remove::<f32>(id));
if r.changed() || r.drag_stopped() {
run = Some(("readAloud.speed", json!({"value": rate})));
}
}
if ui.small_button("1×").on_hover_text(tl!("Normal speed")).clicked() {
run = Some(("readAloud.speed", json!({"value": 1.0})));
}
});
if !open {
let mut skip = app.session.read_aloud.skip_citations;
if ui
.checkbox(&mut skip, tl!("Skip citations & bibliography"))
.on_hover_text(tl!("Don't read Zotero, Mendeley or EndNote citations and bibliographies"))
.changed()
{
run = Some(("readAloud.skipCitations", json!({"value": skip})));
}
// The error `poll` saw this frame, not the worker's latest: the player thread can set
// one between the two, and the window would show a message the status bar hasn't.
if let Some(e) = app.read_aloud_error.as_ref().filter(|e| app.session.read_aloud.error().as_ref() == Some(*e)) {
ui.label(egui::RichText::new(e.as_str()).color(t.red).small());
}
});
if shown.is_some_and(|s| crate::window_close::show(ctx, &s.response, &t)) {
run = Some(("readAloud.stop", json!({})));
}
if let Some((id, params)) = run {
+23
View File
@@ -288,6 +288,11 @@ pub struct Tokens {
pub red: Color32,
pub green: Color32,
pub orange: Color32,
/// A window's close box while the pointer is over it (the red of a Windows caption button),
/// while pressed, and the ✕ drawn on it then (#1885).
pub close_hover: Color32,
pub close_pressed: Color32,
pub on_close: Color32,
}
impl Tokens {
@@ -349,6 +354,9 @@ impl Tokens {
red: Color32::from_rgb(0xD1, 0x3B, 0x3B),
green: Color32::from_rgb(0x2E, 0x8B, 0x57),
orange: Color32::from_rgb(0xE0, 0x7B, 0x1F),
close_hover: Color32::from_rgb(0xC4, 0x2B, 0x1C),
close_pressed: Color32::from_rgb(0xA6, 0x23, 0x16),
on_close: Color32::WHITE,
}
}
pub fn dark() -> Self {
@@ -411,6 +419,10 @@ impl Tokens {
red: Color32::from_rgb(0xEE, 0x6A, 0x6A),
green: Color32::from_rgb(0x5B, 0xC0, 0x8A),
orange: Color32::from_rgb(0xF0, 0xA0, 0x50),
// A little lighter than the light theme's red, so it reads against the dark window.
close_hover: Color32::from_rgb(0xD6, 0x3C, 0x2C),
close_pressed: Color32::from_rgb(0xB3, 0x30, 0x23),
on_close: Color32::WHITE,
}
}
pub fn get(ctx: &egui::Context) -> Tokens {
@@ -780,6 +792,17 @@ mod tests {
(x.max(y) + 0.05) / (x.min(y) + 0.05)
}
/// #1885: the ✕ on a window's close box reads on its hover and pressed reds in both themes.
#[test]
fn close_box_glyph_reads_on_its_reds() {
for t in [super::Tokens::light(), super::Tokens::dark()] {
for (name, bg) in [("close_hover", t.close_hover), ("close_pressed", t.close_pressed)] {
let r = contrast(t.on_close, bg);
assert!(r >= 4.5, "dark={}: on_close on {name} is {r:.2}:1", t.dark);
}
}
}
/// #655: text and knobs on filled accent controls (Share, avatar, AutoSave, keytips) reach 4.5:1
/// on the accent and on its hover shade, in both themes.
#[test]
+178
View File
@@ -0,0 +1,178 @@
//! The close control of a floating window (dialogs, the Read Aloud player, the file browser),
//! as on a Word dialog on Windows (#1885): a box at the right end of the title bar, the full
//! title-bar height and about 1.6× that wide, all of it clickable. It fills red with a white ✕
//! while the pointer is over it and darker red while pressed. Escape closes dialogs as before.
use egui::{CornerRadius, Margin, Rect, Response, Sense, Stroke, TextStyle, Ui, UiBuilder, pos2, vec2};
use crate::theme::Tokens;
/// The box's width as a multiple of the title bar height.
const WIDTH: f32 = 1.6;
/// Half the ✕'s width and height.
const GLYPH: f32 = 5.0;
/// The title bar's measurements for the current style.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Geometry {
/// Title bar height inside the frame line: the heading row plus the window margin.
pub title_h: f32,
/// The box's width, a whole number so the title bar's margin can reserve it.
pub width: f32,
}
pub(crate) fn geometry(ctx: &egui::Context) -> Geometry {
let style = ctx.global_style();
let heading = ctx.fonts_mut(|f| f.row_height(&TextStyle::Heading.resolve(&style)));
let m = style.spacing.window_margin;
let title_h = heading + f32::from(m.top) + f32::from(m.bottom);
Geometry { title_h, width: (WIDTH * title_h).round().clamp(0.0, 127.0) }
}
/// Reserves the box's room at the right end of `w`'s title bar. Pair with [`show`] after
/// `Window::show`; the window must not use egui's own close button (`Window::open`).
pub(crate) fn window<'a>(ctx: &egui::Context, w: egui::Window<'a>) -> egui::Window<'a> {
let style = ctx.global_style();
let m = style.spacing.window_margin;
let right = geometry(ctx).width as i8;
w.title_frame(egui::Frame::window(&style).inner_margin(Margin { right, ..m }))
}
/// The box for a window whose outer rect is `window` (frame line included), with a title bar
/// `g.title_h` tall inside a `stroke` wide frame line.
pub(crate) fn close_rect(window: Rect, stroke: f32, g: Geometry) -> Rect {
let top = window.top() + stroke;
let right = window.right() - stroke;
Rect::from_min_max(pos2(right - g.width, top), pos2(right, top + g.title_h))
}
/// Draws the box over `window` (the response `Window::show` returned) and handles the pointer.
/// True when the window should close: the box was clicked, or something inside asked for it.
pub(crate) fn show(ctx: &egui::Context, window: &Response, t: &Tokens) -> bool {
let style = ctx.global_style();
let stroke = style.visuals.window_stroke.width;
let rect = close_rect(window.rect, stroke, geometry(ctx));
let id = window.layer_id.id.with("close_box");
let ui = Ui::new(ctx.clone(), id, UiBuilder::new().layer_id(window.layer_id).max_rect(rect));
// Pointer-only, like a caption button: Tab never lands on it.
let r = ui.interact(rect, id.with("hit"), Sense::CLICK | Sense::DRAG);
let label = tl!("Close");
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
let r = r.on_hover_text(label);
let down = r.is_pointer_button_down_on() && r.contains_pointer();
let hot = down || r.hovered();
let painter = ctx.layer_painter(window.layer_id);
if hot {
let ne = style.visuals.window_corner_radius.ne.saturating_sub(stroke.round() as u8);
painter.rect_filled(rect, CornerRadius { ne, ..CornerRadius::ZERO }, if down { t.close_pressed } else { t.close_hover });
}
let c = pos2(painter.round_to_pixel_center(rect.center().x), painter.round_to_pixel_center(rect.center().y));
let s = Stroke::new(1.0, if hot { t.on_close } else { t.text });
painter.line_segment([c + vec2(-GLYPH, -GLYPH), c + vec2(GLYPH, GLYPH)], s);
painter.line_segment([c + vec2(-GLYPH, GLYPH), c + vec2(GLYPH, -GLYPH)], s);
r.clicked() || window.should_close()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::WordApp;
use serde_json::json;
fn harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> {
egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
)
}
/// Clicks the close box of the window area `id`: 6 px from its right edge, mid title bar,
/// well outside the ✕.
fn click_close_box(h: &mut egui_kittest::Harness<'_, WordApp>, id: egui::Id) {
let window = h.ctx.memory(|m| m.area_rect(id)).expect("window is open");
let at = pos2(window.right() - 6.0, close_rect(window, 1.0, geometry(&h.ctx)).center().y);
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
h.event(egui::Event::PointerMoved(at));
h.step();
h.event(button(true));
h.step();
h.event(button(false));
for _ in 0..4 {
h.step();
}
}
/// The box fills the title bar's height inside the frame line and is as wide as the
/// geometry says, flush with the window's right edge.
#[test]
fn the_box_is_the_title_bars_right_end() {
let g = Geometry { title_h: 30.0, width: 48.0 };
let r = close_rect(Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 200.0)), 1.0, g);
assert_eq!(r, Rect::from_min_max(pos2(351.0, 51.0), pos2(399.0, 81.0)));
}
/// With the app's style, the box is the heading row plus the window margin tall and about
/// 1.6× as wide, a whole number of points.
#[test]
fn geometry_follows_the_style() {
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions(false, false));
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
ctx.begin_pass(Default::default());
let g = geometry(&ctx);
let m = ctx.global_style().spacing.window_margin;
let font = TextStyle::Heading.resolve(&ctx.global_style());
let heading = ctx.fonts_mut(|f| f.row_height(&font));
ctx.end_pass().drop_without_applying_deltas();
assert!(heading > 12.0, "a heading row: {heading}");
assert_eq!(g.title_h, heading + f32::from(m.top) + f32::from(m.bottom));
assert_eq!(g.width, g.width.round());
assert!((g.width / g.title_h - WIDTH).abs() < 0.05, "{g:?}");
}
/// The Read Aloud player and the file browser use the same box, and close from it.
#[test]
fn the_player_and_the_file_browser_close_from_the_box() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
app.session.read_aloud.open = true;
let mut h = harness(app);
for _ in 0..4 {
h.step();
}
click_close_box(&mut h, egui::Id::new("read-aloud-player"));
assert!(!h.state().session.read_aloud.open, "the player stops and closes");
let _ = h.state_mut().run("ui.fileBrowser", json!({"purpose": "document"}));
for _ in 0..4 {
h.step();
}
assert!(h.state().file_browser.open.is_some(), "the browser opened");
click_close_box(&mut h, egui::Id::new("builtin_file_browser"));
assert!(h.state().file_browser.open.is_none(), "the browser is cancelled");
}
/// The box is pointer-only, like a Windows caption button: Tab never lands on it, so Enter
/// can't close a dialog by accident.
#[test]
fn the_box_is_not_in_the_tab_order() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
let mut h = harness(app);
for _ in 0..4 {
h.step();
}
let hit = egui::Id::new(("dialog", "Word Count")).with("close_box").with("hit");
let r = h.ctx.read_response(hit).expect("the close box is a widget");
assert!(r.sense.senses_click() && !r.sense.is_focusable(), "pointer-only: {:?}", r.sense);
for _ in 0..12 {
h.key_press(egui::Key::Tab);
h.step();
assert_ne!(h.ctx.memory(|m| m.focused()), Some(hit), "Tab skips the close box");
}
assert!(h.state().dialog.is_some(), "the dialog is still open");
}
}
+3 -2
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: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; 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: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); 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
@@ -26,7 +26,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
| Modal dialogs | **55** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 |
| Modal dialogs | **58** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, its height or 1 × 1 cutting it back into rows, more refused with Word's message; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
| Modal dialogs | **58** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, its height or 1 × 1 cutting it back into rows, more refused with Word's message; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344) | | 68% | 4–6 |
@@ -66,6 +66,7 @@ Find selects the Results tab, including when it prefills selected text. Editing
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | Dialogs, the Read Aloud player and the file browser: the close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable; red with a white ✕ while the pointer is over it, darker red pressed, in both themes; Escape as before (#1885) |
| 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) |