authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 00:28:37-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 05:19:31-07:00
log8587d48a5c4256dcc935abf9ad5a8a23329cea5e
treef82c885adf9d772a7f01a05b6b9fc86a3755f472
parent0849ff574906dff021315179ffdd59294034001c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: a Canvas 2D renderer where a browser has neither WebGPU nor WebGL 2, and Options' Renderer, which switches live

The browser draws through WebGPU, else WebGL 2, else its 2D canvas, which each of draw's quads becomes the canvas's own calls on. A popup leaning in draws into a canvas over the page leaned by a CSS 3D transform of the same perspective. Every platform offers its backends in Options (Direct3D 12, 11 or OpenGL; Metal or OpenGL; Vulkan or OpenGL; WebGPU, WebGL 2 or Canvas 2D), the settings' "renderer", SNOWBOUND_RENDERER and --renderer (or ?renderer= in a browser) each over the last; switching tears down the renderer and surface and starts the chosen one with the window as it was, and one that fails falls back to the default and says so. Assisted-by: claude-opus-5.5

34 files changed, 2476 insertions(+), 186 deletions(-)

Cargo.lock+3
......@@ -1033,6 +1033,7 @@ dependencies = [
10331033 "base64",
10341034 "bytemuck",
10351035 "image",
1036 "js-sys",
10361037 "linebender_resource_handle",
10371038 "miniz_oxide 0.8.9",
10381039 "parley",
......@@ -1043,6 +1044,8 @@ dependencies = [
10431044 "skrifa",
10441045 "subsetter",
10451046 "swash",
1047 "wasm-bindgen",
1048 "web-sys",
10461049 "web-time",
10471050 "wgpu",
10481051 "windows",
arc/platforms.md+18-2
......@@ -10,7 +10,7 @@ drawn by Snowbound versus the operating system.
1010shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios)
1111glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI)
1212page canvas ─────────────────────────────────────────────────────────────────────►
13paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12; D3D11 on 7) draw (Metal)
13paint draw (Metal or GL) draw (Vulkan or GL) draw (D3D12, D3D11, GL) draw (Metal)
1414data notebook::session + embedded SMB client ────────────────────────────────────►
1515format onestore ───────────────────────────────────────────────────────────────────►
1616```
......@@ -24,6 +24,12 @@ everything else in the crate is shared: library and settings, the sidebar,
2424menus, page and section management, templates, and screenshot and replay
2525support. Accessibility goes through AccessKit's winit adapter on all three:
2626the interface's tree, with the page's grafted into it.
27Each platform draws through one of several backends (the table's `paint` row; in a
28browser WebGPU, WebGL 2 or the 2D canvas). By default the first that starts draws;
29Options' Renderer, the settings' `renderer`, `SNOWBOUND_RENDERER` or `--renderer`
30(each over the last) picks one, and one that fails to start falls back to the
31default and says so. Choosing another in Options starts it at once: the renderer and
32surface go and new ones begin, the window and everything in it staying as they are.
2733`commands.rs` is the one table of commands: each one's title, its chords on
2834macOS and elsewhere, when it is enabled or checked, and what it does. The
2935keyboard, the toolbar and the macOS menu bar all run commands from it.
......@@ -112,7 +118,7 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
112118 imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8
113119 and later that dependencies still name are answered by
114120 `platform/windows/rt/shims.c`; everything newer is looked up at run time.
115- `draw` builds three backends on Windows and the surface picks one:
121- `draw` builds three backends on Windows, tried in turn by default:
116122 Direct3D 12 through wgpu where Windows has it (10 and 11), otherwise
117123 Direct3D 11, as on Windows 7, on WARP where no device reaches feature
118124 level 10_0, and OpenGL 2.1 on a WGL context only where neither starts
......@@ -187,6 +193,16 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
187193 lack takes Noto's, fetched by script the first time a page holds it (a CJK face cut to
188194 the national standards' characters by `tools/web/subset_cjk.py`; emoji in Noto Color
189195 Emoji's COLRv1 outlines, which `draw` paints itself), and the page is laid out again.
196- Frames draw through WebGPU, else WebGL 2, both by wgpu, else the 2D canvas, which a
197 browser with WebGL turned off still has; `?renderer=` in the address picks one, as
198 `--renderer` does. The canvas draws each of `draw`'s quads with its own calls (paths,
199 `drawImage` from the glyph atlas, glyphs of a colour from a sheet coloured once), and
200 corrects translucent colours' opacity for blending sRGB-encoded. A popup leaning in as it
201 opens draws into a canvas of its own laid over the page and leaned by a CSS 3D transform
202 of the same perspective, which the browser's compositor draws. A canvas keeps the first
203 kind of context it gives, so each renderer starts on a canvas of its own, which then takes
204 the page's place and its input. Only where even the 2D canvas fails does `start` reject,
205 with a `NoGraphicsError`.
190206- AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden
191207 button, then on every visit) the trees AccessKit would get are mirrored as hidden
192208 elements with ARIA roles; acting on one sends AccessKit's action back.
arc/ui.md+4-2
......@@ -159,8 +159,10 @@ for itself, so F6 leaves it.
159159
160160## `draw`: the renderer
161161
162`draw` is a small wgpu renderer. A frame is a list of layers, each with its
163own transform and clip. A layer is a flat sequence of primitives: glyph runs,
162`draw` is a small renderer submitting through wgpu, or Direct3D 11, OpenGL 2.1
163or a browser's 2D canvas where wgpu can't; the backends share everything but
164their device layer. A frame is a list of layers, each with its own transform and
165clip. A layer is a flat sequence of primitives: glyph runs,
164166SVG icons (tinted or in their own colours), SVG paths filled or stroked,
165167rounded or gradient rectangles, pen strokes and raster images.
166168
crates/canvas/examples/reading_inventory.rs+1-1
......@@ -302,7 +302,7 @@ impl Gpu {
302302 });
303303 self.renderer
304304 .draw(
305 &target.create_view(&Default::default()),
305 &target.create_view(&Default::default()).into(),
306306 size,
307307 [1.0; 4],
308308 &[viewport.layer(&primitives)],
crates/canvas/examples/render_page.rs+1-1
......@@ -79,7 +79,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
7979 };
8080 renderer
8181 .draw(
82 &target.create_view(&Default::default()),
82 &target.create_view(&Default::default()).into(),
8383 size,
8484 [1.0; 4],
8585 &[viewport.layer(&primitives)],
crates/canvas/src/gpu/mod.rs+1-1
......@@ -593,7 +593,7 @@ mod tests {
593593 };
594594 renderer
595595 .draw(
596 &target.create_view(&Default::default()),
596 &target.create_view(&Default::default()).into(),
597597 size,
598598 [1.0; 4],
599599 &[viewport.layer(&primitives)],
crates/canvas/src/gpu/page/background.rs+1-1
......@@ -1009,7 +1009,7 @@ mod tests {
10091009 };
10101010 renderer
10111011 .draw(
1012 &target.create_view(&Default::default()),
1012 &target.create_view(&Default::default()).into(),
10131013 size,
10141014 DARK.color,
10151015 &[viewport.layer(&primitives)],
crates/canvas/src/gpu/profile.rs+2-2
......@@ -54,7 +54,7 @@ fn renderer_cost() {
5454 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
5555 view_formats: &[],
5656 });
57 let view = target.create_view(&Default::default());
57 let view: draw::Target = target.create_view(&Default::default()).into();
5858 let mut renderer = Renderer::new(device, queue, format);
5959 let occupancy = renderer.occupancy();
6060 eprintln!(
......@@ -217,7 +217,7 @@ fn frame_cost() {
217217 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
218218 view_formats: &[],
219219 });
220 let target = target.create_view(&Default::default());
220 let target: draw::Target = target.create_view(&Default::default()).into();
221221 let mut renderer = Renderer::new(device, queue, format);
222222 let colors = TextColors {
223223 caret: [0.0, 0.0, 0.0, 1.0],
crates/draw/Cargo.toml+22-3
......@@ -7,10 +7,10 @@ publish = false
77[features]
88default = ["wgpu"]
99# The renderer; `edit` needs only parley. It submits through wgpu, or without it through
10# OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its
11# maker picks one, as Windows 7 has no Direct3D 12.
10# OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, as Windows
11# 7 has no Direct3D 12, on macOS OpenGL, and in a browser the 2D canvas; its maker picks one.
1212render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:skrifa", "dep:swash"]
13wgpu = ["render", "dep:wgpu", "dep:windows"]
13wgpu = ["render", "dep:wgpu", "dep:windows", "dep:js-sys", "dep:wasm-bindgen", "dep:web-sys"]
1414# Writes layers as PDF pages, text as real text in subset fonts.
1515pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]
1616
......@@ -42,6 +42,25 @@ windows = { version = "0.62", default-features = false, optional = true, feature
4242 "Win32_UI_WindowsAndMessaging",
4343] }
4444
45[target.'cfg(target_arch = "wasm32")'.dependencies]
46js-sys = { version = "0.3", optional = true }
47wasm-bindgen = { version = "0.2", optional = true }
48web-sys = { version = "0.3", optional = true, features = [
49 "CanvasGradient",
50 "CanvasRenderingContext2d",
51 "CanvasWindingRule",
52 "console",
53 "CssStyleDeclaration",
54 "Document",
55 "DomRect",
56 "Element",
57 "HtmlCanvasElement",
58 "HtmlElement",
59 "ImageData",
60 "Node",
61 "Window",
62] }
63
4564[dev-dependencies]
4665pollster = "0.4"
4766png = "0.18"
crates/draw/src/render.rs+50-16
......@@ -1,9 +1,14 @@
1#[cfg(all(feature = "wgpu", target_arch = "wasm32"))]
2mod canvas2d;
13mod colr;
24#[cfg(all(feature = "wgpu", windows))]
35mod d3d11;
4#[cfg(all(feature = "wgpu", windows))]
6#[cfg(all(
7 feature = "wgpu",
8 any(windows, target_os = "macos", target_arch = "wasm32")
9))]
510mod dual;
6#[cfg(any(windows, not(feature = "wgpu")))]
11#[cfg(any(windows, target_os = "macos", not(feature = "wgpu")))]
712mod gl;
813mod icon;
914#[cfg(feature = "pdf")]
......@@ -14,11 +19,17 @@ mod translucent;
1419#[cfg(feature = "wgpu")]
1520mod webgpu;
1621
17#[cfg(all(feature = "wgpu", windows))]
22#[cfg(all(
23 feature = "wgpu",
24 any(windows, target_os = "macos", target_arch = "wasm32")
25))]
1826use dual as backend;
1927#[cfg(not(feature = "wgpu"))]
2028use gl as backend;
21#[cfg(all(feature = "wgpu", not(windows)))]
29#[cfg(all(
30 feature = "wgpu",
31 not(any(windows, target_os = "macos", target_arch = "wasm32"))
32))]
2233use webgpu as backend;
2334
2435pub use backend::Target;
......@@ -178,7 +189,7 @@ impl RasterImage {
178189/// own, then the batches painting the target, and the images they show.
179190struct Frame<'a> {
180191 vertices: &'a [Vertex],
181 groups: &'a [Vec<Batch>],
192 groups: &'a [Group],
182193 batches: &'a [Batch],
183194 images: &'a HashMap<u64, CachedImage>,
184195}
......@@ -206,6 +217,14 @@ struct Batch {
206217 scissor: [u32; 4],
207218}
208219
220/// Layers appearing as one, as `Blend::Group` paints their picture.
221struct Group {
222 batches: Vec<Batch>,
223 /// What the 2D canvas, which can't draw the group's strips in perspective, leans it by.
224 #[cfg_attr(not(target_arch = "wasm32"), expect(dead_code))]
225 motion: Motion,
226}
227
209228/// How a batch meets what lies beneath it, and what it samples: the atlas unless it paints
210229/// a picture.
211230#[derive(Clone, Copy, PartialEq)]
......@@ -361,10 +380,12 @@ pub struct Motion {
361380}
362381
363382impl Motion {
383 /// Target heights away the viewer sees a turn from, so a small one reads as depth.
384 const DISTANCE: f32 = 2.0;
385
364386 /// Where device point `[x, y]` on a target `height` tall shows, turned.
365387 fn project(&self, [x, y]: [f32; 2], height: f32) -> [f32; 2] {
366 // Seen from twice the target's height away, so a small turn reads as depth.
367 let distance = 2.0 * height;
388 let distance = Self::DISTANCE * height;
368389 let below = y - self.pivot[1];
369390 let near = distance / (distance + below * self.tilt.sin());
370391 [
......@@ -586,7 +607,7 @@ pub struct Renderer {
586607 glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>,
587608 images: HashMap<u64, CachedImage>,
588609 batches: Vec<Batch>,
589 groups: Vec<Vec<Batch>>,
610 groups: Vec<Group>,
590611 /// Batches before this one take no more primitives.
591612 barrier: usize,
592613 scaler: ScaleContext,
......@@ -594,7 +615,7 @@ pub struct Renderer {
594615 row_height: u32,
595616}
596617
597#[cfg(any(windows, not(feature = "wgpu")))]
618#[cfg(any(windows, target_os = "macos", not(feature = "wgpu")))]
598619impl Renderer {
599620 /// Draws with the OpenGL context current on this thread, which must stay current
600621 /// whenever the renderer or a `Target` is used.
......@@ -613,8 +634,16 @@ impl Renderer {
613634 pub fn present(&self, target: &Target, translucent: bool) {
614635 self.gpu.present(target, translucent);
615636 }
637}
616638
617 /// The sRGB RGBA rows of `target`, from `Renderer::target`, top first.
639#[cfg(any(
640 windows,
641 target_os = "macos",
642 target_arch = "wasm32",
643 not(feature = "wgpu")
644))]
645impl Renderer {
646 /// The sRGB RGBA rows of `target`, from `Renderer::target` or a 2D canvas, top first.
618647 pub fn read_pixels(&self, target: &Target) -> Result<Vec<u8>, String> {
619648 self.gpu.read_pixels(target)
620649 }
......@@ -688,7 +717,12 @@ impl Renderer {
688717 images: self.images.len(),
689718 image_bytes,
690719 vertices: self.vertices.len(),
691 batches: self.batches.len() + self.groups.iter().map(Vec::len).sum::<usize>(),
720 batches: self.batches.len()
721 + self
722 .groups
723 .iter()
724 .map(|group| group.batches.len())
725 .sum::<usize>(),
692726 vertex_capacity_bytes: self.vertices.capacity() * size_of::<Vertex>(),
693727 batch_capacity_bytes: self.batches.capacity() * size_of::<Batch>(),
694728 glyph_capacity: self.glyphs.capacity(),
......@@ -873,7 +907,7 @@ impl Renderer {
873907 blend: Blend::Group(self.groups.len()),
874908 scissor: [0, 0, size[0], size[1]],
875909 });
876 self.groups.push(batches);
910 self.groups.push(Group { batches, motion });
877911 Ok(())
878912 }
879913
......@@ -1132,7 +1166,7 @@ impl Renderer {
11321166 )
11331167 .or_else(|| render.render(&mut scaler, glyph_id))
11341168 .map(|mut image| {
1135 if image.content == Content::Mask {
1169 if image.content == Content::Mask && self.gpu.blends_linear() {
11361170 let coverage = text_coverage(tone);
11371171 for alpha in &mut image.data {
11381172 *alpha = coverage[usize::from(*alpha)];
......@@ -1996,7 +2030,7 @@ mod tests {
19962030
19972031 fn draw(&self, renderer: &mut Renderer, layers: &[Layer<'_>]) -> Result<(), RenderError> {
19982032 renderer.draw(
1999 &self.texture.create_view(&Default::default()),
2033 &self.texture.create_view(&Default::default()).into(),
20002034 [512, 256],
20012035 [1.0; 4],
20022036 layers,
......@@ -2636,7 +2670,7 @@ mod tests {
26362670 ];
26372671 renderer
26382672 .draw(
2639 &target.texture.create_view(&Default::default()),
2673 &target.texture.create_view(&Default::default()).into(),
26402674 [512, 256],
26412675 [0.0; 4],
26422676 &[Layer {
......@@ -2679,7 +2713,7 @@ mod tests {
26792713 color: [0.2, 0.2, 0.2, 1.0],
26802714 },
26812715 ];
2682 let frame = translucent.target(&device, [512, 256]);
2716 let frame = translucent.target(&device, [512, 256]).into();
26832717 renderer
26842718 .draw(
26852719 &frame,
crates/draw/src/render/canvas2d.rs created+1178
......@@ -0,0 +1,1178 @@
1//! Submission through the browser's 2D canvas, where it has neither WebGPU nor WebGL2. Each
2//! quad of the frame becomes the canvas's own calls: shapes as paths, glyphs, icons and paths
3//! from an atlas canvas, pictures and groups by `drawImage`. The canvas blends sRGB-encoded,
4//! so a translucent colour's opacity is corrected toward what linear light shows.
5//!
6//! A group leaning back in perspective, which the canvas's affine transforms can't draw,
7//! paints into a canvas of its own laid over the target and leaned by a CSS 3D transform
8//! with the same projection; what paints after it goes into a canvas above that.
9use super::*;
10use std::{
11 cell::{Cell, RefCell},
12 f64::consts::{FRAC_PI_2, PI},
13};
14use wasm_bindgen::{Clamped, JsCast};
15use web_sys::{
16 CanvasRenderingContext2d as Context, CanvasWindingRule, HtmlCanvasElement as Canvas, ImageData,
17};
18
19/// The widest canvas every browser keeps whole.
20const MAX_SIDE: u32 = 8192;
21/// Most colours glyphs are kept coloured in at once.
22const MAX_TINTS: usize = 16;
23/// Most shadows kept drawn at once.
24const MAX_SHADOWS: usize = 8;
25
26/// A canvas and its 2D context, which a frame is drawn into.
27#[derive(Clone)]
28pub struct Target {
29 canvas: Canvas,
30 context: Context,
31}
32
33impl Target {
34 pub fn new(canvas: Canvas) -> Result<Self, String> {
35 let context = canvas
36 .get_context("2d")
37 .map_err(|error| format!("{error:?}"))?
38 .ok_or("The canvas has no 2D context")?
39 .dyn_into()
40 .map_err(|_| "The canvas's context is not 2D")?;
41 Ok(Self { canvas, context })
42 }
43
44 /// A canvas of its own, in no page, `size` pixels large.
45 fn detached(size: [u32; 2]) -> Result<Self, String> {
46 let canvas: Canvas = web_sys::window()
47 .and_then(|window| window.document())
48 .ok_or("No document")?
49 .create_element("canvas")
50 .map_err(|error| format!("{error:?}"))?
51 .unchecked_into();
52 let target = Self::new(canvas)?;
53 target.resize(size);
54 Ok(target)
55 }
56
57 pub fn size(&self) -> [u32; 2] {
58 [self.canvas.width(), self.canvas.height()]
59 }
60
61 /// Resizes the canvas, which clears it, if it is another size.
62 fn resize(&self, size: [u32; 2]) {
63 if self.size() != size {
64 self.canvas.set_width(size[0]);
65 self.canvas.set_height(size[1]);
66 }
67 }
68}
69
70/// A picture as straight-alpha pixels in a canvas of its own, unless the browser had no
71/// canvas to spare.
72pub(super) struct Image(Option<Canvas>);
73
74impl AsRef<Image> for Image {
75 fn as_ref(&self) -> &Image {
76 self
77 }
78}
79
80/// Glyphs of one colour, coloured once in a sheet of their own that frames copy them from:
81/// the browser copies a whole canvas drawn into after it was last drawn from, so colouring each
82/// run of glyphs afresh, or the whole atlas, would copy a large canvas a frame.
83struct Tint {
84 color: [u32; 4],
85 sheet: Target,
86 /// Where each atlas rectangle `[left, top, right, bottom]` lies coloured in the sheet.
87 glyphs: HashMap<[u32; 4], [u32; 2]>,
88 /// Where the sheet's next glyph goes, and the height of the row it goes in.
89 pen: [u32; 2],
90 row: u32,
91 /// The last frame drawing with it.
92 used: u64,
93}
94
95impl Tint {
96 /// Colours atlas rectangle `glyph` into the sheet, growing it or else emptying it when
97 /// full; where even an empty sheet can't hold it, the glyph takes its colour as it draws.
98 fn add(&mut self, atlas: &Canvas, glyph: [u32; 4]) {
99 let color = css(self.color.map(f32::from_bits));
100 let [width, height] = [glyph[2] - glyph[0], glyph[3] - glyph[1]];
101 loop {
102 let side = self.sheet.size()[0];
103 if self.pen[0] + width > side {
104 self.pen = [0, self.pen[1] + self.row + 1];
105 self.row = 0;
106 }
107 if self.pen[1] + height <= side {
108 break;
109 }
110 if side >= MAX_SIDE / 2 {
111 if self.glyphs.is_empty() {
112 return;
113 }
114 self.glyphs.clear();
115 self.sheet
116 .context
117 .clear_rect(0.0, 0.0, side.into(), side.into());
118 (self.pen, self.row) = ([0; 2], 0);
119 continue;
120 }
121 let Ok(grown) = Target::detached([2 * side; 2]) else {
122 return;
123 };
124 let _ = grown
125 .context
126 .draw_image_with_html_canvas_element(&self.sheet.canvas, 0.0, 0.0);
127 self.sheet = grown;
128 }
129 let [x, y] = self.pen.map(f64::from);
130 let [w, h] = [width, height].map(f64::from);
131 let context = &self.sheet.context;
132 let [u, v] = [glyph[0], glyph[1]].map(f64::from);
133 let _ = context
134 .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
135 atlas, u, v, w, h, x, y, w, h,
136 );
137 context.set_fill_style_str(&color);
138 color_in(context, [x, y, w, h]);
139 self.glyphs.insert(glyph, self.pen);
140 self.pen[0] += width + 1;
141 self.row = self.row.max(height);
142 }
143}
144
145/// A shadow drawn once and copied while it stays the same, as a popup's does while it opens:
146/// the browser may blur in software, slowly.
147struct Shadow {
148 /// The rectangle's half size, corner radius and blur, then the colour, as bits.
149 key: Vec<u32>,
150 sheet: Target,
151 /// The last frame drawing it.
152 used: u64,
153}
154
155pub(super) struct Gpu {
156 atlas: Target,
157 /// Whether the atlas was emptied since the last frame, as writing its white texel at the
158 /// origin shows (`Renderer::clear_glyph_cache`): the tints' glyphs are gone.
159 emptied: Cell<bool>,
160 tints: Vec<Tint>,
161 shadows: RefCell<Vec<Shadow>>,
162 /// Frames submitted.
163 frames: u64,
164 /// Where glyphs of a colour no tint holds take it, together, before reaching the target.
165 scratch: Target,
166 /// Offscreen pictures for groups, the target's size.
167 groups: Vec<Target>,
168 /// Canvases over the target: in turn a group leaning back, then what paints above it.
169 overlays: Vec<Target>,
170}
171
172impl Drop for Gpu {
173 fn drop(&mut self) {
174 for overlay in &self.overlays {
175 overlay.canvas.remove();
176 }
177 }
178}
179
180impl Renderer {
181 /// Draws with the browser's 2D canvas, into `Target`s.
182 pub fn canvas2d() -> Result<Self, String> {
183 Ok(Self::with_gpu(Gpu {
184 atlas: Target::detached([ATLAS_SIZE; 2])?,
185 emptied: Cell::new(false),
186 tints: Vec::new(),
187 shadows: RefCell::new(Vec::new()),
188 frames: 0,
189 scratch: Target::detached([1; 2])?,
190 groups: Vec::new(),
191 overlays: Vec::new(),
192 }))
193 }
194}
195
196impl Gpu {
197 /// Blends sRGB-encoded, as the canvas does.
198 pub(super) fn blends_linear(&self) -> bool {
199 false
200 }
201
202 pub(super) fn flipped(&self) -> bool {
203 false
204 }
205
206 pub(super) fn max_texture_dimension(&self) -> u32 {
207 MAX_SIDE
208 }
209
210 pub(super) fn atlas_side(&self) -> u32 {
211 self.atlas.canvas.width()
212 }
213
214 pub(super) fn new_atlas(&mut self, side: u32) {
215 self.atlas.resize([side; 2]);
216 self.tints.clear();
217 }
218
219 pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
220 put(&self.atlas.context, origin, size, rgba);
221 if origin == [0; 2] {
222 self.emptied.set(true);
223 }
224 }
225
226 /// Colours the glyphs `frame` draws in a colour into that colour's tint, keeping tints
227 /// for as many colours as `MAX_TINTS` allows.
228 fn tint(&mut self, frame: &Frame<'_>) {
229 self.frames += 1;
230 if self.emptied.take() {
231 self.tints.clear();
232 }
233 let side = self.atlas_side() as f32;
234 let batches = (frame.batches.iter())
235 .chain(frame.groups.iter().flat_map(|group| &group.batches))
236 .filter(|batch| batch.blend == Blend::Over);
237 for batch in batches {
238 let range = batch.vertices.start as usize..batch.vertices.end as usize;
239 for quad in frame.vertices[range].chunks_exact(6) {
240 let Some(color) = tinted(quad) else {
241 continue;
242 };
243 let key = color.map(f32::to_bits);
244 let at = match self.tints.iter().position(|tint| tint.color == key) {
245 Some(at) => at,
246 None => {
247 let sheet = if self.tints.len() < MAX_TINTS {
248 match Target::detached([256; 2]) {
249 Ok(sheet) => sheet,
250 Err(error) => return report(&error),
251 }
252 } else if let Some(at) = (0..self.tints.len())
253 .filter(|at| self.tints[*at].used != self.frames)
254 .min_by_key(|at| self.tints[*at].used)
255 {
256 let sheet = self.tints.swap_remove(at).sheet;
257 let [width, height] = sheet.size().map(f64::from);
258 sheet.context.clear_rect(0.0, 0.0, width, height);
259 sheet
260 } else {
261 continue;
262 };
263 self.tints.push(Tint {
264 color: key,
265 sheet,
266 glyphs: HashMap::new(),
267 pen: [0; 2],
268 row: 0,
269 used: 0,
270 });
271 self.tints.len() - 1
272 }
273 };
274 let tint = &mut self.tints[at];
275 tint.used = self.frames;
276 let glyph = source(quad, side).map(|value| value as u32);
277 if !tint.glyphs.contains_key(&glyph) {
278 tint.add(&self.atlas.canvas, glyph);
279 }
280 }
281 }
282 }
283
284 /// The canvas holding shadow `key`, `size` pixels with the shadow in its middle, which
285 /// `cast` draws where it isn't kept; none where `MAX_SHADOWS` are kept for this frame.
286 fn shadow(
287 &self,
288 key: Vec<u32>,
289 size: [u32; 2],
290 cast: impl FnOnce(&Context, [f64; 2]),
291 ) -> Option<Canvas> {
292 let mut shadows = self.shadows.borrow_mut();
293 if let Some(shadow) = shadows.iter_mut().find(|shadow| shadow.key == key) {
294 shadow.used = self.frames;
295 return Some(shadow.sheet.canvas.clone());
296 }
297 let sheet = if shadows.len() < MAX_SHADOWS {
298 Target::detached(size).ok()?
299 } else {
300 let at = (0..shadows.len())
301 .filter(|at| shadows[*at].used != self.frames)
302 .min_by_key(|at| shadows[*at].used)?;
303 let sheet = shadows.swap_remove(at).sheet;
304 sheet.resize(size);
305 let [width, height] = size.map(f64::from);
306 sheet.context.clear_rect(0.0, 0.0, width, height);
307 sheet
308 };
309 cast(&sheet.context, size.map(|side| f64::from(side) / 2.0));
310 let canvas = sheet.canvas.clone();
311 shadows.push(Shadow {
312 key,
313 sheet,
314 used: self.frames,
315 });
316 Some(canvas)
317 }
318
319 pub(super) fn upload_image(&self, image: &RasterImage) -> Image {
320 // The canvas takes straight alpha; the picture's colour was premultiplied in linear
321 // light.
322 let mut pixels = image.pixels().to_vec();
323 for pixel in pixels.chunks_exact_mut(4) {
324 let alpha = f32::from(pixel[3]) / 255.0;
325 if alpha > 0.0 && alpha < 1.0 {
326 for byte in &mut pixel[..3] {
327 *byte = srgb_byte(crate::linear(f32::from(*byte) / 255.0) / alpha);
328 }
329 }
330 }
331 match Target::detached(image.size) {
332 Ok(picture) => {
333 put(&picture.context, [0; 2], image.size, &pixels);
334 Image(Some(picture.canvas))
335 }
336 Err(error) => {
337 report(&error);
338 Image(None)
339 }
340 }
341 }
342
343 /// The target's sRGB RGBA rows, top first.
344 pub(super) fn read_pixels(&self, target: &Target) -> Result<Vec<u8>, String> {
345 let [width, height] = target.size();
346 let data = target
347 .context
348 .get_image_data(0.0, 0.0, width.into(), height.into())
349 .map_err(|error| format!("{error:?}"))?;
350 Ok(data.data().0)
351 }
352
353 /// Clears `target`, `size` device pixels, to linear `clear` and draws the frame.
354 pub(super) fn submit(
355 &mut self,
356 frame: &Frame<'_>,
357 target: &Target,
358 size: [u32; 2],
359 clear: [f32; 4],
360 ) {
361 target.resize(size);
362 self.tint(frame);
363 // Laid over the target only where it is in the page.
364 let onscreen = target.canvas.parent_node().is_some();
365 let leaning = |batch: &&Batch| matches!(batch.blend, Blend::Group(index) if frame.groups[index].motion.tilt != 0.0);
366 let overlays = if onscreen {
367 2 * frame.batches.iter().filter(leaning).count()
368 } else {
369 0
370 };
371 while self.groups.len() < frame.groups.len() || self.overlays.len() < overlays {
372 let made = if self.groups.len() < frame.groups.len() {
373 Target::detached(size).map(|picture| self.groups.push(picture))
374 } else {
375 overlay(self.overlays.last().unwrap_or(target)).map(|o| self.overlays.push(o))
376 };
377 if let Err(error) = made {
378 report(&error);
379 return;
380 }
381 }
382 let gpu = &*self;
383 let light = clear[3] == 0.0 || crate::encode(luminance(clear)) >= 0.5;
384 let mut painter = Painter::new(gpu, frame, target.context.clone(), size, light);
385 if clear[3] < 1.0 {
386 painter
387 .context
388 .clear_rect(0.0, 0.0, size[0].into(), size[1].into());
389 }
390 if clear[3] > 0.0 {
391 painter.context.set_fill_style_str(&css(clear));
392 painter
393 .context
394 .fill_rect(0.0, 0.0, size[0].into(), size[1].into());
395 }
396 let mut used = 0;
397 for batch in frame.batches {
398 match batch.blend {
399 Blend::Group(index) if onscreen && leaning(&batch) => {
400 painter.finish();
401 let [leaned, above] = [used, used + 1].map(|at| &gpu.overlays[at]);
402 leaned.resize(size);
403 let mut group = Painter::new(gpu, frame, leaned.context.clone(), size, light);
404 group
405 .context
406 .clear_rect(0.0, 0.0, size[0].into(), size[1].into());
407 group.batches(&frame.groups[index].batches);
408 group.finish();
409 lean(leaned, target, Some(frame.groups[index].motion), size);
410 above.resize(size);
411 lean(above, target, None, size);
412 painter = Painter::new(gpu, frame, above.context.clone(), size, light);
413 painter
414 .context
415 .clear_rect(0.0, 0.0, size[0].into(), size[1].into());
416 used += 2;
417 }
418 _ => painter.batch(batch),
419 }
420 }
421 painter.finish();
422 for overlay in &gpu.overlays[used..] {
423 let style = overlay.canvas.style();
424 if style.get_property_value("display").as_deref() != Ok("none") {
425 let _ = style.set_property("display", "none");
426 let _ = style.remove_property("transform");
427 let _ = style.remove_property("opacity");
428 }
429 }
430 }
431}
432
433/// The atlas rectangle `[left, top, right, bottom]` a quad copies, the atlas `side` texels
434/// square.
435fn source(quad: &[Vertex], side: f32) -> [f32; 4] {
436 let [u0, v0] = quad[0].uv.map(|value| (value * side).round());
437 let [u1, v1] = quad[2].uv.map(|value| (value * side).round());
438 [u0, v0, u1, v1]
439}
440
441/// The one colour a quad copying a mask from the atlas paints it in, other than white, which
442/// copies as it is.
443fn tinted(quad: &[Vertex]) -> Option<[f32; 4]> {
444 let vertex = &quad[0];
445 let shape = vertex.blur.abs() > 1e-6 || (vertex.shape[0] > 0.0 && vertex.shape[1] > 0.0);
446 let solid = quad[0].uv == quad[2].uv;
447 let color = quad[0].color;
448 (!shape && !solid && quad[1].color == color && color[..3] != [1.0; 3]).then_some(color)
449}
450
451/// Colours what `context` holds within `[x, y, width, height]` in its fill. Clipped there, as
452/// colouring "source-in" clears the whole canvas outside what it fills.
453fn color_in(context: &Context, [x, y, width, height]: [f64; 4]) {
454 context.save();
455 context.begin_path();
456 context.rect(x, y, width, height);
457 context.clip();
458 context.set_global_composite_operation("source-in").ok();
459 context.fill_rect(x, y, width, height);
460 context.restore();
461}
462
463/// Writes straight-alpha `rgba`, `size` pixels, into `context` at `origin`.
464fn put(context: &Context, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) {
465 let written = ImageData::new_with_u8_clamped_array_and_sh(Clamped(rgba), size[0], size[1])
466 .and_then(|data| context.put_image_data(&data, origin[0].into(), origin[1].into()));
467 if let Err(error) = written {
468 report(&format!("{error:?}"));
469 }
470}
471
472fn report(error: &str) {
473 web_sys::console::error_1(&format!("Canvas 2D: {error}").into());
474}
475
476/// A canvas laid just after `previous` in the page, over it, taking no input.
477fn overlay(previous: &Target) -> Result<Target, String> {
478 let overlay = Target::detached([1; 2])?;
479 let canvas = &overlay.canvas;
480 canvas
481 .set_attribute("aria-hidden", "true")
482 .map_err(|error| format!("{error:?}"))?;
483 let style = canvas.style();
484 for (name, value) in [
485 ("position", "fixed"),
486 ("pointer-events", "none"),
487 ("transform-origin", "0 0"),
488 ("display", "none"),
489 ] {
490 style
491 .set_property(name, value)
492 .map_err(|error| format!("{error:?}"))?;
493 }
494 previous
495 .canvas
496 .after_with_node_1(canvas)
497 .map_err(|error| format!("{error:?}"))?;
498 Ok(overlay)
499}
500
501/// Shows `overlay` exactly over `target`, leaned back by `motion` as `Motion::project` leans a
502/// group, or flat without one.
503fn lean(overlay: &Target, target: &Target, motion: Option<Motion>, size: [u32; 2]) {
504 let rect = target.canvas.get_bounding_client_rect();
505 let [width, height] = [rect.width(), rect.height()];
506 let style = overlay.canvas.style();
507 let mut properties = vec![
508 ("display", "block".to_owned()),
509 ("left", format!("{}px", rect.left())),
510 ("top", format!("{}px", rect.top())),
511 ("width", format!("{width}px")),
512 ("height", format!("{height}px")),
513 ];
514 if let Some(motion) = motion.filter(|_| width > 0.0 && height > 0.0) {
515 // CSS pixels per device pixel, on each axis.
516 let ratio = [width / f64::from(size[0]), height / f64::from(size[1])];
517 let [x, y] = [0, 1].map(|axis| f64::from(motion.pivot[axis]) * ratio[axis]);
518 let distance = f64::from(Motion::DISTANCE) * height;
519 properties.push((
520 "transform",
521 format!(
522 "translate({x}px, {y}px) perspective({distance}px) rotateX({}rad) \
523 translate({}px, {}px)",
524 -motion.tilt, -x, -y
525 ),
526 ));
527 properties.push(("opacity", motion.opacity.to_string()));
528 } else {
529 properties.push(("transform", "none".to_owned()));
530 properties.push(("opacity", "1".to_owned()));
531 }
532 for (name, value) in properties {
533 let _ = style.set_property(name, &value);
534 }
535}
536
537/// Where a quad paints: within a scissor rectangle and, where it has one, a rounded rectangle's
538/// middle, half size and corner radius.
539#[derive(Clone, Copy, PartialEq)]
540struct Clip {
541 scissor: [u32; 4],
542 round: Option<[f32; 5]>,
543}
544
545/// Glyph quads of one colour waiting to take it together: their device bounds, and each one's
546/// atlas and device rectangles.
547struct Run {
548 colors: [[f32; 4]; 2],
549 bounds: [f32; 4],
550 quads: Vec<([f32; 4], [f32; 4])>,
551}
552
553/// Draws batches into one context, setting only the state that changes.
554struct Painter<'a> {
555 gpu: &'a Gpu,
556 frame: &'a Frame<'a>,
557 context: Context,
558 size: [u32; 2],
559 /// The clip in force; none paints everywhere.
560 clip: Option<Clip>,
561 composite: &'static str,
562 alpha: f32,
563 run: Option<Run>,
564 /// Whether the frame clears to a light colour, which faded pictures most likely lie on.
565 light: bool,
566}
567
568impl<'a> Painter<'a> {
569 fn new(
570 gpu: &'a Gpu,
571 frame: &'a Frame<'a>,
572 context: Context,
573 size: [u32; 2],
574 light: bool,
575 ) -> Self {
576 context.save();
577 let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
578 context.set_global_composite_operation("source-over").ok();
579 context.set_global_alpha(1.0);
580 context.set_image_smoothing_enabled(true);
581 // Kept between clips: `restore` returns to this state.
582 context.save();
583 Self {
584 gpu,
585 frame,
586 context,
587 size,
588 clip: None,
589 composite: "source-over",
590 alpha: 1.0,
591 run: None,
592 light,
593 }
594 }
595
596 fn finish(&mut self) {
597 self.flush();
598 self.context.restore();
599 self.context.restore();
600 }
601
602 fn batches(&mut self, batches: &[Batch]) {
603 for batch in batches {
604 self.batch(batch);
605 }
606 }
607
608 fn batch(&mut self, batch: &Batch) {
609 let range = batch.vertices.start as usize..batch.vertices.end as usize;
610 let vertices = &self.frame.vertices[range];
611 if let Blend::Group(index) = batch.blend {
612 self.flush();
613 self.set_clip(None);
614 return self.group(index, vertices);
615 }
616 for quad in vertices.chunks_exact(6) {
617 let clip = self.clip_of(batch.scissor, quad);
618 self.set_clip(clip);
619 match batch.blend {
620 Blend::Image(id) => self.image(id, quad),
621 Blend::Over => self.quad(quad, "source-over"),
622 Blend::Erase => self.quad(quad, "destination-out"),
623 Blend::Multiply => self.quad(quad, "multiply"),
624 Blend::Group(_) => unreachable!("Groups paint above"),
625 }
626 }
627 }
628
629 /// What `quad` paints within: none where its scissor and rounded rectangle hold it all,
630 /// as changing the canvas's clip costs.
631 fn clip_of(&self, scissor: [u32; 4], quad: &[Vertex]) -> Option<Clip> {
632 let corners = [&quad[0], &quad[1], &quad[2], &quad[5]].map(|vertex| self.device(vertex));
633 let [mut left, mut top, mut right, mut bottom] = [f32::MAX, f32::MAX, f32::MIN, f32::MIN];
634 for [x, y] in corners {
635 [left, top] = [left.min(x), top.min(y)];
636 [right, bottom] = [right.max(x), bottom.max(y)];
637 }
638 let within =
639 |[x0, y0, x1, y1]: [f32; 4]| left >= x0 && top >= y0 && right <= x1 && bottom <= y1;
640 let full = [0, 0, self.size[0], self.size[1]];
641 let [x, y, width, height] = scissor.map(|value| value as f32);
642 let scissor = if within([x, y, x + width, y + height]) {
643 full
644 } else {
645 scissor
646 };
647 let vertex = &quad[0];
648 let round = (vertex.clip[0] > 0.0)
649 .then(|| {
650 let [x, y] = self.device(vertex);
651 [
652 x - vertex.clip_local[0],
653 y - vertex.clip_local[1],
654 vertex.clip[0],
655 vertex.clip[1],
656 vertex.clip[2],
657 ]
658 .map(snap)
659 })
660 .filter(|[x, y, half_x, half_y, radius]| {
661 !within([
662 x - half_x,
663 y - half_y + radius,
664 x + half_x,
665 y + half_y - radius,
666 ]) && !within([
667 x - half_x + radius,
668 y - half_y,
669 x + half_x - radius,
670 y + half_y,
671 ])
672 });
673 (scissor != full || round.is_some()).then_some(Clip { scissor, round })
674 }
675
676 fn set_clip(&mut self, clip: Option<Clip>) {
677 if self.clip == clip {
678 return;
679 }
680 self.flush();
681 self.context.restore();
682 self.context.save();
683 self.composite = "source-over";
684 self.alpha = 1.0;
685 self.clip = clip;
686 let Some(Clip { scissor, round }) = clip else {
687 return;
688 };
689 let context = &self.context;
690 if scissor != [0, 0, self.size[0], self.size[1]] {
691 context.begin_path();
692 let [x, y, width, height] = scissor.map(f64::from);
693 context.rect(x, y, width, height);
694 context.clip();
695 }
696 if let Some([x, y, half_x, half_y, radius]) = round {
697 let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, x.into(), y.into());
698 context.begin_path();
699 rounded(context, [half_x, half_y], [radius; 2]);
700 let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
701 context.clip();
702 }
703 }
704
705 fn set_blend(&mut self, composite: &'static str, alpha: f32) {
706 if self.composite != composite {
707 self.context.set_global_composite_operation(composite).ok();
708 self.composite = composite;
709 }
710 if self.alpha != alpha {
711 self.context.set_global_alpha(alpha.into());
712 self.alpha = alpha;
713 }
714 }
715
716 /// Device position of `vertex`, whole pixels kept whole.
717 fn device(&self, vertex: &Vertex) -> [f32; 2] {
718 let [width, height] = self.size.map(|side| side as f32);
719 [
720 snap((vertex.position[0] + 1.0) * width / 2.0),
721 snap((1.0 - vertex.position[1]) * height / 2.0),
722 ]
723 }
724
725 /// The device rectangle an axis-aligned quad covers.
726 fn rect(&self, quad: &[Vertex]) -> [f32; 4] {
727 let [left, top] = self.device(&quad[0]);
728 let [right, bottom] = self.device(&quad[2]);
729 [left, top, right, bottom]
730 }
731
732 fn image(&mut self, id: u64, quad: &[Vertex]) {
733 self.flush();
734 self.set_blend("source-over", 1.0);
735 let Image(Some(picture)) = self.frame.image::<Image>(id) else {
736 return;
737 };
738 let [left, top, right, bottom] = self.rect(quad).map(f64::from);
739 let [width, height] = [picture.width(), picture.height()].map(f64::from);
740 let _ = self
741 .context
742 .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
743 picture,
744 0.0,
745 0.0,
746 width,
747 height,
748 left,
749 top,
750 right - left,
751 bottom - top,
752 );
753 }
754
755 fn quad(&mut self, quad: &[Vertex], composite: &'static str) {
756 let vertex = &quad[0];
757 let colors = [quad[0].color, quad[1].color];
758 let solid = quad[0].uv == quad[2].uv;
759 let shape = vertex.blur.abs() > 1e-6 || (vertex.shape[0] > 0.0 && vertex.shape[1] > 0.0);
760 if !shape && !solid {
761 return self.glyph(quad, colors, composite);
762 }
763 self.flush();
764 if shape {
765 return self.shape(quad, colors, composite);
766 }
767 let [left, top, right, bottom] = self.rect(quad).map(f64::from);
768 self.set_blend(composite, 1.0);
769 self.fill(colors, [0.0, top, 0.0, bottom]);
770 self.context
771 .fill_rect(left, top, right - left, bottom - top);
772 }
773
774 /// Sets the fill and stroke to `colors`, shading from `colors[0]` at `span`'s top to
775 /// `colors[1]` at its bottom.
776 fn fill(&self, colors: [[f32; 4]; 2], span: [f64; 4]) {
777 if colors[0] == colors[1] {
778 let color = css(colors[0]);
779 self.context.set_fill_style_str(&color);
780 self.context.set_stroke_style_str(&color);
781 } else {
782 let gradient = self
783 .context
784 .create_linear_gradient(0.0, span[1], 0.0, span[3]);
785 for (stop, color) in [0.0, 1.0].into_iter().zip(colors) {
786 let _ = gradient.add_color_stop(stop, &css(color));
787 }
788 self.context.set_fill_style_canvas_gradient(&gradient);
789 self.context.set_stroke_style_canvas_gradient(&gradient);
790 }
791 }
792
793 /// A rounded rectangle, filled, stroked or dashed, a tapered capsule or a shadow, drawn in
794 /// the quad's own frame.
795 fn shape(&mut self, quad: &[Vertex], colors: [[f32; 4]; 2], composite: &'static str) {
796 let [a, b, d] = [&quad[0], &quad[1], &quad[5]];
797 let [corner, below, across] = [a, b, d].map(|vertex| self.device(vertex));
798 let [span_x, span_y] = [d.local[0] - a.local[0], b.local[1] - a.local[1]];
799 if span_x == 0.0 || span_y == 0.0 {
800 return;
801 }
802 let x_axis = [0, 1].map(|axis| f64::from((across[axis] - corner[axis]) / span_x));
803 let y_axis = [0, 1].map(|axis| f64::from((below[axis] - corner[axis]) / span_y));
804 let origin = [0, 1].map(|axis| {
805 f64::from(corner[axis])
806 - x_axis[axis] * f64::from(a.local[0])
807 - y_axis[axis] * f64::from(a.local[1])
808 });
809 self.set_blend(composite, 1.0);
810 let context = &self.context;
811 let _ = context.set_transform(
812 x_axis[0], x_axis[1], y_axis[0], y_axis[1], origin[0], origin[1],
813 );
814 let span = [0.0, a.local[1], 0.0, b.local[1]].map(f64::from);
815 let [half_x, half_y, radius_x, radius_y] = a.shape;
816 context.begin_path();
817 if a.blur > 1e-6 {
818 let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
819 let color = colors[0];
820 // A wide blur loses nothing drawn smaller and enlarged, which a browser blurring
821 // in software does many times faster.
822 let scale = (a.blur / 3.0).floor().clamp(1.0, 4.0);
823 let [half, radius, sigma] = [[half_x, half_y], [radius_x; 2], [a.blur; 2]]
824 .map(|values| values.map(|value| value / scale));
825 let reach = (3.0 * sigma[0]).ceil() + 2.0;
826 let size = half.map(|half| (2.0 * (half + reach)).ceil() as u32);
827 let key = (half.into_iter().chain([radius[0], sigma[0]]).chain(color))
828 .map(f32::to_bits)
829 .collect::<Vec<_>>();
830 let small = |context: &Context, middle: [f64; 2]| {
831 cast(context, middle, half, radius[0], sigma[0], color);
832 };
833 match self.gpu.shadow(key, size, small) {
834 Some(sheet) => {
835 let [width, height] = size.map(|side| f64::from(side as f32 * scale));
836 let _ = context.draw_image_with_html_canvas_element_and_dw_and_dh(
837 &sheet,
838 origin[0] - width / 2.0,
839 origin[1] - height / 2.0,
840 width,
841 height,
842 );
843 }
844 None => cast(context, origin, [half_x, half_y], radius_x, a.blur, color),
845 }
846 } else if a.blur < -0.5 {
847 self.fill(colors, span);
848 taper(context, half_x, half_y, radius_x);
849 context.fill();
850 } else {
851 self.fill(colors, span);
852 let width = a.stroke.abs();
853 let radii = [radius_x, radius_y];
854 if a.stroke < -1e-6 {
855 let middle = radii.map(|radius| (radius - width / 2.0).max(0.0));
856 let middle = if middle.contains(&0.0) {
857 [0.0; 2]
858 } else {
859 middle
860 };
861 rounded(
862 context,
863 [half_x - width / 2.0, half_y - width / 2.0],
864 middle,
865 );
866 let dash = js_sys::Array::of2(&(2.0 * width).into(), &(2.0 * width).into());
867 let _ = context.set_line_dash(&dash);
868 context.set_line_dash_offset(-f64::from(width));
869 context.set_line_width(width.into());
870 context.stroke();
871 let _ = context.set_line_dash(&js_sys::Array::new());
872 } else {
873 rounded(context, [half_x, half_y], radii);
874 if width > 1e-6 && width < half_x.min(half_y) {
875 let inner = radii.map(|radius| (radius - width).max(0.0));
876 rounded(context, [half_x - width, half_y - width], inner);
877 context.fill_with_canvas_winding_rule(CanvasWindingRule::Evenodd);
878 } else {
879 context.fill();
880 }
881 }
882 }
883 let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
884 }
885
886 /// A glyph, icon or path from the atlas: drawn as it is where white, else gathered with
887 /// glyphs of its colour.
888 fn glyph(&mut self, quad: &[Vertex], colors: [[f32; 4]; 2], composite: &'static str) {
889 let source = source(quad, self.gpu.atlas_side() as f32);
890 let rect = self.rect(quad);
891 let white = colors[0] == colors[1] && colors[0][..3] == [1.0; 3];
892 if composite != "source-over" || white {
893 self.flush();
894 // A faded icon's colours are its own; it fades as a mid grey would over the
895 // frame's light or dark colour.
896 let grey = if self.light { 0.18 } else { 0.5 };
897 let alpha = if white {
898 opacity([grey, grey, grey, colors[0][3]])
899 } else {
900 colors[0][3]
901 };
902 self.set_blend(composite, alpha);
903 return self.copy(&self.gpu.atlas.canvas, &self.context, source, rect);
904 }
905 if let Some(color) = tinted(quad)
906 && let Some(tint) =
907 (self.gpu.tints.iter()).find(|tint| tint.color == color.map(f32::to_bits))
908 && let Some([x, y]) = tint.glyphs.get(&source.map(|value| value as u32))
909 {
910 self.flush();
911 self.set_blend(composite, 1.0);
912 let [x, y] = [*x, *y].map(|value| value as f32);
913 let colored = [x, y, x + source[2] - source[0], y + source[3] - source[1]];
914 return self.copy(&tint.sheet.canvas, &self.context, colored, rect);
915 }
916 if let Some(run) = &mut self.run
917 && run.colors == colors
918 && colors[0] == colors[1]
919 {
920 run.bounds = [
921 run.bounds[0].min(rect[0]),
922 run.bounds[1].min(rect[1]),
923 run.bounds[2].max(rect[2]),
924 run.bounds[3].max(rect[3]),
925 ];
926 run.quads.push((source, rect));
927 return;
928 }
929 self.flush();
930 self.run = Some(Run {
931 colors,
932 bounds: rect,
933 quads: vec![(source, rect)],
934 });
935 }
936
937 /// Copies `source` of `sheet`, the atlas or a tint of it, to `rect` of `context`.
938 fn copy(&self, sheet: &Canvas, context: &Context, source: [f32; 4], rect: [f32; 4]) {
939 let [u0, v0, u1, v1] = source.map(f64::from);
940 let [left, top, right, bottom] = rect.map(f64::from);
941 let _ = context
942 .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
943 sheet,
944 u0,
945 v0,
946 u1 - u0,
947 v1 - v0,
948 left,
949 top,
950 right - left,
951 bottom - top,
952 );
953 }
954
955 /// Paints the glyphs waiting for their colour: their coverage gathered in the scratch
956 /// canvas, coloured there, then laid over the target.
957 fn flush(&mut self) {
958 let Some(run) = self.run.take() else {
959 return;
960 };
961 let [width, height] = self.size.map(|side| side as f32);
962 let [left, top] = [
963 run.bounds[0].max(0.0).floor(),
964 run.bounds[1].max(0.0).floor(),
965 ];
966 let [right, bottom] = [
967 run.bounds[2].min(width).ceil(),
968 run.bounds[3].min(height).ceil(),
969 ];
970 if right <= left || bottom <= top {
971 return;
972 }
973 let area = [left, top, right - left, bottom - top].map(f64::from);
974 // As small as the run, as the browser copies the whole scratch canvas each time it is
975 // drawn from after being drawn into.
976 let [wide, tall] = [right - left, bottom - top].map(|side| side as u32);
977 let scratch = &self.gpu.scratch;
978 let [have_wide, have_tall] = scratch.size();
979 if have_wide < wide || have_tall < tall {
980 scratch.resize(
981 [wide.max(have_wide), tall.max(have_tall)].map(|side| side.next_multiple_of(64)),
982 );
983 }
984 let scratch = &scratch.context;
985 scratch.clear_rect(0.0, 0.0, area[2], area[3]);
986 let _ = scratch.set_transform(1.0, 0.0, 0.0, 1.0, -area[0], -area[1]);
987 for (source, rect) in &run.quads {
988 self.copy(&self.gpu.atlas.canvas, scratch, *source, *rect);
989 }
990 let [top_color, bottom_color] = run.colors;
991 if top_color == bottom_color {
992 scratch.set_fill_style_str(&css(top_color));
993 } else {
994 let gradient = scratch.create_linear_gradient(
995 0.0,
996 run.bounds[1].into(),
997 0.0,
998 run.bounds[3].into(),
999 );
1000 let _ = gradient.add_color_stop(0.0, &css(top_color));
1001 let _ = gradient.add_color_stop(1.0, &css(bottom_color));
1002 scratch.set_fill_style_canvas_gradient(&gradient);
1003 }
1004 color_in(scratch, area);
1005 let _ = scratch.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
1006 self.set_blend("source-over", 1.0);
1007 let _ = self
1008 .context
1009 .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
1010 &self.gpu.scratch.canvas,
1011 0.0,
1012 0.0,
1013 area[2],
1014 area[3],
1015 area[0],
1016 area[1],
1017 area[2],
1018 area[3],
1019 );
1020 }
1021
1022 /// Paints `group` offscreen, then its picture over the target, faded. One leaning back
1023 /// leans in a canvas of its own (`lean`) where the target is in the page, and lies flat on
1024 /// one that isn't, as the canvas has no perspective.
1025 fn group(&mut self, index: usize, vertices: &[Vertex]) {
1026 let group = &self.frame.groups[index];
1027 let picture = &self.gpu.groups[index];
1028 picture.resize(self.size);
1029 let [width, height] = self.size.map(|side| side as f32);
1030 // The picture's painted bounds, as the group's batch samples it.
1031 let [mut left, mut top, mut right, mut bottom] = [width, height, 0.0, 0.0];
1032 for vertex in vertices {
1033 let [x, y] = [vertex.uv[0] * width, vertex.uv[1] * height];
1034 [left, top] = [left.min(x), top.min(y)];
1035 [right, bottom] = [right.max(x), bottom.max(y)];
1036 }
1037 if right <= left || bottom <= top {
1038 return;
1039 }
1040 let mut painter = Painter::new(
1041 self.gpu,
1042 self.frame,
1043 picture.context.clone(),
1044 self.size,
1045 self.light,
1046 );
1047 painter
1048 .context
1049 .clear_rect(0.0, 0.0, width.into(), height.into());
1050 painter.batches(&group.batches);
1051 painter.finish();
1052 self.set_blend("source-over", group.motion.opacity);
1053 let [x, y, w, h] = [left, top, right - left, bottom - top].map(f64::from);
1054 let _ = self
1055 .context
1056 .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
1057 &picture.canvas,
1058 x,
1059 y,
1060 w,
1061 h,
1062 x,
1063 y,
1064 w,
1065 h,
1066 );
1067 }
1068}
1069
1070/// `value`, made whole within a thousandth of a whole number, as quads placed on whole pixels
1071/// are, so pictures on them copy without resampling.
1072fn snap(value: f32) -> f32 {
1073 if (value - value.round()).abs() < 1e-3 {
1074 value.round()
1075 } else {
1076 value
1077 }
1078}
1079
1080/// Draws the shadow a rounded rectangle about `middle`, `half` its half size, casts: blurred
1081/// by `sigma` device pixels, in `color`.
1082fn cast(
1083 context: &Context,
1084 middle: [f64; 2],
1085 half: [f32; 2],
1086 radius: f32,
1087 sigma: f32,
1088 color: [f32; 4],
1089) {
1090 // The canvas's shadow is a gaussian of half its blur as deviation, as `sigma` is here; its
1091 // offset and blur ignore the transform. The rectangle itself lies far off the canvas.
1092 let side = context
1093 .canvas()
1094 .map_or(0, |canvas| canvas.width().max(canvas.height()));
1095 let away = 4.0 * f64::from(side) + 8.0 * f64::from(sigma);
1096 let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, middle[0] - away, middle[1]);
1097 context.begin_path();
1098 rounded(context, half, [radius; 2]);
1099 context.set_shadow_color(&css(color));
1100 context.set_shadow_blur(2.0 * f64::from(sigma));
1101 context.set_shadow_offset_x(away);
1102 context.set_fill_style_str("#000");
1103 context.fill();
1104 context.set_shadow_color("transparent");
1105 context.set_shadow_blur(0.0);
1106 context.set_shadow_offset_x(0.0);
1107 let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
1108}
1109
1110/// Adds a rounded rectangle about the origin, `half` its half size, to the path: clockwise
1111/// from the middle of its right edge, which is where a dashed outline's pattern starts.
1112fn rounded(context: &Context, half: [f32; 2], radius: [f32; 2]) {
1113 let [x, y] = half.map(f64::from);
1114 if radius.iter().any(|radius| *radius <= 0.0) {
1115 context.rect(-x, -y, 2.0 * x, 2.0 * y);
1116 return;
1117 }
1118 let [rx, ry] = [0, 1].map(|axis| f64::from(radius[axis].min(half[axis])));
1119 context.move_to(x, 0.0);
1120 for (corner, from) in [
1121 ([x - rx, y - ry], 0.0),
1122 ([-x + rx, y - ry], FRAC_PI_2),
1123 ([-x + rx, -y + ry], PI),
1124 ([x - rx, -y + ry], 3.0 * FRAC_PI_2),
1125 ] {
1126 let _ = context.ellipse(corner[0], corner[1], rx, ry, 0.0, from, from + FRAC_PI_2);
1127 }
1128 context.close_path();
1129}
1130
1131/// Adds the tapered capsule from `(-half, 0)` to `(half, 0)`, round ends `radii` across, to
1132/// the path: the two circles and the lines touching both.
1133fn taper(context: &Context, half: f32, start: f32, end: f32) {
1134 let [half, start, end] = [half, start, end].map(f64::from);
1135 let slope = (start - end) / (2.0 * half).max(1e-6);
1136 if slope.abs() >= 1.0 {
1137 let (x, radius) = if start >= end {
1138 (-half, start)
1139 } else {
1140 (half, end)
1141 };
1142 let _ = context.arc(x, 0.0, radius, 0.0, 2.0 * PI);
1143 return;
1144 }
1145 // Where the touching lines meet each circle, by their outward normal's angle.
1146 let angle = (1.0 - slope * slope).sqrt().atan2(slope);
1147 context.move_to(-half + start * angle.cos(), start * angle.sin());
1148 let _ = context.arc_with_anticlockwise(half, 0.0, end, angle, -angle, true);
1149 let _ = context.arc_with_anticlockwise(-half, 0.0, start, -angle, angle, true);
1150 context.close_path();
1151}
1152
1153/// A CSS colour for linear `color`, its opacity corrected for the canvas's sRGB blending.
1154fn css(color: [f32; 4]) -> String {
1155 let [red, green, blue] = srgb_bytes(color);
1156 format!("rgba({red},{green},{blue},{:.4})", opacity(color))
1157}
1158
1159/// The opacity that, blended sRGB-encoded as the canvas blends, shows `color` as linear light
1160/// blends it over what it stands out against most: white under a dark colour, black under a
1161/// light one.
1162fn opacity([red, green, blue, alpha]: [f32; 4]) -> f32 {
1163 if alpha <= 0.0 || alpha >= 1.0 {
1164 return alpha.clamp(0.0, 1.0);
1165 }
1166 let ink = luminance([red, green, blue, alpha]);
1167 let shown = crate::encode(ink);
1168 let corrected = if shown < 0.5 {
1169 (1.0 - crate::encode(1.0 - alpha * (1.0 - ink))) / (1.0 - shown)
1170 } else {
1171 crate::encode(alpha * ink) / shown
1172 };
1173 corrected.clamp(0.0, 1.0)
1174}
1175
1176fn luminance([red, green, blue, _]: [f32; 4]) -> f32 {
1177 0.2126 * red + 0.7152 * green + 0.0722 * blue
1178}
crates/draw/src/render/d3d11.rs+7-2
......@@ -354,6 +354,11 @@ impl Gpu {
354354 Ok(pixels)
355355 }
356356
357 /// Blends in linear light, its targets being sRGB.
358 pub(super) fn blends_linear(&self) -> bool {
359 true
360 }
361
357362 /// Offscreen pictures' rows run top first.
358363 pub(super) fn flipped(&self) -> bool {
359364 false
......@@ -514,8 +519,8 @@ impl Gpu {
514519 context.Draw(batch.vertices.len() as u32, batch.vertices.start);
515520 }
516521 };
517 for (batches, picture) in frame.groups.iter().zip(&gpu.groups) {
518 paint(picture, [0.0; 4], batches);
522 for (group, picture) in frame.groups.iter().zip(&gpu.groups) {
523 paint(picture, [0.0; 4], &group.batches);
519524 }
520525 paint(target, clear, frame.batches);
521526 unsafe {
crates/draw/src/render/dual.rs+109-29
......@@ -1,14 +1,23 @@
1//! Every backend in one build, for Windows: wgpu where Direct3D 12 answers, Direct3D 11
2//! where it doesn't, as on Windows 7, and OpenGL 2.1 where neither does. Whoever makes the
3//! renderer picks one.
4use super::{d3d11, gl, webgpu, *};
5use std::ffi::c_void;
1//! Every backend a platform offers in one build, its maker picking one: wgpu everywhere,
2//! with Direct3D 11 and OpenGL 2.1 on Windows, where Windows 7 has no Direct3D 12, OpenGL
3//! on macOS, and the 2D canvas in a browser with neither WebGPU nor WebGL2.
4#[cfg(target_arch = "wasm32")]
5use super::canvas2d;
6#[cfg(windows)]
7use super::d3d11;
8#[cfg(any(windows, target_os = "macos"))]
9use super::gl;
10use super::{webgpu, *};
611
712/// What a frame is drawn into, for the backend the renderer draws with.
813pub enum Target {
914 Wgpu(wgpu::TextureView),
15 #[cfg(windows)]
1016 D3d11(d3d11::Target),
17 #[cfg(any(windows, target_os = "macos"))]
1118 Gl(gl::Target),
19 #[cfg(target_arch = "wasm32")]
20 Canvas(canvas2d::Target),
1221}
1322
1423impl From<wgpu::TextureView> for Target {
......@@ -17,53 +26,71 @@ impl From<wgpu::TextureView> for Target {
1726 }
1827}
1928
29#[cfg(target_arch = "wasm32")]
30impl Target {
31 /// Frames drawn into `canvas` with its 2D context.
32 pub fn canvas2d(canvas: web_sys::HtmlCanvasElement) -> Result<Self, String> {
33 canvas2d::Target::new(canvas).map(Self::Canvas)
34 }
35}
36
2037impl Target {
2138 pub fn size(&self) -> [u32; 2] {
2239 match self {
2340 Self::Wgpu(view) => [view.texture().width(), view.texture().height()],
41 #[cfg(windows)]
2442 Self::D3d11(target) => target.size(),
43 #[cfg(any(windows, target_os = "macos"))]
2544 Self::Gl(target) => target.size(),
45 #[cfg(target_arch = "wasm32")]
46 Self::Canvas(target) => target.size(),
2647 }
2748 }
2849}
2950
3051pub(super) enum Image {
3152 Wgpu(webgpu::Image),
53 #[cfg(windows)]
3254 D3d11(d3d11::Image),
55 #[cfg(any(windows, target_os = "macos"))]
3356 Gl(gl::Image),
57 #[cfg(target_arch = "wasm32")]
58 Canvas(canvas2d::Image),
3459}
3560
36impl AsRef<webgpu::Image> for Image {
37 fn as_ref(&self) -> &webgpu::Image {
38 match self {
39 Self::Wgpu(image) => image,
40 _ => unreachable!("Images belong to the renderer's backend"),
41 }
42 }
43}
44
45impl AsRef<d3d11::Image> for Image {
46 fn as_ref(&self) -> &d3d11::Image {
47 match self {
48 Self::D3d11(image) => image,
49 _ => unreachable!("Images belong to the renderer's backend"),
61/// `AsRef` from `Image` to its variant `$variant`, which holds `$image`.
62macro_rules! image {
63 ($variant:ident, $image:ty) => {
64 impl AsRef<$image> for Image {
65 fn as_ref(&self) -> &$image {
66 match self {
67 Self::$variant(image) => image,
68 #[allow(unreachable_patterns)]
69 _ => unreachable!("Images belong to the renderer's backend"),
70 }
71 }
5072 }
51 }
73 };
5274}
5375
54impl AsRef<gl::Image> for Image {
55 fn as_ref(&self) -> &gl::Image {
56 match self {
57 Self::Gl(image) => image,
58 _ => unreachable!("Images belong to the renderer's backend"),
59 }
60 }
61}
76image!(Wgpu, webgpu::Image);
77#[cfg(windows)]
78image!(D3d11, d3d11::Image);
79#[cfg(any(windows, target_os = "macos"))]
80image!(Gl, gl::Image);
81#[cfg(target_arch = "wasm32")]
82image!(Canvas, canvas2d::Image);
6283
84// A renderer holds one, so its size goes unnoticed.
85#[allow(clippy::large_enum_variant)]
6386pub(super) enum Gpu {
6487 Wgpu(webgpu::Gpu),
88 #[cfg(windows)]
6589 D3d11(d3d11::Gpu),
90 #[cfg(any(windows, target_os = "macos"))]
6691 Gl(gl::Gpu),
92 #[cfg(target_arch = "wasm32")]
93 Canvas(canvas2d::Gpu),
6794}
6895
6996impl From<webgpu::Gpu> for Gpu {
......@@ -72,16 +99,44 @@ impl From<webgpu::Gpu> for Gpu {
7299 }
73100}
74101
102#[cfg(any(windows, target_os = "macos"))]
75103impl From<gl::Gpu> for Gpu {
76104 fn from(gpu: gl::Gpu) -> Self {
77105 Self::Gl(gpu)
78106 }
79107}
80108
109#[cfg(target_arch = "wasm32")]
110impl From<canvas2d::Gpu> for Gpu {
111 fn from(gpu: canvas2d::Gpu) -> Self {
112 Self::Canvas(gpu)
113 }
114}
115
116#[cfg(not(windows))]
117impl Renderer {
118 /// The device a renderer `Renderer::new` made draws with.
119 pub fn device(&self) -> &wgpu::Device {
120 self.wgpu().device()
121 }
122
123 pub fn queue(&self) -> &wgpu::Queue {
124 self.wgpu().queue()
125 }
126
127 fn wgpu(&self) -> &webgpu::Gpu {
128 match &self.gpu {
129 Gpu::Wgpu(gpu) => gpu,
130 _ => unreachable!("Only `Renderer::new` makes a renderer drawing with wgpu"),
131 }
132 }
133}
134
135#[cfg(windows)]
81136impl Renderer {
82137 /// Draws with Direct3D 11 into a swap chain on `window`, an `HWND`; with the adapter's
83138 /// description.
84 pub fn direct3d11(window: *mut c_void) -> Result<(Self, String), String> {
139 pub fn direct3d11(window: *mut std::ffi::c_void) -> Result<(Self, String), String> {
85140 d3d11::Gpu::new(window)
86141 .map(|(gpu, adapter)| (Self::with_gpu(Gpu::D3d11(gpu)), adapter))
87142 .map_err(|error| error.to_string())
......@@ -93,15 +148,21 @@ macro_rules! each {
93148 ($gpu:expr, $backend:ident => $body:expr) => {
94149 match $gpu {
95150 Gpu::Wgpu($backend) => $body,
151 #[cfg(windows)]
96152 Gpu::D3d11($backend) => $body,
153 #[cfg(any(windows, target_os = "macos"))]
97154 Gpu::Gl($backend) => $body,
155 #[cfg(target_arch = "wasm32")]
156 Gpu::Canvas($backend) => $body,
98157 }
99158 };
100159}
101160
102161impl Gpu {
162 #[cfg(any(windows, target_os = "macos"))]
103163 pub(super) fn target(&self, size: [u32; 2]) -> Result<Target, String> {
104164 match self {
165 #[cfg(windows)]
105166 Self::D3d11(gpu) => gpu
106167 .target(size)
107168 .map(Target::D3d11)
......@@ -111,9 +172,11 @@ impl Gpu {
111172 }
112173 }
113174
175 #[cfg(any(windows, target_os = "macos"))]
114176 pub(super) fn present(&self, target: &Target, translucent: bool) {
115177 match (self, target) {
116178 // Direct3D 11 premultiplies every frame again, which leaves opaque ones be.
179 #[cfg(windows)]
117180 (Self::D3d11(gpu), Target::D3d11(target)) => {
118181 if let Err(error) = gpu.present(target) {
119182 eprintln!("Presenting failed: {error}");
......@@ -126,14 +189,22 @@ impl Gpu {
126189
127190 pub(super) fn read_pixels(&self, target: &Target) -> Result<Vec<u8>, String> {
128191 match (self, target) {
192 #[cfg(windows)]
129193 (Self::D3d11(gpu), Target::D3d11(target)) => {
130194 gpu.read_pixels(target).map_err(|error| error.to_string())
131195 }
196 #[cfg(any(windows, target_os = "macos"))]
132197 (Self::Gl(gpu), Target::Gl(target)) => gpu.read_pixels(target),
198 #[cfg(target_arch = "wasm32")]
199 (Self::Canvas(gpu), Target::Canvas(target)) => gpu.read_pixels(target),
133200 _ => unreachable!("Snapshots come from the renderer's backend's targets"),
134201 }
135202 }
136203
204 pub(super) fn blends_linear(&self) -> bool {
205 each!(self, gpu => gpu.blends_linear())
206 }
207
137208 pub(super) fn flipped(&self) -> bool {
138209 each!(self, gpu => gpu.flipped())
139210 }
......@@ -157,8 +228,12 @@ impl Gpu {
157228 pub(super) fn upload_image(&self, image: &RasterImage) -> Image {
158229 match self {
159230 Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)),
231 #[cfg(windows)]
160232 Self::D3d11(gpu) => Image::D3d11(gpu.upload_image(image)),
233 #[cfg(any(windows, target_os = "macos"))]
161234 Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)),
235 #[cfg(target_arch = "wasm32")]
236 Self::Canvas(gpu) => Image::Canvas(gpu.upload_image(image)),
162237 }
163238 }
164239
......@@ -171,8 +246,13 @@ impl Gpu {
171246 ) {
172247 match (self, target) {
173248 (Self::Wgpu(gpu), Target::Wgpu(view)) => gpu.submit(frame, view, size, clear),
249 #[cfg(windows)]
174250 (Self::D3d11(gpu), Target::D3d11(target)) => gpu.submit(frame, target, size, clear),
251 #[cfg(any(windows, target_os = "macos"))]
175252 (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear),
253 #[cfg(target_arch = "wasm32")]
254 (Self::Canvas(gpu), Target::Canvas(target)) => gpu.submit(frame, target, size, clear),
255 #[allow(unreachable_patterns)]
176256 _ => unreachable!("Frames are drawn into the renderer's backend's targets"),
177257 }
178258 }
crates/draw/src/render/gl.rs+7-2
......@@ -434,6 +434,11 @@ impl Gpu {
434434 }
435435 }
436436
437 /// Blends in linear light, its targets being sRGB.
438 pub(super) fn blends_linear(&self) -> bool {
439 true
440 }
441
437442 /// Offscreen pictures' rows run bottom first.
438443 pub(super) fn flipped(&self) -> bool {
439444 true
......@@ -581,8 +586,8 @@ impl Gpu {
581586 );
582587 }
583588 glEnable(BLEND);
584 for (batches, picture) in frame.groups.iter().zip(&gpu.groups) {
585 paint(picture, [0.0; 4], batches);
589 for (group, picture) in frame.groups.iter().zip(&gpu.groups) {
590 paint(picture, [0.0; 4], &group.batches);
586591 }
587592 paint(target, clear, frame.batches);
588593 glDisable(SCISSOR_TEST);
crates/draw/src/render/webgpu.rs+27-7
......@@ -2,7 +2,7 @@
22use super::*;
33
44/// What a frame is drawn into.
5#[cfg(not(windows))]
5#[cfg(not(any(windows, target_os = "macos", target_arch = "wasm32")))]
66pub type Target = wgpu::TextureView;
77
88/// A picture's texture, as its batches bind it.
......@@ -34,14 +34,25 @@ impl Renderer {
3434 Self::with_gpu(Gpu::new(device, queue, format))
3535 }
3636
37 #[cfg(not(windows))]
37 #[cfg(not(any(windows, target_os = "macos", target_arch = "wasm32")))]
3838 pub fn device(&self) -> &wgpu::Device {
39 &self.gpu.device
39 self.gpu.device()
4040 }
4141
42 #[cfg(not(windows))]
42 #[cfg(not(any(windows, target_os = "macos", target_arch = "wasm32")))]
4343 pub fn queue(&self) -> &wgpu::Queue {
44 &self.gpu.queue
44 self.gpu.queue()
45 }
46}
47
48#[cfg(not(windows))]
49impl Gpu {
50 pub(super) fn device(&self) -> &wgpu::Device {
51 &self.device
52 }
53
54 pub(super) fn queue(&self) -> &wgpu::Queue {
55 &self.queue
4556 }
4657}
4758
......@@ -154,6 +165,11 @@ impl Gpu {
154165 }
155166 }
156167
168 /// Blends in linear light, its targets being sRGB.
169 pub(super) fn blends_linear(&self) -> bool {
170 true
171 }
172
157173 pub(super) fn flipped(&self) -> bool {
158174 false
159175 }
......@@ -273,8 +289,12 @@ impl Gpu {
273289 pass.draw(batch.vertices.clone(), 0..1);
274290 }
275291 };
276 for (batches, (picture, _)) in frame.groups.iter().zip(&gpu.groups) {
277 paint(&picture.create_view(&Default::default()), [0.0; 4], batches);
292 for (group, (picture, _)) in frame.groups.iter().zip(&gpu.groups) {
293 paint(
294 &picture.create_view(&Default::default()),
295 [0.0; 4],
296 &group.batches,
297 );
278298 }
279299 paint(target, clear, frame.batches);
280300 gpu.queue.submit([encoder.finish()]);
crates/mobile/src/lib.rs+1-1
......@@ -783,7 +783,7 @@ impl View {
783783 let primitives = canvas.page.primitives(canvas.colors())?;
784784 renderer
785785 .draw(
786 &frame.texture.create_view(&Default::default()),
786 &frame.texture.create_view(&Default::default()).into(),
787787 [self.config.width, self.config.height],
788788 paper.color,
789789 &[canvas.page.viewport.layer(&primitives)],
crates/snowbound/Cargo.toml+1-1
......@@ -57,7 +57,7 @@ spellbook = "0.4"
5757wasm-bindgen = "0.2"
5858wasm-bindgen-futures = "0.4"
5959js-sys = "0.3"
60web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Window"] }
60web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Node", "Window"] }
6161
6262[target.'cfg(target_os = "macos")'.dependencies]
6363libc = "0.2"
crates/snowbound/src/conflict_render.rs+1-1
......@@ -156,7 +156,7 @@ fn paint(
156156 });
157157 renderer
158158 .draw(
159 &texture.create_view(&Default::default()),
159 &texture.create_view(&Default::default()).into(),
160160 size,
161161 ui.theme.base,
162162 &layers,
crates/snowbound/src/macos.rs+27
......@@ -488,6 +488,25 @@ pub fn install_backdrop(window: &Window) -> bool {
488488 true
489489}
490490
491/// Shows or hides the material `install_backdrop` put behind the window's frames: OpenGL
492/// draws into the view's own layer, which the material's view covers.
493#[cfg(feature = "wgpu")]
494pub fn show_backdrop(window: &Window, shown: bool) {
495 let Some(class) = BACKDROP_CLASS.get() else {
496 return;
497 };
498 let view = &*ns_view(window);
499 unsafe {
500 let subviews: Retained<objc2_foundation::NSArray<AnyObject>> = msg_send_id![view, subviews];
501 for subview in subviews.iter() {
502 let ours: bool = msg_send![subview, isKindOfClass: *class];
503 if ours {
504 let _: () = msg_send![subview, setHidden: !shown];
505 }
506 }
507 }
508}
509
491510/// AppKit's window frame takes resizing presses.
492511pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::ResizeDirection> {
493512 None
......@@ -1339,6 +1358,14 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) {
13391358 }
13401359}
13411360
1361/// Takes the surface's layer out of the view, for a renderer started after it.
1362#[cfg(feature = "wgpu")]
1363pub fn release_presentation(surface: &wgpu::Surface<'_>) {
1364 if let Some(surface) = unsafe { surface.as_hal::<wgpu::hal::api::Metal>() } {
1365 surface.render_layer().lock().removeFromSuperlayer();
1366 }
1367}
1368
13421369/// Commits a frame presented with the transaction: winit redraws after Core Animation's
13431370/// commit observer, so otherwise the frame waits for the next event. A live resize leaves
13441371/// the commit to AppKit, which pairs the frame with the window's new size.
crates/snowbound/src/main.rs+172-6
......@@ -77,7 +77,9 @@ mod sidebar;
7777#[cfg_attr(target_arch = "wasm32", path = "spell_web.rs")]
7878mod spell;
7979#[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")]
80#[cfg_attr(all(target_os = "macos", feature = "wgpu"), path = "surface_macos.rs")]
8081#[cfg_attr(windows, path = "surface_windows.rs")]
82#[cfg_attr(target_arch = "wasm32", path = "surface_web.rs")]
8183mod surface;
8284mod symbol;
8385mod sync;
......@@ -305,6 +307,8 @@ enum UserEvent {
305307 /// forward despite the desktop's focus-stealing prevention.
306308 #[cfg(target_os = "linux")]
307309 Activate(String),
310 /// Draws with this backend from now on.
311 Renderer(settings::Backend),
308312}
309313
310314/// Asks the event loop for a frame from any thread.
......@@ -384,6 +388,8 @@ enum Replay {
384388 Appearance(winit::window::Theme),
385389 /// Resizes the window's content, in points.
386390 Resize([f32; 2]),
391 /// Draws with this backend from then on, as Options' Renderer does.
392 Renderer(settings::Backend),
387393 Quit,
388394}
389395
......@@ -783,8 +789,18 @@ struct State {
783789 swipe: navigation::Swipe,
784790 /// Asks for a frame when a worker thread finishes something the page shows.
785791 redraw: std::task::Waker,
786 surface: surface::Surface,
792 /// Dropped before `surface`, as an OpenGL renderer needs its context.
787793 renderer: Renderer,
794 surface: surface::Surface,
795 /// Whether the system's material shows behind the window, which a surface started
796 /// again keeps.
797 backdrop: bool,
798 /// Options' Renderer, as the settings keep it.
799 renderer_choice: settings::Backend,
800 /// The backend frames are drawn with, and its adapter's name.
801 drawing: (settings::Backend, String),
802 /// Backends that failed to start this run, and why.
803 unavailable: Vec<(settings::Backend, String)>,
788804 ui: Ui,
789805 view: PageView,
790806 /// The open notebooks in the sidebar's order.
......@@ -972,6 +988,62 @@ fn sections() -> Id {
972988 Id::ROOT.child("sections")
973989}
974990
991/// Why no backend could draw the window: each one tried, and how it failed.
992#[derive(Debug)]
993struct NoGraphics(Vec<(settings::Backend, String)>);
994
995impl std::fmt::Display for NoGraphics {
996 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
997 let tried: Vec<_> = (self.0.iter())
998 .map(|(backend, error)| format!("{} ({error})", backend.label()))
999 .collect();
1000 write!(f, "Nothing could draw the window: {}", tried.join("; "))
1001 }
1002}
1003
1004impl Error for NoGraphics {}
1005
1006/// Notes what drawing started or failed with, where the platform keeps such notes.
1007fn note(message: &str) {
1008 #[cfg(target_arch = "wasm32")]
1009 web_sys::console::info_1(&message.into());
1010 #[cfg(not(target_arch = "wasm32"))]
1011 eprintln!("{message}");
1012}
1013
1014/// Starts the window's surface with `chosen`, or else with the platform's backends in turn,
1015/// skipping those in `unavailable`, which gains each that fails. Answers the backend that
1016/// started and its adapter's name.
1017async fn start_surface(
1018 window: &Arc<Window>,
1019 backdrop: bool,
1020 chosen: settings::Backend,
1021 unavailable: &mut Vec<(settings::Backend, String)>,
1022) -> Result<(surface::Surface, Renderer, (settings::Backend, String)), Box<dyn Error>> {
1023 let mut order = vec![chosen];
1024 order.extend(settings::Backend::PLATFORM.iter().filter(|backend| {
1025 **backend != chosen && !unavailable.iter().any(|(failed, _)| failed == *backend)
1026 }));
1027 for backend in order {
1028 if backend == settings::Backend::Default {
1029 continue;
1030 }
1031 match surface::Surface::new(window.clone(), backdrop, backend).await {
1032 Ok((surface, renderer, adapter)) => {
1033 note(&format!("Drawing with {} ({adapter})", backend.label()));
1034 unavailable.retain(|(failed, _)| *failed != backend);
1035 return Ok((surface, renderer, (backend, adapter)));
1036 }
1037 Err(error) => {
1038 note(&format!("{} didn't start: {error}", backend.label()));
1039 unavailable.retain(|(failed, _)| *failed != backend);
1040 unavailable.push((backend, error.to_string()));
1041 }
1042 }
1043 }
1044 Err(Box::new(NoGraphics(unavailable.clone())))
1045}
1046
9751047impl State {
9761048 async fn new(
9771049 event_loop: &ActiveEventLoop,
......@@ -982,6 +1054,7 @@ impl State {
9821054 settings::Launch {
9831055 file: settings,
9841056 saved: stored,
1057 renderer: forced,
9851058 cache,
9861059 }: settings::Launch,
9871060 ) -> Result<Self, Box<dyn Error>> {
......@@ -1022,7 +1095,10 @@ impl State {
10221095 let access_adapter =
10231096 AccessAdapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
10241097 window.set_visible(visible);
1025 let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?;
1098 let chosen = forced.unwrap_or(stored.renderer);
1099 let mut unavailable = Vec::new();
1100 let (surface, renderer, drawing) =
1101 start_surface(&window, backdrop, chosen, &mut unavailable).await?;
10261102 let size = window.inner_size();
10271103 let mut engine = TextEngine::default();
10281104 let fallbacks = platform::symbol_fonts();
......@@ -1161,11 +1237,11 @@ impl State {
11611237 .unwrap_or_else(|| platform::appearance(&window));
11621238 platform::follow_appearance(&window, appearance);
11631239 let mut ui = Ui::new(
1164 theme(appearance, stored.light_pages, backdrop),
1240 theme(appearance, stored.light_pages, surface.translucent()),
11651241 platform::double_click_interval(),
11661242 );
11671243 let titlebar = platform::titlebar(appearance)
1168 .filter(|_| !backdrop)
1244 .filter(|_| !surface.translucent())
11691245 .unwrap_or([ui.theme.strip; 2]);
11701246 // A window shown but never focused hears no focus event; a hidden one draws as focused.
11711247 ui.window_focused = !visible || window.has_focus();
......@@ -1205,8 +1281,12 @@ impl State {
12051281 window,
12061282 redraw,
12071283 proxy,
1208 surface,
12091284 renderer,
1285 surface,
1286 backdrop,
1287 renderer_choice: stored.renderer,
1288 drawing,
1289 unavailable,
12101290 ui,
12111291 view: PageView::new(
12121292 editor,
......@@ -1336,9 +1416,68 @@ impl State {
13361416 state.show_spelling();
13371417 state.title();
13381418 platform::update_tag_menu(&state.tags);
1419 state.tell_fallback(chosen);
13391420 Ok(state)
13401421 }
13411422
1423 /// Says so where `chosen` didn't start and another backend draws instead.
1424 fn tell_fallback(&self, chosen: settings::Backend) {
1425 if chosen != settings::Backend::Default && self.drawing.0 != chosen {
1426 platform::alert(
1427 &format!("Couldn't start {}", chosen.label()),
1428 &format!(
1429 "Snowbound is drawing with {} instead.",
1430 self.drawing.0.label()
1431 ),
1432 );
1433 }
1434 }
1435
1436 /// Draws with `chosen` from now on, in place of what draws now, the window's state
1437 /// staying as it is. The window takes one swap chain or context at a time, so the old
1438 /// renderer and surface go first; where nothing starts, the window can't go on.
1439 #[cfg(not(target_arch = "wasm32"))]
1440 fn switch_renderer(mut self, chosen: settings::Backend) -> Result<Self, Box<dyn Error>> {
1441 drop(self.renderer);
1442 drop(self.surface);
1443 let (surface, renderer, drawing) = pollster::block_on(start_surface(
1444 &self.window,
1445 self.backdrop,
1446 chosen,
1447 &mut self.unavailable,
1448 ))?;
1449 self.surface = surface;
1450 self.renderer = renderer;
1451 self.drawing = drawing;
1452 self.started_surface(chosen);
1453 Ok(self)
1454 }
1455
1456 /// Switches to the renderer the settings file names, where it was edited to name another
1457 /// while the window was away.
1458 fn follow_renderer_setting(&mut self) {
1459 let Some(path) = &self.settings else {
1460 return;
1461 };
1462 let chosen = settings::Settings::load(path).renderer;
1463 if chosen != self.renderer_choice {
1464 self.renderer_choice = chosen;
1465 let _ = self.proxy.send_event(UserEvent::Renderer(chosen));
1466 }
1467 }
1468
1469 /// Fits a surface just started to the window, and says where it isn't what was chosen.
1470 fn started_surface(&mut self, chosen: settings::Backend) {
1471 let ratio = self.scale() / self.window.scale_factor() as f32;
1472 let size = self.window.inner_size();
1473 self.surface.size = [size.width, size.height].map(|side| (side as f32 * ratio) as u32);
1474 self.surface.configure(&self.renderer);
1475 // Its colours follow whether the system's material shows through.
1476 self.follow_color_scheme();
1477 self.tell_fallback(chosen);
1478 self.window.request_redraw();
1479 }
1480
13421481 /// Builds, lays out and paints one frame, then does what it asked for and asks for the
13431482 /// frame that shows the result.
13441483 fn frame(&mut self) -> Result<(), Box<dyn Error>> {
......@@ -6010,6 +6149,10 @@ impl State {
60106149 self.window.request_redraw();
60116150 }
60126151 UserEvent::Redraw => self.window.request_redraw(),
6152 #[cfg(target_arch = "wasm32")]
6153 UserEvent::Renderer(chosen) => platform::switch_renderer(self, chosen),
6154 #[cfg(not(target_arch = "wasm32"))]
6155 UserEvent::Renderer(_) => unreachable!("The app switches renderers"),
60136156 UserEvent::Replay(replay) => {
60146157 let mark = match replay {
60156158 Replay::Mark(at) => Some(at),
......@@ -6036,6 +6179,7 @@ impl State {
60366179 .window
60376180 .request_inner_size(LogicalSize::new(width, height));
60386181 }
6182 Replay::Renderer(_) => unreachable!("The app switches renderers"),
60396183 }
60406184 // A covered window gets no redraws, so each step draws its own frame.
60416185 if let Err(error) = self.frame() {
......@@ -6163,6 +6307,16 @@ impl ApplicationHandler<UserEvent> for App {
61636307 match event {
61646308 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop),
61656309 UserEvent::Exit => self.exit(event_loop),
6310 UserEvent::Renderer(chosen) | UserEvent::Replay(Replay::Renderer(chosen)) => {
6311 let state = self.state.take().expect("The window is open");
6312 match state.switch_renderer(chosen) {
6313 Ok(state) => self.state = Some(state),
6314 Err(error) => {
6315 self.startup_error = Some(error);
6316 event_loop.exit();
6317 }
6318 }
6319 }
61666320 event => state.user_event(event),
61676321 }
61686322 }
......@@ -6266,6 +6420,7 @@ impl ApplicationHandler<UserEvent> for App {
62666420 } else {
62676421 // Notebooks another device added or removed meanwhile.
62686422 state.list_icloud();
6423 state.follow_renderer_setting();
62696424 }
62706425 state.window.request_redraw();
62716426 }
......@@ -6457,6 +6612,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
64576612 "snapshot" => Ok(Replay::Snapshot(rest.into())),
64586613 "settle" => Ok(Replay::Settle(None, settle.clone())),
64596614 "accessibility" => Ok(Replay::Settle(Some(rest.into()), settle.clone())),
6615 "renderer" => Ok(Replay::Renderer(settings::Backend::named(rest)?)),
64606616 "appearance" => Ok(Replay::Appearance(match rest {
64616617 "light" => winit::window::Theme::Light,
64626618 "dark" => winit::window::Theme::Dark,
......@@ -6538,6 +6694,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
65386694 let mut cache = None;
65396695 let mut settings_file = None;
65406696 let mut screenshot = None;
6697 let mut renderer = None;
65416698 let mut opening = Vec::new();
65426699 while let Some(arg) = args.next() {
65436700 if arg == "--substitute-font" {
......@@ -6574,6 +6731,9 @@ fn launch() -> Result<(), Box<dyn Error>> {
65746731 args.next()
65756732 .ok_or("Provide a settings file after --settings.")?,
65766733 ));
6734 } else if arg == "--renderer" {
6735 let name = args.next().ok_or("Provide a renderer after --renderer.")?;
6736 renderer = Some(settings::Backend::named(&name.to_string_lossy())?);
65776737 } else if arg == "--screenshot" {
65786738 screenshot = Some(PathBuf::from(
65796739 args.next()
......@@ -6606,6 +6766,11 @@ fn launch() -> Result<(), Box<dyn Error>> {
66066766 positional.push(arg);
66076767 }
66086768 }
6769 let renderer = match (renderer, std::env::var("SNOWBOUND_RENDERER")) {
6770 (Some(renderer), _) => Some(renderer),
6771 (None, Ok(name)) => Some(settings::Backend::named(&name)?),
6772 (None, Err(_)) => None,
6773 };
66096774 AUTOMATED.store(
66106775 screenshot.is_some() || std::env::var_os("SNOWBOUND_REPLAY").is_some(),
66116776 Ordering::Relaxed,
......@@ -6620,7 +6785,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
66206785 }
66216786 if positional.len() > 2 {
66226787 return Err(
6623 "Usage: snowbound [NOTEBOOK_FOLDER | SECTION.one | TOC.onetoc2]... [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--screenshot PNG_PREFIX] [--substitute-font FONT_FILE]..."
6788 "Usage: snowbound [NOTEBOOK_FOLDER | SECTION.one | TOC.onetoc2]... [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--renderer NAME] [--screenshot PNG_PREFIX] [--substitute-font FONT_FILE]..."
66246789 .into(),
66256790 );
66266791 }
......@@ -6700,6 +6865,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
67006865 launch: Some(settings::Launch {
67016866 file: settings_file.filter(|_| screenshot.is_none()),
67026867 saved,
6868 renderer,
67036869 cache,
67046870 }),
67056871 opening,
crates/snowbound/src/options.rs+91-1
......@@ -2,7 +2,11 @@
22//! indexed on the left, filtered by the search field above, each choice kept when OK is
33//! chosen.
44
5use crate::{State, platform, settings::ColorScheme, update};
5use crate::{
6 State, UserEvent, platform,
7 settings::{Backend, ColorScheme},
8 update,
9};
610use accesskit::Role;
711use canvas::editor::DefaultFont;
812use ui::{Anchor, Axis, Extent, Flags, Id, Size, Spec, Theme, Ui, fill, popup::Item, px};
......@@ -181,6 +185,12 @@ const SECTIONS: &[Section] = &[
181185 keywords: "notebooks sidebar side right layout",
182186 control: Control::Check(|options| &mut options.navigation_bar_left),
183187 },
188 Row {
189 label: "Renderer:",
190 keywords: "graphics gpu backend drawing metal opengl vulkan direct3d \
191 webgpu webgl canvas",
192 control: Control::Field(renderer),
193 },
184194 ],
185195 }],
186196 },
......@@ -282,6 +292,7 @@ pub struct Options {
282292 /// Live Share's relay; empty uses Snowbound's.
283293 relay: String,
284294 notebook_theme: Option<String>,
295 renderer: Backend,
285296 pub(crate) keyboard: crate::keys::Keyboard,
286297}
287298
......@@ -325,6 +336,10 @@ fn notebook_themes() -> Id {
325336 id().child("notebook-themes")
326337}
327338
339fn renderers() -> Id {
340 id().child("renderers")
341}
342
328343/// Whether `word`, lowercase, starts a word of `text`: "pen" finds "Pen" and "pen-like",
329344/// not "Open".
330345fn starts_word(text: &str, word: &str) -> bool {
......@@ -386,6 +401,7 @@ impl State {
386401 picture: self.live_options.picture,
387402 relay: self.live_options.relay.clone().unwrap_or_default(),
388403 notebook_theme: self.notebook_theme.clone(),
404 renderer: self.renderer_choice,
389405 keyboard: crate::keys::Keyboard::new(),
390406 });
391407 self.ui.open_popup(id());
......@@ -706,7 +722,12 @@ impl State {
706722 self.show_spelling();
707723 self.follow_color_scheme();
708724 self.install_keymap(options.keyboard.keymap);
725 let switch = options.renderer != self.renderer_choice;
726 self.renderer_choice = options.renderer;
709727 self.save_settings();
728 if switch {
729 let _ = self.proxy.send_event(UserEvent::Renderer(options.renderer));
730 }
710731 } else if !cancel {
711732 self.options = Some(options);
712733 return;
......@@ -838,6 +859,75 @@ fn notebook_theme(state: &mut State, options: &mut Options) {
838859 }
839860}
840861
862/// The platform's backends, the one the default starts with marked, those that didn't start
863/// this run disabled; then the backend and adapter drawing now.
864fn renderer(state: &mut State, options: &mut Options) {
865 let State {
866 ui,
867 unavailable,
868 drawing,
869 ..
870 } = state;
871 let failed = |backend: Backend| {
872 let (_, reason) = unavailable.iter().find(|(failed, _)| *failed == backend)?;
873 // A long reason is the system's, kept in the log.
874 Some(if reason.chars().count() <= 28 {
875 reason.as_str()
876 } else {
877 "Didn't start"
878 })
879 };
880 let automatic = (Backend::PLATFORM.iter().copied()).find(|backend| failed(*backend).is_none());
881 let label = |backend: Backend| {
882 if Some(backend) == automatic {
883 format!("{} (Default)", backend.label())
884 } else {
885 backend.label().to_owned()
886 }
887 };
888 let shown = match options.renderer {
889 Backend::Default => automatic,
890 chosen => Some(chosen),
891 };
892 let labels: Vec<String> = Backend::PLATFORM.iter().copied().map(label).collect();
893 let current = shown.map(label).unwrap_or_default();
894 let combo = ui.id("combo");
895 ui::shell::combo(ui, "combo", "Renderer", &current, 160.0, renderers(), true);
896 let items: Vec<Item> = (Backend::PLATFORM.iter().zip(&labels))
897 .map(|(backend, text)| Item {
898 text,
899 checked: Some(Some(*backend) == shown),
900 current: Some(*backend) == shown,
901 disabled: failed(*backend).is_some(),
902 badge: failed(*backend),
903 ..Item::default()
904 })
905 .collect();
906 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
907 if let Some(index) = ui::popup::menu(ui, renderers(), anchor, &items, None) {
908 let picked = Backend::PLATFORM[index];
909 options.renderer = if Some(picked) == automatic {
910 Backend::Default
911 } else {
912 picked
913 };
914 }
915 let (backend, adapter) = &*drawing;
916 let used = match adapter.as_str() {
917 "" => backend.label().to_owned(),
918 adapter if adapter == backend.label() => adapter.to_owned(),
919 adapter => format!("{} · {adapter}", backend.label()),
920 };
921 let spec = Spec {
922 flags: Flags::CLIP,
923 size: [fill(), px(row_height(&ui.theme))],
924 text: Some(&used),
925 color: Some(ui.theme.text_dim),
926 ..Spec::default()
927 };
928 ui.leaf("adapter", spec);
929}
930
841931fn lock_after(state: &mut State, options: &mut Options) {
842932 let ui = &mut state.ui;
843933 let combo = ui.id("combo");
crates/snowbound/src/settings.rs+99
......@@ -54,6 +54,96 @@ pub struct Settings {
5454 pub live: Live,
5555 /// Options' "Default for new notebooks": the built-in theme new notebooks take.
5656 pub notebook_theme: NotebookTheme,
57 /// Options' Renderer, which `--renderer` and `SNOWBOUND_RENDERER` override.
58 pub renderer: Backend,
59}
60
61/// What the window draws with: one of the platform's backends, or the first of them that
62/// starts.
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(from = "String", into = "&'static str")]
65pub enum Backend {
66 #[default]
67 Default,
68 D3d12,
69 D3d11,
70 Metal,
71 Vulkan,
72 Gl,
73 Webgpu,
74 Webgl2,
75 Canvas2d,
76}
77
78impl Backend {
79 /// Each backend's name in the settings and on the command line, and as Options shows it.
80 const NAMES: [(Self, &str, &str); 9] = [
81 (Self::Default, "default", "Default"),
82 (Self::D3d12, "d3d12", "Direct3D 12"),
83 (Self::D3d11, "d3d11", "Direct3D 11"),
84 (Self::Metal, "metal", "Metal"),
85 (Self::Vulkan, "vulkan", "Vulkan"),
86 (Self::Gl, "gl", "OpenGL"),
87 (Self::Webgpu, "webgpu", "WebGPU"),
88 (Self::Webgl2, "webgl2", "WebGL 2"),
89 (Self::Canvas2d, "canvas2d", "Canvas 2D"),
90 ];
91
92 /// The platform's backends, in the order the default tries them.
93 pub const PLATFORM: &[Self] = if cfg!(target_arch = "wasm32") {
94 &[Self::Webgpu, Self::Webgl2, Self::Canvas2d]
95 } else if cfg!(windows) {
96 &[Self::D3d12, Self::D3d11, Self::Gl]
97 } else if cfg!(all(target_os = "macos", feature = "wgpu")) {
98 &[Self::Metal, Self::Gl]
99 } else if cfg!(target_os = "macos") {
100 &[Self::Gl]
101 } else {
102 &[Self::Vulkan, Self::Gl]
103 };
104
105 /// The backend `name` names, or why it names none.
106 pub fn named(name: &str) -> Result<Self, String> {
107 let platform = || std::iter::once(&Self::Default).chain(Self::PLATFORM);
108 platform()
109 .find(|backend| backend.name() == name)
110 .copied()
111 .ok_or_else(|| {
112 let names: Vec<_> = platform().map(|backend| backend.name()).collect();
113 format!("Unknown renderer {name}; use one of {}.", names.join(", "))
114 })
115 }
116
117 fn entry(self) -> (Self, &'static str, &'static str) {
118 Self::NAMES
119 .into_iter()
120 .find(|(backend, ..)| *backend == self)
121 .expect("Every backend is named")
122 }
123
124 pub fn name(self) -> &'static str {
125 self.entry().1
126 }
127
128 pub fn label(self) -> &'static str {
129 self.entry().2
130 }
131}
132
133/// A name another platform's backend, or none, has falls back to the default.
134impl From<String> for Backend {
135 fn from(name: String) -> Self {
136 Self::named(&name).unwrap_or_else(|error| {
137 eprintln!("{error}");
138 Self::Default
139 })
140 }
141}
142
143impl From<Backend> for &'static str {
144 fn from(backend: Backend) -> Self {
145 backend.name()
146 }
57147}
58148
59149/// Options' Live Share.
......@@ -232,6 +322,7 @@ impl crate::State {
232322 passwords: self.passwords,
233323 notebook_theme: NotebookTheme(self.notebook_theme.clone()),
234324 live: self.live_options.clone(),
325 renderer: self.renderer_choice,
235326 };
236327 if let Err(error) = settings.save(path) {
237328 eprintln!("Cannot save the settings in {}: {error}", path.display());
......@@ -244,6 +335,8 @@ pub struct Launch {
244335 /// Where the settings are saved; `None` leaves them as they were read.
245336 pub file: Option<PathBuf>,
246337 pub saved: Settings,
338 /// The renderer `--renderer` or `SNOWBOUND_RENDERER` asks for, over the settings'.
339 pub renderer: Option<Backend>,
247340 /// The directory holding the sections' replicas.
248341 pub cache: PathBuf,
249342}
......@@ -322,6 +415,7 @@ mod tests {
322415 relay: Some("wss://relay.example.net".into()),
323416 },
324417 notebook_theme: NotebookTheme(None),
418 renderer: Backend::PLATFORM[0],
325419 };
326420 settings.save(&path).unwrap();
327421 assert_eq!(Settings::load(&path), settings);
......@@ -341,6 +435,11 @@ mod tests {
341435 // New notebooks take Modern until another theme, or none, is picked.
342436 let theme = Settings::load(&path).notebook_theme;
343437 assert_eq!(theme.0.as_deref(), Some(notebook::sidecar::themes::DEFAULT));
438 // A renderer this platform lacks keeps the other settings and draws with the default.
439 notebook::fs::write(&path, br#"{"sidebar": true, "renderer": "glide"}"#).unwrap();
440 let settings = Settings::load(&path);
441 assert!(settings.sidebar);
442 assert_eq!(settings.renderer, Backend::Default);
344443 notebook::fs::remove_dir_all(&directory).unwrap();
345444 }
346445}
crates/snowbound/src/surface.rs+61-37
......@@ -1,6 +1,6 @@
11//! Frames reach the window through wgpu.
22
3use crate::{Window, platform, trace_input};
3use crate::{Window, platform, settings::Backend, trace_input};
44use draw::Renderer;
55use std::{error::Error, sync::Arc};
66
......@@ -8,6 +8,9 @@ pub struct Surface {
88 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
99 pub size: [u32; 2],
1010 window: Arc<Window>,
11 /// The canvas a browser's frames show in.
12 #[cfg(target_arch = "wasm32")]
13 canvas: web_sys::HtmlCanvasElement,
1114 device: wgpu::Device,
1215 queue: wgpu::Queue,
1316 instance: wgpu::Instance,
......@@ -32,47 +35,56 @@ pub struct Offscreen {
3235}
3336
3437impl Surface {
35 /// With `backdrop`, frames leave the system's material showing through their
36 /// transparent pixels.
38 /// Draws with `backend` through wgpu; with `backdrop`, frames leave the system's
39 /// material showing through their transparent pixels. Answers the adapter's name too.
3740 pub async fn new(
3841 window: Arc<Window>,
3942 backdrop: bool,
40 ) -> Result<(Self, Renderer), Box<dyn Error>> {
43 backend: Backend,
44 #[cfg(target_arch = "wasm32")] canvas: web_sys::HtmlCanvasElement,
45 ) -> Result<(Self, Renderer, String), Box<dyn Error>> {
4146 #[cfg(target_arch = "wasm32")]
42 let instance = {
43 let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
44 descriptor.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
45 wgpu::util::new_instance_with_webgpu_detection(descriptor).await
46 };
47 let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
4748 #[cfg(not(target_arch = "wasm32"))]
48 #[cfg_attr(not(any(windows, target_os = "linux")), expect(unused_mut))]
4949 let mut descriptor =
5050 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone()));
51 descriptor.backends = match backend {
52 Backend::Webgpu => wgpu::Backends::BROWSER_WEBGPU,
53 Backend::Webgl2 | Backend::Gl => wgpu::Backends::GL,
54 Backend::Metal => wgpu::Backends::METAL,
55 Backend::Vulkan => wgpu::Backends::VULKAN,
56 Backend::D3d12 => wgpu::Backends::DX12,
57 _ => return Err(format!("wgpu has no {}", backend.label()).into()),
58 };
5159 // wgpu's OpenGL panics drawing to a Wayland window without libwayland-egl.
5260 #[cfg(target_os = "linux")]
5361 {
5462 use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
55 if matches!(
56 window.window_handle()?.as_raw(),
57 RawWindowHandle::Wayland(_)
58 ) && !crate::loader::loads(c"libwayland-egl.so.1")
63 if backend == Backend::Gl
64 && matches!(
65 window.window_handle()?.as_raw(),
66 RawWindowHandle::Wayland(_)
67 )
68 && !crate::loader::loads(c"libwayland-egl.so.1")
5969 {
60 descriptor.backends.remove(wgpu::Backends::GL);
70 return Err("Needs libwayland-egl".into());
6171 }
6272 }
63 // Direct3D 12, which Windows 10 and 11 always have, and through DirectComposition
64 // where the backdrop shows through; elsewhere `surface_windows` draws with OpenGL.
73 // Through DirectComposition where the backdrop shows through.
6574 #[cfg(windows)]
66 {
67 descriptor.backends = wgpu::Backends::DX12;
68 if backdrop {
69 descriptor.backend_options.dx12.presentation_system =
70 wgpu::Dx12SwapchainKind::DxgiFromVisual;
71 }
75 if backdrop {
76 descriptor.backend_options.dx12.presentation_system =
77 wgpu::Dx12SwapchainKind::DxgiFromVisual;
7278 }
79 #[cfg(target_arch = "wasm32")]
80 let instance = wgpu::util::new_instance_with_webgpu_detection(descriptor).await;
7381 #[cfg(not(target_arch = "wasm32"))]
7482 let instance = wgpu::Instance::new(descriptor);
75 let surface = instance.create_surface(target(&window))?;
83 #[cfg(target_arch = "wasm32")]
84 let target = wgpu::SurfaceTarget::Canvas(canvas.clone());
85 #[cfg(not(target_arch = "wasm32"))]
86 let target: wgpu::SurfaceTarget<'static> = window.clone().into();
87 let surface = instance.create_surface(target)?;
7688 platform::configure_presentation(&surface);
7789 let adapter = instance
7890 .request_adapter(&wgpu::RequestAdapterOptions {
......@@ -121,12 +133,15 @@ impl Surface {
121133 config.view_formats.push(view_format);
122134 }
123135 surface.configure(&device, &config);
124 eprintln!("Canvas GPU: {:?}", adapter.get_info());
136 let info = adapter.get_info();
137 eprintln!("Canvas GPU: {info:?}");
125138 let renderer = Renderer::new(device.clone(), queue.clone(), view_format);
126139 Ok((
127140 Self {
128141 size: [config.width, config.height],
129142 window,
143 #[cfg(target_arch = "wasm32")]
144 canvas,
130145 device,
131146 queue,
132147 instance,
......@@ -136,6 +151,7 @@ impl Surface {
136151 translucent,
137152 },
138153 renderer,
154 info.name,
139155 ))
140156 }
141157
......@@ -163,7 +179,7 @@ impl Surface {
163179 return Ok(None);
164180 }
165181 wgpu::CurrentSurfaceTexture::Lost => {
166 self.surface = self.instance.create_surface(target(&self.window))?;
182 self.surface = self.instance.create_surface(self.target())?;
167183 platform::configure_presentation(&self.surface);
168184 self.configure(renderer);
169185 self.window.request_redraw();
......@@ -237,6 +253,14 @@ impl Surface {
237253 })
238254 }
239255
256 /// What the surface presents to: the window, or in the browser its canvas.
257 fn target(&self) -> wgpu::SurfaceTarget<'static> {
258 #[cfg(target_arch = "wasm32")]
259 return wgpu::SurfaceTarget::Canvas(self.canvas.clone());
260 #[cfg(not(target_arch = "wasm32"))]
261 self.window.clone().into()
262 }
263
240264 /// The offscreen target's sRGB RGBA rows, top first.
241265 pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result<Vec<u8>, Box<dyn Error>> {
242266 let size = self.size;
......@@ -283,20 +307,20 @@ impl Surface {
283307 }
284308}
285309
286/// What the surface presents to: the window, or in the browser the page's canvas.
287fn target(window: &Arc<Window>) -> wgpu::SurfaceTarget<'static> {
288 #[cfg(target_arch = "wasm32")]
289 return {
290 let _ = window;
291 wgpu::SurfaceTarget::Canvas(platform::canvas())
292 };
293 #[cfg(not(target_arch = "wasm32"))]
294 window.clone().into()
310/// Takes the layer out of the view, for whatever draws next.
311#[cfg(target_os = "macos")]
312impl Drop for Surface {
313 fn drop(&mut self) {
314 platform::release_presentation(&self.surface);
315 }
295316}
296317
297318/// `view` as what the renderer draws into, which is the view itself unless `draw` also
298/// paints through OpenGL, as on Windows.
299#[cfg_attr(not(windows), expect(clippy::useless_conversion))]
319/// paints otherwise: through Direct3D 11 or OpenGL, or a browser's 2D canvas.
320#[cfg_attr(
321 not(any(windows, target_os = "macos", target_arch = "wasm32")),
322 expect(clippy::useless_conversion)
323)]
300324fn target_of(view: wgpu::TextureView) -> draw::Target {
301325 view.into()
302326}
crates/snowbound/src/surface_gl.rs+41-5
......@@ -1,6 +1,8 @@
1//! Frames reach the window through an OpenGL context on its view, where wgpu has no
2//! backend: Mac OS X 10.6. Frames draw into an sRGB target and are copied to the view.
1//! Frames reach the window through an OpenGL context on its view: where wgpu has no
2//! backend, Mac OS X 10.6, or where OpenGL is chosen. Frames draw into an sRGB target and are
3//! copied to the view.
34
5use crate::settings::Backend;
46use draw::{Renderer, Target};
57use objc2::{
68 ffi::NSInteger,
......@@ -29,14 +31,24 @@ pub struct Frame {
2931 pub target: Target,
3032}
3133
34#[link(name = "OpenGL", kind = "framework")]
35unsafe extern "C" {
36 fn glGetString(name: u32) -> *const u8;
37}
38
3239pub type Offscreen = Frame;
3340
3441impl Surface {
35 /// With `backdrop`, the surface is transparent where frames are.
42 /// Draws with OpenGL, the only `backend` it has; with `backdrop`, the surface is
43 /// transparent where frames are. Answers the driver's name too.
3644 pub async fn new(
3745 window: Arc<Window>,
3846 backdrop: bool,
39 ) -> Result<(Self, Renderer), Box<dyn Error>> {
47 backend: Backend,
48 ) -> Result<(Self, Renderer, String), Box<dyn Error>> {
49 if backend != Backend::Gl {
50 return Err(format!("This Mac has no {}", backend.label()).into());
51 }
4052 let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else {
4153 unreachable!()
4254 };
......@@ -70,7 +82,19 @@ impl Surface {
7082 context
7183 };
7284 let renderer = Renderer::opengl()?;
73 eprintln!("Canvas GPU: OpenGL");
85 let string = |name| unsafe {
86 let text = glGetString(name);
87 if text.is_null() {
88 String::new()
89 } else {
90 std::ffi::CStr::from_ptr(text.cast())
91 .to_string_lossy()
92 .into_owned()
93 }
94 };
95 // GL_RENDERER and GL_VERSION.
96 let driver = format!("{} (OpenGL {})", string(0x1F01), string(0x1F02));
97 eprintln!("Canvas GPU: {driver}");
7498 let size = window.inner_size();
7599 Ok((
76100 Self {
......@@ -80,6 +104,7 @@ impl Surface {
80104 target: None,
81105 },
82106 renderer,
107 driver,
83108 ))
84109 }
85110
......@@ -125,3 +150,14 @@ impl Surface {
125150 Ok(renderer.read_pixels(&offscreen.target)?)
126151 }
127152}
153
154/// Leaves the view to whatever draws next.
155impl Drop for Surface {
156 fn drop(&mut self) {
157 let class = AnyClass::get("NSOpenGLContext").expect("AppKit is linked");
158 unsafe {
159 let _: () = msg_send![&self.context, clearDrawable];
160 let _: () = msg_send![class, clearCurrentContext];
161 }
162 }
163}
crates/snowbound/src/surface_macos.rs created+166
......@@ -0,0 +1,166 @@
1//! Frames reach the window through Metal, or through OpenGL on its view where chosen.
2
3#[path = "surface_gl.rs"]
4mod gl;
5#[path = "surface.rs"]
6mod metal;
7
8use crate::settings::Backend as Chosen;
9use draw::{Renderer, Target};
10use std::{error::Error, sync::Arc};
11use winit::window::Window;
12
13pub struct Surface {
14 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
15 pub size: [u32; 2],
16 backend: Backend,
17}
18
19enum Backend {
20 Metal(Box<metal::Surface>),
21 Gl(gl::Surface),
22}
23
24pub struct Frame {
25 pub target: Target,
26 /// The drawable's texture, and whether it asks for reconfiguring, where Metal draws.
27 texture: Option<(wgpu::SurfaceTexture, bool)>,
28}
29
30pub struct Offscreen {
31 pub target: Target,
32 texture: Option<wgpu::Texture>,
33}
34
35impl Surface {
36 /// Draws with `chosen`; with `backdrop`, frames leave the window's material showing
37 /// through their transparent pixels. Answers the adapter's name too.
38 pub async fn new(
39 window: Arc<Window>,
40 backdrop: bool,
41 chosen: Chosen,
42 ) -> Result<(Self, Renderer, String), Box<dyn Error>> {
43 let size = window.inner_size();
44 let size = [size.width, size.height];
45 // OpenGL draws beneath the window's material, so draws opaque without it.
46 let opengl = chosen == Chosen::Gl;
47 if backdrop {
48 crate::platform::show_backdrop(&window, !opengl);
49 }
50 let (backend, renderer, adapter) = if opengl {
51 let (surface, renderer, adapter) = gl::Surface::new(window, false, chosen).await?;
52 (Backend::Gl(surface), renderer, adapter)
53 } else {
54 let (surface, renderer, adapter) =
55 metal::Surface::new(window, backdrop, chosen).await?;
56 (Backend::Metal(Box::new(surface)), renderer, adapter)
57 };
58 Ok((Self { size, backend }, renderer, adapter))
59 }
60
61 /// Whether frames leave the system's backdrop showing.
62 pub fn translucent(&self) -> bool {
63 match &self.backend {
64 Backend::Metal(surface) => surface.translucent(),
65 Backend::Gl(surface) => surface.translucent(),
66 }
67 }
68
69 pub fn configure(&mut self, renderer: &Renderer) {
70 match &mut self.backend {
71 Backend::Metal(surface) => {
72 surface.size = self.size;
73 surface.configure(renderer);
74 }
75 Backend::Gl(surface) => {
76 surface.size = self.size;
77 surface.configure(renderer);
78 }
79 }
80 }
81
82 /// The window's next frame, or none to skip this one.
83 pub fn frame(&mut self, renderer: &Renderer) -> Result<Option<Frame>, Box<dyn Error>> {
84 Ok(match &mut self.backend {
85 Backend::Metal(surface) => {
86 surface.size = self.size;
87 surface.frame(renderer)?.map(|frame| Frame {
88 target: frame.target,
89 texture: Some((frame.texture, frame.reconfigure)),
90 })
91 }
92 Backend::Gl(surface) => {
93 surface.size = self.size;
94 surface.frame(renderer)?.map(|frame| Frame {
95 target: frame.target,
96 texture: None,
97 })
98 }
99 })
100 }
101
102 pub fn present(&mut self, renderer: &Renderer, frame: Frame) {
103 match (&mut self.backend, frame.texture) {
104 (Backend::Metal(surface), Some((texture, reconfigure))) => {
105 let frame = metal::Frame {
106 target: frame.target,
107 texture,
108 reconfigure,
109 };
110 surface.present(renderer, frame);
111 }
112 (Backend::Gl(surface), None) => surface.present(
113 renderer,
114 gl::Frame {
115 target: frame.target,
116 },
117 ),
118 _ => unreachable!("Frames come from the surface's own backend"),
119 }
120 }
121
122 /// A target the size of the window's frames that `read` reads back.
123 pub fn offscreen(&mut self, renderer: &Renderer) -> Result<Offscreen, Box<dyn Error>> {
124 match &mut self.backend {
125 Backend::Metal(surface) => {
126 surface.size = self.size;
127 let offscreen = surface.offscreen(renderer)?;
128 Ok(Offscreen {
129 target: offscreen.target,
130 texture: Some(offscreen.texture),
131 })
132 }
133 Backend::Gl(surface) => {
134 surface.size = self.size;
135 Ok(Offscreen {
136 target: surface.offscreen(renderer)?.target,
137 texture: None,
138 })
139 }
140 }
141 }
142
143 /// The offscreen target's sRGB RGBA rows, top first.
144 pub fn read(
145 &self,
146 renderer: &Renderer,
147 offscreen: Offscreen,
148 ) -> Result<Vec<u8>, Box<dyn Error>> {
149 match (&self.backend, offscreen.texture) {
150 (Backend::Metal(surface), Some(texture)) => surface.read(
151 renderer,
152 metal::Offscreen {
153 target: offscreen.target,
154 texture,
155 },
156 ),
157 (Backend::Gl(surface), None) => surface.read(
158 renderer,
159 gl::Frame {
160 target: offscreen.target,
161 },
162 ),
163 _ => unreachable!("Snapshots come from the surface's own backend"),
164 }
165 }
166}
crates/snowbound/src/surface_web.rs created+166
......@@ -0,0 +1,166 @@
1//! Frames reach the page through WebGPU or WebGL2, by wgpu, or else through its 2D canvas.
2//! Each start takes a canvas of its own, as a canvas keeps the first kind of context it
3//! hands out, which the page shows once `show` is called.
4
5#[path = "surface.rs"]
6mod webgpu;
7
8use crate::{Window, platform, settings::Backend as Chosen};
9use draw::{Renderer, Target};
10use std::{error::Error, sync::Arc};
11use web_sys::HtmlCanvasElement;
12
13pub struct Surface {
14 /// Device pixels frames and snapshots are drawn at; `configure` gives the window it.
15 pub size: [u32; 2],
16 canvas: HtmlCanvasElement,
17 /// wgpu's surface, or none where the 2D canvas draws.
18 wgpu: Option<Box<webgpu::Surface>>,
19}
20
21pub struct Frame {
22 pub target: Target,
23 /// The canvas's texture, and whether it asks for reconfiguring, where wgpu draws.
24 texture: Option<(wgpu::SurfaceTexture, bool)>,
25}
26
27pub struct Offscreen {
28 pub target: Target,
29 texture: Option<wgpu::Texture>,
30}
31
32impl Surface {
33 /// Draws with `chosen` into a canvas of its own. Answers the adapter's name too.
34 pub async fn new(
35 window: Arc<Window>,
36 backdrop: bool,
37 chosen: Chosen,
38 ) -> Result<(Self, Renderer, String), Box<dyn Error>> {
39 let canvas = platform::fresh_canvas()?;
40 let size = window.inner_size();
41 let size = [size.width, size.height];
42 if chosen == Chosen::Canvas2d {
43 Target::canvas2d(canvas.clone())?;
44 let surface = Self {
45 size,
46 canvas,
47 wgpu: None,
48 };
49 return Ok((surface, Renderer::canvas2d()?, chosen.label().into()));
50 }
51 // wgpu's own errors say no more than this to someone choosing a renderer.
52 let offered = match chosen {
53 Chosen::Webgpu => web_sys::window().is_some_and(|window| {
54 js_sys::Reflect::has(&window.navigator(), &"gpu".into()).unwrap_or(false)
55 }),
56 // On a canvas of its own: a context keeps the settings it was first asked with.
57 _ => (platform::fresh_canvas()?
58 .get_context("webgl2")
59 .ok()
60 .flatten())
61 .is_some(),
62 };
63 if !offered {
64 return Err("Not in this browser".into());
65 }
66 let (wgpu, renderer, adapter) =
67 webgpu::Surface::new(window, backdrop, chosen, canvas.clone()).await?;
68 let surface = Self {
69 size,
70 canvas,
71 wgpu: Some(Box::new(wgpu)),
72 };
73 Ok((surface, renderer, adapter))
74 }
75
76 /// Puts the surface's canvas in the page in place of the one there.
77 pub fn show(&self) {
78 platform::adopt_canvas(&self.canvas);
79 }
80
81 pub fn translucent(&self) -> bool {
82 false
83 }
84
85 pub fn configure(&mut self, renderer: &Renderer) {
86 if let Some(surface) = &mut self.wgpu {
87 surface.size = self.size;
88 surface.configure(renderer);
89 }
90 }
91
92 /// The page's next frame, or none to skip this one.
93 pub fn frame(&mut self, renderer: &Renderer) -> Result<Option<Frame>, Box<dyn Error>> {
94 if self.size.contains(&0) {
95 return Ok(None);
96 }
97 Ok(match &mut self.wgpu {
98 Some(surface) => {
99 surface.size = self.size;
100 surface.frame(renderer)?.map(|frame| Frame {
101 target: frame.target,
102 texture: Some((frame.texture, frame.reconfigure)),
103 })
104 }
105 None => Some(Frame {
106 target: Target::canvas2d(self.canvas.clone())?,
107 texture: None,
108 }),
109 })
110 }
111
112 /// Hands wgpu's frame to the canvas; the 2D canvas shows what was drawn once the frame's
113 /// task ends.
114 pub fn present(&mut self, renderer: &Renderer, frame: Frame) {
115 if let (Some(surface), Some((texture, reconfigure))) = (&mut self.wgpu, frame.texture) {
116 let frame = webgpu::Frame {
117 target: frame.target,
118 texture,
119 reconfigure,
120 };
121 surface.present(renderer, frame);
122 }
123 }
124
125 /// A target the size of the page's frames that `read` reads back.
126 pub fn offscreen(&mut self, renderer: &Renderer) -> Result<Offscreen, Box<dyn Error>> {
127 match &mut self.wgpu {
128 Some(surface) => {
129 surface.size = self.size;
130 let offscreen = surface.offscreen(renderer)?;
131 Ok(Offscreen {
132 target: offscreen.target,
133 texture: Some(offscreen.texture),
134 })
135 }
136 None => {
137 let canvas = platform::fresh_canvas()?;
138 canvas.set_width(self.size[0]);
139 canvas.set_height(self.size[1]);
140 Ok(Offscreen {
141 target: Target::canvas2d(canvas)?,
142 texture: None,
143 })
144 }
145 }
146 }
147
148 /// The offscreen target's sRGB RGBA rows, top first.
149 pub fn read(
150 &self,
151 renderer: &Renderer,
152 offscreen: Offscreen,
153 ) -> Result<Vec<u8>, Box<dyn Error>> {
154 match (&self.wgpu, offscreen.texture) {
155 (Some(surface), Some(texture)) => surface.read(
156 renderer,
157 webgpu::Offscreen {
158 target: offscreen.target,
159 texture,
160 },
161 ),
162 (None, None) => Ok(renderer.read_pixels(&offscreen.target)?),
163 _ => unreachable!("Snapshots come from the surface's own backend"),
164 }
165 }
166}
crates/snowbound/src/surface_windows.rs+54-41
......@@ -1,18 +1,17 @@
11//! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), otherwise
2//! through Direct3D 11, as on Windows 7, and failing both through OpenGL 2.1 on a WGL
3//! context. The last two draw into an sRGB target, copied to the window's back buffer.
4//! `SNOWBOUND_RENDERER` set to `d3d11` or `gl` picks one of those, and to anything else
5//! insists on Direct3D 12.
2//! through Direct3D 11, as on Windows 7, or OpenGL 2.1 on a WGL context. The last two draw
3//! into an sRGB target, copied to the window's back buffer.
64
75#[path = "surface.rs"]
86mod webgpu;
97
8use crate::settings::Backend as Chosen;
109use draw::{Renderer, Target};
1110use std::{error::Error, sync::Arc};
1211use windows_sys::Win32::{
1312 Foundation::HWND,
1413 Graphics::{
15 Gdi::{GetDC, HDC},
14 Gdi::{GetDC, HDC, ReleaseDC},
1615 OpenGL as gl,
1716 },
1817};
......@@ -31,8 +30,9 @@ enum Backend {
3130 Wgpu(Box<webgpu::Surface>),
3231 /// Direct3D 11 or OpenGL, which present the renderer's own targets.
3332 Native {
34 /// For OpenGL, the window's device context, which the context draws to.
35 opengl: Option<HDC>,
33 /// For OpenGL, the window, its device context, which the context draws to, and the
34 /// context.
35 opengl: Option<(HWND, HDC, gl::HGLRC)>,
3636 /// Frames leave the desktop's glass showing through their transparent pixels.
3737 translucent: bool,
3838 /// The frame target, kept between frames.
......@@ -53,47 +53,40 @@ pub struct Offscreen {
5353}
5454
5555impl Surface {
56 /// With `backdrop`, frames leave the system's material showing through their
57 /// transparent pixels.
56 /// Draws with `chosen`; with `backdrop`, frames leave the system's material showing
57 /// through their transparent pixels. Answers the adapter's name too.
5858 pub async fn new(
5959 window: Arc<Window>,
6060 backdrop: bool,
61 ) -> Result<(Self, Renderer), Box<dyn Error>> {
62 let forced = std::env::var("SNOWBOUND_RENDERER").ok();
63 let forced = forced.as_deref();
61 chosen: Chosen,
62 ) -> Result<(Self, Renderer, String), Box<dyn Error>> {
6463 let size = window.inner_size();
6564 let size = [size.width, size.height];
66 let native = |opengl, translucent, renderer| {
65 let native = |opengl, translucent, renderer, adapter| {
6766 let backend = Backend::Native {
6867 opengl,
6968 translucent,
7069 target: None,
7170 };
72 Ok((Self { size, backend }, renderer))
71 Ok((Self { size, backend }, renderer, adapter))
7372 };
74 if !matches!(forced, Some("d3d11" | "gl")) {
75 match webgpu::Surface::new(window.clone(), backdrop).await {
76 Ok((surface, renderer)) => {
77 let backend = Backend::Wgpu(Box::new(surface));
78 return Ok((Self { size, backend }, renderer));
79 }
80 Err(error) if forced.is_some() => return Err(error),
81 Err(error) => eprintln!("No Direct3D 12 ({error})"),
82 }
73 if chosen == Chosen::D3d12 {
74 let (surface, renderer, adapter) =
75 webgpu::Surface::new(window.clone(), backdrop, chosen).await?;
76 let backend = Backend::Wgpu(Box::new(surface));
77 return Ok((Self { size, backend }, renderer, adapter));
8378 }
8479 let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else {
8580 unreachable!()
8681 };
8782 let hwnd = handle.hwnd.get() as HWND;
88 if forced != Some("gl") {
89 match Renderer::direct3d11(hwnd) {
90 Ok((renderer, adapter)) => {
91 eprintln!("Canvas GPU: Direct3D 11 ({adapter})");
92 return native(None, backdrop, renderer);
93 }
94 Err(error) if forced.is_some() => return Err(error.into()),
95 Err(error) => eprintln!("No Direct3D 11 ({error}); drawing with OpenGL"),
96 }
83 if chosen == Chosen::D3d11 {
84 let (renderer, adapter) = Renderer::direct3d11(hwnd)?;
85 eprintln!("Canvas GPU: Direct3D 11 ({adapter})");
86 return native(None, backdrop, renderer, adapter);
87 }
88 if chosen != Chosen::Gl {
89 return Err(format!("Windows has no {}", chosen.label()).into());
9790 }
9891 let device = unsafe { GetDC(hwnd) };
9992 let request = |alpha| gl::PIXELFORMATDESCRIPTOR {
......@@ -123,16 +116,18 @@ impl Surface {
123116 };
124117 let mut format = request(if backdrop { 8 } else { 0 });
125118 let mut translucent = backdrop;
126 let mut chosen = accelerated(&format);
127 if chosen.is_none() && backdrop {
119 let mut picked = accelerated(&format);
120 if picked.is_none() && backdrop {
128121 eprintln!("No accelerated OpenGL format with alpha; drawing opaque");
129122 format = request(0);
130123 translucent = false;
131 chosen = accelerated(&format);
124 picked = accelerated(&format);
132125 }
133 unsafe {
134 let chosen = chosen.unwrap_or_else(|| gl::ChoosePixelFormat(device, &format));
135 if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 {
126 let context = unsafe {
127 // A window takes its pixel format once, so OpenGL started again keeps the first.
128 let set = gl::GetPixelFormat(device);
129 let picked = picked.unwrap_or_else(|| gl::ChoosePixelFormat(device, &format));
130 if set == 0 && (picked == 0 || gl::SetPixelFormat(device, picked, &format) == 0) {
136131 return Err("No OpenGL pixel format".into());
137132 }
138133 let context = gl::wglCreateContext(device);
......@@ -146,7 +141,8 @@ impl Surface {
146141 1,
147142 );
148143 }
149 }
144 context
145 };
150146 let string = |name| unsafe {
151147 let text = gl::glGetString(name);
152148 if text.is_null() {
......@@ -170,7 +166,7 @@ impl Surface {
170166 the driver from your graphics card's maker. ({error})"
171167 )
172168 })?;
173 native(Some(device), translucent, renderer)
169 native(Some((hwnd, device, context)), translucent, renderer, driver)
174170 }
175171
176172 /// Whether frames leave the system's backdrop showing.
......@@ -234,7 +230,7 @@ impl Surface {
234230 None,
235231 ) => {
236232 renderer.present(&target, *translucent);
237 if let Some(device) = opengl {
233 if let Some((_, device, _)) = opengl {
238234 unsafe { gl::SwapBuffers(*device) };
239235 }
240236 *kept = Some(target);
......@@ -276,3 +272,20 @@ impl Surface {
276272 }
277273 }
278274}
275
276/// Gives the window back its device context, for a renderer started after this one.
277impl Drop for Surface {
278 fn drop(&mut self) {
279 if let Backend::Native {
280 opengl: Some((window, device, context)),
281 ..
282 } = self.backend
283 {
284 unsafe {
285 gl::wglMakeCurrent(std::ptr::null_mut(), std::ptr::null_mut());
286 gl::wglDeleteContext(context);
287 ReleaseDC(window, device);
288 }
289 }
290 }
291}
crates/snowbound/src/sync.rs+1-1
......@@ -1273,7 +1273,7 @@ mod tests {
12731273 });
12741274 self.renderer
12751275 .draw(
1276 &texture.create_view(&Default::default()),
1276 &texture.create_view(&Default::default()).into(),
12771277 size,
12781278 ui.theme.base,
12791279 &layers,
crates/snowbound/src/web.rs+76-5
......@@ -88,6 +88,10 @@ extern "C" {
8888 /// Keeps what `index.html`'s loading shell paints on the next visit.
8989 #[wasm_bindgen(js_name = keepLook)]
9090 fn keep_look(look: &str);
91 /// Puts `canvas` in the page in place of the canvas called page, which its input
92 /// follows.
93 #[wasm_bindgen(js_name = adoptCanvas)]
94 pub fn adopt_canvas(canvas: &web_sys::HtmlCanvasElement);
9195}
9296
9397/// Where the browser keeps its notebooks, which a first visit makes one in.
......@@ -223,6 +227,16 @@ pub fn canvas() -> web_sys::HtmlCanvasElement {
223227 .expect("index.html has a canvas called page")
224228}
225229
230/// A canvas like the page's, out of the page, with no context yet.
231pub fn fresh_canvas() -> Result<web_sys::HtmlCanvasElement, String> {
232 use wasm_bindgen::JsCast;
233 canvas()
234 .clone_node()
235 .map_err(|error| format!("{error:?}"))?
236 .dyn_into()
237 .map_err(|_| "A canvas's copy is a canvas".into())
238}
239
226240fn host<T>(act: impl FnOnce(&mut Host) -> T) -> T {
227241 HOST.with_borrow_mut(act)
228242}
......@@ -921,9 +935,17 @@ pub async fn start(
921935 notebook::fs::mount(&root);
922936 restore(folder.get(1).into());
923937 }
924 let mut state = open(fonts)
925 .await
926 .map_err(|error| JsValue::from_str(&error.to_string()))?;
938 let renderer = location_renderer().map_err(|error| JsValue::from_str(&error))?;
939 let mut state = open(fonts, renderer).await.map_err(|error| {
940 // The page tells this apart from other failures: no way to draw at all.
941 if error.downcast_ref::<crate::NoGraphics>().is_some() {
942 let thrown = js_sys::Error::new(&error.to_string());
943 thrown.set_name("NoGraphicsError");
944 return JsValue::from(thrown);
945 }
946 JsValue::from_str(&error.to_string())
947 })?;
948 state.surface.show();
927949 // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases
928950 // the rest in.
929951 state.full_page = true;
......@@ -1015,7 +1037,51 @@ fn store() {
10151037
10161038/// The window, as `launch` opens it with the notebooks the settings list, making the
10171039/// browser's first notebook on a first visit.
1018async fn open(fonts: Vec<js_sys::Uint8Array>) -> Result<State, Box<dyn Error>> {
1040/// The renderer the page's address asks for with `?renderer=NAME`, over the settings', as
1041/// `--renderer` does on the desktop.
1042fn location_renderer() -> Result<Option<settings::Backend>, String> {
1043 let search = web_sys::window()
1044 .and_then(|window| window.location().search().ok())
1045 .unwrap_or_default();
1046 search
1047 .trim_start_matches('?')
1048 .split('&')
1049 .find_map(|pair| pair.strip_prefix("renderer="))
1050 .map(settings::Backend::named)
1051 .transpose()
1052}
1053
1054/// Draws with `chosen` from now on: its surface starts on a canvas of its own while the
1055/// old one goes on drawing, then takes the old one's place.
1056pub fn switch_renderer(state: &mut State, chosen: settings::Backend) {
1057 let window = state.window.clone();
1058 let mut unavailable = state.unavailable.clone();
1059 wasm_bindgen_futures::spawn_local(async move {
1060 let started = crate::start_surface(&window, false, chosen, &mut unavailable).await;
1061 STATE.with_borrow_mut(|slot| {
1062 let Some(state) = slot else {
1063 return;
1064 };
1065 state.unavailable = unavailable;
1066 match started {
1067 Ok((surface, renderer, drawing)) => {
1068 surface.show();
1069 state.surface = surface;
1070 state.renderer = renderer;
1071 state.drawing = drawing;
1072 state.started_surface(chosen);
1073 }
1074 Err(error) => report(error),
1075 }
1076 });
1077 request_frame();
1078 });
1079}
1080
1081async fn open(
1082 fonts: Vec<js_sys::Uint8Array>,
1083 renderer: Option<settings::Backend>,
1084) -> Result<State, Box<dyn Error>> {
10191085 let cache = PathBuf::from(CACHE);
10201086 for folder in [NOTEBOOKS, CACHE, SETTINGS, CHOSEN] {
10211087 notebook::fs::create_dir_all(folder)?;
......@@ -1031,7 +1097,12 @@ async fn open(fonts: Vec<js_sys::Uint8Array>) -> Result<State, Box<dyn Error>> {
10311097 locations: saved.notebooks.clone(),
10321098 current: saved.current.clone(),
10331099 };
1034 let launch = settings::Launch { file, saved, cache };
1100 let launch = settings::Launch {
1101 file,
1102 saved,
1103 renderer,
1104 cache,
1105 };
10351106 // Where `State` reads them from, kept out of what IndexedDB stores.
10361107 let substitutes: Vec<PathBuf> = fonts
10371108 .iter()
crates/snowbound/tests/replay.rs+38
......@@ -778,3 +778,41 @@ fn open_shared_notebook_is_offered_with_notebooks_open() {
778778 "{dialog}"
779779 );
780780}
781
782/// Switching between Metal and OpenGL, again and again, leaves the page, its edits and the
783/// interface as they were.
784#[test]
785fn switching_renderers_keeps_the_window_as_it_was() {
786 let scratch = Scratch::new("renderer-switch");
787 let notebook = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/search/notebook");
788 let mut steps = vec![
789 "move 600 400".to_owned(),
790 "press".to_owned(),
791 "release".to_owned(),
792 "type switching renderers ".to_owned(),
793 "settle".to_owned(),
794 "accessibility before".to_owned(),
795 ];
796 for round in 0..5 {
797 for renderer in ["gl", "metal"] {
798 steps.push(format!("renderer {renderer}"));
799 steps.push("settle".to_owned());
800 steps.push(format!("accessibility {renderer}-{round}"));
801 }
802 }
803 steps.extend([
804 "type and after".to_owned(),
805 "accessibility after".to_owned(),
806 ]);
807 let steps: Vec<&str> = steps.iter().map(String::as_str).collect();
808 let trees = replay(&scratch, Some(&notebook), &steps);
809 let (before, after) = (&trees[0], &trees[trees.len() - 1]);
810 assert!(before.contains("switching renderers"), "{before}");
811 for tree in &trees[1..trees.len() - 1] {
812 assert_eq!(tree, before, "a switch changed the window");
813 }
814 assert!(
815 after.contains("switching renderers and after"),
816 "the caret stayed put: {after}"
817 );
818}
crates/snowbound/web/glue.js+32-9
......@@ -9,6 +9,9 @@ let picker;
99let framePending = false;
1010let wakeTimer;
1111let storage;
12// What `attach` hangs on the page's canvas, for `adoptCanvas` to move to the next one.
13const canvasListeners = [];
14let resizeObserver;
1215
1316/** The storage worker: the only place OPFS hands out synchronous handles, which write a
1417 * file's changed ranges in place. Runs as a worker of its own, from this source. */
......@@ -508,6 +511,21 @@ function read(list) {
508511
509512const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i;
510513
514/** Puts `fresh` in the page in place of the canvas called page, as a renderer started on a
515 * canvas of its own needs; the page's input moves to it. */
516export function adoptCanvas(fresh) {
517 const old = document.getElementById("page");
518 if (old !== fresh) old.replaceWith(fresh);
519 if (!canvas) return;
520 for (const [type, listener, options] of canvasListeners) {
521 canvas.removeEventListener(type, listener, options);
522 fresh.addEventListener(type, listener, options);
523 }
524 resizeObserver.unobserve(canvas);
525 canvas = fresh;
526 resizeObserver.observe(canvas);
527}
528
511529/** Wires the page's canvas, text area and file input to `module`'s exports. */
512530export function attach(module) {
513531 wasm = module;
......@@ -519,6 +537,10 @@ export function attach(module) {
519537 return [event.clientX - rect.left, event.clientY - rect.top];
520538 };
521539 const pressure = (event) => (event.pointerType === "pen" ? event.pressure : NaN);
540 const listen = (type, listener, options) => {
541 canvasListeners.push([type, listener, options]);
542 canvas.addEventListener(type, listener, options);
543 };
522544
523545 // A finger pans the page or, with a second, pinches it; one that doesn't move taps it.
524546 const touches = new Map();
......@@ -546,7 +568,7 @@ export function attach(module) {
546568 }
547569 };
548570
549 canvas.addEventListener("pointerdown", (event) => {
571 listen("pointerdown", (event) => {
550572 event.preventDefault();
551573 input.focus({ preventScroll: true });
552574 canvas.setPointerCapture(event.pointerId);
......@@ -559,7 +581,7 @@ export function attach(module) {
559581 wasm.pointer(0, ...point(event), -1, pressure(event), modifiers(event));
560582 wasm.pointer(1, ...point(event), event.button, pressure(event), modifiers(event));
561583 });
562 canvas.addEventListener("pointermove", (event) => {
584 listen("pointermove", (event) => {
563585 if (event.pointerType === "touch") {
564586 if (touches.has(event.pointerId)) touchMoved(event);
565587 return;
......@@ -580,13 +602,13 @@ export function attach(module) {
580602 }
581603 wasm.pointer(2, ...point(event), event.button, pressure(event), modifiers(event));
582604 };
583 canvas.addEventListener("pointerup", up);
584 canvas.addEventListener("pointercancel", up);
585 canvas.addEventListener("pointerleave", (event) => {
605 listen("pointerup", up);
606 listen("pointercancel", up);
607 listen("pointerleave", (event) => {
586608 if (event.pointerType !== "touch") wasm.pointer(3, 0, 0, -1, NaN, modifiers(event));
587609 });
588 canvas.addEventListener("contextmenu", (event) => event.preventDefault());
589 canvas.addEventListener(
610 listen("contextmenu", (event) => event.preventDefault());
611 listen(
590612 "wheel",
591613 (event) => {
592614 event.preventDefault();
......@@ -666,12 +688,13 @@ export function attach(module) {
666688 };
667689 // Device pixels from the CSS size: emulated pixel ratios leave devicePixelContentBoxSize
668690 // at the CSS size.
669 new ResizeObserver(([entry]) =>
691 resizeObserver = new ResizeObserver(([entry]) =>
670692 resized(
671693 Math.round(entry.contentRect.width * devicePixelRatio),
672694 Math.round(entry.contentRect.height * devicePixelRatio),
673695 ),
674 ).observe(canvas);
696 );
697 resizeObserver.observe(canvas);
675698 offerAccessibility();
676699 input.focus({ preventScroll: true });
677700}
crates/snowbound/web/index.html+15-6
......@@ -191,10 +191,6 @@
191191 fail("Snowbound is open in another tab. Close it, then reload this one.");
192192 throw new Error("Snowbound is open in another tab");
193193 }
194 if (!navigator.gpu && !document.createElement("canvas").getContext("webgl2")) {
195 fail("This browser can’t draw Snowbound: it has neither WebGPU nor WebGL 2.\nOpen it in a current Chrome, Edge, Safari or Firefox.");
196 throw new Error("No WebGPU or WebGL 2");
197 }
198194 try {
199195 const [, fonts, dictionaries] = await Promise.all([
200196 // Typed here, so the module compiles as it streams in from any server.
......@@ -207,9 +203,22 @@
207203 ]);
208204 status.textContent = "Starting Snowbound";
209205 await snowbound.start(snowbound, fonts, dictionaries);
206 sessionStorage.removeItem("snowbound.refetched");
210207 } catch (error) {
211 // A download that failed rejects with a TypeError, or a status `counted` names.
212 fail(error instanceof TypeError || /: \d{3}$/.test(error.message)
208 // A module and JavaScript from different builds can't link: an old cached copy. Once,
209 // load the page afresh, which names the newest build's.
210 if (error instanceof WebAssembly.LinkError && !sessionStorage.getItem("snowbound.refetched")) {
211 sessionStorage.setItem("snowbound.refetched", "1");
212 const fresh = new URL(location.href);
213 fresh.searchParams.set("fresh", Date.now());
214 location.replace(fresh);
215 throw error;
216 }
217 // `start` rejects with a NoGraphicsError where not even the 2D canvas draws; a download
218 // that failed rejects with a TypeError, or a status `counted` names.
219 fail(error.name === "NoGraphicsError"
220 ? "This browser can’t draw Snowbound.\nOpen it in a current Chrome, Edge, Safari or Firefox."
221 : error instanceof TypeError || /: \d{3}$/.test(error.message)
213222 ? "Snowbound couldn’t download. Check your connection, then reload."
214223 : `Snowbound couldn’t start: ${error.message ?? error}`);
215224 throw error;
crates/ui/examples/popups.rs+1-1
......@@ -902,7 +902,7 @@ fn paint(
902902 });
903903 renderer
904904 .draw(
905 &texture.create_view(&Default::default()),
905 &texture.create_view(&Default::default()).into(),
906906 size,
907907 ui.theme.base,
908908 &layers,
tools/canvas/README.md+2-2
......@@ -50,13 +50,13 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a
5050
5151`--notebook FOLDER` opens a notebook laid out as OneNote lays one out. The title bar shows the application's icon and the notebook's name; below it two rows of toolbar buttons follow the Home ribbon's Basic Text group, the first nine tags and zoom (only zoom acts yet). Readable top-level sections are tabs in the notebook's order, each leaning over the next at 45° with a faint highlight along its top, and the open one on top, casting a soft shadow and framing the page and the page tabs on the right in its colour, which shades slightly down the window; a tab that opens rises to meet the frame as its outline fades in. The frame's top corners and the page's corners are rounded concentric with the window's. The open page's tab is the page's colour and joins it with rounded inside corners; subpages are indented. The interface follows the system's light or dark appearance, and in the dark one the page's paper darkens and text left at OneNote's automatic colour turns light (except on a highlight), resolved when painting so nothing re-lays out; explicit colours, highlights, pictures and ink keep theirs, except that the background art of OneNote 2010's page templates, recognised by the picture's hash, paints from bundled vector recreations drawn for the paper. Sections take their stored colour's hue at a saturation and lightness chosen for each appearance. The section's colour is also the interface accent, easing to the next section's when another opens. The search box, New Page and the page-tab toggle sit above the page tabs. Command-F searches the page titles; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links.
5252
53Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Notebook. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings (⌘,, Ctrl+, on Linux, or Options in the sidebar) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Notebook takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, drawn on or clicked, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`).
53Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Notebook. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings (⌘,, Ctrl+, on Linux, or Options in the sidebar) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. What draws the window is the settings' `"renderer"`: `"default"` (the platform's first that starts), or `"metal"` or `"gl"` on macOS, `"vulkan"` or `"gl"` on Linux, `"d3d12"`, `"d3d11"` or `"gl"` on Windows, and `"webgpu"`, `"webgl2"` or `"canvas2d"` in a browser. `SNOWBOUND_RENDERER=NAME` overrides it and `--renderer NAME` overrides both, without changing the settings; in a browser, `index.html?renderer=NAME` does. A running app switches when Options' Renderer changes, or when the file's `renderer` was edited while the window was in the background. A backend that fails to start falls back to the default and says so. Open Notebook takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, drawn on or clicked, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`).
5454
5555As in OneNote 2010, Notebook Recycle Bin (File menu, a notebook's context menu, or the palette) shows the notebook's recycle bin as its own row of tabs, read-only under a bar: a page's context menu restores it to a section, keeping its identity, copies it out, or deletes it for good, a section's restores it to a folder, and Empty Recycle Bin deletes what it holds (`corpus/recycle-bin-view`). Pages another author changed since they were viewed are bold in the page list, and so are their section's tab and row and the groups and notebook holding them, until left or marked read (Mark as Read, Ctrl+Q off macOS; Mark Notebook as Read; Next Unread; Show Unread Changes in This Notebook); what was read is kept in the cache folder's `read.json`, as OneNote keeps it in its cache. Save As (⇧⌘S) writes the page or section as a OneNote section, the notebook as a OneNote package, or any of them as a PDF; opening a `.onepkg` asks, as Unpack Notebook does, for its name, colour and folder (`corpus/notebook-package`).
5656
5757The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. Its tab is selected at once, and an open that takes over 80 ms shows a page's outline in place of the page leaving, which takes no input. The pages beside the open one, the sections beside the open section and a hovered page or section tab are read ahead on a thread of their own. A notebook keeps the last three sections left or read ahead open, which `Library::open` hands out, and the last 32 pages shown or read ahead keep their scenes, with up to 128 MiB of decoded pictures, so a page shown again is laid out afresh around pictures already drawn.
5858
59A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. A replay or `--screenshot` run never reads the account's own settings or iCloud: without `--settings` it starts from none, and its iCloud folder is only the one `SNOWBOUND_ICLOUD_FOLDER` names.
59A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT`, `renderer NAME` (switches as Options' Renderer does) and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. A replay or `--screenshot` run never reads the account's own settings or iCloud: without `--settings` it starts from none, and its iCloud folder is only the one `SNOWBOUND_ICLOUD_FOLDER` names.
6060
6161```sh
6262cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one