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
2026-11-08 17:55:10 +01:00
committed by GitHub
18 changed files with 493 additions and 31 deletions
+5 -1
View File
File diff suppressed because one or more lines are too long
+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
File diff suppressed because one or more lines are too long