Merge pull request #3197 from vangel76/ui-text-hinting

Dark-theme interface text on Linux no longer looks semi-bold
This commit is contained in:
FredApps authored and GitHub committed 2026-11-08 17:55:10 +01:00
commit c5fe6c6816
18 files changed
+493 -31

No files matched your search

+5 -1
View File
@@ -8,7 +8,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-11-08 · **Change:** trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including baseLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Line truncated
>
> **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged.
>
@@ -445,6 +445,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-19 | A picture, shape or text box dragged near the bottom of a page lands where it is dropped. It used to jump far below the page, cut off at its edge with only its selection box showing over the next page (#3169) |
| 2026-10-19 | Insert Caption picks the label used last, as Word does, and keeps it between runs (#3120). |
| 2026-10-20 | Pinned layout test fonts also initialize before the new caret-height and ligature fixtures, keeping isolated test runs independent of system fonts (#2729). |
| 2026-10-19 | Dark-theme interface text on Linux and the BSDs no longer looks semi-bold: egui thickened light-on-dark glyph edges by default (about 21 % more ink than Firefox draws the same text); it now adds a light 7 %, chosen side by side on a 145 % desktop (Firefox's plain weight looked soft on the dark tab row). Light-theme interface text was measured against headless Firefox and already matches it (Inter's ttfautohint hints draw as FreeType's light autohinting does). Headless screenshots take a scale (`{"ppp": 1.45}` in `ui_shot`) to see text as a fractional desktop scale draws it. |
| 2026-10-19 | Page text on Linux and the BSDs is no longer soft: it is hinted the way FreeType's `hintslight` draws text in Firefox and other apps there, its autohinter putting baselines, x-heights and cap heights on whole pixels without moving anything sideways, so lines break as before. Measured against headless Firefox on Arial at 21.27 px: the same rows of pixels and the same weight. Re-rendering costs the same; the first render at a new zoom about 1.5 ms more per page. macOS, Windows and exports are unchanged. |
| 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. |
| 2026-10-20 | Russian sorting keeps ё with е and recognises decomposed ё/й: case-insensitive words and index headings share the corrected collation, with regressions and no new dependencies. |
| 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). |
@@ -1078,6 +1080,8 @@ MATH glyphs that reference the same construction now share its immutable variant
| 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) |
| 2026-10-19 | trivial | Progress log: a picture dragged near the bottom of a page lands there (#3169) |
| 2026-10-19 | trivial | Progress log: a Thai run with Latin finds one installed font covering it all on Linux too (#1549) |
| 2026-10-19 | trivial | Progress log: dark-theme interface text on Linux no longer looks semi-bold |
| 2026-10-19 | trivial | Progress log: page text on Linux is hinted like Firefox draws text there |
| 2026-10-20 | trivial | Progress log: Times and Courier take their Windows counterparts' line spacing |
| 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control |
| 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised |
+11 -3
View File
@@ -7,7 +7,9 @@ use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{Location, LocationRef, Size};
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
use skrifa::outline::{
DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target,
};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
@@ -15,8 +17,6 @@ use skrifa::{GlyphId, MetadataProvider};
pub(crate) mod user;
pub use user::{FONT_FILE_EXTS, FontFormat, Installed, MAX_FONT_FILE_BYTES, UserFont, UserFontError, decode_font_file, is_font_file_name, sniff};
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
mod catalog;
mod face;
mod fallback;
@@ -41,10 +41,16 @@ use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
/// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted.
hinted: Mutex<HashMap<(u32, u32, u32), Option<Arc<BezPath>>>>,
/// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted.
hinters: Mutex<HashMap<(u32, u32), Option<Arc<HintingInstance>>>>,
/// Fallback faces found per (character, excluded face), valid while the face count is the
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
/// search sorts every loaded face, so without this text in a script the requested font lacks
@@ -110,6 +116,8 @@ impl FontDb {
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
hinted: Mutex::new(HashMap::new()),
hinters: Mutex::new(HashMap::new()),
fallbacks: RwLock::new(FallbackCache::default()),
run_fallbacks: RwLock::new(RunFallbackCache::default()),
#[cfg(not(target_arch = "wasm32"))]
+47 -4
View File
@@ -1,7 +1,7 @@
use super::names::{LocalName, east_asian_font};
use super::{
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider,
Ordering, OutlinePen, RwLock, Size, Target,
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef,
MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target,
};
/// The family used when a requested family is unknown (the UI sans).
@@ -77,6 +77,8 @@ pub struct FontFace {
pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
// Some legacy CJK fonts assemble their contours with TrueType instructions.
pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
/// The autohinter's analysis of the font for screen hinting, made once for every size.
pub(super) glyph_styles: std::sync::OnceLock<GlyphStyles>,
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
/// ([`FontFace::count_hinted`]).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -288,6 +290,42 @@ impl FontFace {
})
.as_ref()
}
/// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device
/// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light,
/// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto
/// whole pixels, nothing moves sideways, so advances and layout stay as they are.
pub(super) fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option<BezPath> {
let f = self.skrifa()?;
let g = f.outline_glyphs().get(GlyphId::new(gid))?;
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {:?}: {e}", self.family)).ok()?;
Some(pen.path)
}
/// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light
/// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored;
/// measured against Firefox on Linux, the autohinter matches its rows of pixels and the
/// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines, and for
/// fonts whose glyphs need their instructions to be assembled at all their instructions with
/// the monochrome target, as skrifa requires for them.
pub(super) fn screen_hinter(&self, ppem: f32) -> Option<HintingInstance> {
let f = self.skrifa()?;
let outlines = f.outline_glyphs();
let light = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let (engine, target) = if outlines.require_interpreter() {
// FreeType's "tricky" fonts (old CJK fonts that assemble glyphs with their
// instructions): skrifa asks for the interpreter with the monochrome target, under
// which the instructions may move points sideways, or glyphs come apart.
(Engine::Interpreter, Target::Mono)
} else if outlines.format() == Some(OutlineGlyphFormat::Glyf) {
(Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())), light)
} else {
(Engine::Interpreter, light)
};
HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target })
.map_err(|e| log::warn!("Cannot hint {:?} at {ppem} px: {e}", self.family))
.ok()
}
/// Is `family` this face's typographic, legacy, full or PostScript name?
pub fn named(&self, family: &str) -> bool {
self.family.eq_ignore_ascii_case(family)
@@ -358,13 +396,18 @@ impl FontFace {
pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
pub(super) const OUTLINE_CACHE_MAX: usize = 50_000;
/// Hinting instances kept (one per face and text size on screen).
pub(super) const HINTER_CACHE_MAX: usize = 256;
/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the
/// half-pixel hinting moves is invisible (and each size would cost a cache entry).
pub(super) const HINT_PPEM: std::ops::RangeInclusive<f32> = 4.0..=96.0;
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
/// had to drop one).
pub(super) struct FlipPen {
pub(super) path: BezPath,
room: usize,
over: bool,
pub(super) room: usize,
pub(super) over: bool,
/// Drawn with the face's TrueType interpreter.
pub(super) hinted: bool,
}
+38 -1
View File
@@ -1,4 +1,4 @@
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX};
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX};
#[cfg(not(target_arch = "wasm32"))]
use super::fallback_data::read_fallback_file;
#[cfg(not(target_arch = "wasm32"))]
@@ -227,4 +227,41 @@ impl FontDb {
cache.insert(key, p.clone());
p
}
/// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in
/// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph,
/// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`].
pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option<Arc<BezPath>> {
if !HINT_PPEM.contains(&ppem) {
return None;
}
let key = (face.id, gid, ppem.to_bits());
if let Some(p) = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let hinter = {
let hkey = (face.id, ppem.to_bits());
let known = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&hkey).cloned();
match known {
Some(h) => h,
None => {
// Built outside the lock: running a font's hinting programs takes a while.
let h = face.screen_hinter(ppem).map(Arc::new);
let mut cache = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= HINTER_CACHE_MAX {
cache.clear();
}
cache.insert(hkey, h.clone());
h
}
}
};
let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new);
let mut cache = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p
}
}
+1
View File
@@ -394,6 +394,7 @@ pub(super) fn make_face(
index,
bmp: std::sync::OnceLock::new(),
outline_hinter: std::sync::OnceLock::new(),
glyph_styles: std::sync::OnceLock::new(),
hint_draws: Default::default(),
hint_slow: Default::default(),
hinting_off: Default::default(),
+62
View File
@@ -38,6 +38,7 @@ fn kai_contours_include_complete_character_body() {
}
}
use super::face::FlipPen;
use super::*;
#[test]
@@ -92,6 +93,67 @@ fn run_fallback_is_cached_per_char_set() {
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
}
#[test]
fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
// 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens.
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted");
let plain = db.outline(&face, x);
let k = f64::from(ppem) / face.upem;
let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain));
// The x-height (the top, y-down) and the baseline sit on the pixel grid…
assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0);
assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1);
assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k);
// …and the sides stay where the font puts them, so advances and line breaks don't change.
assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k));
// Cached per size.
assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached")));
}
#[test]
fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() {
// FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on
// TrueType outlines and ignores their instructions: measured against Firefox, the
// instructions drew Arial's rows elsewhere and heavier.
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let drawn = |engine: Engine, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
let mut differs = false;
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
let ours = db.hinted_outline(&face, gid, ppem).unwrap();
assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline");
differs |= *ours != drawn(Engine::Interpreter, gid);
}
assert!(differs, "the font's own instructions would draw it differently");
}
#[test]
fn screen_hinting_refuses_hostile_sizes() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] {
assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}");
}
// A glyph id past the font's glyphs has no outline to hint.
assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty()));
}
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
+34
View File
@@ -2,6 +2,7 @@
#[allow(unused_imports)]
use std::path::{Path, PathBuf};
use crate::fontdb::face::FlipPen;
use crate::fontdb::*;
const FAMILY: &str = "Sysfont Sans3";
@@ -420,6 +421,39 @@ fn a_family_partly_loaded_by_a_character_fallback_is_loaded_whole_for_a_run() {
assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3);
}
#[test]
fn screen_hinting_runs_a_tricky_fonts_instructions_with_the_monochrome_target() {
// FreeType's "tricky" fonts (old CJK fonts listed by name, such as DynaLab's "cpop") assemble
// their glyphs with their instructions; skrifa requires the interpreter with `Target::Mono`
// for them. A bundled font renamed into the list stands in for one.
let dir = font_dir("tricky");
std::fs::write(dir.join("Tricky.ttf"), renamed_to("SourceSans3-Regular.ttf", "Sysfont cpop1")).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
let face = db.face("Sysfont cpop1", "Regular");
assert_eq!(face.family, "Sysfont cpop1");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.require_interpreter(), "the name makes it tricky");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let drawn = |target: Target, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine: Engine::Interpreter, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
// The target decides whether the instructions may move points sideways (the light target's
// backward compatibility forbids it): this font's instructions only move points vertically,
// so the target itself is checked, and the outlines drawn with it.
assert!(matches!(face.screen_hinter(ppem).unwrap().target(), Target::Mono));
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
assert_eq!(*db.hinted_outline(&face, gid, ppem).unwrap(), drawn(Target::Mono, gid), "{c}: the monochrome target");
}
// Any other font keeps the light target.
let plain = db.face(FALLBACK_FAMILY, "Regular");
assert!(matches!(plain.screen_hinter(ppem).unwrap().target(), Target::Smooth { mode: SmoothMode::Light, preserve_linear_metrics: true, .. }));
}
#[test]
fn coverage_ranges_hold_exactly_the_mapped_characters() {
let data = renamed("SourceSans3-Regular.ttf");
+1
View File
@@ -35,6 +35,7 @@ fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) ->
#[test]
fn eq_array_rows_without_ampersand_are_centred() {
crate::test_fonts();
// Word centres a row with no `&` in the array (#2617: a case row "a, n odd" above
// "|a|, &n even"), whether or not the other rows have one.
use wordcraft_doc::math::*;
+1
View File
@@ -7845,6 +7845,7 @@ fn graphic_text_in_a_degenerate_rectangle_is_not_drawn() {
#[test]
fn chart_text_the_font_lacks_is_drawn_in_a_fallback_face() {
crate::test_fonts();
// #4720: a Chinese legend in a chart drawn in the body font (which has no Chinese) showed boxes.
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign};
use wordcraft_doc::props::Rgb;
+41 -1
View File
@@ -163,6 +163,11 @@ pub struct RenderOptions {
/// Thicken glyphs slightly the way macOS draws text on screen ("font smoothing"), so pages
/// look like they do in other Mac apps. For on-screen rasters only: exports stay exact.
pub text_darkening: bool,
/// Hint text for the screen the way FreeType's `hintslight` does on Linux desktops: glyph
/// baselines sit on whole pixels and their horizontal edges (x-height, cap height) are moved
/// onto the pixel grid, so text looks as crisp as in other apps there. Nothing moves
/// sideways, so lines break as without it. For on-screen rasters only: exports stay exact.
pub hinting: bool,
/// Dark page (View › Switch Modes): every colour but pictures has its lightness inverted, so
/// white paper turns black and black text white while hues stay the same. Screen only.
pub dark: bool,
@@ -265,6 +270,7 @@ impl Default for RenderOptions {
paper: Rgb::WHITE,
mark_color: Rgb(0x2B, 0x57, 0x9A),
text_darkening: false,
hinting: false,
dark: false,
dark_paper: DARK_PAPER,
read_paper: None,
@@ -491,6 +497,13 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc
}
}
/// Does `view` map page points to device pixels without rotation, skew or mirroring, scaling both
/// axes alike? Only then do hinted outlines (made on the pixel grid) line up with the screen.
fn is_upright(view: Affine) -> bool {
let [a, b, c, d, e, f] = view.as_coeffs();
[a, b, c, d, e, f].iter().all(|v| v.is_finite()) && b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= a * 1e-6
}
/// How far, in device pixels, macOS-style font smoothing pushes each side of a glyph outline out
/// at `ppem` device pixels per em. Values from Pathfinder (MIT/Apache-2.0), which measured them
/// against macOS: 1.21% of the pixel size horizontally and 1.25 times that vertically, each
@@ -729,13 +742,19 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let darken_px = if opts.text_darkening && *alpha >= 1.0 { stem_darkening(*size as f64 * px_per_pt) } else { 0.0 };
let darken = if darken_px > 0.0 && k * px_per_pt > 0.0 { 2.0 * darken_px / (k * px_per_pt) } else { 0.0 };
let outline_stroke = if *synth_bold { face.upem * 0.03 } else { 0.0 } + darken;
let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter };
if outline_stroke > 0.0 {
let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter };
ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join));
}
// Emoji and other colour glyphs (#1269) are drawn from the font's colour data.
let colour = face.has_color_glyphs();
let ppem = *size as f64 * px_per_pt;
// Hinted outlines are in device pixels: only for an upright, unmirrored, evenly scaled
// view (not rotated text boxes), where the pixel grid lines up with the glyphs.
let hint_ppem = (opts.hinting && !colour && is_upright(view)).then_some(ppem as f32);
// The outline strokes in device pixels, for the hinted outlines.
let stroke_px = outline_stroke * k * px_per_pt;
let mut hinted_stroke = false;
for (gid, x, y) in glyphs {
let (gx, gy) = (*x as f64, *y as f64);
// Written so a NaN position is skipped too.
@@ -749,6 +768,23 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
ctx.set_paint(color(opts.ink(*c), *alpha));
continue;
}
if let Some(o) = hint_ppem.and_then(|p| db.hinted_outline(face, *gid, p)) {
if o.elements().is_empty() {
continue;
}
// The baseline on a whole pixel, as the hinting assumes.
let at = view * kurbo::Point::new(gx, gy);
ctx.set_transform(Affine::translate((at.x, at.y.round())) * skew);
ctx.fill_path(&o);
if outline_stroke > 0.0 {
if !hinted_stroke {
ctx.set_stroke(kurbo::Stroke::new(stroke_px).with_join(join));
hinted_stroke = true;
}
ctx.stroke_path(&o);
}
continue;
}
let o = db.outline(face, *gid);
if o.elements().is_empty() {
continue;
@@ -756,6 +792,10 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
ctx.set_transform(view * Affine::translate((gx, gy)) * skew * Affine::scale(k));
ctx.fill_path(&o);
if outline_stroke > 0.0 {
if hinted_stroke {
ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join));
hinted_stroke = false;
}
ctx.stroke_path(&o);
}
}
+55
View File
@@ -290,6 +290,61 @@ fn text_darkening_adds_ink_on_screen_only() {
assert!(!RenderOptions::default().text_darkening);
}
/// Inked pixels that are only partly covered (grey): an edge between two pixel rows.
fn grey(img: &Rendered) -> usize {
img.pixels.as_chunks::<4>().0.iter().filter(|p| (40..215).contains(&p[0])).count()
}
/// The first and last pixel columns with any ink.
fn ink_columns(img: &Rendered) -> (usize, usize) {
let w = img.width as usize;
let inked: Vec<usize> = img.pixels.as_chunks::<4>().0.iter().enumerate().filter(|(_, p)| p[0] < 250).map(|(i, _)| i % w.max(1)).collect();
(inked.iter().copied().min().unwrap_or(0), inked.iter().copied().max().unwrap_or(0))
}
#[test]
fn hinting_sharpens_screen_text_without_making_it_bolder_or_moving_it() {
let d = Document::from_text("Every Thursday evening the old print shop fills with people who make things. Painters set up by the north windows.");
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
// 100 % zoom on a 145 % desktop: glyph edges land between pixels.
let scale = 96.0 / 72.0 * 1.45;
let plain = render_page(&d, &l.pages[0], scale, &RenderOptions::default());
let hinted = render_page(&d, &l.pages[0], scale, &RenderOptions { hinting: true, ..Default::default() });
// Fewer grey pixels: the baseline, x-height and cap height fill whole pixel rows. Only
// horizontal edges snap (the vertical stems stay put), so on the fonts the tests resolve
// to (system or bundled) the drop is 4 to 5 %: asked for at least 3 %.
let (g0, g1) = (grey(&plain), grey(&hinted));
assert!((g1 as f64) < g0 as f64 * 0.97, "grey pixels: plain {g0}, hinted {g1}");
// The same weight, as FreeType's light hinting draws it: no darker than a few percent.
let (a, b) = (ink(&plain), ink(&hinted));
assert!(b as f64 > a as f64 * 0.95 && (b as f64) < a as f64 * 1.12, "ink: plain {a}, hinted {b}");
// Nothing moves sideways: the text starts and ends in the same pixel columns.
let ((l0, r0), (l1, r1)) = (ink_columns(&plain), ink_columns(&hinted));
assert!(l0.abs_diff(l1) <= 1 && r0.abs_diff(r1) <= 1, "plain {l0}..{r0}, hinted {l1}..{r1}");
// Off by default: exports and thumbnails are untouched.
assert!(!RenderOptions::default().hinting);
}
#[test]
fn rotated_or_mirrored_views_are_not_hinted() {
let d = Document::from_text("Rotated text stays as the font draws it.");
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let hint = RenderOptions { hinting: true, ..Default::default() };
for view in [
Affine::translate((300.0, -50.0)) * Affine::rotate(0.3) * Affine::scale(1.9),
Affine::translate((600.0, 0.0)) * Affine::scale_non_uniform(-1.9, 1.9),
Affine::scale_non_uniform(1.9, 2.4),
] {
let plain = render_region(&d, &l.pages[0], 600, 300, view, &RenderOptions::default());
let hinted = render_region(&d, &l.pages[0], 600, 300, view, &hint);
assert!(ink(&plain) > 0, "{view:?}");
assert_eq!(plain.pixels, hinted.pixels, "{view:?}");
}
assert!(is_upright(Affine::translate((3.5, 7.25)) * Affine::scale(1.93)));
assert!(!is_upright(Affine::scale(f64::NAN)));
assert!(!is_upright(Affine::scale(0.0)));
}
#[test]
fn page_picture_is_painted_behind_the_text() {
let mut png = Vec::new();
+4 -1
View File
@@ -9,6 +9,8 @@
//! background work such as a Zotero session). The window is 1440×900 pt at 2× and opens the sample
//! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}`
//! starts the way the desktop app does with nothing to open (File › Home, #766). A line
//! `{"width": w}`, `{"height": h}` or `{"ppp": s}` sets the window size in points or its pixels
//! per point (e.g. 1.45 for a 145 % desktop, to see text as it is drawn there).
//! `{"demoPacks": true}` lists a few sample Language packs (installs only record themselves), and
//! (with `{"network": true}` on a line, downloading is already on) `{"demoPacks": "store"}` uses a real font store, as the desktop app does, in a new temporary
//! folder rather than the person's, or in `"storeDir"` given on the same line (with
@@ -57,8 +59,9 @@ fn main() {
}
let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32;
let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32;
let ppp = lines.iter().find_map(|l| l.get("ppp").and_then(|v| v.as_f64())).map(|p| p.clamp(0.5, 4.0)).unwrap_or(2.0) as f32;
let mut harness =
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state(
egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(ppp).with_max_steps(1_000_000).wgpu().build_ui_state(
|ui, app: &mut WordApp| {
if !READY.load(std::sync::atomic::Ordering::Relaxed) {
return;
+31 -5
View File
@@ -168,6 +168,8 @@ pub struct CanvasState {
pub viewed_page: Option<usize>,
/// Effective scale (screen points per document point).
pub scale: f32,
/// Whether this frame's pages were drawn hinted ([`hint_pages`]).
pub(crate) pages_hinted: bool,
pub canvas_rect: Option<Rect>,
pub focused: bool,
/// The page's widget id and the pass that last drew it, so a click elsewhere can hand the
@@ -402,6 +404,7 @@ impl Default for CanvasState {
scrollbar_page: None,
viewed_page: None,
scale: PX_PER_PT,
pages_hinted: screen_render_options().hinting,
canvas_rect: None,
focused: false,
widget_id: None,
@@ -526,6 +529,8 @@ struct CanvasView {
vruler: Option<Rect>,
paper: Color32,
animate: bool,
/// Whether this frame draws the pages hinted ([`hint_pages`]).
hinted: bool,
opts: egui::InputOptions,
geo: Geometry,
dim_body: bool,
@@ -580,9 +585,12 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C
if read_mode {
app.canvas.scroll_max = (geo.size - area.size()).max(egui::Vec2::ZERO);
}
if (geo.scale - app.canvas.scale).abs() > 1e-6 {
let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-6;
if rescaled {
ui.ctx().request_repaint();
}
let hinted = hint_pages(zoom_delta, rescaled);
app.canvas.pages_hinted = hinted;
// A zoom moves the scroll offset without anyone scrolling: not passed on to a partner window.
let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-4;
app.canvas.scale = geo.scale;
@@ -612,7 +620,7 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C
{
app.session.page_hint = c.page;
}
CanvasView { area, hruler, vruler, paper, animate, opts, geo, dim_body, grid_story, caret, rescaled }
CanvasView { area, hruler, vruler, paper, animate, hinted, opts, geo, dim_body, grid_story, caret, rescaled }
}
fn update_caret_scroll(app: &mut WordApp, view: &CanvasView) -> Option<Rect> {
@@ -912,6 +920,7 @@ struct PagePaint<'a> {
paper: Color32,
hidden: Option<(usize, Pos)>,
view: u64,
hinted: bool,
content_origin: Pos2,
ppp: f32,
max_tex: f32,
@@ -924,6 +933,20 @@ struct PagePaintOutput {
rendered: usize,
}
/// Are pages drawn hinted this frame (as [`screen_render_options`] says)? Not while a pointer
/// zoom (Ctrl/⌘+wheel easing in, a pinch: `zoom_delta` other than 1) changes the scale
/// (`rescaled`): each such frame is a new size shown for a moment, and hinting every one would
/// cost each page about 1.5 ms more and fill the hint caches with sizes nobody rests on, pushing
/// out the one the zoom ends at. The first frame at rest draws the page hinted (the hinting is in
/// the page's key). A zoom that leaves the scale as it is (at 10 % or 500 %, or over the ribbon)
/// keeps the pages hinted, and other scale changes (the Zoom dialog or slider, a fit zoom
/// following the window) are drawn hinted at once.
fn hint_pages(zoom_delta: f32, rescaled: bool) -> bool {
let gesture = zoom_delta.is_nan() || (zoom_delta - 1.0).abs() > 1e-3;
let zooming = gesture && rescaled;
screen_render_options().hinting && !zooming
}
fn draw_page_gridlines(app: &WordApp, painter: &egui::Painter, t: &Tokens, page: &Page, visual_sr: Rect, page_scale: f32) {
// View › Gridlines: the drawing grid over the text area (Print Layout, on screen only).
if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode {
@@ -956,7 +979,7 @@ fn draw_page_gridlines(app: &WordApp, painter: &egui::Painter, t: &Tokens, page:
}
fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput {
let PagePaint { app, ui, layout, geo, t, painter, dim_body, paper, hidden, view, content_origin, ppp, max_tex, grid_story } = pass;
let PagePaint { app, ui, layout, geo, t, painter, dim_body, paper, hidden, view, hinted, content_origin, ppp, max_tex, grid_story } = pass;
let mut visible_tile_keys = HashSet::new();
let mut rendered = 0;
let mut rects = Vec::with_capacity(geo.rects.len());
@@ -985,6 +1008,7 @@ fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput {
// edit) then sits on the pixel grid in this frame too, rather than a frame later.
if visible && !tiled && !fresh && (forced || rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
let mut opts = page_render_options(app, dim_body);
opts.hinting &= hinted;
// Only on the page it is on: the `Pos` can't match an object of another page.
opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone());
let img = wordcraft_render::render_page(page_doc(app), page, scale_px, &opts);
@@ -1038,6 +1062,7 @@ fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput {
let fresh = app.canvas.tiles.get(&id).is_some_and(|(k, _)| *k == key);
if !fresh && rendered < 4 {
let mut opts = page_render_options(app, dim_body);
opts.hinting &= hinted;
// Only on the page it is on: the `Pos` can't match an object of another page.
opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone());
// The tiny subtraction avoids a 1px overshoot from round-tripping f32
@@ -1446,7 +1471,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) };
let motion = scroll_motion(app, ui, full, &view);
let (scroll_area, scroll_base) = configure_scroll_area(app, ui, &view, motion);
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, rescaled, .. } = view;
let CanvasView { area, hruler, vruler, paper, hinted, geo, dim_body, grid_story, caret, rescaled, .. } = view;
let mut origin = area.min;
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
@@ -1468,7 +1493,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let max_tex = ui.ctx().input(|i| i.max_texture_side).min(wordcraft_render::MAX_SIDE as usize).max(1) as f32;
let content_origin = ui.min_rect().min;
let hidden = app.canvas.dragged_off_page();
let page_view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p));
let page_view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p), hinted);
let t0 = crate::now_ms();
let PagePaintOutput { rects, visible_tile_keys, rendered } = paint_pages(PagePaint {
app,
@@ -1481,6 +1506,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
paper,
hidden,
view: page_view,
hinted,
content_origin,
ppp,
max_tex,
+13 -4
View File
@@ -4,8 +4,10 @@ use super::{DocLayout, Hash, Hasher, Page, Placed, Pos, Pos2, Rect, StoryRef, Wo
/// marks and markup, dark mode, the page colour and watermark, header/body dimming, the media,
/// and (while a drag lasts) the object left out of the page.
/// Taken once per frame.
pub(super) fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 {
pub(super) fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>, hinted: bool) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
// A page drawn unhinted mid-zoom must not pass for the hinted page (see [`hint_pages`]).
hinted.hash(&mut h);
let v = &app.session.view;
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
(v.read_mode, v.read_page_color, app.session.prefs.paper_color).hash(&mut h);
@@ -263,8 +265,15 @@ pub(super) fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_re
opts
}
/// Render options for page rasters shown on screen: on macOS, text is darkened the way the system
/// draws it, so a page looks as it does in other Mac apps (exports never are).
/// Render options for page rasters shown on screen, so a page looks as text does in the
/// platform's other apps (exports never change): on macOS, text is darkened the way the system
/// draws it; on Linux and the BSDs it is hinted lightly, as FreeType's `hintslight` draws it
/// there (not darkened: hinted text already has FreeType's weight, and darkening made it
/// look semi-bold).
pub(crate) fn screen_render_options() -> wordcraft_render::RenderOptions {
wordcraft_render::RenderOptions { text_darkening: cfg!(target_os = "macos"), ..Default::default() }
wordcraft_render::RenderOptions {
text_darkening: cfg!(target_os = "macos"),
hinting: cfg!(all(unix, not(target_os = "macos"))),
..Default::default()
}
}
+28 -10
View File
@@ -345,7 +345,7 @@ fn caret_contrasts_with_what_lies_under_it() {
let at = |a: &mut WordApp, off: usize| {
let layout = a.session.layout();
let c = layout.caret_on(&Pos::body(0, off), 0).unwrap();
let view = view_key(a, false, None);
let view = view_key(a, false, None, true);
caret_color_at(a, &layout, c.page, c.x, c.top + c.height / 2.0, view, false)
};
assert_eq!(at(&mut a, 2), Color32::BLACK, "white paper");
@@ -372,19 +372,19 @@ fn read_mode_page_color_sets_the_reading_paper() {
a.run("design.pageColor", json!({"color": "#102040"})).unwrap();
let paper = |a: &WordApp| page_render_options(a, false).paper_of(&a.session.doc);
assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "Print Layout shows the page color");
let print_key = view_key(&a, false, None);
let print_key = view_key(&a, false, None, true);
a.run("view.readMode", json!({})).unwrap();
assert_eq!(paper(&a), Rgb::WHITE, "None: white, the page color ignored");
let none_key = view_key(&a, false, None);
let none_key = view_key(&a, false, None, true);
a.run("view.readPageColor", json!({"value": "sepia"})).unwrap();
assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER);
assert!(!page_render_options(&a, false).dark, "Sepia keeps the text's colors");
let sepia_key = view_key(&a, false, None);
let sepia_key = view_key(&a, false, None, true);
a.run("view.readPageColor", json!({"value": "inverse"})).unwrap();
assert!(page_render_options(&a, false).dark);
let d = wordcraft_render::DARK_PAPER;
assert_eq!(paper(&a), Rgb(d, d, d));
let inverse_key = view_key(&a, false, None);
let inverse_key = view_key(&a, false, None, true);
let keys = [print_key, none_key, sepia_key, inverse_key];
for (i, k) in keys.iter().enumerate() {
assert!(!keys[..i].contains(k), "each paper has its own view key");
@@ -4930,9 +4930,27 @@ fn the_view_key_changes_when_a_drag_takes_an_object_off_the_page() {
let doc = wordcraft_doc::Document::from_text("Hello WordCraft");
let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default());
let object = Pos::body(0, 5);
let hidden = view_key(&app, false, Some(&object));
assert_ne!(hidden, view_key(&app, false, None), "the page without the dragged object is rendered again");
assert_eq!(hidden, view_key(&app, false, Some(&object)), "the same drag keeps the key: no re-render every frame");
let hidden = view_key(&app, false, Some(&object), true);
assert_ne!(hidden, view_key(&app, false, None, true), "the page without the dragged object is rendered again");
assert_eq!(hidden, view_key(&app, false, Some(&object), true), "the same drag keeps the key: no re-render every frame");
}
/// A zoom easing in (or a pinch) shows a new size every frame for a moment: those pages are
/// drawn unhinted, so they fill no hint caches with sizes nobody rests on. The first frame
/// without zooming draws the page hinted again, even at the size the last zoom frame drew.
#[test]
fn pages_are_hinted_again_once_a_zoom_stops() {
let platform = screen_render_options().hinting;
assert_eq!(hint_pages(1.0, false), platform, "at rest, as the platform draws text");
for zoom in [1.1, 0.9, 1.002, 0.998, f32::NAN, f32::INFINITY, 0.0] {
assert!(!hint_pages(zoom, true), "{zoom}: the scale changes under a pointer zoom");
assert_eq!(hint_pages(zoom, false), platform, "{zoom}: at 10 % or 500 %, or over the ribbon: nothing changes");
}
assert_eq!(hint_pages(1.0, true), platform, "the Zoom dialog or slider, a fit zoom: hinted at once");
// The hinting is part of the page's key: the last zoom frame's raster is replaced.
let doc = wordcraft_doc::Document::from_text("Hello WordCraft");
let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default());
assert_ne!(view_key(&app, false, None, true), view_key(&app, false, None, false));
}
#[test]
@@ -4942,10 +4960,10 @@ fn screen_paper_color_changes_raster_and_cache_without_changing_export_paper() {
let layout = app.session.layout();
let page = &layout.pages[0];
let print_before = wordcraft_render::render_page(&app.session.doc, page, 0.1, &Default::default());
let mut keys = vec![view_key(&app, false, None)];
let mut keys = vec![view_key(&app, false, None, true)];
for (value, expected) in [("green", Rgb(0xCC, 0xE8, 0xCF)), ("cream", Rgb(0xFA, 0xF0, 0xDC)), ("gray", Rgb(0xE8, 0xEA, 0xED))] {
app.run("view.paperColor", json!({"value":value})).unwrap();
let key = view_key(&app, false, None);
let key = view_key(&app, false, None, true);
assert!(!keys.contains(&key), "each preset invalidates page and tile caches");
keys.push(key);
let opts = page_render_options(&app, false);
+60
View File
@@ -1742,6 +1742,66 @@ fn wheel_notches_land_at_once_with_animation_effects_off() {
assert!(first > 0.0 && first < notch, "a notch eases in again: {first}");
}
/// A Ctrl+wheel notch eases in over several frames, each a new size: those pages are drawn
/// unhinted (they show for a moment, and hinting each size would fill the hint caches), and
/// the first frame after the zoom stops draws them hinted, as the platform draws text.
#[test]
fn an_easing_zoom_draws_unhinted_pages_until_it_stops() {
let ctx = egui::Context::default();
let system = std::rc::Rc::new(std::cell::Cell::new(Some(true)));
let mut a = app_with_system_animations(&system);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0));
let mut t = 0.0;
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
t += 1.0 / 60.0;
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
};
for _ in 0..3 {
frame(&mut a, Vec::new());
}
let platform = crate::canvas::screen_render_options().hinting;
assert_eq!(a.canvas.pages_hinted, platform, "at rest");
let over_page = a.canvas.canvas_rect.unwrap().center();
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
let wheel = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
source: egui::MouseWheelSource::Unknown,
delta: egui::vec2(0.0, 1.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::COMMAND,
};
let before = a.canvas.scale;
frame(&mut a, vec![wheel]);
// The wheel's own frame already starts the zoom, unhinted.
assert_ne!(a.canvas.scale, before, "the zoom starts in the wheel's frame");
assert!(!platform || !a.canvas.pages_hinted, "the wheel's frame is unhinted");
let (mut eased, mut unhinted) = (0, 0);
let mut last = a.canvas.scale;
for _ in 0..60 {
frame(&mut a, Vec::new());
if a.canvas.scale != last {
eased += 1;
unhinted += usize::from(!a.canvas.pages_hinted);
last = a.canvas.scale;
} else {
break;
}
}
assert!(a.canvas.scale > before && eased >= 2, "the notch eased in over frames: {eased}");
if platform {
assert_eq!(unhinted, eased, "every in-between size unhinted");
}
// The frame after the zoom stops: hinted again, at the size it ended on.
frame(&mut a, Vec::new());
assert_eq!(a.canvas.scale, last);
assert_eq!(a.canvas.pages_hinted, platform, "hinted once the zoom stops");
}
/// With animation effects off a zoom step is finished in its own frame: Ctrl+wheel zooms the
/// notch's whole step at once, the page is already placed and drawn as it will stay, and the
/// status bar's zoom readout follows in the very next frame. Turns that arrive in small pieces
+56
View File
@@ -850,9 +850,20 @@ pub fn apply_animations(ctx: &egui::Context, on: bool) {
});
}
/// Coverage exponent for light-on-dark interface text on Linux and the BSDs (see [`visuals`]).
const DARK_TEXT_GAMMA: f32 = 0.8;
/// egui's visuals for one of our palettes.
fn visuals(t: &Tokens) -> Visuals {
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
// Light text on a dark background, on Linux and the BSDs: egui's dark default thickens every
// glyph edge, about a fifth more ink than FreeType gives it in Firefox and the other apps
// there, so the interface looked semi-bold. Plain coverage (Firefox's weight) left the thin
// light strokes on the darkest strips (the tab row) looking soft; a light boost, about 7 %
// more ink, was chosen side by side on a 145 % desktop.
if t.dark && cfg!(all(unix, not(target_os = "macos"))) {
v.text_options.color_transfer_function = egui::epaint::FontColorTransferFunction::Gamma(DARK_TEXT_GAMMA);
}
v.panel_fill = t.ribbon;
v.window_fill = t.menu;
v.extreme_bg_color = t.input;
@@ -894,6 +905,51 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
mod tests {
use std::collections::BTreeSet;
/// Ink of a line of interface text at 1.45x (a 145 % desktop) in `appearance`, drawn in the
/// palette's text colour extremes (black on white, white on black) so the two compare.
#[cfg(all(unix, not(target_os = "macos")))]
fn interface_text_ink(appearance: super::Appearance) -> u64 {
let dark = matches!(appearance, super::Appearance::Dark);
let (fg, bg) = if dark { (egui::Color32::WHITE, egui::Color32::BLACK) } else { (egui::Color32::BLACK, egui::Color32::WHITE) };
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(560.0, 40.0)).with_pixels_per_point(1.45).wgpu().build_ui_state(
move |ui, set: &mut bool| {
if !*set {
super::apply(ui.ctx(), appearance);
ui.ctx().set_fonts(super::font_definitions(false, false));
*set = true;
}
egui::Frame::new().fill(bg).inner_margin(8.0).show(ui, |ui| {
ui.label(egui::RichText::new("Every Thursday evening the old print shop").font(egui::FontId::proportional(12.5)).color(fg));
});
},
false,
);
h.run();
let img = h.render().unwrap();
// Ink: how far each pixel, laid over the background (the window outside the frame is
// transparent), is from the background.
let back = u64::from(bg.r());
img.pixels()
.map(|p| {
let (v, a) = (u64::from(p.0[0]), u64::from(p.0[3]));
let shown = (v * a + back * (255 - a)) / 255;
shown.abs_diff(back)
})
.sum()
}
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn dark_interface_text_is_only_a_little_heavier_than_light_interface_text() {
// egui's dark default added about a fifth more ink than FreeType gives the same text in
// Firefox on Linux (semi-bold); plain coverage, Firefox's weight, looked soft on the dark
// tab row. The light boost chosen side by side adds about 7 %.
let (light, dark) = (interface_text_ink(super::Appearance::Light), interface_text_ink(super::Appearance::Dark));
assert!(light > 0);
let ratio = dark as f64 / light as f64;
assert!((1.03..1.12).contains(&ratio), "light {light}, dark {dark}: {ratio:.3}");
}
/// WCAG contrast ratio between two opaque colours.
pub(super) fn contrast(a: egui::Color32, b: egui::Color32) -> f32 {
let lum = |c: egui::Color32| {
+5 -1
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, AdLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes anLine truncated
> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged.
@@ -59,6 +59,8 @@ Document title opens Rename Document; the account badge opens Options › Genera
| Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — |
| Document scroll bars | ✅ always drawn beside the page in the room left for them, wherever the pointer is, with a grey thumb that stands out from its track in the light and the dark theme, and more so under the pointer and while dragged; they were invisible until the pointer reached the window's right edge (#5746, `canvas::tests::the_document_scroll_bars_show_with_the_pointer_away`); hidden in Read Mode, as in Word | always shown | 90% | — |
| Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 |
| Interface text on screen (Linux, BSD) | Drawn by egui with Inter's ttfautohint hints, which match FreeType's light autohinting: measured against headless Firefox (Inter, 18.1 and 15.2 px at 1.45x), the same rows of pixels and weight (ink within 1 %) in the light theme. The dark theme no longer looks semi-bold: egui's dark default thickened light-on-dark text (about 21 % more ink than Firefox); it now adds a light 7 % (plain coverage, Firefox's weight, looked soft on the dark tab row; chosen side by side on a 145 % desktop). Not yet: subpixel (LCD) edges | sharp interface text | 90% | — |
| Page text on screen (Linux, BSD) | Lightly hinted as FreeType's `hintslight` draws it (Firefox and other apps there): FreeType's autohinter puts baselines, x-heights and cap heights on whole pixels, nothing moves sideways, so lines break as before. Measured against headless Firefox (Arial, 21.27 px): the same rows of pixels and weight. Not yet: subpixel (LCD) edges; the interface text (egui) is hinted separately. macOS keeps its darkened unhinted text; Windows and exports are unchanged | sharp document text | 90% | 4–8 (subpixel edges) |
| Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — |
| 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. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772); Page color: None (white, the document's page color and page picture not shown), Sepia or Inverse (a dark page with light text), as Word's View › Page Color in Read Mode, starting at None and kept between Read Mode entries, documents and runs (`view.readPageColor`); ink, object drag previews and table drag arrows drawn over the pages follow it; Read Mode's View menu: Column Width and Page Color work, Layout is missing ([parity-checklist.md](parity-checklist.md), Read Mode 2/3) | reading-optimized view and navigation | 75% | 1–2 |
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the page is the caret's while the caret is on screen, else the page at the top of the window, so scrolling updates it (#3880); the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other). The count at the right is what it shows in Word: words (default), characters (with spaces) or characters (no spaces), chosen from a right-click menu on it or File › Options › Display (Status bar count, `ui.statusCount`), kept between runs; a selection shows "selected of total" as the Word Count dialog counts them (#2797) | ✅ | 90% | — |
@@ -198,6 +200,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| 2026-10-20 | trivial | The scrollbar page tip stays on one line: it broke into two or three lines ("Page 10 of" / "40") as the page number grew during a drag (#3341) |
| 2026-10-19 | trivial | Format Page Numbers (Insert › Header & Footer › Page Number ▸ Format Page Numbers…, #1113): the dialog sets the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and whether the pages number on from the section before or start at a number; the ribbon item did nothing before (a plain command call that also cleared the section's start), and `layout.pageNumberFormat` keeps the start when `start` is left out and takes null to continue |
| 2026-10-16 | trivial | KeyTips: AltGr (Right Alt with Level 3 Shift on Linux) is not an Alt tap, held or tapped, so AltGr+letter types instead of reaching the badges (#2388) |
| 2026-10-19 | trivial | Dark-theme interface text on Linux and the BSDs no longer looks semi-bold: about 7 % heavier than light-theme text instead of egui's 21 % (chosen side by side); light-theme interface text measured to match Firefox already |
| 2026-10-19 | trivial | Page text on Linux and the BSDs is hinted like FreeType's `hintslight`: as sharp as in Firefox, same weight, same line breaks |
| 2026-10-15 | trivial | Three-column header/footer table preset and automatic screen-only cell guides while editing (#2197) |
| 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) |
| 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |