mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
* #1252: dim highlights on a dark page so the text on them reads On a dark page (View > Switch Modes) highlight and character-shading fills were lightness-inverted like every other colour, which leaves saturated hues such as yellow bright under white text. They are now marked as text backgrounds (Draw::Fill text_bg) and dimmed toward black keeping their hue (RenderOptions::ink_bg; yellow -> ~#4a4a10), and text drawn on them (Draw::Glyphs on_text_bg) takes the lighter of its colour and its inverted colour (RenderOptions::ink_text), so black and Automatic text draw white with WCAG contrast >= 4.5 on all 16 highlight colours. Light page, print and PDF are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1252: text on a dimmed highlight always reaches 4.5:1; lines too Glyphs and lines now carry the text background they sit on (Draw::Glyphs/Draw::Line text_bg: Option<Rgb>) instead of a bool. On a dark page RenderOptions::ink_text takes the lighter of the colour and its inversion, and falls back to white when that is under 4.5:1 against the dimmed background (pure blue on a dim olive was 1.34:1). Underlines and strikethroughs on a highlight use the same rule. The dim constants are crate-private and documented as assuming the default dark paper grey. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1252: docs: the 4.5:1 rule for text and lines on highlights Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
42c1f71b52
commit
a124d58e5b
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: custom page size, #1433; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: dark-page highlights, #1252; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -154,6 +154,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
|
||||
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
|
||||
| 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either |
|
||||
| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged |
|
||||
| 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 |
|
||||
| 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents |
|
||||
| 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents |
|
||||
@@ -321,6 +322,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
|
||||
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
|
||||
| 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) |
|
||||
| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) |
|
||||
| 2026-10-13 | trivial | Progress log: full screen on F11 (#980) |
|
||||
| 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) |
|
||||
| 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) |
|
||||
|
||||
@@ -38,11 +38,17 @@ pub enum Draw {
|
||||
/// Byte range of `text` each glyph shows (one per glyph; a ligature's range covers all
|
||||
/// its characters). Empty when unknown: the PDF writer then guesses from the font.
|
||||
ranges: Vec<std::ops::Range<usize>>,
|
||||
/// The text background (highlight or character shading) these glyphs are drawn on, if
|
||||
/// any: a dark page dims it, so it picks the text colour against the dimmed colour.
|
||||
text_bg: Option<Rgb>,
|
||||
},
|
||||
Fill {
|
||||
rect: Rect,
|
||||
color: Rgb,
|
||||
alpha: f32,
|
||||
/// A text background (highlight or character shading), which a dark page dims instead
|
||||
/// of inverting so the text on it stays readable. Other fills (paragraph, cell, page) don't.
|
||||
text_bg: bool,
|
||||
},
|
||||
Line {
|
||||
x0: f32,
|
||||
@@ -53,6 +59,9 @@ pub enum Draw {
|
||||
color: Rgb,
|
||||
stroke: Stroke,
|
||||
alpha: f32,
|
||||
/// The text background this line (an underline or strikethrough) is drawn on, as for
|
||||
/// [`Draw::Glyphs`]; `None` for every other line.
|
||||
text_bg: Option<Rgb>,
|
||||
},
|
||||
Image {
|
||||
rect: Rect,
|
||||
@@ -506,7 +515,17 @@ fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out:
|
||||
|| (mark_rev && line.end == LineEnd::Para);
|
||||
if changed {
|
||||
let top = y + (line.top - first.top);
|
||||
out.push(Draw::Line { x0: bx, y0: top, x1: bx, y1: top + line.height, width: 0.75, color, stroke: Stroke::Solid, alpha });
|
||||
out.push(Draw::Line {
|
||||
x0: bx,
|
||||
y0: top,
|
||||
x1: bx,
|
||||
y1: top + line.height,
|
||||
width: 0.75,
|
||||
color,
|
||||
stroke: Stroke::Solid,
|
||||
alpha,
|
||||
text_bg: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -533,6 +552,7 @@ fn rule(x0: f32, y0: f32, x1: f32, y1: f32, b: &Border, alpha: f32) -> Draw {
|
||||
color: b.color.unwrap_or(Rgb::BLACK),
|
||||
stroke: border_stroke(b.style),
|
||||
alpha,
|
||||
text_bg: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +574,7 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
|
||||
/// effects follow all the turns it is drawn inside.
|
||||
fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer: Spin, out: &mut Vec<Draw>) {
|
||||
match it {
|
||||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }),
|
||||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
|
||||
Placed::Image { rect, media, crop, spin, .. } => {
|
||||
spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }], out)
|
||||
@@ -637,7 +657,12 @@ fn lines(
|
||||
}
|
||||
for (x0, x1) in pl.x_spans(li, a.max(line.start), b.min(line.stop)) {
|
||||
if x1 > x0 {
|
||||
out.push(Draw::Fill { rect: Rect::new(x + x0, top, x1 - x0, line.height), color: Rgb(0xEF, 0xE3, 0xF7), alpha });
|
||||
out.push(Draw::Fill {
|
||||
rect: Rect::new(x + x0, top, x1 - x0, line.height),
|
||||
color: Rgb(0xEF, 0xE3, 0xF7),
|
||||
alpha,
|
||||
text_bg: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,7 +676,7 @@ fn lines(
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = st.rc.highlight.or(st.rc.shading) {
|
||||
out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha });
|
||||
out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha, text_bg: true });
|
||||
}
|
||||
let b = st.rc.border;
|
||||
if let Some((ob, x0, x1)) = open.as_mut()
|
||||
@@ -689,6 +714,7 @@ fn lines(
|
||||
text: run.text.clone(),
|
||||
link: None,
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -708,6 +734,7 @@ fn lines(
|
||||
text: "-".into(),
|
||||
link: None,
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
});
|
||||
}
|
||||
// Glyph runs grouped by style.
|
||||
@@ -809,6 +836,8 @@ fn lines(
|
||||
(None, Some(r)) => m.delete_color.unwrap_or_else(|| author(r)),
|
||||
_ => text_color(&rc.color, rc.shading.or(rc.highlight)),
|
||||
};
|
||||
// The highlight or shading drawn under this run (as filled in the backgrounds pass).
|
||||
let text_bg = rc.highlight.or(rc.shading);
|
||||
let ins_mark = ins.map(|_| if moved_to { InsertMark::DoubleUnderline } else { m.insert_mark });
|
||||
let del_mark = del.map(|_| if moved_from { DeleteMark::DoubleStrikethrough } else { m.delete_mark });
|
||||
if del_mark == Some(DeleteMark::Caret) {
|
||||
@@ -825,6 +854,7 @@ fn lines(
|
||||
color,
|
||||
stroke: Stroke::Solid,
|
||||
alpha,
|
||||
text_bg,
|
||||
});
|
||||
out.push(Draw::Line {
|
||||
x0: cx,
|
||||
@@ -835,6 +865,7 @@ fn lines(
|
||||
color,
|
||||
stroke: Stroke::Solid,
|
||||
alpha,
|
||||
text_bg,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -865,6 +896,7 @@ fn lines(
|
||||
text,
|
||||
link: rc.link.clone(),
|
||||
ranges,
|
||||
text_bg,
|
||||
});
|
||||
}
|
||||
// Decorations across the run (underline skips trailing spaces of the line). Right-to-left
|
||||
@@ -900,17 +932,17 @@ fn lines(
|
||||
if a < x0 - 0.01 || b > x1 + 0.01 {
|
||||
continue; // drawn with its own span
|
||||
}
|
||||
out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha });
|
||||
out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha, text_bg });
|
||||
}
|
||||
} else {
|
||||
let w = if underline == Underline::Thick { thick * 2.0 } else { thick };
|
||||
out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha });
|
||||
out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha, text_bg });
|
||||
}
|
||||
}
|
||||
if struck {
|
||||
let sy = base - st.shift - st.size * 0.28;
|
||||
let stroke = if double { Stroke::Double } else { Stroke::Solid };
|
||||
out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha });
|
||||
out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha, text_bg });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -934,6 +966,7 @@ fn lines(
|
||||
color,
|
||||
stroke: if *grammar { Stroke::Double } else { Stroke::Wave },
|
||||
alpha,
|
||||
text_bg: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -963,6 +996,7 @@ fn lines(
|
||||
text: String::new(),
|
||||
link: None,
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
});
|
||||
}
|
||||
// Inline objects.
|
||||
@@ -1037,6 +1071,7 @@ fn lines(
|
||||
text: r.text.clone(),
|
||||
link: None,
|
||||
ranges,
|
||||
text_bg: st.rc.highlight.or(st.rc.shading),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1069,6 +1104,7 @@ fn lines(
|
||||
color: Rgb(0x60, 0x60, 0x60),
|
||||
stroke: Stroke::Dotted,
|
||||
alpha,
|
||||
text_bg: None,
|
||||
});
|
||||
out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: label, color: None });
|
||||
}
|
||||
@@ -1159,7 +1195,7 @@ fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f3
|
||||
let tx = (x0 + x1 - w) / 2.0;
|
||||
for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] {
|
||||
for y in [mid - 1.2, mid + 1.2] {
|
||||
out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha });
|
||||
out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha, text_bg: None });
|
||||
}
|
||||
}
|
||||
out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() });
|
||||
@@ -1181,8 +1217,11 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &
|
||||
link: None,
|
||||
// Equation text maps back through the font (no per-glyph ranges from the math layout).
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
}),
|
||||
MItem::Rect { x: rx, y, w, h, color } => out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha }),
|
||||
MItem::Rect { x: rx, y, w, h, color } => {
|
||||
out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha, text_bg: false })
|
||||
}
|
||||
MItem::Line { x0, y0, x1, y1, width, color, dotted } => out.push(Draw::Line {
|
||||
x0: x + x0,
|
||||
y0: base - y0,
|
||||
@@ -1192,6 +1231,7 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &
|
||||
color: *color,
|
||||
stroke: if *dotted { Stroke::Dotted } else { Stroke::Solid },
|
||||
alpha,
|
||||
text_bg: None,
|
||||
}),
|
||||
MItem::ScreenOnly(inner) => {
|
||||
if screen {
|
||||
@@ -1428,6 +1468,7 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) ->
|
||||
text: text.clone(),
|
||||
link: None,
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1575,13 +1616,13 @@ pub fn upright_in_vertical(c: char) -> bool {
|
||||
fn upright_east_asian(items: Vec<Draw>) -> Vec<Draw> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for d in items {
|
||||
let Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } = d else {
|
||||
let Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, text_bg } = d else {
|
||||
out.push(d);
|
||||
continue;
|
||||
};
|
||||
let up = |k: usize| ranges.get(k).and_then(|r| text.get(r.clone())).and_then(|t| t.chars().next()).is_some_and(upright_in_vertical);
|
||||
if ranges.len() != glyphs.len() || !(0..glyphs.len()).any(up) {
|
||||
out.push(Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges });
|
||||
out.push(Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, text_bg });
|
||||
continue;
|
||||
}
|
||||
let piece = |k0: usize, k1: usize| -> Draw {
|
||||
@@ -1590,7 +1631,7 @@ fn upright_east_asian(items: Vec<Draw>) -> Vec<Draw> {
|
||||
let (b0, b1) = (rs.iter().map(|r| r.start).min().unwrap_or(0), rs.iter().map(|r| r.end).max().unwrap_or(0));
|
||||
let t = text.get(b0..b1).unwrap_or("").to_string();
|
||||
let rs = rs.iter().map(|r| r.start.saturating_sub(b0)..r.end.saturating_sub(b0)).collect();
|
||||
Draw::Glyphs { face, size, glyphs: gs, color, alpha, synth_bold, synth_italic, text: t, link: link.clone(), ranges: rs }
|
||||
Draw::Glyphs { face, size, glyphs: gs, color, alpha, synth_bold, synth_italic, text: t, link: link.clone(), ranges: rs, text_bg }
|
||||
};
|
||||
let mut k0 = 0;
|
||||
while k0 < glyphs.len() {
|
||||
|
||||
@@ -5397,6 +5397,48 @@ fn automatic_spacing_drops_between_list_items() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1252: highlight and character-shading fills are marked as text backgrounds (a dark page dims
|
||||
/// them), and so is the text drawn on them; a paragraph's shading is an ordinary fill.
|
||||
#[test]
|
||||
fn highlight_fill_is_marked_as_a_text_background() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Yellow), ..Default::default() };
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Marked", cp);
|
||||
p.props.shading = Some(wordcraft_doc::Rgb(0xDD, 0xEE, 0xFF));
|
||||
let plain = wordcraft_doc::Paragraph::with_text("Plain", Default::default());
|
||||
let mut d = Document::from_text("x");
|
||||
d.body = vec![wordcraft_doc::para_block(p), wordcraft_doc::para_block(plain)];
|
||||
let l = lay(&d);
|
||||
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default());
|
||||
let fills: Vec<(wordcraft_doc::Rgb, bool)> =
|
||||
items.iter().filter_map(|i| if let display::Draw::Fill { color, text_bg, .. } = i { Some((*color, *text_bg)) } else { None }).collect();
|
||||
assert!(fills.contains(&(wordcraft_doc::Rgb(255, 255, 0), true)), "highlight is a text background: {fills:?}");
|
||||
assert!(fills.contains(&(wordcraft_doc::Rgb(0xDD, 0xEE, 0xFF), false)), "paragraph shading is not: {fills:?}");
|
||||
let yellow = Some(wordcraft_doc::Rgb(255, 255, 0));
|
||||
let glyphs: Vec<(String, Option<wordcraft_doc::Rgb>)> = items
|
||||
.iter()
|
||||
.filter_map(|i| if let display::Draw::Glyphs { text, text_bg, .. } = i { Some((text.clone(), *text_bg)) } else { None })
|
||||
.collect();
|
||||
assert!(glyphs.iter().any(|(t, bg)| t.contains("Marked") && *bg == yellow), "{glyphs:?}");
|
||||
assert!(glyphs.iter().any(|(t, bg)| t.contains("Plain") && bg.is_none()), "{glyphs:?}");
|
||||
}
|
||||
|
||||
/// #1252 review: an underline and strikethrough on a highlighted run carry the highlight, like
|
||||
/// its glyphs; other lines (here a paragraph border) don't.
|
||||
#[test]
|
||||
fn underline_on_a_highlight_carries_the_text_background() {
|
||||
use wordcraft_doc::props::{Highlight, Underline};
|
||||
let cp =
|
||||
wordcraft_doc::CharProps { highlight: Some(Highlight::Teal), underline: Some(Underline::Single), strike: Some(true), ..Default::default() };
|
||||
let mut d = Document::from_text("x");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Marked", cp))];
|
||||
let l = lay(&d);
|
||||
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default());
|
||||
let lines: Vec<Option<wordcraft_doc::Rgb>> =
|
||||
items.iter().filter_map(|i| if let display::Draw::Line { text_bg, .. } = i { Some(*text_bg) } else { None }).collect();
|
||||
assert_eq!(lines, vec![Some(wordcraft_doc::Rgb(0, 128, 128)); 2], "underline and strikethrough");
|
||||
}
|
||||
|
||||
/// #1352 helpers: a body paragraph with a PAGE field after each piece of `pieces` (a piece may
|
||||
/// hold a page break), after `before` paragraphs that each start a new page.
|
||||
fn page_field_doc(before: usize, pieces: &[&str]) -> Document {
|
||||
|
||||
@@ -787,13 +787,13 @@ impl Exporter<'_> {
|
||||
self.draw(s, d);
|
||||
}
|
||||
}
|
||||
Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } => {
|
||||
Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, .. } => {
|
||||
self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text, ranges);
|
||||
if let Some(l) = link {
|
||||
self.link_rect(face, *size, glyphs, l);
|
||||
}
|
||||
}
|
||||
Draw::Fill { rect, color, alpha } => {
|
||||
Draw::Fill { rect, color, alpha, .. } => {
|
||||
if let Some(p) = rect_path(rect) {
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(fill(*color, *alpha)));
|
||||
@@ -801,7 +801,7 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
}
|
||||
}
|
||||
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
|
||||
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha, .. } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
|
||||
Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha),
|
||||
Draw::Path { segs, fill: f, stroke, stroke_width } => {
|
||||
let mut pb = PathBuilder::new();
|
||||
|
||||
+155
-7
@@ -374,6 +374,50 @@ impl RenderOptions {
|
||||
let lift = |v: u8| (p + v as u32 * (255 - p) / 255).min(255) as u8;
|
||||
Rgb(lift(r), lift(g), lift(b))
|
||||
}
|
||||
|
||||
/// The on-screen colour for a text background (highlight, character shading). A dark page
|
||||
/// dims it toward black, keeping its hue, instead of inverting it: inverting leaves bright
|
||||
/// hues such as yellow bright, so the white text on them can't be read (#1252). Every
|
||||
/// colour lands dark enough for white text (WCAG contrast ≥ 4.5); yellow turns a dim olive.
|
||||
pub fn ink_bg(&self, c: Rgb) -> Rgb {
|
||||
if !self.dark {
|
||||
return c;
|
||||
}
|
||||
let f = |v: u8| (DARK_TEXT_BG_FLOOR as u32 + v as u32 * DARK_TEXT_BG_SPAN as u32 / 255).min(255) as u8;
|
||||
Rgb(f(c.0), f(c.1), f(c.2))
|
||||
}
|
||||
|
||||
/// The on-screen colour for text (or its underline) drawn on the text background `bg`, if
|
||||
/// any. A dark page dims those backgrounds ([`RenderOptions::ink_bg`]), so the text takes the
|
||||
/// lighter of its colour and its inverted colour (black, explicit or Automatic, turns white;
|
||||
/// Automatic white on a dark highlight stays white), and white if that still falls short of
|
||||
/// 4.5:1 against the dimmed background (pure blue or red on a dim olive). White always
|
||||
/// reaches it: no dimmed background is lighter than #4A4A4A.
|
||||
pub fn ink_text(&self, c: Rgb, bg: Option<Rgb>) -> Rgb {
|
||||
let inked = self.ink(c);
|
||||
let Some(bg) = bg.filter(|_| self.dark) else { return inked };
|
||||
let lighter = if c.luma() > inked.luma() { c } else { inked };
|
||||
if contrast(lighter, self.ink_bg(bg)) >= 4.5 { lighter } else { Rgb::WHITE }
|
||||
}
|
||||
}
|
||||
|
||||
// The dim assumes the default dark paper grey (`DARK_PAPER`; the app never sets another
|
||||
// `dark_paper`): the backgrounds stay close to it and under #4A4A4A, so white text on them
|
||||
// reaches 4.5:1. Revisit both if the paper grey becomes configurable.
|
||||
/// Darkest channel of a text background on a dark page ([`RenderOptions::ink_bg`]).
|
||||
const DARK_TEXT_BG_FLOOR: u8 = 0x10;
|
||||
/// How far a full channel lifts a dark page's text background above the floor.
|
||||
const DARK_TEXT_BG_SPAN: u8 = 0x3A;
|
||||
|
||||
/// WCAG 2 contrast ratio between two sRGB colours (1 to 21).
|
||||
fn contrast(a: Rgb, b: Rgb) -> f32 {
|
||||
let lin = |v: u8| {
|
||||
let s = v as f32 / 255.0;
|
||||
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
let lum = |c: Rgb| 0.2126 * lin(c.0) + 0.7152 * lin(c.1) + 0.0722 * lin(c.2);
|
||||
let (la, lb) = (lum(a), lum(b));
|
||||
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
|
||||
}
|
||||
|
||||
/// Invert HSL lightness, keeping hue and saturation: shifting every channel by
|
||||
@@ -452,7 +496,7 @@ fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOpti
|
||||
}
|
||||
for it in items {
|
||||
let (color, alpha) = match it {
|
||||
Draw::Fill { rect, color, alpha } if rect.contains(p) => (*color, *alpha),
|
||||
Draw::Fill { rect, color, alpha, .. } if rect.contains(p) => (*color, *alpha),
|
||||
// The shape's own outline (an ellipse's corners are not inside it). Shape fills are
|
||||
// drawn opaque (the model has no fill transparency), so they cover what's beneath.
|
||||
Draw::Shape { rect, kind, fill: Some(fill), corner, .. }
|
||||
@@ -590,18 +634,18 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
draw(ctx, doc, d, view, visible, opts);
|
||||
}
|
||||
}
|
||||
Draw::Fill { rect, color: c, alpha } => {
|
||||
Draw::Fill { rect, color: c, alpha, text_bg } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
if !r.overlaps(*visible) {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(color(opts.ink(*c), *alpha));
|
||||
ctx.set_paint(color(if *text_bg { opts.ink_bg(*c) } else { opts.ink(*c) }, *alpha));
|
||||
ctx.fill_rect(&r);
|
||||
}
|
||||
Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha } => {
|
||||
Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha, text_bg } => {
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(color(opts.ink(*c), *alpha));
|
||||
ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha));
|
||||
let w = *width as f64;
|
||||
let mut st = kurbo::Stroke::new(w);
|
||||
match stroke {
|
||||
@@ -632,14 +676,14 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.set_stroke(st);
|
||||
line(ctx, 0.0);
|
||||
}
|
||||
Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, .. } => {
|
||||
Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, text_bg, .. } => {
|
||||
// Invisible glyphs (WordArt letters are drawn from their outlines) carry text only.
|
||||
if *alpha <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let db = FontDb::global();
|
||||
let k = *size as f64 / face.upem.max(1.0);
|
||||
ctx.set_paint(color(opts.ink(*c), *alpha));
|
||||
ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha));
|
||||
let skew = if *synth_italic { Affine::new([1.0, 0.0, -0.21, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
// Outline strokes, in font units: synthetic bold, then on-screen stem darkening (a
|
||||
// stroke grows the outline by half its width on each side). Darkening is skipped for
|
||||
@@ -1254,6 +1298,110 @@ mod tests {
|
||||
assert!(light > 30, "light {light}");
|
||||
}
|
||||
|
||||
/// A document whose only text is a yellow-highlighted run.
|
||||
fn yellow_highlight_doc() -> (Document, wordcraft_layout::DocLayout) {
|
||||
let cp = wordcraft_doc::CharProps { highlight: Some(wordcraft_doc::props::Highlight::Yellow), size: Some(24.0), ..Default::default() };
|
||||
let mut d = Document::from_text("x");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))];
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
(d, l)
|
||||
}
|
||||
|
||||
/// #1252: on a dark page a highlight is drawn dimmed (keeping its hue) so the white text on it
|
||||
/// reads; a light page is unchanged.
|
||||
#[test]
|
||||
fn dark_page_dims_a_highlight_raster() {
|
||||
let (d, l) = yellow_highlight_doc();
|
||||
let items = page_display(&d, &l.pages[0], &DisplayOptions::default());
|
||||
let r = items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Draw::Fill { rect, color, .. } if *color == Rgb(255, 255, 0) => Some(*rect),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
// Just inside the top-left corner of the highlight: above the capitals, so pure background.
|
||||
let (px, py) = ((r.x + 1.5) as u32, (r.y + 1.5) as u32);
|
||||
let light = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default());
|
||||
assert_eq!(light.pixel(px, py), [255, 255, 0, 255], "light page keeps bright yellow");
|
||||
let dark = render_page(&d, &l.pages[0], 1.0, &RenderOptions { dark: true, ..Default::default() });
|
||||
let [r8, g8, b8, _] = dark.pixel(px, py);
|
||||
assert!(r8 < 0x60 && g8 < 0x60, "dark page highlight is dim, got {:?}", (r8, g8, b8));
|
||||
assert!(r8.abs_diff(g8) <= 4 && g8 > b8.saturating_add(0x20), "dark page highlight stays yellow in hue, got {:?}", (r8, g8, b8));
|
||||
}
|
||||
|
||||
/// #1252: on a dark page every highlight colour is dimmed so the text drawn on it reads
|
||||
/// (Automatic and explicit black text both draw white); a light page is the identity.
|
||||
#[test]
|
||||
fn dark_page_text_backgrounds_keep_text_readable() {
|
||||
use wordcraft_doc::props::{Highlight, TextColor};
|
||||
use wordcraft_layout::display::text_color;
|
||||
let dark = RenderOptions { dark: true, ..Default::default() };
|
||||
let light = RenderOptions::default();
|
||||
let colours: Vec<Rgb> = Highlight::ALL.iter().filter_map(|h| h.rgb()).collect();
|
||||
assert_eq!(colours.len(), 16);
|
||||
for c in colours {
|
||||
let bg = dark.ink_bg(c);
|
||||
for (what, tc) in [("automatic", text_color(&TextColor::Auto, Some(c))), ("black", Rgb::BLACK)] {
|
||||
let fg = dark.ink_text(tc, Some(c));
|
||||
assert_eq!(fg, Rgb::WHITE, "{what} text on {c:?} draws white");
|
||||
let k = contrast(fg, bg);
|
||||
assert!(k >= 4.5, "{what} text on {c:?}: {fg:?} on {bg:?} has contrast {k:.2}");
|
||||
}
|
||||
assert_eq!(light.ink_bg(c), c, "light page keeps {c:?}");
|
||||
assert_eq!(light.ink_text(c, Some(c)), c, "light page keeps text {c:?}");
|
||||
}
|
||||
// Yellow turns a dim olive (~#4a4a10), still yellow in hue.
|
||||
let Rgb(r, g, b) = dark.ink_bg(Rgb(255, 255, 0));
|
||||
assert!(r.abs_diff(0x4A) <= 8 && g.abs_diff(0x4A) <= 8 && b.abs_diff(0x10) <= 8, "yellow → {:?}", (r, g, b));
|
||||
assert!(r == g && g > b);
|
||||
// Text off a highlight keeps the plain inversion.
|
||||
assert_eq!(dark.ink_text(Rgb::WHITE, None), dark.ink(Rgb::WHITE));
|
||||
}
|
||||
|
||||
/// #1252 review: explicit coloured text (pure blue, red, every highlight colour) on every
|
||||
/// dimmed highlight still reaches 4.5:1 on a dark page.
|
||||
#[test]
|
||||
fn dark_page_coloured_text_on_highlights_reads() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
let dark = RenderOptions { dark: true, ..Default::default() };
|
||||
let colours: Vec<Rgb> = Highlight::ALL.iter().filter_map(|h| h.rgb()).collect();
|
||||
let texts: Vec<Rgb> = [Rgb(0, 0, 255), Rgb(255, 0, 0)].into_iter().chain(colours.iter().copied()).collect();
|
||||
for &h in &colours {
|
||||
let bg = dark.ink_bg(h);
|
||||
for &tc in &texts {
|
||||
let fg = dark.ink_text(tc, Some(h));
|
||||
let k = contrast(fg, bg);
|
||||
assert!(k >= 4.5, "text {tc:?} on {h:?}: {fg:?} on {bg:?} has contrast {k:.2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1252 review: an Automatic underline on a dark highlight (black) draws light on a dark page,
|
||||
/// like the text it underlines, not the dark grey plain inversion gives white.
|
||||
#[test]
|
||||
fn dark_page_underline_on_a_dark_highlight_reads() {
|
||||
use wordcraft_doc::props::{Highlight, Underline};
|
||||
let cp =
|
||||
wordcraft_doc::CharProps { highlight: Some(Highlight::Black), underline: Some(Underline::Thick), size: Some(24.0), ..Default::default() };
|
||||
let mut d = Document::from_text("x");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))];
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let items = page_display(&d, &l.pages[0], &DisplayOptions::default());
|
||||
let (x0, x1, y, w) = items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Draw::Line { x0, x1, y0, width, .. } => Some((*x0, *x1, *y0, *width)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(w >= 1.5, "thick underline {w}");
|
||||
let (px, py) = (((x0 + x1) / 2.0) as u32, y.round() as u32);
|
||||
let dark = render_page(&d, &l.pages[0], 4.0, &RenderOptions { dark: true, ..Default::default() });
|
||||
let [r8, g8, b8, _] = dark.pixel(px * 4, py * 4);
|
||||
assert!(r8 > 0xC0 && g8 > 0xC0 && b8 > 0xC0, "underline draws light, got {:?}", (r8, g8, b8));
|
||||
}
|
||||
|
||||
/// One EMF record: type, size, payload (a multiple of 4 bytes).
|
||||
fn emf_rec(typ: u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = typ.to_le_bytes().to_vec();
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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-14 · **Change:** trivial (Page Setup paper size and Layout › Size › More Paper Sizes…, #1433; previously trivial: dark page dims highlights, #1252; previously trivial: full screen on F11, #980; previously trivial: File dialogs row: Save As lists each format by name, #1399; 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
|
||||
@@ -35,6 +35,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged | reading-optimized view and navigation | 75% | 1–2 |
|
||||
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 85% | — |
|
||||
| Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299) | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 |
|
||||
| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252) | ✅ | ✅ | 90% | — |
|
||||
| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — |
|
||||
| Interface size (File › Options › General: Auto, which keeps the system display scale and shows the size it draws at, or an absolute 75–300 %, so "100%" is one screen pixel per point; `ui.interfaceSize`) (#475, #822). A size from before #822 (on top of the display scale) becomes the absolute size it drew at. On the web the browser's zoom is part of the display scale, so a chosen size cancels browser zoom (Ctrl+plus/minus no longer resize the interface); Auto follows it | ✅ | system display scaling only | 100% | — |
|
||||
| Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 |
|
||||
@@ -53,6 +54,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) |
|
||||
| 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only |
|
||||
| 2026-10-14 | trivial | Page Setup: a Paper group with Paper size (the named sizes, the one the section has in either orientation, or Custom size) and Width and Height in the measurement unit; a wider-than-tall size turns the orientation to landscape; a size the margins don't fit keeps the dialog open with a message and applies nothing. Layout › Size ends with More Paper Sizes…, which opens it (#1433) |
|
||||
| 2026-10-13 | trivial | Dark page (View › Switch Modes): highlights and character shading are dimmed toward black, keeping their hue (yellow turns a dim olive), instead of having their lightness inverted, which left yellow bright under white text; text on them draws light (white for black and Automatic), coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule. Light page, print and exports unchanged. Unlike Word, Automatic text stays white on the dark page (#1252) |
|
||||
| 2026-10-13 | trivial | Full screen (#980): View › Window › Full Screen and F11 (⌃⌘F on macOS), Escape leaves it once nothing else takes it (a selection collapses first); Next Field moves to Alt+F1; keyboard counts re-measured (152 commands, 172 combinations) |
|
||||
| 2026-10-14 | trivial | File dialogs: the system Save As type list names each format, the document's own first (#1399) |
|
||||
| 2026-10-11 | trivial | Tables on the page: Ctrl shares a column border's change among the columns right of it, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); 80% → 90% |
|
||||
|
||||
Reference in New Issue
Block a user