Skip to content
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-09: Touchpad and mouse-wheel scroll speed settings (#6146): scales Wayland touchpad point input by default (4×) so two-finger scrolling matches desktop speed rather than crawling 1:1, and scales mouse wheel line distance; File › Options › Advanced › Scrolling; commands ui.scrollSpeed and ui.touchpadScrollSpeed.

- 2026-11-12: DOCX: `w:lnNumType` without a positive `w:countBy` leaves line numbering off, as Word does, instead of numbering every line on screen, in PDF export and on re-save (#6209). Readiness estimates unchanged.
main
- 2026-11-08: Caret: a click or navigation that leaves the caret in place keeps pending font and size for typing; moving the caret to a different position clears it; ribbon font and size return focus to the page; observed in Word (#5580). Readiness estimates unchanged.
- 2026-11-04: Three behaviours lost in the #4501 refactor are back (#4683). From the second section on, headers, footers and note separators are measured with their own section's document grid and SECTION/SECTIONPAGES values, not the previous section's. An ODT paragraph default style that names no font no longer clears the document's default font. A press the canvas handles itself (Ctrl+click on a link, switching into a header or footer, equation entry) no longer also shows the link tooltip in that frame. Readiness unchanged.
- 2026-11-08: `wordcraft-cli convert --strict` (#6005) and `render --strict` (#6008): a PDF or PNG export (render: the page it writes) refuses, exit 3 and nothing written, when it would draw a character no font has or leave out a picture (missing part, external target, link only, undecodable or unsupported format); the report is on stderr with one JSON line. Engine: `strict: true` on `file.save`, `file.saveAs`, `file.exportPdf` and `file.exportPng`, and the pure `file.strictReport`. External picture targets are never fetched. The result depends on the host's installed fonts (#5624); readiness estimates unchanged.
- 2026-11-05: ODT tables with a usable width keep their column proportions when the file gives only shares (gap 19): LibreOffice's CV template opened and saved with equal columns.
Expand Down Expand Up @@ -918,6 +918,7 @@ MATH glyphs that reference the same construction now share its immutable variant
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | trivial | Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf (#6180) |
| 2026-11-08 | trivial | Progress log: caret keeps pending font and size on same-position click/navigation, ribbon font and size return focus (#5580); readiness unchanged |
| 2026-11-04 | trivial | Progress log: #4501 refactor regressions restored (#4683) |
| 2026-11-06 | trivial | Progress log: edited ODT paragraphs keep custom styles with an outline level (gap 19) |
| 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged |
Expand Down
5 changes: 4 additions & 1 deletion crates/engine/src/cmd/caret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,15 @@ fn extend(v: &Value) -> bool {
}

fn apply(s: &mut Session, to: Pos, ext: bool) {
let moved = if ext { s.sel.focus != to } else { s.sel.anchor != to || s.sel.focus != to };
if ext {
s.sel.focus = to;
} else {
s.sel = Selection::caret(to);
}
s.pending = None;
if moved {
s.pending = None;
}
}

fn mv(s: &mut Session, v: &Value, f: fn(&mut Session, &Pos) -> Pos) -> CmdResult {
Expand Down
55 changes: 55 additions & 0 deletions crates/engine/src/tests/typing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4036,3 +4036,58 @@ fn issues_1281_1045_english_dialects_proof_typed_text() {
let words: Vec<&str> = issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str()).collect();
assert_eq!(words, ["21th"]);
}

#[test]
fn setting_caret_to_same_position_preserves_pending_formatting() {
let mut s = typed("Hello ");
run(&mut s, "format.font", json!({"name": "JetBrains Mono"}));
run(&mut s, "format.size", json!({"size": 18.0}));
assert_eq!(s.pending.as_ref().and_then(|p| p.font.as_deref()), Some("JetBrains Mono"));
assert_eq!(s.pending.as_ref().and_then(|p| p.size), Some(18.0));

// Setting the caret to the exact same position (e.g. clicking the sheet at the caret)
// must not drop pending formatting (#5580).
let pos = s.sel.focus.clone();
run(&mut s, "caret.set", json!({"pos": pos}));
assert_eq!(s.pending.as_ref().and_then(|p| p.font.as_deref()), Some("JetBrains Mono"));
assert_eq!(s.pending.as_ref().and_then(|p| p.size), Some(18.0));

// Extending to the same focus (no-op shift extend) preserves pending formatting.
run(&mut s, "caret.set", json!({"pos": pos, "extend": true}));
assert_eq!(s.pending.as_ref().and_then(|p| p.font.as_deref()), Some("JetBrains Mono"));
assert_eq!(s.pending.as_ref().and_then(|p| p.size), Some(18.0));

// Subsequent typing takes the pending font and size across the whole inserted run.
typ(&mut s, "world");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
for off in 6..11 {
assert_eq!(p.props_of_char(off).font.as_deref(), Some("JetBrains Mono"), "char at {off} font");
assert_eq!(p.props_of_char(off).size, Some(18.0), "char at {off} size");
}

// Moving the caret away drops pending formatting.
run(&mut s, "format.font", json!({"name": "Georgia"}));
run(&mut s, "format.size", json!({"size": 24.0}));
assert_eq!(s.pending.as_ref().and_then(|p| p.font.as_deref()), Some("Georgia"));
assert_eq!(s.pending.as_ref().and_then(|p| p.size), Some(24.0));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
assert!(s.pending.is_none());

// Collapsing a non-empty selection to its focus drops pending formatting.
run(&mut s, "format.font", json!({"name": "Georgia"}));
assert!(s.pending.is_some());
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 5) };
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5)}));
assert!(s.pending.is_none());

// No-op navigation at a document boundary (e.g. left arrow at doc start) preserves pending formatting.
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
run(&mut s, "format.font", json!({"name": "Courier New"}));
assert_eq!(s.pending.as_ref().and_then(|p| p.font.as_deref()), Some("Courier New"));
run(&mut s, "caret.left", json!({}));
assert_eq!(s.sel.focus, Pos::body(0, 0));
assert_eq!(s.pending.as_ref().and_then(|p| p.font.as_deref()), Some("Courier New"));
run(&mut s, "caret.left", json!({"extend": true}));
assert_eq!(s.sel.focus, Pos::body(0, 0));
assert_eq!(s.pending.as_ref().and_then(|p| p.font.as_deref()), Some("Courier New"));
}
12 changes: 6 additions & 6 deletions crates/ui-egui/src/ribbon/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,14 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
shown_as: warning.as_deref(),
},
) {
let _ = if f == crate::widgets::ADD_FONT {
app.run("font.add", json!({}))
if f == crate::widgets::ADD_FONT {
let _ = app.run("font.add", json!({}));
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
app.run("font.loadSystem", json!({}))
let _ = app.run("font.loadSystem", json!({}));
} else {
let f = app.previews.font_stored(&f);
app.run("format.font", json!({"name": f}))
};
crate::widgets::run_from_ribbon(app, "format.font", json!({"name": f}));
}
}
let size = st
.get("size")
Expand All @@ -83,7 +83,7 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
if let Some(v) = crate::widgets::combo_hint(ui, "size", 52.0, &size, &sizes, None, tl!("Font Size"))
&& let Ok(x) = v.trim().parse::<f64>()
{
let _ = app.run("format.size", json!({"size": x}));
crate::widgets::run_from_ribbon(app, "format.size", json!({"size": x}));
}
ui.add_space(3.0);
small(ui, app, "grow", None, "Increase Font Size", crate::widgets::ButtonAction::new("format.growFont", json!({}), false));
Expand Down
87 changes: 87 additions & 0 deletions crates/ui-egui/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8557,3 +8557,90 @@ fn mouse_wheel_scroll_speed_does_not_alter_ctrl_wheel_zoom_distance() {
.drop_without_applying_deltas();
}
}

/// #5580: selecting font or font size from the ribbon returns keyboard focus to the document
/// and preserves pending formatting for immediate typing.
#[test]
fn ribbon_font_and_size_return_focus_and_preserve_typing_font() {
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 end = a.session.doc.end_of(wordcraft_doc::StoryRef::Body);
a.run("caret.set", json!({"pos": end})).unwrap();

let mut h = harness(a);
h.run_steps(6);

let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };

// Pick "Recent Font Alpha" from the font combo via the ribbon UI
h.get(By::new().role(Role::TextInput).value("Recent Font Beta")).focus();
h.step();
assert!(!h.state().canvas.focused, "the font box took keyboard focus");

h.event(key(egui::Key::A, egui::Modifiers::COMMAND));
h.step();
h.event(egui::Event::Text("x".into()));
h.run_steps(2);
h.event(key(egui::Key::Backspace, egui::Modifiers::NONE));
h.run_steps(2);
// Down moves to "Recent Font Alpha", 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(3);

// Font combo in home.rs dispatched through run_from_ribbon:
assert!(h.state().canvas.focused, "picking a font from the ribbon returns focus to the document");
assert_eq!(h.state().session.pending.as_ref().and_then(|p| p.font.as_deref()), Some("Recent Font Alpha"));

// Pick a new size from the size combo via the ribbon UI
let size = h
.state_mut()
.session
.run("format.state", &json!({}))
.unwrap()
.get("size")
.and_then(Value::as_f64)
.map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") })
.expect("format.state reports a size");
h.get(By::new().role(Role::TextInput).value(&size)).focus();
h.step();
assert!(!h.state().canvas.focused, "the font size box took keyboard focus");

h.event(key(egui::Key::A, egui::Modifiers::COMMAND));
h.step();
h.event(egui::Event::Text("18".into()));
h.step();
h.event(key(egui::Key::Enter, egui::Modifiers::NONE));
h.run_steps(3);

assert!(h.state().canvas.focused, "committing font size returns focus to the document");
assert_eq!(h.state().session.pending.as_ref().and_then(|p| p.size), Some(18.0));

// Clicking where the caret already rests keeps pending font and size (#5580)
let before_pos = h.state().session.sel.focus.clone();
let (caret_pos, height) = canvas::caret_screen(h.state_mut()).expect("caret is on screen");
let at = caret_pos + egui::vec2(0.0, height / 2.0);
h.event(egui::Event::PointerMoved(at));
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);

assert_eq!(h.state().session.sel.focus, before_pos, "caret remains in place after click");
assert_eq!(h.state().session.pending.as_ref().and_then(|p| p.font.as_deref()), Some("Recent Font Alpha"));
assert_eq!(h.state().session.pending.as_ref().and_then(|p| p.size), Some(18.0));

// Typing immediately inherits both font and size across the whole inserted run
h.state_mut().run("text.insert", json!({"text": " extra"})).unwrap();
let p = h.state().session.doc.para_at(&wordcraft_doc::Pos::body(0, 0)).unwrap();
let base_len = UNSAVED.len();
for i in base_len..p.len() {
assert_eq!(p.props_of_char(i).font.as_deref(), Some("Recent Font Alpha"), "char at {i} has font");
assert_eq!(p.props_of_char(i).size, Some(18.0), "char at {i} has size");
}
}
3 changes: 3 additions & 0 deletions docs/gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Group connectors (gap 10): re-routing preserves displayed rotated and flipped fr

Table Sort rejects invalid explicit column selectors instead of silently sorting another column (#5531). This command-boundary correction leaves the ranked gaps and readiness estimates unchanged.

Caret formatting: clicking where the collapsed caret already rests or navigating without moving preserves pending font and size waiting for typing (#5580); ribbon font and size combo boxes return focus to the document. This typing-parity correction leaves ranked gaps and readiness estimates unchanged.

The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs
Expand Down Expand Up @@ -200,6 +202,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|---|---|---|
| 2026-11-12 | trivial | Clipboard: a copied picture is published to the system clipboard as PNG, with HTML where the host writes it, and WordCraft's own paste keeps the original fragment; a copy of only shapes stays in WordCraft (#6205). Native external-app paste remains untested. |
| 2026-11-04 | trivial | #4501 refactor regressions restored: per-section header/footer grid, ODT default font, link tooltip (#4683); no ranked gap changed; readiness estimates unchanged |
| 2026-11-08 | trivial | Caret: pending font and size kept when caret is unchanged, ribbon font and size return focus (#5580); ranked gaps unchanged |
| 2026-11-08 | trivial | #10 narrowed: a turned inline picture with no recorded effect extent keeps its line to its frame (exchanged near a quarter turn in Word 2013+ layout), cut at the line; gap stays open |
| 2026-11-08 | trivial | #4 narrowed: a cell's last Normal paragraph drops its contextual space after, the end-of-cell mark counting as a Normal paragraph (#5450) |
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
Expand Down
Loading