diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c36514eef..9f05c22b0 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1,6 +1,7 @@ //! Line breaking and glyph placement for point, area and on-path type. use std::ops::Range; +use std::sync::Arc; use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; @@ -13,13 +14,13 @@ use vectorcraft_geom::{ArcPath, PathData}; use crate::GlyphBend; use crate::composer::{Breakpoint, Params, compose}; -use crate::fontdb::FontDb; +use crate::fontdb::{FontDb, FontFace}; use crate::hyphen::points_for; use crate::justify::{Flex, LineFit, apply_desired, fit_line, is_word_space}; use crate::kashida; use crate::shape::{ - Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start, - style_metrics, + Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, run_face, shape_range, shape_runs, + soft_line_start, style_metrics, }; use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; @@ -36,6 +37,8 @@ pub(crate) use bidi::{is_rtl, para_bidi}; use breaking::tab_advance; #[cfg(test)] use breaking::{break_line, candidates}; +#[cfg(test)] +pub(crate) use driver::layout_faces_per_call; pub use driver::{decorations, fit_first_line, layout, layout_with, scaled}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; @@ -108,6 +111,8 @@ struct Ctx<'a> { runs: Vec<(Range, &'a CharStyle)>, /// The inline graphic of each run that is one (parallel to `runs`). inlines: Vec>, + /// The face of each run (parallel to `runs`), looked up once ([`run_face`]). + faces: Vec>>>, default: CharStyle, opts: &'a LayoutOptions, /// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned @@ -126,13 +131,26 @@ struct Ctx<'a> { } impl Ctx<'_> { - fn style_at(&self, b: usize) -> &CharStyle { + /// The index of the run whose style [`Self::style_at`] gives for byte `b`; `None` without runs. + fn run_at(&self, b: usize) -> Option { // The runs follow one another: the first that ends after `b` is the only one that can hold it. let find = |b: usize| { let i = self.runs.partition_point(|(r, _)| r.end <= b); - self.runs.get(i).filter(|(r, _)| r.start <= b).map(|(_, s)| *s) + self.runs.get(i).filter(|(r, _)| r.start <= b).map(|_| i) }; - find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) + find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| (!self.runs.is_empty()).then_some(0)) + } + + fn style_at(&self, b: usize) -> &CharStyle { + self.run_at(b).and_then(|i| self.runs.get(i)).map_or(&self.default, |(_, s)| *s) + } + + /// The face of [`Self::style_at`] `b` ([`FontDb::style_face`]). + fn face_at(&self, b: usize) -> Option> { + match self.run_at(b).and_then(|i| self.runs.get(i).map(|(_, s)| (i, *s))) { + Some((i, st)) => run_face(self.db, &self.faces, i, st), + None => self.db.style_face(&self.default), + } } /// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges @@ -158,7 +176,7 @@ impl Ctx<'_> { fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); - shape_range(self.db, self.text, r, &self.runs, &self.inlines, &self.opts.features, levels, &mut v); + shape_runs(self.db, self.text, r, &self.runs, &self.inlines, &self.faces, &self.opts.features, levels, &mut v); self.finish_shaping(&mut v, |g| self.style_at(g.byte)); v } diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index fac55c5d6..895e3a1a7 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -204,6 +204,22 @@ pub fn scaled(t: &TextObject, f: f64) -> TextObject { s } +#[cfg(test)] +thread_local! { + /// Set while [`layout_faces_per_call`] lays text out: layout keeps no run faces. + static FACES_PER_CALL: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// [`layout`] keeping no run faces, so that every lookup of a face asks [`FontDb::style_face`] +/// again: the reference the faces kept per run are compared with. +#[cfg(test)] +pub(crate) fn layout_faces_per_call(db: &FontDb, t: &TextObject) -> TextLayout { + FACES_PER_CALL.set(true); + let l = layout(db, t); + FACES_PER_CALL.set(false); + l +} + /// One layout pass, without fitting. fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { let text = t.plain_text(); @@ -234,10 +250,14 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout } else { opts }; + let faces = runs.iter().map(|_| std::cell::OnceCell::new()).collect(); + #[cfg(test)] + let faces = if FACES_PER_CALL.get() { Vec::new() } else { faces }; let mut cx = Ctx { on_path: is_on_path, db, text: &text, + faces, runs, inlines, default: CharStyle::default(), @@ -256,7 +276,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout TextKind::OnPath { .. } => Affine::IDENTITY, TextKind::Point => { let first = cx.style_at(0); - let centre = db.style_face(first).map_or(EM_CENTER, |f| f.ideographic_centre()); + let centre = cx.face_at(0).map_or(EM_CENTER, |f| f.ideographic_centre()); Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN } _ => QUARTER_TURN, diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 112dabb23..79cc79ba6 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -232,11 +232,12 @@ fn prepare_paragraph<'a, 'text>( } } let pm = { - let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); + let face = cx.face_at(pr.start); + let (asc, desc, _) = style_metrics(face.as_deref(), cx.style_at(pr.start)); let lead = auto_leading(cx.style_at(pr.start)); let style = cx.style_at(pr.start); - let (cap, xh) = cap_x_heights(cx.db, style); - let centre = cx.db.style_face(style).map_or(EM_CENTER, |face| face.ideographic_centre()); + let (cap, xh) = cap_x_heights(face.as_deref(), style); + let centre = face.map_or(EM_CENTER, |face| face.ideographic_centre()); Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * style.size * style.v_scale / 100.0 } }; if pi > 0 { diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index ddf4e56f3..14bc3ef4b 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -170,7 +170,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, // A path out of range holds no text, as a path with no length holds none. let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() }; let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| { - let s = style_metrics(cx.db, cx.style_at(0)); + let s = style_metrics(cx.face_at(0).as_deref(), cx.style_at(0)); (s.0, s.1) }); if ap.is_empty() { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 413ea72d7..aec815641 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -115,6 +115,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { text: "A", runs: vec![(0..1, &style)], inlines: vec![None], + faces: vec![std::cell::OnceCell::new()], default: style.clone(), opts: &opts, vertical: false, diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 1a6716c5f..88f9a1f39 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -324,20 +324,33 @@ pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } -/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading). -pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { +/// The faces of a text's runs ([`FontDb::style_face`] of each run's style), each looked up the first +/// time layout asks for it and kept for the rest of that layout. +pub(crate) type RunFaces = [std::cell::OnceCell>>]; + +/// The face of run `ri`, whose style is `st`: kept in `faces` when it has a place for the run, +/// otherwise looked up. +pub(crate) fn run_face(db: &FontDb, faces: &RunFaces, ri: usize, st: &CharStyle) -> Option> { + match faces.get(ri) { + Some(face) => face.get_or_init(|| db.style_face(st)).clone(), + None => db.style_face(st), + } +} + +/// Vertical metrics (points) of style `st` in its resolved face `face`: (ascent, descent, leading). +pub(crate) fn style_metrics(face: Option<&FontFace>, st: &CharStyle) -> (f64, f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.style_face(st) else { + let Some(face) = face else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()); }; let k = st.size / face.upem; (face.ascent * k * vs, face.descent * k * vs, st.effective_leading()) } -/// Cap height and x height (points) of a style's resolved face. -pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) { +/// Cap height and x height (points) of style `st` in its resolved face `face`. +pub(crate) fn cap_x_heights(face: Option<&FontFace>, st: &CharStyle) -> (f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.style_face(st) else { + let Some(face) = face else { return (st.size * 0.7 * vs, st.size * 0.5 * vs); }; let k = st.size / face.upem * vs; @@ -358,6 +371,23 @@ pub(crate) fn shape_range( feats: &OtFeatures, levels: &[Level], out: &mut Vec, +) { + shape_runs(db, text, range, runs, inlines, &[], feats, levels, out); +} + +/// [`shape_range`] with `faces`, parallel to `runs`, holding the face of each run +/// ([`run_face`]). +#[allow(clippy::too_many_arguments)] +pub(crate) fn shape_runs( + db: &FontDb, + text: &str, + range: Range, + runs: &[(Range, &CharStyle)], + inlines: &[Option<&InlineArt>], + faces: &RunFaces, + feats: &OtFeatures, + levels: &[Level], + out: &mut Vec, ) { if text.get(range.clone()).is_none() || range.is_empty() { return; @@ -409,12 +439,13 @@ pub(crate) fn shape_range( let a = bytes.start; let b = bytes.end; if let Some(art) = art { - if let Some(face) = db.style_face(st).or_else(|| db.face_covering('a')) { + if let Some(face) = run_face(db, faces, source_runs.start, st).or_else(|| db.face_covering('a')) { out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a), feats.vertical)); } continue; } - let Some(primary) = db.style_face(st) else { continue }; + // A span's style is the style of its first source run. + let Some(primary) = run_face(db, faces, source_runs.start, st) else { continue }; let pmap = primary.skrifa().map(|f| f.charmap()); // Synthesized Small Caps shape lowercase letters separately (as smaller capitals). let small_caps = st.small_caps.is_some() && !st.all_caps; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8a3371e8f..6183278ed 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -558,6 +558,100 @@ fn multiple_runs_and_styles() { assert_ne!(l.glyphs[0].font_id, l.glyphs[3].font_id); } +/// Each run's glyphs are set in its own style's face, and each empty paragraph's line takes the +/// ascent and descent of its own style's face: families mixed in a paragraph, a missing family that +/// falls back, a style the family does not have, and a change of family in the middle of a +/// paragraph. +#[test] +fn each_run_and_paragraph_takes_its_own_face() { + let styles = [ + serif(30.0), + CharStyle { font_family: "No Such Family".into(), font_style: "Bold".into(), ..style(10.0) }, + CharStyle { font_family: "Inter".into(), font_style: "Bold Italic".into(), ..style(20.0) }, + CharStyle { font_family: "JetBrains Mono".into(), ..style(14.0) }, + style(16.0), + ]; + let run = |text: &str, i: usize| TextRun::new(text, styles[i].clone()); + let mut t = point("", style(10.0)); + t.runs = vec![run("Ab", 0), run("cd\n", 1), run("\n", 2), run("\n", 3), run("xy", 1), run("zw", 4), run("Qr", 2)]; + let l = layout(db(), &t); + let faces: Vec<_> = styles.iter().map(|st| db().style_face(st).unwrap()).collect(); + let face_of_run = [0, 1, 2, 3, 1, 4, 2]; + let text = t.plain_text(); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| text.get(g.byte..).is_some_and(|s| s.starts_with(char::is_alphabetic))).collect(); + assert_eq!(drawn.len(), 10); + for g in drawn { + assert_eq!(g.font_id, faces[face_of_run[g.run]].id(), "run {}", g.run); + } + assert_eq!(l.lines.len(), 4); + for (line, i) in [(1, 2), (2, 3)] { + let (face, size) = (&faces[i], styles[i].size); + let (ascent, descent) = (face.ascent * size / face.upem, face.descent * size / face.upem); + assert!((l.lines[line].ascent - ascent).abs() < 1e-9, "line {line}: {} versus {ascent}", l.lines[line].ascent); + assert!((l.lines[line].descent - descent).abs() < 1e-9, "line {line}: {} versus {descent}", l.lines[line].descent); + } + assert_ne!(faces[2].id(), faces[3].id()); +} + +/// Faces kept per run lay text out exactly as looking each face up again at every use does +/// ([`crate::layout::layout_faces_per_call`]): the same glyphs, positions, bounds and baselines, +/// with mixed families, a missing family that falls back, Bold and Italic asked of families +/// without those styles (their closest style stands in), and families that change in the middle of +/// a paragraph, in point, vertical, area and path type. +#[test] +fn faces_kept_per_run_lay_out_as_faces_looked_up_at_each_use() { + let styles = [ + serif(24.0), + CharStyle { font_family: "No Such Family".into(), ..style(12.0) }, + CharStyle { font_family: "Source Serif 4".into(), font_style: "Bold".into(), ..style(18.0) }, + CharStyle { font_family: "Inter".into(), font_style: "Italic".into(), ..style(20.0) }, + CharStyle { font_family: "JetBrains Mono".into(), font_style: "Bold Italic".into(), ..style(14.0) }, + style(16.0), + ]; + let matched = [(1, FontMatch::Missing), (2, FontMatch::Style), (3, FontMatch::Style), (4, FontMatch::Style)]; + for (i, m) in matched { + let st = &styles[i]; + assert_eq!(db().resolve(&st.font_family, &st.font_style).map(|(_, m)| m), Some(m), "{} {}", st.font_family, st.font_style); + } + let style_of_run = [0, 1, 2, 3, 4, 2, 4, 5, 3, 1]; + let texts = ["Ab ", "cd ef\n", "\n", "gh ", "ij", " kl\n", "\n", "mn ", "op ", "qr"]; + let runs: Vec<_> = texts.into_iter().zip(style_of_run).map(|(text, i)| TextRun::new(text, styles[i].clone())).collect(); + let mut horizontal = point("", style(10.0)); + horizontal.runs = runs; + let mut vertical = horizontal.clone(); + vertical.vertical = true; + let mut wrapped = area("", style(10.0), Rect::new(0.0, 0.0, 60.0, 400.0), Justify::Left); + wrapped.runs = horizontal.runs.clone(); + let mut on_path = horizontal.clone(); + let path = kurbo::Line::new((0.0, 0.0), (400.0, 60.0)).to_path(0.1); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&path), start: 0.0, end: None }; + let mut empty_path = on_path.clone(); + empty_path.runs = vec![TextRun::new("", styles[1].clone())]; + let glyph = |g: &PositionedGlyph| { + let b = g.outline.bounding_box(); + (g.font_id, g.gid, g.run, g.byte, g.line, [g.origin.x, g.origin.y, g.advance, g.width, b.x0, b.y0, b.x1, b.y1], g.xf.as_coeffs()) + }; + let line = |l: &LineInfo| [l.baseline, l.first_baseline, l.ascent, l.descent, l.x0, l.x1]; + for (name, t) in [("point", &horizontal), ("vertical", &vertical), ("area", &wrapped), ("path", &on_path), ("empty path", &empty_path)] { + let kept = layout(db(), t); + let looked_up = crate::layout::layout_faces_per_call(db(), t); + assert_eq!(kept.glyphs.iter().map(glyph).collect::>(), looked_up.glyphs.iter().map(glyph).collect::>(), "{name}: glyphs"); + assert_eq!(kept.lines.iter().map(line).collect::>(), looked_up.lines.iter().map(line).collect::>(), "{name}: lines"); + assert_eq!(kept.bounds, looked_up.bounds, "{name}: bounds"); + assert_eq!(kept.line_xf, looked_up.line_xf, "{name}: line transform"); + assert_eq!(kept.overflow, looked_up.overflow, "{name}: overflow"); + } + // The stand-in faces set the glyphs of the runs that ask for a missing family or style. + let l = layout(db(), &horizontal); + let text = horizontal.plain_text(); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| text.get(g.byte..).is_some_and(|s| s.starts_with(char::is_alphabetic))).collect(); + assert_eq!(drawn.len(), 18); + for g in drawn { + assert_eq!(Some(g.font_id), db().style_face(&styles[style_of_run[g.run]]).map(|f| f.id()), "run {}", g.run); + } + assert_eq!(l.lines.len(), 5); +} + #[test] fn many_paragraphs_of_their_own_runs_lay_out_in_linear_time() { // 20,000 one-letter paragraphs, each a run of its own: every paragraph looking through every diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2b4a39f25..c80550264 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1379,6 +1379,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. + Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way. - **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen on Windows, on the web and on Linux under X11 and XWayland, while winit 0.30 reports none on macOS or native Wayland. On Linux, see `docs/development.md` for pens under Wayland.