diff --git a/Cargo.lock b/Cargo.lock index 4043255a6f0aec3527a4360e4ec931a7a6552565..9945cdff77c13f2a68a1ce8f98ff72004d263818 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1033,6 +1033,7 @@ dependencies = [ "base64", "bytemuck", "image", + "js-sys", "linebender_resource_handle", "miniz_oxide 0.8.9", "parley", @@ -1043,6 +1044,8 @@ dependencies = [ "skrifa", "subsetter", "swash", + "wasm-bindgen", + "web-sys", "web-time", "wgpu", "windows", diff --git a/arc/platforms.md b/arc/platforms.md index 9fe226fe81f52583edeb5e8f8672a8ff0b85a6af..bebe517aebece8a40eef93be5c6b9d00400c4573 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -10,7 +10,7 @@ drawn by Snowbound versus the operating system. shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios) glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI) page canvas ─────────────────────────────────────────────────────────────────────► -paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12; D3D11 on 7) draw (Metal) +paint draw (Metal or GL) draw (Vulkan or GL) draw (D3D12, D3D11, GL) draw (Metal) data notebook::session + embedded SMB client ────────────────────────────────────► format onestore ───────────────────────────────────────────────────────────────────► ``` @@ -24,6 +24,12 @@ everything else in the crate is shared: library and settings, the sidebar, menus, page and section management, templates, and screenshot and replay support. Accessibility goes through AccessKit's winit adapter on all three: the interface's tree, with the page's grafted into it. +Each platform draws through one of several backends (the table's `paint` row; in a +browser WebGPU, WebGL 2 or the 2D canvas). By default the first that starts draws; +Options' Renderer, the settings' `renderer`, `SNOWBOUND_RENDERER` or `--renderer` +(each over the last) picks one, and one that fails to start falls back to the +default and says so. Choosing another in Options starts it at once: the renderer and +surface go and new ones begin, the window and everything in it staying as they are. `commands.rs` is the one table of commands: each one's title, its chords on macOS and elsewhere, when it is enabled or checked, and what it does. The keyboard, 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. imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8 and later that dependencies still name are answered by `platform/windows/rt/shims.c`; everything newer is looked up at run time. -- `draw` builds three backends on Windows and the surface picks one: +- `draw` builds three backends on Windows, tried in turn by default: Direct3D 12 through wgpu where Windows has it (10 and 11), otherwise Direct3D 11, as on Windows 7, on WARP where no device reaches feature 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. lack takes Noto's, fetched by script the first time a page holds it (a CJK face cut to the national standards' characters by `tools/web/subset_cjk.py`; emoji in Noto Color Emoji's COLRv1 outlines, which `draw` paints itself), and the page is laid out again. +- Frames draw through WebGPU, else WebGL 2, both by wgpu, else the 2D canvas, which a + browser with WebGL turned off still has; `?renderer=` in the address picks one, as + `--renderer` does. The canvas draws each of `draw`'s quads with its own calls (paths, + `drawImage` from the glyph atlas, glyphs of a colour from a sheet coloured once), and + corrects translucent colours' opacity for blending sRGB-encoded. A popup leaning in as it + opens draws into a canvas of its own laid over the page and leaned by a CSS 3D transform + of the same perspective, which the browser's compositor draws. A canvas keeps the first + kind of context it gives, so each renderer starts on a canvas of its own, which then takes + the page's place and its input. Only where even the 2D canvas fails does `start` reject, + with a `NoGraphicsError`. - AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden button, then on every visit) the trees AccessKit would get are mirrored as hidden elements with ARIA roles; acting on one sends AccessKit's action back. diff --git a/arc/ui.md b/arc/ui.md index 2969a6b60ec8904d75e821587f7d0ca8e92be516..421de1aa1db3ee3e3340093d0141bb11ae68a886 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -159,8 +159,10 @@ for itself, so F6 leaves it. ## `draw`: the renderer -`draw` is a small wgpu renderer. A frame is a list of layers, each with its -own transform and clip. A layer is a flat sequence of primitives: glyph runs, +`draw` is a small renderer submitting through wgpu, or Direct3D 11, OpenGL 2.1 +or a browser's 2D canvas where wgpu can't; the backends share everything but +their device layer. A frame is a list of layers, each with its own transform and +clip. A layer is a flat sequence of primitives: glyph runs, SVG icons (tinted or in their own colours), SVG paths filled or stroked, rounded or gradient rectangles, pen strokes and raster images. diff --git a/crates/canvas/examples/reading_inventory.rs b/crates/canvas/examples/reading_inventory.rs index af47b8321106ec710cdb2c4f4da0478ef37b2556..77537eabbe9206db28a29c3633c419a49593fea4 100644 --- a/crates/canvas/examples/reading_inventory.rs +++ b/crates/canvas/examples/reading_inventory.rs @@ -302,7 +302,7 @@ impl Gpu { }); self.renderer .draw( - &target.create_view(&Default::default()), + &target.create_view(&Default::default()).into(), size, [1.0; 4], &[viewport.layer(&primitives)], diff --git a/crates/canvas/examples/render_page.rs b/crates/canvas/examples/render_page.rs index 8b5c51c2a08752264aaca33739996d16fcad3284..724c109e6092614107eb40d4ea3c6654c490d759 100644 --- a/crates/canvas/examples/render_page.rs +++ b/crates/canvas/examples/render_page.rs @@ -79,7 +79,7 @@ fn main() -> Result<(), Box> { }; renderer .draw( - &target.create_view(&Default::default()), + &target.create_view(&Default::default()).into(), size, [1.0; 4], &[viewport.layer(&primitives)], diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 5bce91c1880590db8e3b5b83f9e5fba175b22f6c..c09ea0b8a132ee8ebb584fe28c745a567206b4b7 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -593,7 +593,7 @@ mod tests { }; renderer .draw( - &target.create_view(&Default::default()), + &target.create_view(&Default::default()).into(), size, [1.0; 4], &[viewport.layer(&primitives)], diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index 2b8b3e316088041c309bc8d52913437f04f7d4ba..5b6bf2da8d108555b4d23a635ab6032cb9f1e1e1 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -1009,7 +1009,7 @@ mod tests { }; renderer .draw( - &target.create_view(&Default::default()), + &target.create_view(&Default::default()).into(), size, DARK.color, &[viewport.layer(&primitives)], diff --git a/crates/canvas/src/gpu/profile.rs b/crates/canvas/src/gpu/profile.rs index 65583922b6d9524b12c805f413be25e49ce3dcd0..37a17fafb6010e97971899300d50d990fd59e577 100644 --- a/crates/canvas/src/gpu/profile.rs +++ b/crates/canvas/src/gpu/profile.rs @@ -54,7 +54,7 @@ fn renderer_cost() { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); - let view = target.create_view(&Default::default()); + let view: draw::Target = target.create_view(&Default::default()).into(); let mut renderer = Renderer::new(device, queue, format); let occupancy = renderer.occupancy(); eprintln!( @@ -217,7 +217,7 @@ fn frame_cost() { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); - let target = target.create_view(&Default::default()); + let target: draw::Target = target.create_view(&Default::default()).into(); let mut renderer = Renderer::new(device, queue, format); let colors = TextColors { caret: [0.0, 0.0, 0.0, 1.0], diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index bda33f7bf9760675c899447d104afdce310524b2..1789ccb6071734f4e8c208ff1513911b79361c5e 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -7,10 +7,10 @@ publish = false [features] default = ["wgpu"] # The renderer; `edit` needs only parley. It submits through wgpu, or without it through -# OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its -# maker picks one, as Windows 7 has no Direct3D 12. +# OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, as Windows +# 7 has no Direct3D 12, on macOS OpenGL, and in a browser the 2D canvas; its maker picks one. render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:skrifa", "dep:swash"] -wgpu = ["render", "dep:wgpu", "dep:windows"] +wgpu = ["render", "dep:wgpu", "dep:windows", "dep:js-sys", "dep:wasm-bindgen", "dep:web-sys"] # Writes layers as PDF pages, text as real text in subset fonts. pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] @@ -42,6 +42,25 @@ windows = { version = "0.62", default-features = false, optional = true, feature "Win32_UI_WindowsAndMessaging", ] } +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = { version = "0.3", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +web-sys = { version = "0.3", optional = true, features = [ + "CanvasGradient", + "CanvasRenderingContext2d", + "CanvasWindingRule", + "console", + "CssStyleDeclaration", + "Document", + "DomRect", + "Element", + "HtmlCanvasElement", + "HtmlElement", + "ImageData", + "Node", + "Window", +] } + [dev-dependencies] pollster = "0.4" png = "0.18" diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 01689747fec1b9bb022763637834e2ea4a81f236..2625eec830b1db3f9542c9784f26a8846988aaa6 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -1,9 +1,14 @@ +#[cfg(all(feature = "wgpu", target_arch = "wasm32"))] +mod canvas2d; mod colr; #[cfg(all(feature = "wgpu", windows))] mod d3d11; -#[cfg(all(feature = "wgpu", windows))] +#[cfg(all( + feature = "wgpu", + any(windows, target_os = "macos", target_arch = "wasm32") +))] mod dual; -#[cfg(any(windows, not(feature = "wgpu")))] +#[cfg(any(windows, target_os = "macos", not(feature = "wgpu")))] mod gl; mod icon; #[cfg(feature = "pdf")] @@ -14,11 +19,17 @@ mod translucent; #[cfg(feature = "wgpu")] mod webgpu; -#[cfg(all(feature = "wgpu", windows))] +#[cfg(all( + feature = "wgpu", + any(windows, target_os = "macos", target_arch = "wasm32") +))] use dual as backend; #[cfg(not(feature = "wgpu"))] use gl as backend; -#[cfg(all(feature = "wgpu", not(windows)))] +#[cfg(all( + feature = "wgpu", + not(any(windows, target_os = "macos", target_arch = "wasm32")) +))] use webgpu as backend; pub use backend::Target; @@ -178,7 +189,7 @@ impl RasterImage { /// own, then the batches painting the target, and the images they show. struct Frame<'a> { vertices: &'a [Vertex], - groups: &'a [Vec], + groups: &'a [Group], batches: &'a [Batch], images: &'a HashMap, } @@ -206,6 +217,14 @@ struct Batch { scissor: [u32; 4], } +/// Layers appearing as one, as `Blend::Group` paints their picture. +struct Group { + batches: Vec, + /// What the 2D canvas, which can't draw the group's strips in perspective, leans it by. + #[cfg_attr(not(target_arch = "wasm32"), expect(dead_code))] + motion: Motion, +} + /// How a batch meets what lies beneath it, and what it samples: the atlas unless it paints /// a picture. #[derive(Clone, Copy, PartialEq)] @@ -361,10 +380,12 @@ pub struct Motion { } impl Motion { + /// Target heights away the viewer sees a turn from, so a small one reads as depth. + const DISTANCE: f32 = 2.0; + /// Where device point `[x, y]` on a target `height` tall shows, turned. fn project(&self, [x, y]: [f32; 2], height: f32) -> [f32; 2] { - // Seen from twice the target's height away, so a small turn reads as depth. - let distance = 2.0 * height; + let distance = Self::DISTANCE * height; let below = y - self.pivot[1]; let near = distance / (distance + below * self.tilt.sin()); [ @@ -586,7 +607,7 @@ pub struct Renderer { glyphs: HashMap>, images: HashMap, batches: Vec, - groups: Vec>, + groups: Vec, /// Batches before this one take no more primitives. barrier: usize, scaler: ScaleContext, @@ -594,7 +615,7 @@ pub struct Renderer { row_height: u32, } -#[cfg(any(windows, not(feature = "wgpu")))] +#[cfg(any(windows, target_os = "macos", not(feature = "wgpu")))] impl Renderer { /// Draws with the OpenGL context current on this thread, which must stay current /// whenever the renderer or a `Target` is used. @@ -613,8 +634,16 @@ impl Renderer { pub fn present(&self, target: &Target, translucent: bool) { self.gpu.present(target, translucent); } +} - /// The sRGB RGBA rows of `target`, from `Renderer::target`, top first. +#[cfg(any( + windows, + target_os = "macos", + target_arch = "wasm32", + not(feature = "wgpu") +))] +impl Renderer { + /// The sRGB RGBA rows of `target`, from `Renderer::target` or a 2D canvas, top first. pub fn read_pixels(&self, target: &Target) -> Result, String> { self.gpu.read_pixels(target) } @@ -688,7 +717,12 @@ impl Renderer { images: self.images.len(), image_bytes, vertices: self.vertices.len(), - batches: self.batches.len() + self.groups.iter().map(Vec::len).sum::(), + batches: self.batches.len() + + self + .groups + .iter() + .map(|group| group.batches.len()) + .sum::(), vertex_capacity_bytes: self.vertices.capacity() * size_of::(), batch_capacity_bytes: self.batches.capacity() * size_of::(), glyph_capacity: self.glyphs.capacity(), @@ -873,7 +907,7 @@ impl Renderer { blend: Blend::Group(self.groups.len()), scissor: [0, 0, size[0], size[1]], }); - self.groups.push(batches); + self.groups.push(Group { batches, motion }); Ok(()) } @@ -1132,7 +1166,7 @@ impl Renderer { ) .or_else(|| render.render(&mut scaler, glyph_id)) .map(|mut image| { - if image.content == Content::Mask { + if image.content == Content::Mask && self.gpu.blends_linear() { let coverage = text_coverage(tone); for alpha in &mut image.data { *alpha = coverage[usize::from(*alpha)]; @@ -1996,7 +2030,7 @@ mod tests { fn draw(&self, renderer: &mut Renderer, layers: &[Layer<'_>]) -> Result<(), RenderError> { renderer.draw( - &self.texture.create_view(&Default::default()), + &self.texture.create_view(&Default::default()).into(), [512, 256], [1.0; 4], layers, @@ -2636,7 +2670,7 @@ mod tests { ]; renderer .draw( - &target.texture.create_view(&Default::default()), + &target.texture.create_view(&Default::default()).into(), [512, 256], [0.0; 4], &[Layer { @@ -2679,7 +2713,7 @@ mod tests { color: [0.2, 0.2, 0.2, 1.0], }, ]; - let frame = translucent.target(&device, [512, 256]); + let frame = translucent.target(&device, [512, 256]).into(); renderer .draw( &frame, diff --git a/crates/draw/src/render/canvas2d.rs b/crates/draw/src/render/canvas2d.rs new file mode 100644 index 0000000000000000000000000000000000000000..6e138ed8d4d1fe43e8a294252cadf28d50b5a77e --- /dev/null +++ b/crates/draw/src/render/canvas2d.rs @@ -0,0 +1,1178 @@ +//! Submission through the browser's 2D canvas, where it has neither WebGPU nor WebGL2. Each +//! quad of the frame becomes the canvas's own calls: shapes as paths, glyphs, icons and paths +//! from an atlas canvas, pictures and groups by `drawImage`. The canvas blends sRGB-encoded, +//! so a translucent colour's opacity is corrected toward what linear light shows. +//! +//! A group leaning back in perspective, which the canvas's affine transforms can't draw, +//! paints into a canvas of its own laid over the target and leaned by a CSS 3D transform +//! with the same projection; what paints after it goes into a canvas above that. +use super::*; +use std::{ + cell::{Cell, RefCell}, + f64::consts::{FRAC_PI_2, PI}, +}; +use wasm_bindgen::{Clamped, JsCast}; +use web_sys::{ + CanvasRenderingContext2d as Context, CanvasWindingRule, HtmlCanvasElement as Canvas, ImageData, +}; + +/// The widest canvas every browser keeps whole. +const MAX_SIDE: u32 = 8192; +/// Most colours glyphs are kept coloured in at once. +const MAX_TINTS: usize = 16; +/// Most shadows kept drawn at once. +const MAX_SHADOWS: usize = 8; + +/// A canvas and its 2D context, which a frame is drawn into. +#[derive(Clone)] +pub struct Target { + canvas: Canvas, + context: Context, +} + +impl Target { + pub fn new(canvas: Canvas) -> Result { + let context = canvas + .get_context("2d") + .map_err(|error| format!("{error:?}"))? + .ok_or("The canvas has no 2D context")? + .dyn_into() + .map_err(|_| "The canvas's context is not 2D")?; + Ok(Self { canvas, context }) + } + + /// A canvas of its own, in no page, `size` pixels large. + fn detached(size: [u32; 2]) -> Result { + let canvas: Canvas = web_sys::window() + .and_then(|window| window.document()) + .ok_or("No document")? + .create_element("canvas") + .map_err(|error| format!("{error:?}"))? + .unchecked_into(); + let target = Self::new(canvas)?; + target.resize(size); + Ok(target) + } + + pub fn size(&self) -> [u32; 2] { + [self.canvas.width(), self.canvas.height()] + } + + /// Resizes the canvas, which clears it, if it is another size. + fn resize(&self, size: [u32; 2]) { + if self.size() != size { + self.canvas.set_width(size[0]); + self.canvas.set_height(size[1]); + } + } +} + +/// A picture as straight-alpha pixels in a canvas of its own, unless the browser had no +/// canvas to spare. +pub(super) struct Image(Option); + +impl AsRef for Image { + fn as_ref(&self) -> &Image { + self + } +} + +/// Glyphs of one colour, coloured once in a sheet of their own that frames copy them from: +/// the browser copies a whole canvas drawn into after it was last drawn from, so colouring each +/// run of glyphs afresh, or the whole atlas, would copy a large canvas a frame. +struct Tint { + color: [u32; 4], + sheet: Target, + /// Where each atlas rectangle `[left, top, right, bottom]` lies coloured in the sheet. + glyphs: HashMap<[u32; 4], [u32; 2]>, + /// Where the sheet's next glyph goes, and the height of the row it goes in. + pen: [u32; 2], + row: u32, + /// The last frame drawing with it. + used: u64, +} + +impl Tint { + /// Colours atlas rectangle `glyph` into the sheet, growing it or else emptying it when + /// full; where even an empty sheet can't hold it, the glyph takes its colour as it draws. + fn add(&mut self, atlas: &Canvas, glyph: [u32; 4]) { + let color = css(self.color.map(f32::from_bits)); + let [width, height] = [glyph[2] - glyph[0], glyph[3] - glyph[1]]; + loop { + let side = self.sheet.size()[0]; + if self.pen[0] + width > side { + self.pen = [0, self.pen[1] + self.row + 1]; + self.row = 0; + } + if self.pen[1] + height <= side { + break; + } + if side >= MAX_SIDE / 2 { + if self.glyphs.is_empty() { + return; + } + self.glyphs.clear(); + self.sheet + .context + .clear_rect(0.0, 0.0, side.into(), side.into()); + (self.pen, self.row) = ([0; 2], 0); + continue; + } + let Ok(grown) = Target::detached([2 * side; 2]) else { + return; + }; + let _ = grown + .context + .draw_image_with_html_canvas_element(&self.sheet.canvas, 0.0, 0.0); + self.sheet = grown; + } + let [x, y] = self.pen.map(f64::from); + let [w, h] = [width, height].map(f64::from); + let context = &self.sheet.context; + let [u, v] = [glyph[0], glyph[1]].map(f64::from); + let _ = context + .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( + atlas, u, v, w, h, x, y, w, h, + ); + context.set_fill_style_str(&color); + color_in(context, [x, y, w, h]); + self.glyphs.insert(glyph, self.pen); + self.pen[0] += width + 1; + self.row = self.row.max(height); + } +} + +/// A shadow drawn once and copied while it stays the same, as a popup's does while it opens: +/// the browser may blur in software, slowly. +struct Shadow { + /// The rectangle's half size, corner radius and blur, then the colour, as bits. + key: Vec, + sheet: Target, + /// The last frame drawing it. + used: u64, +} + +pub(super) struct Gpu { + atlas: Target, + /// Whether the atlas was emptied since the last frame, as writing its white texel at the + /// origin shows (`Renderer::clear_glyph_cache`): the tints' glyphs are gone. + emptied: Cell, + tints: Vec, + shadows: RefCell>, + /// Frames submitted. + frames: u64, + /// Where glyphs of a colour no tint holds take it, together, before reaching the target. + scratch: Target, + /// Offscreen pictures for groups, the target's size. + groups: Vec, + /// Canvases over the target: in turn a group leaning back, then what paints above it. + overlays: Vec, +} + +impl Drop for Gpu { + fn drop(&mut self) { + for overlay in &self.overlays { + overlay.canvas.remove(); + } + } +} + +impl Renderer { + /// Draws with the browser's 2D canvas, into `Target`s. + pub fn canvas2d() -> Result { + Ok(Self::with_gpu(Gpu { + atlas: Target::detached([ATLAS_SIZE; 2])?, + emptied: Cell::new(false), + tints: Vec::new(), + shadows: RefCell::new(Vec::new()), + frames: 0, + scratch: Target::detached([1; 2])?, + groups: Vec::new(), + overlays: Vec::new(), + })) + } +} + +impl Gpu { + /// Blends sRGB-encoded, as the canvas does. + pub(super) fn blends_linear(&self) -> bool { + false + } + + pub(super) fn flipped(&self) -> bool { + false + } + + pub(super) fn max_texture_dimension(&self) -> u32 { + MAX_SIDE + } + + pub(super) fn atlas_side(&self) -> u32 { + self.atlas.canvas.width() + } + + pub(super) fn new_atlas(&mut self, side: u32) { + self.atlas.resize([side; 2]); + self.tints.clear(); + } + + pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { + put(&self.atlas.context, origin, size, rgba); + if origin == [0; 2] { + self.emptied.set(true); + } + } + + /// Colours the glyphs `frame` draws in a colour into that colour's tint, keeping tints + /// for as many colours as `MAX_TINTS` allows. + fn tint(&mut self, frame: &Frame<'_>) { + self.frames += 1; + if self.emptied.take() { + self.tints.clear(); + } + let side = self.atlas_side() as f32; + let batches = (frame.batches.iter()) + .chain(frame.groups.iter().flat_map(|group| &group.batches)) + .filter(|batch| batch.blend == Blend::Over); + for batch in batches { + let range = batch.vertices.start as usize..batch.vertices.end as usize; + for quad in frame.vertices[range].chunks_exact(6) { + let Some(color) = tinted(quad) else { + continue; + }; + let key = color.map(f32::to_bits); + let at = match self.tints.iter().position(|tint| tint.color == key) { + Some(at) => at, + None => { + let sheet = if self.tints.len() < MAX_TINTS { + match Target::detached([256; 2]) { + Ok(sheet) => sheet, + Err(error) => return report(&error), + } + } else if let Some(at) = (0..self.tints.len()) + .filter(|at| self.tints[*at].used != self.frames) + .min_by_key(|at| self.tints[*at].used) + { + let sheet = self.tints.swap_remove(at).sheet; + let [width, height] = sheet.size().map(f64::from); + sheet.context.clear_rect(0.0, 0.0, width, height); + sheet + } else { + continue; + }; + self.tints.push(Tint { + color: key, + sheet, + glyphs: HashMap::new(), + pen: [0; 2], + row: 0, + used: 0, + }); + self.tints.len() - 1 + } + }; + let tint = &mut self.tints[at]; + tint.used = self.frames; + let glyph = source(quad, side).map(|value| value as u32); + if !tint.glyphs.contains_key(&glyph) { + tint.add(&self.atlas.canvas, glyph); + } + } + } + } + + /// The canvas holding shadow `key`, `size` pixels with the shadow in its middle, which + /// `cast` draws where it isn't kept; none where `MAX_SHADOWS` are kept for this frame. + fn shadow( + &self, + key: Vec, + size: [u32; 2], + cast: impl FnOnce(&Context, [f64; 2]), + ) -> Option { + let mut shadows = self.shadows.borrow_mut(); + if let Some(shadow) = shadows.iter_mut().find(|shadow| shadow.key == key) { + shadow.used = self.frames; + return Some(shadow.sheet.canvas.clone()); + } + let sheet = if shadows.len() < MAX_SHADOWS { + Target::detached(size).ok()? + } else { + let at = (0..shadows.len()) + .filter(|at| shadows[*at].used != self.frames) + .min_by_key(|at| shadows[*at].used)?; + let sheet = shadows.swap_remove(at).sheet; + sheet.resize(size); + let [width, height] = size.map(f64::from); + sheet.context.clear_rect(0.0, 0.0, width, height); + sheet + }; + cast(&sheet.context, size.map(|side| f64::from(side) / 2.0)); + let canvas = sheet.canvas.clone(); + shadows.push(Shadow { + key, + sheet, + used: self.frames, + }); + Some(canvas) + } + + pub(super) fn upload_image(&self, image: &RasterImage) -> Image { + // The canvas takes straight alpha; the picture's colour was premultiplied in linear + // light. + let mut pixels = image.pixels().to_vec(); + for pixel in pixels.chunks_exact_mut(4) { + let alpha = f32::from(pixel[3]) / 255.0; + if alpha > 0.0 && alpha < 1.0 { + for byte in &mut pixel[..3] { + *byte = srgb_byte(crate::linear(f32::from(*byte) / 255.0) / alpha); + } + } + } + match Target::detached(image.size) { + Ok(picture) => { + put(&picture.context, [0; 2], image.size, &pixels); + Image(Some(picture.canvas)) + } + Err(error) => { + report(&error); + Image(None) + } + } + } + + /// The target's sRGB RGBA rows, top first. + pub(super) fn read_pixels(&self, target: &Target) -> Result, String> { + let [width, height] = target.size(); + let data = target + .context + .get_image_data(0.0, 0.0, width.into(), height.into()) + .map_err(|error| format!("{error:?}"))?; + Ok(data.data().0) + } + + /// Clears `target`, `size` device pixels, to linear `clear` and draws the frame. + pub(super) fn submit( + &mut self, + frame: &Frame<'_>, + target: &Target, + size: [u32; 2], + clear: [f32; 4], + ) { + target.resize(size); + self.tint(frame); + // Laid over the target only where it is in the page. + let onscreen = target.canvas.parent_node().is_some(); + let leaning = |batch: &&Batch| matches!(batch.blend, Blend::Group(index) if frame.groups[index].motion.tilt != 0.0); + let overlays = if onscreen { + 2 * frame.batches.iter().filter(leaning).count() + } else { + 0 + }; + while self.groups.len() < frame.groups.len() || self.overlays.len() < overlays { + let made = if self.groups.len() < frame.groups.len() { + Target::detached(size).map(|picture| self.groups.push(picture)) + } else { + overlay(self.overlays.last().unwrap_or(target)).map(|o| self.overlays.push(o)) + }; + if let Err(error) = made { + report(&error); + return; + } + } + let gpu = &*self; + let light = clear[3] == 0.0 || crate::encode(luminance(clear)) >= 0.5; + let mut painter = Painter::new(gpu, frame, target.context.clone(), size, light); + if clear[3] < 1.0 { + painter + .context + .clear_rect(0.0, 0.0, size[0].into(), size[1].into()); + } + if clear[3] > 0.0 { + painter.context.set_fill_style_str(&css(clear)); + painter + .context + .fill_rect(0.0, 0.0, size[0].into(), size[1].into()); + } + let mut used = 0; + for batch in frame.batches { + match batch.blend { + Blend::Group(index) if onscreen && leaning(&batch) => { + painter.finish(); + let [leaned, above] = [used, used + 1].map(|at| &gpu.overlays[at]); + leaned.resize(size); + let mut group = Painter::new(gpu, frame, leaned.context.clone(), size, light); + group + .context + .clear_rect(0.0, 0.0, size[0].into(), size[1].into()); + group.batches(&frame.groups[index].batches); + group.finish(); + lean(leaned, target, Some(frame.groups[index].motion), size); + above.resize(size); + lean(above, target, None, size); + painter = Painter::new(gpu, frame, above.context.clone(), size, light); + painter + .context + .clear_rect(0.0, 0.0, size[0].into(), size[1].into()); + used += 2; + } + _ => painter.batch(batch), + } + } + painter.finish(); + for overlay in &gpu.overlays[used..] { + let style = overlay.canvas.style(); + if style.get_property_value("display").as_deref() != Ok("none") { + let _ = style.set_property("display", "none"); + let _ = style.remove_property("transform"); + let _ = style.remove_property("opacity"); + } + } + } +} + +/// The atlas rectangle `[left, top, right, bottom]` a quad copies, the atlas `side` texels +/// square. +fn source(quad: &[Vertex], side: f32) -> [f32; 4] { + let [u0, v0] = quad[0].uv.map(|value| (value * side).round()); + let [u1, v1] = quad[2].uv.map(|value| (value * side).round()); + [u0, v0, u1, v1] +} + +/// The one colour a quad copying a mask from the atlas paints it in, other than white, which +/// copies as it is. +fn tinted(quad: &[Vertex]) -> Option<[f32; 4]> { + let vertex = &quad[0]; + let shape = vertex.blur.abs() > 1e-6 || (vertex.shape[0] > 0.0 && vertex.shape[1] > 0.0); + let solid = quad[0].uv == quad[2].uv; + let color = quad[0].color; + (!shape && !solid && quad[1].color == color && color[..3] != [1.0; 3]).then_some(color) +} + +/// Colours what `context` holds within `[x, y, width, height]` in its fill. Clipped there, as +/// colouring "source-in" clears the whole canvas outside what it fills. +fn color_in(context: &Context, [x, y, width, height]: [f64; 4]) { + context.save(); + context.begin_path(); + context.rect(x, y, width, height); + context.clip(); + context.set_global_composite_operation("source-in").ok(); + context.fill_rect(x, y, width, height); + context.restore(); +} + +/// Writes straight-alpha `rgba`, `size` pixels, into `context` at `origin`. +fn put(context: &Context, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { + let written = ImageData::new_with_u8_clamped_array_and_sh(Clamped(rgba), size[0], size[1]) + .and_then(|data| context.put_image_data(&data, origin[0].into(), origin[1].into())); + if let Err(error) = written { + report(&format!("{error:?}")); + } +} + +fn report(error: &str) { + web_sys::console::error_1(&format!("Canvas 2D: {error}").into()); +} + +/// A canvas laid just after `previous` in the page, over it, taking no input. +fn overlay(previous: &Target) -> Result { + let overlay = Target::detached([1; 2])?; + let canvas = &overlay.canvas; + canvas + .set_attribute("aria-hidden", "true") + .map_err(|error| format!("{error:?}"))?; + let style = canvas.style(); + for (name, value) in [ + ("position", "fixed"), + ("pointer-events", "none"), + ("transform-origin", "0 0"), + ("display", "none"), + ] { + style + .set_property(name, value) + .map_err(|error| format!("{error:?}"))?; + } + previous + .canvas + .after_with_node_1(canvas) + .map_err(|error| format!("{error:?}"))?; + Ok(overlay) +} + +/// Shows `overlay` exactly over `target`, leaned back by `motion` as `Motion::project` leans a +/// group, or flat without one. +fn lean(overlay: &Target, target: &Target, motion: Option, size: [u32; 2]) { + let rect = target.canvas.get_bounding_client_rect(); + let [width, height] = [rect.width(), rect.height()]; + let style = overlay.canvas.style(); + let mut properties = vec![ + ("display", "block".to_owned()), + ("left", format!("{}px", rect.left())), + ("top", format!("{}px", rect.top())), + ("width", format!("{width}px")), + ("height", format!("{height}px")), + ]; + if let Some(motion) = motion.filter(|_| width > 0.0 && height > 0.0) { + // CSS pixels per device pixel, on each axis. + let ratio = [width / f64::from(size[0]), height / f64::from(size[1])]; + let [x, y] = [0, 1].map(|axis| f64::from(motion.pivot[axis]) * ratio[axis]); + let distance = f64::from(Motion::DISTANCE) * height; + properties.push(( + "transform", + format!( + "translate({x}px, {y}px) perspective({distance}px) rotateX({}rad) \ + translate({}px, {}px)", + -motion.tilt, -x, -y + ), + )); + properties.push(("opacity", motion.opacity.to_string())); + } else { + properties.push(("transform", "none".to_owned())); + properties.push(("opacity", "1".to_owned())); + } + for (name, value) in properties { + let _ = style.set_property(name, &value); + } +} + +/// Where a quad paints: within a scissor rectangle and, where it has one, a rounded rectangle's +/// middle, half size and corner radius. +#[derive(Clone, Copy, PartialEq)] +struct Clip { + scissor: [u32; 4], + round: Option<[f32; 5]>, +} + +/// Glyph quads of one colour waiting to take it together: their device bounds, and each one's +/// atlas and device rectangles. +struct Run { + colors: [[f32; 4]; 2], + bounds: [f32; 4], + quads: Vec<([f32; 4], [f32; 4])>, +} + +/// Draws batches into one context, setting only the state that changes. +struct Painter<'a> { + gpu: &'a Gpu, + frame: &'a Frame<'a>, + context: Context, + size: [u32; 2], + /// The clip in force; none paints everywhere. + clip: Option, + composite: &'static str, + alpha: f32, + run: Option, + /// Whether the frame clears to a light colour, which faded pictures most likely lie on. + light: bool, +} + +impl<'a> Painter<'a> { + fn new( + gpu: &'a Gpu, + frame: &'a Frame<'a>, + context: Context, + size: [u32; 2], + light: bool, + ) -> Self { + context.save(); + let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0); + context.set_global_composite_operation("source-over").ok(); + context.set_global_alpha(1.0); + context.set_image_smoothing_enabled(true); + // Kept between clips: `restore` returns to this state. + context.save(); + Self { + gpu, + frame, + context, + size, + clip: None, + composite: "source-over", + alpha: 1.0, + run: None, + light, + } + } + + fn finish(&mut self) { + self.flush(); + self.context.restore(); + self.context.restore(); + } + + fn batches(&mut self, batches: &[Batch]) { + for batch in batches { + self.batch(batch); + } + } + + fn batch(&mut self, batch: &Batch) { + let range = batch.vertices.start as usize..batch.vertices.end as usize; + let vertices = &self.frame.vertices[range]; + if let Blend::Group(index) = batch.blend { + self.flush(); + self.set_clip(None); + return self.group(index, vertices); + } + for quad in vertices.chunks_exact(6) { + let clip = self.clip_of(batch.scissor, quad); + self.set_clip(clip); + match batch.blend { + Blend::Image(id) => self.image(id, quad), + Blend::Over => self.quad(quad, "source-over"), + Blend::Erase => self.quad(quad, "destination-out"), + Blend::Multiply => self.quad(quad, "multiply"), + Blend::Group(_) => unreachable!("Groups paint above"), + } + } + } + + /// What `quad` paints within: none where its scissor and rounded rectangle hold it all, + /// as changing the canvas's clip costs. + fn clip_of(&self, scissor: [u32; 4], quad: &[Vertex]) -> Option { + let corners = [&quad[0], &quad[1], &quad[2], &quad[5]].map(|vertex| self.device(vertex)); + let [mut left, mut top, mut right, mut bottom] = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + for [x, y] in corners { + [left, top] = [left.min(x), top.min(y)]; + [right, bottom] = [right.max(x), bottom.max(y)]; + } + let within = + |[x0, y0, x1, y1]: [f32; 4]| left >= x0 && top >= y0 && right <= x1 && bottom <= y1; + let full = [0, 0, self.size[0], self.size[1]]; + let [x, y, width, height] = scissor.map(|value| value as f32); + let scissor = if within([x, y, x + width, y + height]) { + full + } else { + scissor + }; + let vertex = &quad[0]; + let round = (vertex.clip[0] > 0.0) + .then(|| { + let [x, y] = self.device(vertex); + [ + x - vertex.clip_local[0], + y - vertex.clip_local[1], + vertex.clip[0], + vertex.clip[1], + vertex.clip[2], + ] + .map(snap) + }) + .filter(|[x, y, half_x, half_y, radius]| { + !within([ + x - half_x, + y - half_y + radius, + x + half_x, + y + half_y - radius, + ]) && !within([ + x - half_x + radius, + y - half_y, + x + half_x - radius, + y + half_y, + ]) + }); + (scissor != full || round.is_some()).then_some(Clip { scissor, round }) + } + + fn set_clip(&mut self, clip: Option) { + if self.clip == clip { + return; + } + self.flush(); + self.context.restore(); + self.context.save(); + self.composite = "source-over"; + self.alpha = 1.0; + self.clip = clip; + let Some(Clip { scissor, round }) = clip else { + return; + }; + let context = &self.context; + if scissor != [0, 0, self.size[0], self.size[1]] { + context.begin_path(); + let [x, y, width, height] = scissor.map(f64::from); + context.rect(x, y, width, height); + context.clip(); + } + if let Some([x, y, half_x, half_y, radius]) = round { + let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, x.into(), y.into()); + context.begin_path(); + rounded(context, [half_x, half_y], [radius; 2]); + let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0); + context.clip(); + } + } + + fn set_blend(&mut self, composite: &'static str, alpha: f32) { + if self.composite != composite { + self.context.set_global_composite_operation(composite).ok(); + self.composite = composite; + } + if self.alpha != alpha { + self.context.set_global_alpha(alpha.into()); + self.alpha = alpha; + } + } + + /// Device position of `vertex`, whole pixels kept whole. + fn device(&self, vertex: &Vertex) -> [f32; 2] { + let [width, height] = self.size.map(|side| side as f32); + [ + snap((vertex.position[0] + 1.0) * width / 2.0), + snap((1.0 - vertex.position[1]) * height / 2.0), + ] + } + + /// The device rectangle an axis-aligned quad covers. + fn rect(&self, quad: &[Vertex]) -> [f32; 4] { + let [left, top] = self.device(&quad[0]); + let [right, bottom] = self.device(&quad[2]); + [left, top, right, bottom] + } + + fn image(&mut self, id: u64, quad: &[Vertex]) { + self.flush(); + self.set_blend("source-over", 1.0); + let Image(Some(picture)) = self.frame.image::(id) else { + return; + }; + let [left, top, right, bottom] = self.rect(quad).map(f64::from); + let [width, height] = [picture.width(), picture.height()].map(f64::from); + let _ = self + .context + .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( + picture, + 0.0, + 0.0, + width, + height, + left, + top, + right - left, + bottom - top, + ); + } + + fn quad(&mut self, quad: &[Vertex], composite: &'static str) { + let vertex = &quad[0]; + let colors = [quad[0].color, quad[1].color]; + let solid = quad[0].uv == quad[2].uv; + let shape = vertex.blur.abs() > 1e-6 || (vertex.shape[0] > 0.0 && vertex.shape[1] > 0.0); + if !shape && !solid { + return self.glyph(quad, colors, composite); + } + self.flush(); + if shape { + return self.shape(quad, colors, composite); + } + let [left, top, right, bottom] = self.rect(quad).map(f64::from); + self.set_blend(composite, 1.0); + self.fill(colors, [0.0, top, 0.0, bottom]); + self.context + .fill_rect(left, top, right - left, bottom - top); + } + + /// Sets the fill and stroke to `colors`, shading from `colors[0]` at `span`'s top to + /// `colors[1]` at its bottom. + fn fill(&self, colors: [[f32; 4]; 2], span: [f64; 4]) { + if colors[0] == colors[1] { + let color = css(colors[0]); + self.context.set_fill_style_str(&color); + self.context.set_stroke_style_str(&color); + } else { + let gradient = self + .context + .create_linear_gradient(0.0, span[1], 0.0, span[3]); + for (stop, color) in [0.0, 1.0].into_iter().zip(colors) { + let _ = gradient.add_color_stop(stop, &css(color)); + } + self.context.set_fill_style_canvas_gradient(&gradient); + self.context.set_stroke_style_canvas_gradient(&gradient); + } + } + + /// A rounded rectangle, filled, stroked or dashed, a tapered capsule or a shadow, drawn in + /// the quad's own frame. + fn shape(&mut self, quad: &[Vertex], colors: [[f32; 4]; 2], composite: &'static str) { + let [a, b, d] = [&quad[0], &quad[1], &quad[5]]; + let [corner, below, across] = [a, b, d].map(|vertex| self.device(vertex)); + let [span_x, span_y] = [d.local[0] - a.local[0], b.local[1] - a.local[1]]; + if span_x == 0.0 || span_y == 0.0 { + return; + } + let x_axis = [0, 1].map(|axis| f64::from((across[axis] - corner[axis]) / span_x)); + let y_axis = [0, 1].map(|axis| f64::from((below[axis] - corner[axis]) / span_y)); + let origin = [0, 1].map(|axis| { + f64::from(corner[axis]) + - x_axis[axis] * f64::from(a.local[0]) + - y_axis[axis] * f64::from(a.local[1]) + }); + self.set_blend(composite, 1.0); + let context = &self.context; + let _ = context.set_transform( + x_axis[0], x_axis[1], y_axis[0], y_axis[1], origin[0], origin[1], + ); + let span = [0.0, a.local[1], 0.0, b.local[1]].map(f64::from); + let [half_x, half_y, radius_x, radius_y] = a.shape; + context.begin_path(); + if a.blur > 1e-6 { + let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0); + let color = colors[0]; + // A wide blur loses nothing drawn smaller and enlarged, which a browser blurring + // in software does many times faster. + let scale = (a.blur / 3.0).floor().clamp(1.0, 4.0); + let [half, radius, sigma] = [[half_x, half_y], [radius_x; 2], [a.blur; 2]] + .map(|values| values.map(|value| value / scale)); + let reach = (3.0 * sigma[0]).ceil() + 2.0; + let size = half.map(|half| (2.0 * (half + reach)).ceil() as u32); + let key = (half.into_iter().chain([radius[0], sigma[0]]).chain(color)) + .map(f32::to_bits) + .collect::>(); + let small = |context: &Context, middle: [f64; 2]| { + cast(context, middle, half, radius[0], sigma[0], color); + }; + match self.gpu.shadow(key, size, small) { + Some(sheet) => { + let [width, height] = size.map(|side| f64::from(side as f32 * scale)); + let _ = context.draw_image_with_html_canvas_element_and_dw_and_dh( + &sheet, + origin[0] - width / 2.0, + origin[1] - height / 2.0, + width, + height, + ); + } + None => cast(context, origin, [half_x, half_y], radius_x, a.blur, color), + } + } else if a.blur < -0.5 { + self.fill(colors, span); + taper(context, half_x, half_y, radius_x); + context.fill(); + } else { + self.fill(colors, span); + let width = a.stroke.abs(); + let radii = [radius_x, radius_y]; + if a.stroke < -1e-6 { + let middle = radii.map(|radius| (radius - width / 2.0).max(0.0)); + let middle = if middle.contains(&0.0) { + [0.0; 2] + } else { + middle + }; + rounded( + context, + [half_x - width / 2.0, half_y - width / 2.0], + middle, + ); + let dash = js_sys::Array::of2(&(2.0 * width).into(), &(2.0 * width).into()); + let _ = context.set_line_dash(&dash); + context.set_line_dash_offset(-f64::from(width)); + context.set_line_width(width.into()); + context.stroke(); + let _ = context.set_line_dash(&js_sys::Array::new()); + } else { + rounded(context, [half_x, half_y], radii); + if width > 1e-6 && width < half_x.min(half_y) { + let inner = radii.map(|radius| (radius - width).max(0.0)); + rounded(context, [half_x - width, half_y - width], inner); + context.fill_with_canvas_winding_rule(CanvasWindingRule::Evenodd); + } else { + context.fill(); + } + } + } + let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0); + } + + /// A glyph, icon or path from the atlas: drawn as it is where white, else gathered with + /// glyphs of its colour. + fn glyph(&mut self, quad: &[Vertex], colors: [[f32; 4]; 2], composite: &'static str) { + let source = source(quad, self.gpu.atlas_side() as f32); + let rect = self.rect(quad); + let white = colors[0] == colors[1] && colors[0][..3] == [1.0; 3]; + if composite != "source-over" || white { + self.flush(); + // A faded icon's colours are its own; it fades as a mid grey would over the + // frame's light or dark colour. + let grey = if self.light { 0.18 } else { 0.5 }; + let alpha = if white { + opacity([grey, grey, grey, colors[0][3]]) + } else { + colors[0][3] + }; + self.set_blend(composite, alpha); + return self.copy(&self.gpu.atlas.canvas, &self.context, source, rect); + } + if let Some(color) = tinted(quad) + && let Some(tint) = + (self.gpu.tints.iter()).find(|tint| tint.color == color.map(f32::to_bits)) + && let Some([x, y]) = tint.glyphs.get(&source.map(|value| value as u32)) + { + self.flush(); + self.set_blend(composite, 1.0); + let [x, y] = [*x, *y].map(|value| value as f32); + let colored = [x, y, x + source[2] - source[0], y + source[3] - source[1]]; + return self.copy(&tint.sheet.canvas, &self.context, colored, rect); + } + if let Some(run) = &mut self.run + && run.colors == colors + && colors[0] == colors[1] + { + run.bounds = [ + run.bounds[0].min(rect[0]), + run.bounds[1].min(rect[1]), + run.bounds[2].max(rect[2]), + run.bounds[3].max(rect[3]), + ]; + run.quads.push((source, rect)); + return; + } + self.flush(); + self.run = Some(Run { + colors, + bounds: rect, + quads: vec![(source, rect)], + }); + } + + /// Copies `source` of `sheet`, the atlas or a tint of it, to `rect` of `context`. + fn copy(&self, sheet: &Canvas, context: &Context, source: [f32; 4], rect: [f32; 4]) { + let [u0, v0, u1, v1] = source.map(f64::from); + let [left, top, right, bottom] = rect.map(f64::from); + let _ = context + .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( + sheet, + u0, + v0, + u1 - u0, + v1 - v0, + left, + top, + right - left, + bottom - top, + ); + } + + /// Paints the glyphs waiting for their colour: their coverage gathered in the scratch + /// canvas, coloured there, then laid over the target. + fn flush(&mut self) { + let Some(run) = self.run.take() else { + return; + }; + let [width, height] = self.size.map(|side| side as f32); + let [left, top] = [ + run.bounds[0].max(0.0).floor(), + run.bounds[1].max(0.0).floor(), + ]; + let [right, bottom] = [ + run.bounds[2].min(width).ceil(), + run.bounds[3].min(height).ceil(), + ]; + if right <= left || bottom <= top { + return; + } + let area = [left, top, right - left, bottom - top].map(f64::from); + // As small as the run, as the browser copies the whole scratch canvas each time it is + // drawn from after being drawn into. + let [wide, tall] = [right - left, bottom - top].map(|side| side as u32); + let scratch = &self.gpu.scratch; + let [have_wide, have_tall] = scratch.size(); + if have_wide < wide || have_tall < tall { + scratch.resize( + [wide.max(have_wide), tall.max(have_tall)].map(|side| side.next_multiple_of(64)), + ); + } + let scratch = &scratch.context; + scratch.clear_rect(0.0, 0.0, area[2], area[3]); + let _ = scratch.set_transform(1.0, 0.0, 0.0, 1.0, -area[0], -area[1]); + for (source, rect) in &run.quads { + self.copy(&self.gpu.atlas.canvas, scratch, *source, *rect); + } + let [top_color, bottom_color] = run.colors; + if top_color == bottom_color { + scratch.set_fill_style_str(&css(top_color)); + } else { + let gradient = scratch.create_linear_gradient( + 0.0, + run.bounds[1].into(), + 0.0, + run.bounds[3].into(), + ); + let _ = gradient.add_color_stop(0.0, &css(top_color)); + let _ = gradient.add_color_stop(1.0, &css(bottom_color)); + scratch.set_fill_style_canvas_gradient(&gradient); + } + color_in(scratch, area); + let _ = scratch.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0); + self.set_blend("source-over", 1.0); + let _ = self + .context + .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( + &self.gpu.scratch.canvas, + 0.0, + 0.0, + area[2], + area[3], + area[0], + area[1], + area[2], + area[3], + ); + } + + /// Paints `group` offscreen, then its picture over the target, faded. One leaning back + /// leans in a canvas of its own (`lean`) where the target is in the page, and lies flat on + /// one that isn't, as the canvas has no perspective. + fn group(&mut self, index: usize, vertices: &[Vertex]) { + let group = &self.frame.groups[index]; + let picture = &self.gpu.groups[index]; + picture.resize(self.size); + let [width, height] = self.size.map(|side| side as f32); + // The picture's painted bounds, as the group's batch samples it. + let [mut left, mut top, mut right, mut bottom] = [width, height, 0.0, 0.0]; + for vertex in vertices { + let [x, y] = [vertex.uv[0] * width, vertex.uv[1] * height]; + [left, top] = [left.min(x), top.min(y)]; + [right, bottom] = [right.max(x), bottom.max(y)]; + } + if right <= left || bottom <= top { + return; + } + let mut painter = Painter::new( + self.gpu, + self.frame, + picture.context.clone(), + self.size, + self.light, + ); + painter + .context + .clear_rect(0.0, 0.0, width.into(), height.into()); + painter.batches(&group.batches); + painter.finish(); + self.set_blend("source-over", group.motion.opacity); + let [x, y, w, h] = [left, top, right - left, bottom - top].map(f64::from); + let _ = self + .context + .draw_image_with_html_canvas_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh( + &picture.canvas, + x, + y, + w, + h, + x, + y, + w, + h, + ); + } +} + +/// `value`, made whole within a thousandth of a whole number, as quads placed on whole pixels +/// are, so pictures on them copy without resampling. +fn snap(value: f32) -> f32 { + if (value - value.round()).abs() < 1e-3 { + value.round() + } else { + value + } +} + +/// Draws the shadow a rounded rectangle about `middle`, `half` its half size, casts: blurred +/// by `sigma` device pixels, in `color`. +fn cast( + context: &Context, + middle: [f64; 2], + half: [f32; 2], + radius: f32, + sigma: f32, + color: [f32; 4], +) { + // The canvas's shadow is a gaussian of half its blur as deviation, as `sigma` is here; its + // offset and blur ignore the transform. The rectangle itself lies far off the canvas. + let side = context + .canvas() + .map_or(0, |canvas| canvas.width().max(canvas.height())); + let away = 4.0 * f64::from(side) + 8.0 * f64::from(sigma); + let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, middle[0] - away, middle[1]); + context.begin_path(); + rounded(context, half, [radius; 2]); + context.set_shadow_color(&css(color)); + context.set_shadow_blur(2.0 * f64::from(sigma)); + context.set_shadow_offset_x(away); + context.set_fill_style_str("#000"); + context.fill(); + context.set_shadow_color("transparent"); + context.set_shadow_blur(0.0); + context.set_shadow_offset_x(0.0); + let _ = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0); +} + +/// Adds a rounded rectangle about the origin, `half` its half size, to the path: clockwise +/// from the middle of its right edge, which is where a dashed outline's pattern starts. +fn rounded(context: &Context, half: [f32; 2], radius: [f32; 2]) { + let [x, y] = half.map(f64::from); + if radius.iter().any(|radius| *radius <= 0.0) { + context.rect(-x, -y, 2.0 * x, 2.0 * y); + return; + } + let [rx, ry] = [0, 1].map(|axis| f64::from(radius[axis].min(half[axis]))); + context.move_to(x, 0.0); + for (corner, from) in [ + ([x - rx, y - ry], 0.0), + ([-x + rx, y - ry], FRAC_PI_2), + ([-x + rx, -y + ry], PI), + ([x - rx, -y + ry], 3.0 * FRAC_PI_2), + ] { + let _ = context.ellipse(corner[0], corner[1], rx, ry, 0.0, from, from + FRAC_PI_2); + } + context.close_path(); +} + +/// Adds the tapered capsule from `(-half, 0)` to `(half, 0)`, round ends `radii` across, to +/// the path: the two circles and the lines touching both. +fn taper(context: &Context, half: f32, start: f32, end: f32) { + let [half, start, end] = [half, start, end].map(f64::from); + let slope = (start - end) / (2.0 * half).max(1e-6); + if slope.abs() >= 1.0 { + let (x, radius) = if start >= end { + (-half, start) + } else { + (half, end) + }; + let _ = context.arc(x, 0.0, radius, 0.0, 2.0 * PI); + return; + } + // Where the touching lines meet each circle, by their outward normal's angle. + let angle = (1.0 - slope * slope).sqrt().atan2(slope); + context.move_to(-half + start * angle.cos(), start * angle.sin()); + let _ = context.arc_with_anticlockwise(half, 0.0, end, angle, -angle, true); + let _ = context.arc_with_anticlockwise(-half, 0.0, start, -angle, angle, true); + context.close_path(); +} + +/// A CSS colour for linear `color`, its opacity corrected for the canvas's sRGB blending. +fn css(color: [f32; 4]) -> String { + let [red, green, blue] = srgb_bytes(color); + format!("rgba({red},{green},{blue},{:.4})", opacity(color)) +} + +/// The opacity that, blended sRGB-encoded as the canvas blends, shows `color` as linear light +/// blends it over what it stands out against most: white under a dark colour, black under a +/// light one. +fn opacity([red, green, blue, alpha]: [f32; 4]) -> f32 { + if alpha <= 0.0 || alpha >= 1.0 { + return alpha.clamp(0.0, 1.0); + } + let ink = luminance([red, green, blue, alpha]); + let shown = crate::encode(ink); + let corrected = if shown < 0.5 { + (1.0 - crate::encode(1.0 - alpha * (1.0 - ink))) / (1.0 - shown) + } else { + crate::encode(alpha * ink) / shown + }; + corrected.clamp(0.0, 1.0) +} + +fn luminance([red, green, blue, _]: [f32; 4]) -> f32 { + 0.2126 * red + 0.7152 * green + 0.0722 * blue +} diff --git a/crates/draw/src/render/d3d11.rs b/crates/draw/src/render/d3d11.rs index 610bc2bb616d9485aaead77c8972364b75381746..f80aa475df73adaa5829496decb43bd7e2ea1682 100644 --- a/crates/draw/src/render/d3d11.rs +++ b/crates/draw/src/render/d3d11.rs @@ -354,6 +354,11 @@ impl Gpu { Ok(pixels) } + /// Blends in linear light, its targets being sRGB. + pub(super) fn blends_linear(&self) -> bool { + true + } + /// Offscreen pictures' rows run top first. pub(super) fn flipped(&self) -> bool { false @@ -514,8 +519,8 @@ impl Gpu { context.Draw(batch.vertices.len() as u32, batch.vertices.start); } }; - for (batches, picture) in frame.groups.iter().zip(&gpu.groups) { - paint(picture, [0.0; 4], batches); + for (group, picture) in frame.groups.iter().zip(&gpu.groups) { + paint(picture, [0.0; 4], &group.batches); } paint(target, clear, frame.batches); unsafe { diff --git a/crates/draw/src/render/dual.rs b/crates/draw/src/render/dual.rs index 109863cc65eaeff408064db1eb153c08f23a087d..53d795e3a9bc72cf80ad00b11c24aa3d14eb041b 100644 --- a/crates/draw/src/render/dual.rs +++ b/crates/draw/src/render/dual.rs @@ -1,14 +1,23 @@ -//! Every backend in one build, for Windows: wgpu where Direct3D 12 answers, Direct3D 11 -//! where it doesn't, as on Windows 7, and OpenGL 2.1 where neither does. Whoever makes the -//! renderer picks one. -use super::{d3d11, gl, webgpu, *}; -use std::ffi::c_void; +//! Every backend a platform offers in one build, its maker picking one: wgpu everywhere, +//! with Direct3D 11 and OpenGL 2.1 on Windows, where Windows 7 has no Direct3D 12, OpenGL +//! on macOS, and the 2D canvas in a browser with neither WebGPU nor WebGL2. +#[cfg(target_arch = "wasm32")] +use super::canvas2d; +#[cfg(windows)] +use super::d3d11; +#[cfg(any(windows, target_os = "macos"))] +use super::gl; +use super::{webgpu, *}; /// What a frame is drawn into, for the backend the renderer draws with. pub enum Target { Wgpu(wgpu::TextureView), + #[cfg(windows)] D3d11(d3d11::Target), + #[cfg(any(windows, target_os = "macos"))] Gl(gl::Target), + #[cfg(target_arch = "wasm32")] + Canvas(canvas2d::Target), } impl From for Target { @@ -17,53 +26,71 @@ impl From for Target { } } +#[cfg(target_arch = "wasm32")] +impl Target { + /// Frames drawn into `canvas` with its 2D context. + pub fn canvas2d(canvas: web_sys::HtmlCanvasElement) -> Result { + canvas2d::Target::new(canvas).map(Self::Canvas) + } +} + impl Target { pub fn size(&self) -> [u32; 2] { match self { Self::Wgpu(view) => [view.texture().width(), view.texture().height()], + #[cfg(windows)] Self::D3d11(target) => target.size(), + #[cfg(any(windows, target_os = "macos"))] Self::Gl(target) => target.size(), + #[cfg(target_arch = "wasm32")] + Self::Canvas(target) => target.size(), } } } pub(super) enum Image { Wgpu(webgpu::Image), + #[cfg(windows)] D3d11(d3d11::Image), + #[cfg(any(windows, target_os = "macos"))] Gl(gl::Image), + #[cfg(target_arch = "wasm32")] + Canvas(canvas2d::Image), } -impl AsRef for Image { - fn as_ref(&self) -> &webgpu::Image { - match self { - Self::Wgpu(image) => image, - _ => unreachable!("Images belong to the renderer's backend"), +/// `AsRef` from `Image` to its variant `$variant`, which holds `$image`. +macro_rules! image { + ($variant:ident, $image:ty) => { + impl AsRef<$image> for Image { + fn as_ref(&self) -> &$image { + match self { + Self::$variant(image) => image, + #[allow(unreachable_patterns)] + _ => unreachable!("Images belong to the renderer's backend"), + } + } } - } + }; } -impl AsRef for Image { - fn as_ref(&self) -> &d3d11::Image { - match self { - Self::D3d11(image) => image, - _ => unreachable!("Images belong to the renderer's backend"), - } - } -} - -impl AsRef for Image { - fn as_ref(&self) -> &gl::Image { - match self { - Self::Gl(image) => image, - _ => unreachable!("Images belong to the renderer's backend"), - } - } -} +image!(Wgpu, webgpu::Image); +#[cfg(windows)] +image!(D3d11, d3d11::Image); +#[cfg(any(windows, target_os = "macos"))] +image!(Gl, gl::Image); +#[cfg(target_arch = "wasm32")] +image!(Canvas, canvas2d::Image); +// A renderer holds one, so its size goes unnoticed. +#[allow(clippy::large_enum_variant)] pub(super) enum Gpu { Wgpu(webgpu::Gpu), + #[cfg(windows)] D3d11(d3d11::Gpu), + #[cfg(any(windows, target_os = "macos"))] Gl(gl::Gpu), + #[cfg(target_arch = "wasm32")] + Canvas(canvas2d::Gpu), } impl From for Gpu { @@ -72,16 +99,44 @@ impl From for Gpu { } } +#[cfg(any(windows, target_os = "macos"))] impl From for Gpu { fn from(gpu: gl::Gpu) -> Self { Self::Gl(gpu) } } +#[cfg(target_arch = "wasm32")] +impl From for Gpu { + fn from(gpu: canvas2d::Gpu) -> Self { + Self::Canvas(gpu) + } +} + +#[cfg(not(windows))] +impl Renderer { + /// The device a renderer `Renderer::new` made draws with. + pub fn device(&self) -> &wgpu::Device { + self.wgpu().device() + } + + pub fn queue(&self) -> &wgpu::Queue { + self.wgpu().queue() + } + + fn wgpu(&self) -> &webgpu::Gpu { + match &self.gpu { + Gpu::Wgpu(gpu) => gpu, + _ => unreachable!("Only `Renderer::new` makes a renderer drawing with wgpu"), + } + } +} + +#[cfg(windows)] impl Renderer { /// Draws with Direct3D 11 into a swap chain on `window`, an `HWND`; with the adapter's /// description. - pub fn direct3d11(window: *mut c_void) -> Result<(Self, String), String> { + pub fn direct3d11(window: *mut std::ffi::c_void) -> Result<(Self, String), String> { d3d11::Gpu::new(window) .map(|(gpu, adapter)| (Self::with_gpu(Gpu::D3d11(gpu)), adapter)) .map_err(|error| error.to_string()) @@ -93,15 +148,21 @@ macro_rules! each { ($gpu:expr, $backend:ident => $body:expr) => { match $gpu { Gpu::Wgpu($backend) => $body, + #[cfg(windows)] Gpu::D3d11($backend) => $body, + #[cfg(any(windows, target_os = "macos"))] Gpu::Gl($backend) => $body, + #[cfg(target_arch = "wasm32")] + Gpu::Canvas($backend) => $body, } }; } impl Gpu { + #[cfg(any(windows, target_os = "macos"))] pub(super) fn target(&self, size: [u32; 2]) -> Result { match self { + #[cfg(windows)] Self::D3d11(gpu) => gpu .target(size) .map(Target::D3d11) @@ -111,9 +172,11 @@ impl Gpu { } } + #[cfg(any(windows, target_os = "macos"))] pub(super) fn present(&self, target: &Target, translucent: bool) { match (self, target) { // Direct3D 11 premultiplies every frame again, which leaves opaque ones be. + #[cfg(windows)] (Self::D3d11(gpu), Target::D3d11(target)) => { if let Err(error) = gpu.present(target) { eprintln!("Presenting failed: {error}"); @@ -126,14 +189,22 @@ impl Gpu { pub(super) fn read_pixels(&self, target: &Target) -> Result, String> { match (self, target) { + #[cfg(windows)] (Self::D3d11(gpu), Target::D3d11(target)) => { gpu.read_pixels(target).map_err(|error| error.to_string()) } + #[cfg(any(windows, target_os = "macos"))] (Self::Gl(gpu), Target::Gl(target)) => gpu.read_pixels(target), + #[cfg(target_arch = "wasm32")] + (Self::Canvas(gpu), Target::Canvas(target)) => gpu.read_pixels(target), _ => unreachable!("Snapshots come from the renderer's backend's targets"), } } + pub(super) fn blends_linear(&self) -> bool { + each!(self, gpu => gpu.blends_linear()) + } + pub(super) fn flipped(&self) -> bool { each!(self, gpu => gpu.flipped()) } @@ -157,8 +228,12 @@ impl Gpu { pub(super) fn upload_image(&self, image: &RasterImage) -> Image { match self { Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)), + #[cfg(windows)] Self::D3d11(gpu) => Image::D3d11(gpu.upload_image(image)), + #[cfg(any(windows, target_os = "macos"))] Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)), + #[cfg(target_arch = "wasm32")] + Self::Canvas(gpu) => Image::Canvas(gpu.upload_image(image)), } } @@ -171,8 +246,13 @@ impl Gpu { ) { match (self, target) { (Self::Wgpu(gpu), Target::Wgpu(view)) => gpu.submit(frame, view, size, clear), + #[cfg(windows)] (Self::D3d11(gpu), Target::D3d11(target)) => gpu.submit(frame, target, size, clear), + #[cfg(any(windows, target_os = "macos"))] (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear), + #[cfg(target_arch = "wasm32")] + (Self::Canvas(gpu), Target::Canvas(target)) => gpu.submit(frame, target, size, clear), + #[allow(unreachable_patterns)] _ => unreachable!("Frames are drawn into the renderer's backend's targets"), } } diff --git a/crates/draw/src/render/gl.rs b/crates/draw/src/render/gl.rs index 0b9e27e09ae8ee3bd4651ce96d1cbcdbc7e2e393..c53f7324afd8f510964153ffd222ddefd4795aa8 100644 --- a/crates/draw/src/render/gl.rs +++ b/crates/draw/src/render/gl.rs @@ -434,6 +434,11 @@ impl Gpu { } } + /// Blends in linear light, its targets being sRGB. + pub(super) fn blends_linear(&self) -> bool { + true + } + /// Offscreen pictures' rows run bottom first. pub(super) fn flipped(&self) -> bool { true @@ -581,8 +586,8 @@ impl Gpu { ); } glEnable(BLEND); - for (batches, picture) in frame.groups.iter().zip(&gpu.groups) { - paint(picture, [0.0; 4], batches); + for (group, picture) in frame.groups.iter().zip(&gpu.groups) { + paint(picture, [0.0; 4], &group.batches); } paint(target, clear, frame.batches); glDisable(SCISSOR_TEST); diff --git a/crates/draw/src/render/webgpu.rs b/crates/draw/src/render/webgpu.rs index dae2dc48ed6b7cb531f7d11f3b833eaac670cbb3..3197a593c13ed993fc51d6526a1951440c7aeee4 100644 --- a/crates/draw/src/render/webgpu.rs +++ b/crates/draw/src/render/webgpu.rs @@ -2,7 +2,7 @@ use super::*; /// What a frame is drawn into. -#[cfg(not(windows))] +#[cfg(not(any(windows, target_os = "macos", target_arch = "wasm32")))] pub type Target = wgpu::TextureView; /// A picture's texture, as its batches bind it. @@ -34,14 +34,25 @@ impl Renderer { Self::with_gpu(Gpu::new(device, queue, format)) } - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "macos", target_arch = "wasm32")))] pub fn device(&self) -> &wgpu::Device { - &self.gpu.device + self.gpu.device() } - #[cfg(not(windows))] + #[cfg(not(any(windows, target_os = "macos", target_arch = "wasm32")))] pub fn queue(&self) -> &wgpu::Queue { - &self.gpu.queue + self.gpu.queue() + } +} + +#[cfg(not(windows))] +impl Gpu { + pub(super) fn device(&self) -> &wgpu::Device { + &self.device + } + + pub(super) fn queue(&self) -> &wgpu::Queue { + &self.queue } } @@ -154,6 +165,11 @@ impl Gpu { } } + /// Blends in linear light, its targets being sRGB. + pub(super) fn blends_linear(&self) -> bool { + true + } + pub(super) fn flipped(&self) -> bool { false } @@ -273,8 +289,12 @@ impl Gpu { pass.draw(batch.vertices.clone(), 0..1); } }; - for (batches, (picture, _)) in frame.groups.iter().zip(&gpu.groups) { - paint(&picture.create_view(&Default::default()), [0.0; 4], batches); + for (group, (picture, _)) in frame.groups.iter().zip(&gpu.groups) { + paint( + &picture.create_view(&Default::default()), + [0.0; 4], + &group.batches, + ); } paint(target, clear, frame.batches); gpu.queue.submit([encoder.finish()]); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index ec4281c7ce8ee3bcbefa75cd49bb17210afea0ef..89d282d3aeed992e6e21fbe31da4ffbc2d82daf5 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -783,7 +783,7 @@ impl View { let primitives = canvas.page.primitives(canvas.colors())?; renderer .draw( - &frame.texture.create_view(&Default::default()), + &frame.texture.create_view(&Default::default()).into(), [self.config.width, self.config.height], paper.color, &[canvas.page.viewport.layer(&primitives)], diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 82d50e047cca0724080bf6217b9f6aefc13231c8..9dfa3777ec0e949d487809aa37d3f475da247b2c 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -57,7 +57,7 @@ spellbook = "0.4" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Window"] } +web-sys = { version = "0.3", features = ["console", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Node", "Window"] } [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index 01ff951c1d9a8b5f47dedbcc44f15cb6ca1313cd..021fb96cdda636fe2ff09bdb125bbdd58dda330f 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -156,7 +156,7 @@ fn paint( }); renderer .draw( - &texture.create_view(&Default::default()), + &texture.create_view(&Default::default()).into(), size, ui.theme.base, &layers, diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index b5f28af68cea96f891da8e5f5508d69ddc7034fd..8a4ca35bb5785c0577c6f68dd7f33d4f9d00335e 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -488,6 +488,25 @@ pub fn install_backdrop(window: &Window) -> bool { true } +/// Shows or hides the material `install_backdrop` put behind the window's frames: OpenGL +/// draws into the view's own layer, which the material's view covers. +#[cfg(feature = "wgpu")] +pub fn show_backdrop(window: &Window, shown: bool) { + let Some(class) = BACKDROP_CLASS.get() else { + return; + }; + let view = &*ns_view(window); + unsafe { + let subviews: Retained> = msg_send_id![view, subviews]; + for subview in subviews.iter() { + let ours: bool = msg_send![subview, isKindOfClass: *class]; + if ours { + let _: () = msg_send![subview, setHidden: !shown]; + } + } + } +} + /// AppKit's window frame takes resizing presses. pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option { None @@ -1339,6 +1358,14 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) { } } +/// Takes the surface's layer out of the view, for a renderer started after it. +#[cfg(feature = "wgpu")] +pub fn release_presentation(surface: &wgpu::Surface<'_>) { + if let Some(surface) = unsafe { surface.as_hal::() } { + surface.render_layer().lock().removeFromSuperlayer(); + } +} + /// Commits a frame presented with the transaction: winit redraws after Core Animation's /// commit observer, so otherwise the frame waits for the next event. A live resize leaves /// the commit to AppKit, which pairs the frame with the window's new size. diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 502348d35f1be2894533b7ed27207d87637b72b6..ec7ead7967a2b4a3ce49dc9f907a3f5c184d4055 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -77,7 +77,9 @@ mod sidebar; #[cfg_attr(target_arch = "wasm32", path = "spell_web.rs")] mod spell; #[cfg_attr(not(feature = "wgpu"), path = "surface_gl.rs")] +#[cfg_attr(all(target_os = "macos", feature = "wgpu"), path = "surface_macos.rs")] #[cfg_attr(windows, path = "surface_windows.rs")] +#[cfg_attr(target_arch = "wasm32", path = "surface_web.rs")] mod surface; mod symbol; mod sync; @@ -305,6 +307,8 @@ enum UserEvent { /// forward despite the desktop's focus-stealing prevention. #[cfg(target_os = "linux")] Activate(String), + /// Draws with this backend from now on. + Renderer(settings::Backend), } /// Asks the event loop for a frame from any thread. @@ -384,6 +388,8 @@ enum Replay { Appearance(winit::window::Theme), /// Resizes the window's content, in points. Resize([f32; 2]), + /// Draws with this backend from then on, as Options' Renderer does. + Renderer(settings::Backend), Quit, } @@ -783,8 +789,18 @@ struct State { swipe: navigation::Swipe, /// Asks for a frame when a worker thread finishes something the page shows. redraw: std::task::Waker, - surface: surface::Surface, + /// Dropped before `surface`, as an OpenGL renderer needs its context. renderer: Renderer, + surface: surface::Surface, + /// Whether the system's material shows behind the window, which a surface started + /// again keeps. + backdrop: bool, + /// Options' Renderer, as the settings keep it. + renderer_choice: settings::Backend, + /// The backend frames are drawn with, and its adapter's name. + drawing: (settings::Backend, String), + /// Backends that failed to start this run, and why. + unavailable: Vec<(settings::Backend, String)>, ui: Ui, view: PageView, /// The open notebooks in the sidebar's order. @@ -972,6 +988,62 @@ fn sections() -> Id { Id::ROOT.child("sections") } +/// Why no backend could draw the window: each one tried, and how it failed. +#[derive(Debug)] +struct NoGraphics(Vec<(settings::Backend, String)>); + +impl std::fmt::Display for NoGraphics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let tried: Vec<_> = (self.0.iter()) + .map(|(backend, error)| format!("{} ({error})", backend.label())) + .collect(); + write!(f, "Nothing could draw the window: {}", tried.join("; ")) + } +} + +impl Error for NoGraphics {} + +/// Notes what drawing started or failed with, where the platform keeps such notes. +fn note(message: &str) { + #[cfg(target_arch = "wasm32")] + web_sys::console::info_1(&message.into()); + #[cfg(not(target_arch = "wasm32"))] + eprintln!("{message}"); +} + +/// Starts the window's surface with `chosen`, or else with the platform's backends in turn, +/// skipping those in `unavailable`, which gains each that fails. Answers the backend that +/// started and its adapter's name. +async fn start_surface( + window: &Arc, + backdrop: bool, + chosen: settings::Backend, + unavailable: &mut Vec<(settings::Backend, String)>, +) -> Result<(surface::Surface, Renderer, (settings::Backend, String)), Box> { + let mut order = vec![chosen]; + order.extend(settings::Backend::PLATFORM.iter().filter(|backend| { + **backend != chosen && !unavailable.iter().any(|(failed, _)| failed == *backend) + })); + for backend in order { + if backend == settings::Backend::Default { + continue; + } + match surface::Surface::new(window.clone(), backdrop, backend).await { + Ok((surface, renderer, adapter)) => { + note(&format!("Drawing with {} ({adapter})", backend.label())); + unavailable.retain(|(failed, _)| *failed != backend); + return Ok((surface, renderer, (backend, adapter))); + } + Err(error) => { + note(&format!("{} didn't start: {error}", backend.label())); + unavailable.retain(|(failed, _)| *failed != backend); + unavailable.push((backend, error.to_string())); + } + } + } + Err(Box::new(NoGraphics(unavailable.clone()))) +} + impl State { async fn new( event_loop: &ActiveEventLoop, @@ -982,6 +1054,7 @@ impl State { settings::Launch { file: settings, saved: stored, + renderer: forced, cache, }: settings::Launch, ) -> Result> { @@ -1022,7 +1095,10 @@ impl State { let access_adapter = AccessAdapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); window.set_visible(visible); - let (surface, renderer) = surface::Surface::new(window.clone(), backdrop).await?; + let chosen = forced.unwrap_or(stored.renderer); + let mut unavailable = Vec::new(); + let (surface, renderer, drawing) = + start_surface(&window, backdrop, chosen, &mut unavailable).await?; let size = window.inner_size(); let mut engine = TextEngine::default(); let fallbacks = platform::symbol_fonts(); @@ -1161,11 +1237,11 @@ impl State { .unwrap_or_else(|| platform::appearance(&window)); platform::follow_appearance(&window, appearance); let mut ui = Ui::new( - theme(appearance, stored.light_pages, backdrop), + theme(appearance, stored.light_pages, surface.translucent()), platform::double_click_interval(), ); let titlebar = platform::titlebar(appearance) - .filter(|_| !backdrop) + .filter(|_| !surface.translucent()) .unwrap_or([ui.theme.strip; 2]); // A window shown but never focused hears no focus event; a hidden one draws as focused. ui.window_focused = !visible || window.has_focus(); @@ -1205,8 +1281,12 @@ impl State { window, redraw, proxy, + renderer, surface, - renderer, + backdrop, + renderer_choice: stored.renderer, + drawing, + unavailable, ui, view: PageView::new( editor, @@ -1336,9 +1416,68 @@ impl State { state.show_spelling(); state.title(); platform::update_tag_menu(&state.tags); + state.tell_fallback(chosen); Ok(state) } + /// Says so where `chosen` didn't start and another backend draws instead. + fn tell_fallback(&self, chosen: settings::Backend) { + if chosen != settings::Backend::Default && self.drawing.0 != chosen { + platform::alert( + &format!("Couldn't start {}", chosen.label()), + &format!( + "Snowbound is drawing with {} instead.", + self.drawing.0.label() + ), + ); + } + } + + /// Draws with `chosen` from now on, in place of what draws now, the window's state + /// staying as it is. The window takes one swap chain or context at a time, so the old + /// renderer and surface go first; where nothing starts, the window can't go on. + #[cfg(not(target_arch = "wasm32"))] + fn switch_renderer(mut self, chosen: settings::Backend) -> Result> { + drop(self.renderer); + drop(self.surface); + let (surface, renderer, drawing) = pollster::block_on(start_surface( + &self.window, + self.backdrop, + chosen, + &mut self.unavailable, + ))?; + self.surface = surface; + self.renderer = renderer; + self.drawing = drawing; + self.started_surface(chosen); + Ok(self) + } + + /// Switches to the renderer the settings file names, where it was edited to name another + /// while the window was away. + fn follow_renderer_setting(&mut self) { + let Some(path) = &self.settings else { + return; + }; + let chosen = settings::Settings::load(path).renderer; + if chosen != self.renderer_choice { + self.renderer_choice = chosen; + let _ = self.proxy.send_event(UserEvent::Renderer(chosen)); + } + } + + /// Fits a surface just started to the window, and says where it isn't what was chosen. + fn started_surface(&mut self, chosen: settings::Backend) { + let ratio = self.scale() / self.window.scale_factor() as f32; + let size = self.window.inner_size(); + self.surface.size = [size.width, size.height].map(|side| (side as f32 * ratio) as u32); + self.surface.configure(&self.renderer); + // Its colours follow whether the system's material shows through. + self.follow_color_scheme(); + self.tell_fallback(chosen); + self.window.request_redraw(); + } + /// Builds, lays out and paints one frame, then does what it asked for and asks for the /// frame that shows the result. fn frame(&mut self) -> Result<(), Box> { @@ -6010,6 +6149,10 @@ impl State { self.window.request_redraw(); } UserEvent::Redraw => self.window.request_redraw(), + #[cfg(target_arch = "wasm32")] + UserEvent::Renderer(chosen) => platform::switch_renderer(self, chosen), + #[cfg(not(target_arch = "wasm32"))] + UserEvent::Renderer(_) => unreachable!("The app switches renderers"), UserEvent::Replay(replay) => { let mark = match replay { Replay::Mark(at) => Some(at), @@ -6036,6 +6179,7 @@ impl State { .window .request_inner_size(LogicalSize::new(width, height)); } + Replay::Renderer(_) => unreachable!("The app switches renderers"), } // A covered window gets no redraws, so each step draws its own frame. if let Err(error) = self.frame() { @@ -6163,6 +6307,16 @@ impl ApplicationHandler for App { match event { UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop), UserEvent::Exit => self.exit(event_loop), + UserEvent::Renderer(chosen) | UserEvent::Replay(Replay::Renderer(chosen)) => { + let state = self.state.take().expect("The window is open"); + match state.switch_renderer(chosen) { + Ok(state) => self.state = Some(state), + Err(error) => { + self.startup_error = Some(error); + event_loop.exit(); + } + } + } event => state.user_event(event), } } @@ -6266,6 +6420,7 @@ impl ApplicationHandler for App { } else { // Notebooks another device added or removed meanwhile. state.list_icloud(); + state.follow_renderer_setting(); } state.window.request_redraw(); } @@ -6457,6 +6612,7 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box Ok(Replay::Snapshot(rest.into())), "settle" => Ok(Replay::Settle(None, settle.clone())), "accessibility" => Ok(Replay::Settle(Some(rest.into()), settle.clone())), + "renderer" => Ok(Replay::Renderer(settings::Backend::named(rest)?)), "appearance" => Ok(Replay::Appearance(match rest { "light" => winit::window::Theme::Light, "dark" => winit::window::Theme::Dark, @@ -6538,6 +6694,7 @@ fn launch() -> Result<(), Box> { let mut cache = None; let mut settings_file = None; let mut screenshot = None; + let mut renderer = None; let mut opening = Vec::new(); while let Some(arg) = args.next() { if arg == "--substitute-font" { @@ -6574,6 +6731,9 @@ fn launch() -> Result<(), Box> { args.next() .ok_or("Provide a settings file after --settings.")?, )); + } else if arg == "--renderer" { + let name = args.next().ok_or("Provide a renderer after --renderer.")?; + renderer = Some(settings::Backend::named(&name.to_string_lossy())?); } else if arg == "--screenshot" { screenshot = Some(PathBuf::from( args.next() @@ -6606,6 +6766,11 @@ fn launch() -> Result<(), Box> { positional.push(arg); } } + let renderer = match (renderer, std::env::var("SNOWBOUND_RENDERER")) { + (Some(renderer), _) => Some(renderer), + (None, Ok(name)) => Some(settings::Backend::named(&name)?), + (None, Err(_)) => None, + }; AUTOMATED.store( screenshot.is_some() || std::env::var_os("SNOWBOUND_REPLAY").is_some(), Ordering::Relaxed, @@ -6620,7 +6785,7 @@ fn launch() -> Result<(), Box> { } if positional.len() > 2 { return Err( - "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]..." + "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]..." .into(), ); } @@ -6700,6 +6865,7 @@ fn launch() -> Result<(), Box> { launch: Some(settings::Launch { file: settings_file.filter(|_| screenshot.is_none()), saved, + renderer, cache, }), opening, diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index db3c2b2f16f5e1f53b6bad58d296ccc282b16f65..e48d9db1110fedaa74de4dbdf5ea6ddbb2c5f802 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -2,7 +2,11 @@ //! indexed on the left, filtered by the search field above, each choice kept when OK is //! chosen. -use crate::{State, platform, settings::ColorScheme, update}; +use crate::{ + State, UserEvent, platform, + settings::{Backend, ColorScheme}, + update, +}; use accesskit::Role; use canvas::editor::DefaultFont; use ui::{Anchor, Axis, Extent, Flags, Id, Size, Spec, Theme, Ui, fill, popup::Item, px}; @@ -181,6 +185,12 @@ const SECTIONS: &[Section] = &[ keywords: "notebooks sidebar side right layout", control: Control::Check(|options| &mut options.navigation_bar_left), }, + Row { + label: "Renderer:", + keywords: "graphics gpu backend drawing metal opengl vulkan direct3d \ + webgpu webgl canvas", + control: Control::Field(renderer), + }, ], }], }, @@ -282,6 +292,7 @@ pub struct Options { /// Live Share's relay; empty uses Snowbound's. relay: String, notebook_theme: Option, + renderer: Backend, pub(crate) keyboard: crate::keys::Keyboard, } @@ -325,6 +336,10 @@ fn notebook_themes() -> Id { id().child("notebook-themes") } +fn renderers() -> Id { + id().child("renderers") +} + /// Whether `word`, lowercase, starts a word of `text`: "pen" finds "Pen" and "pen-like", /// not "Open". fn starts_word(text: &str, word: &str) -> bool { @@ -386,6 +401,7 @@ impl State { picture: self.live_options.picture, relay: self.live_options.relay.clone().unwrap_or_default(), notebook_theme: self.notebook_theme.clone(), + renderer: self.renderer_choice, keyboard: crate::keys::Keyboard::new(), }); self.ui.open_popup(id()); @@ -706,7 +722,12 @@ impl State { self.show_spelling(); self.follow_color_scheme(); self.install_keymap(options.keyboard.keymap); + let switch = options.renderer != self.renderer_choice; + self.renderer_choice = options.renderer; self.save_settings(); + if switch { + let _ = self.proxy.send_event(UserEvent::Renderer(options.renderer)); + } } else if !cancel { self.options = Some(options); return; @@ -838,6 +859,75 @@ fn notebook_theme(state: &mut State, options: &mut Options) { } } +/// The platform's backends, the one the default starts with marked, those that didn't start +/// this run disabled; then the backend and adapter drawing now. +fn renderer(state: &mut State, options: &mut Options) { + let State { + ui, + unavailable, + drawing, + .. + } = state; + let failed = |backend: Backend| { + let (_, reason) = unavailable.iter().find(|(failed, _)| *failed == backend)?; + // A long reason is the system's, kept in the log. + Some(if reason.chars().count() <= 28 { + reason.as_str() + } else { + "Didn't start" + }) + }; + let automatic = (Backend::PLATFORM.iter().copied()).find(|backend| failed(*backend).is_none()); + let label = |backend: Backend| { + if Some(backend) == automatic { + format!("{} (Default)", backend.label()) + } else { + backend.label().to_owned() + } + }; + let shown = match options.renderer { + Backend::Default => automatic, + chosen => Some(chosen), + }; + let labels: Vec = Backend::PLATFORM.iter().copied().map(label).collect(); + let current = shown.map(label).unwrap_or_default(); + let combo = ui.id("combo"); + ui::shell::combo(ui, "combo", "Renderer", ¤t, 160.0, renderers(), true); + let items: Vec = (Backend::PLATFORM.iter().zip(&labels)) + .map(|(backend, text)| Item { + text, + checked: Some(Some(*backend) == shown), + current: Some(*backend) == shown, + disabled: failed(*backend).is_some(), + badge: failed(*backend), + ..Item::default() + }) + .collect(); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, renderers(), anchor, &items, None) { + let picked = Backend::PLATFORM[index]; + options.renderer = if Some(picked) == automatic { + Backend::Default + } else { + picked + }; + } + let (backend, adapter) = &*drawing; + let used = match adapter.as_str() { + "" => backend.label().to_owned(), + adapter if adapter == backend.label() => adapter.to_owned(), + adapter => format!("{} · {adapter}", backend.label()), + }; + let spec = Spec { + flags: Flags::CLIP, + size: [fill(), px(row_height(&ui.theme))], + text: Some(&used), + color: Some(ui.theme.text_dim), + ..Spec::default() + }; + ui.leaf("adapter", spec); +} + fn lock_after(state: &mut State, options: &mut Options) { let ui = &mut state.ui; let combo = ui.id("combo"); diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index b9326922040d493e3a1d6193971dd3f9c1f5f12e..157eb3bcdf9762167e2c2c37b91a7b902a055a63 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -54,6 +54,96 @@ pub struct Settings { pub live: Live, /// Options' "Default for new notebooks": the built-in theme new notebooks take. pub notebook_theme: NotebookTheme, + /// Options' Renderer, which `--renderer` and `SNOWBOUND_RENDERER` override. + pub renderer: Backend, +} + +/// What the window draws with: one of the platform's backends, or the first of them that +/// starts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "String", into = "&'static str")] +pub enum Backend { + #[default] + Default, + D3d12, + D3d11, + Metal, + Vulkan, + Gl, + Webgpu, + Webgl2, + Canvas2d, +} + +impl Backend { + /// Each backend's name in the settings and on the command line, and as Options shows it. + const NAMES: [(Self, &str, &str); 9] = [ + (Self::Default, "default", "Default"), + (Self::D3d12, "d3d12", "Direct3D 12"), + (Self::D3d11, "d3d11", "Direct3D 11"), + (Self::Metal, "metal", "Metal"), + (Self::Vulkan, "vulkan", "Vulkan"), + (Self::Gl, "gl", "OpenGL"), + (Self::Webgpu, "webgpu", "WebGPU"), + (Self::Webgl2, "webgl2", "WebGL 2"), + (Self::Canvas2d, "canvas2d", "Canvas 2D"), + ]; + + /// The platform's backends, in the order the default tries them. + pub const PLATFORM: &[Self] = if cfg!(target_arch = "wasm32") { + &[Self::Webgpu, Self::Webgl2, Self::Canvas2d] + } else if cfg!(windows) { + &[Self::D3d12, Self::D3d11, Self::Gl] + } else if cfg!(all(target_os = "macos", feature = "wgpu")) { + &[Self::Metal, Self::Gl] + } else if cfg!(target_os = "macos") { + &[Self::Gl] + } else { + &[Self::Vulkan, Self::Gl] + }; + + /// The backend `name` names, or why it names none. + pub fn named(name: &str) -> Result { + let platform = || std::iter::once(&Self::Default).chain(Self::PLATFORM); + platform() + .find(|backend| backend.name() == name) + .copied() + .ok_or_else(|| { + let names: Vec<_> = platform().map(|backend| backend.name()).collect(); + format!("Unknown renderer {name}; use one of {}.", names.join(", ")) + }) + } + + fn entry(self) -> (Self, &'static str, &'static str) { + Self::NAMES + .into_iter() + .find(|(backend, ..)| *backend == self) + .expect("Every backend is named") + } + + pub fn name(self) -> &'static str { + self.entry().1 + } + + pub fn label(self) -> &'static str { + self.entry().2 + } +} + +/// A name another platform's backend, or none, has falls back to the default. +impl From for Backend { + fn from(name: String) -> Self { + Self::named(&name).unwrap_or_else(|error| { + eprintln!("{error}"); + Self::Default + }) + } +} + +impl From for &'static str { + fn from(backend: Backend) -> Self { + backend.name() + } } /// Options' Live Share. @@ -232,6 +322,7 @@ impl crate::State { passwords: self.passwords, notebook_theme: NotebookTheme(self.notebook_theme.clone()), live: self.live_options.clone(), + renderer: self.renderer_choice, }; if let Err(error) = settings.save(path) { eprintln!("Cannot save the settings in {}: {error}", path.display()); @@ -244,6 +335,8 @@ pub struct Launch { /// Where the settings are saved; `None` leaves them as they were read. pub file: Option, pub saved: Settings, + /// The renderer `--renderer` or `SNOWBOUND_RENDERER` asks for, over the settings'. + pub renderer: Option, /// The directory holding the sections' replicas. pub cache: PathBuf, } @@ -322,6 +415,7 @@ mod tests { relay: Some("wss://relay.example.net".into()), }, notebook_theme: NotebookTheme(None), + renderer: Backend::PLATFORM[0], }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); @@ -341,6 +435,11 @@ mod tests { // New notebooks take Modern until another theme, or none, is picked. let theme = Settings::load(&path).notebook_theme; assert_eq!(theme.0.as_deref(), Some(notebook::sidecar::themes::DEFAULT)); + // A renderer this platform lacks keeps the other settings and draws with the default. + notebook::fs::write(&path, br#"{"sidebar": true, "renderer": "glide"}"#).unwrap(); + let settings = Settings::load(&path); + assert!(settings.sidebar); + assert_eq!(settings.renderer, Backend::Default); notebook::fs::remove_dir_all(&directory).unwrap(); } } diff --git a/crates/snowbound/src/surface.rs b/crates/snowbound/src/surface.rs index bf07c045b5b273c71ef0851c99da37794a54b570..05f664334df192e98968595e2dceae4f804c93df 100644 --- a/crates/snowbound/src/surface.rs +++ b/crates/snowbound/src/surface.rs @@ -1,6 +1,6 @@ //! Frames reach the window through wgpu. -use crate::{Window, platform, trace_input}; +use crate::{Window, platform, settings::Backend, trace_input}; use draw::Renderer; use std::{error::Error, sync::Arc}; @@ -8,6 +8,9 @@ pub struct Surface { /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. pub size: [u32; 2], window: Arc, + /// The canvas a browser's frames show in. + #[cfg(target_arch = "wasm32")] + canvas: web_sys::HtmlCanvasElement, device: wgpu::Device, queue: wgpu::Queue, instance: wgpu::Instance, @@ -32,47 +35,56 @@ pub struct Offscreen { } impl Surface { - /// With `backdrop`, frames leave the system's material showing through their - /// transparent pixels. + /// Draws with `backend` through wgpu; with `backdrop`, frames leave the system's + /// material showing through their transparent pixels. Answers the adapter's name too. pub async fn new( window: Arc, backdrop: bool, - ) -> Result<(Self, Renderer), Box> { + backend: Backend, + #[cfg(target_arch = "wasm32")] canvas: web_sys::HtmlCanvasElement, + ) -> Result<(Self, Renderer, String), Box> { #[cfg(target_arch = "wasm32")] - let instance = { - let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); - descriptor.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL; - wgpu::util::new_instance_with_webgpu_detection(descriptor).await - }; + let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); #[cfg(not(target_arch = "wasm32"))] - #[cfg_attr(not(any(windows, target_os = "linux")), expect(unused_mut))] let mut descriptor = wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())); + descriptor.backends = match backend { + Backend::Webgpu => wgpu::Backends::BROWSER_WEBGPU, + Backend::Webgl2 | Backend::Gl => wgpu::Backends::GL, + Backend::Metal => wgpu::Backends::METAL, + Backend::Vulkan => wgpu::Backends::VULKAN, + Backend::D3d12 => wgpu::Backends::DX12, + _ => return Err(format!("wgpu has no {}", backend.label()).into()), + }; // wgpu's OpenGL panics drawing to a Wayland window without libwayland-egl. #[cfg(target_os = "linux")] { use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; - if matches!( - window.window_handle()?.as_raw(), - RawWindowHandle::Wayland(_) - ) && !crate::loader::loads(c"libwayland-egl.so.1") + if backend == Backend::Gl + && matches!( + window.window_handle()?.as_raw(), + RawWindowHandle::Wayland(_) + ) + && !crate::loader::loads(c"libwayland-egl.so.1") { - descriptor.backends.remove(wgpu::Backends::GL); + return Err("Needs libwayland-egl".into()); } } - // Direct3D 12, which Windows 10 and 11 always have, and through DirectComposition - // where the backdrop shows through; elsewhere `surface_windows` draws with OpenGL. + // Through DirectComposition where the backdrop shows through. #[cfg(windows)] - { - descriptor.backends = wgpu::Backends::DX12; - if backdrop { - descriptor.backend_options.dx12.presentation_system = - wgpu::Dx12SwapchainKind::DxgiFromVisual; - } + if backdrop { + descriptor.backend_options.dx12.presentation_system = + wgpu::Dx12SwapchainKind::DxgiFromVisual; } + #[cfg(target_arch = "wasm32")] + let instance = wgpu::util::new_instance_with_webgpu_detection(descriptor).await; #[cfg(not(target_arch = "wasm32"))] let instance = wgpu::Instance::new(descriptor); - let surface = instance.create_surface(target(&window))?; + #[cfg(target_arch = "wasm32")] + let target = wgpu::SurfaceTarget::Canvas(canvas.clone()); + #[cfg(not(target_arch = "wasm32"))] + let target: wgpu::SurfaceTarget<'static> = window.clone().into(); + let surface = instance.create_surface(target)?; platform::configure_presentation(&surface); let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { @@ -121,12 +133,15 @@ impl Surface { config.view_formats.push(view_format); } surface.configure(&device, &config); - eprintln!("Canvas GPU: {:?}", adapter.get_info()); + let info = adapter.get_info(); + eprintln!("Canvas GPU: {info:?}"); let renderer = Renderer::new(device.clone(), queue.clone(), view_format); Ok(( Self { size: [config.width, config.height], window, + #[cfg(target_arch = "wasm32")] + canvas, device, queue, instance, @@ -136,6 +151,7 @@ impl Surface { translucent, }, renderer, + info.name, )) } @@ -163,7 +179,7 @@ impl Surface { return Ok(None); } wgpu::CurrentSurfaceTexture::Lost => { - self.surface = self.instance.create_surface(target(&self.window))?; + self.surface = self.instance.create_surface(self.target())?; platform::configure_presentation(&self.surface); self.configure(renderer); self.window.request_redraw(); @@ -237,6 +253,14 @@ impl Surface { }) } + /// What the surface presents to: the window, or in the browser its canvas. + fn target(&self) -> wgpu::SurfaceTarget<'static> { + #[cfg(target_arch = "wasm32")] + return wgpu::SurfaceTarget::Canvas(self.canvas.clone()); + #[cfg(not(target_arch = "wasm32"))] + self.window.clone().into() + } + /// The offscreen target's sRGB RGBA rows, top first. pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result, Box> { let size = self.size; @@ -283,20 +307,20 @@ impl Surface { } } -/// What the surface presents to: the window, or in the browser the page's canvas. -fn target(window: &Arc) -> wgpu::SurfaceTarget<'static> { - #[cfg(target_arch = "wasm32")] - return { - let _ = window; - wgpu::SurfaceTarget::Canvas(platform::canvas()) - }; - #[cfg(not(target_arch = "wasm32"))] - window.clone().into() +/// Takes the layer out of the view, for whatever draws next. +#[cfg(target_os = "macos")] +impl Drop for Surface { + fn drop(&mut self) { + platform::release_presentation(&self.surface); + } } /// `view` as what the renderer draws into, which is the view itself unless `draw` also -/// paints through OpenGL, as on Windows. -#[cfg_attr(not(windows), expect(clippy::useless_conversion))] +/// paints otherwise: through Direct3D 11 or OpenGL, or a browser's 2D canvas. +#[cfg_attr( + not(any(windows, target_os = "macos", target_arch = "wasm32")), + expect(clippy::useless_conversion) +)] fn target_of(view: wgpu::TextureView) -> draw::Target { view.into() } diff --git a/crates/snowbound/src/surface_gl.rs b/crates/snowbound/src/surface_gl.rs index f1a2e0105ef1a10290ca04606cb4fa45df1adc39..481c10c9820137eca766760b61a0790b791174cf 100644 --- a/crates/snowbound/src/surface_gl.rs +++ b/crates/snowbound/src/surface_gl.rs @@ -1,6 +1,8 @@ -//! Frames reach the window through an OpenGL context on its view, where wgpu has no -//! backend: Mac OS X 10.6. Frames draw into an sRGB target and are copied to the view. +//! Frames reach the window through an OpenGL context on its view: where wgpu has no +//! backend, Mac OS X 10.6, or where OpenGL is chosen. Frames draw into an sRGB target and are +//! copied to the view. +use crate::settings::Backend; use draw::{Renderer, Target}; use objc2::{ ffi::NSInteger, @@ -29,14 +31,24 @@ pub struct Frame { pub target: Target, } +#[link(name = "OpenGL", kind = "framework")] +unsafe extern "C" { + fn glGetString(name: u32) -> *const u8; +} + pub type Offscreen = Frame; impl Surface { - /// With `backdrop`, the surface is transparent where frames are. + /// Draws with OpenGL, the only `backend` it has; with `backdrop`, the surface is + /// transparent where frames are. Answers the driver's name too. pub async fn new( window: Arc, backdrop: bool, - ) -> Result<(Self, Renderer), Box> { + backend: Backend, + ) -> Result<(Self, Renderer, String), Box> { + if backend != Backend::Gl { + return Err(format!("This Mac has no {}", backend.label()).into()); + } let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else { unreachable!() }; @@ -70,7 +82,19 @@ impl Surface { context }; let renderer = Renderer::opengl()?; - eprintln!("Canvas GPU: OpenGL"); + let string = |name| unsafe { + let text = glGetString(name); + if text.is_null() { + String::new() + } else { + std::ffi::CStr::from_ptr(text.cast()) + .to_string_lossy() + .into_owned() + } + }; + // GL_RENDERER and GL_VERSION. + let driver = format!("{} (OpenGL {})", string(0x1F01), string(0x1F02)); + eprintln!("Canvas GPU: {driver}"); let size = window.inner_size(); Ok(( Self { @@ -80,6 +104,7 @@ impl Surface { target: None, }, renderer, + driver, )) } @@ -125,3 +150,14 @@ impl Surface { Ok(renderer.read_pixels(&offscreen.target)?) } } + +/// Leaves the view to whatever draws next. +impl Drop for Surface { + fn drop(&mut self) { + let class = AnyClass::get("NSOpenGLContext").expect("AppKit is linked"); + unsafe { + let _: () = msg_send![&self.context, clearDrawable]; + let _: () = msg_send![class, clearCurrentContext]; + } + } +} diff --git a/crates/snowbound/src/surface_macos.rs b/crates/snowbound/src/surface_macos.rs new file mode 100644 index 0000000000000000000000000000000000000000..03ba6ee691c5272108da2addbd20bfbf3b374742 --- /dev/null +++ b/crates/snowbound/src/surface_macos.rs @@ -0,0 +1,166 @@ +//! Frames reach the window through Metal, or through OpenGL on its view where chosen. + +#[path = "surface_gl.rs"] +mod gl; +#[path = "surface.rs"] +mod metal; + +use crate::settings::Backend as Chosen; +use draw::{Renderer, Target}; +use std::{error::Error, sync::Arc}; +use winit::window::Window; + +pub struct Surface { + /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. + pub size: [u32; 2], + backend: Backend, +} + +enum Backend { + Metal(Box), + Gl(gl::Surface), +} + +pub struct Frame { + pub target: Target, + /// The drawable's texture, and whether it asks for reconfiguring, where Metal draws. + texture: Option<(wgpu::SurfaceTexture, bool)>, +} + +pub struct Offscreen { + pub target: Target, + texture: Option, +} + +impl Surface { + /// Draws with `chosen`; with `backdrop`, frames leave the window's material showing + /// through their transparent pixels. Answers the adapter's name too. + pub async fn new( + window: Arc, + backdrop: bool, + chosen: Chosen, + ) -> Result<(Self, Renderer, String), Box> { + let size = window.inner_size(); + let size = [size.width, size.height]; + // OpenGL draws beneath the window's material, so draws opaque without it. + let opengl = chosen == Chosen::Gl; + if backdrop { + crate::platform::show_backdrop(&window, !opengl); + } + let (backend, renderer, adapter) = if opengl { + let (surface, renderer, adapter) = gl::Surface::new(window, false, chosen).await?; + (Backend::Gl(surface), renderer, adapter) + } else { + let (surface, renderer, adapter) = + metal::Surface::new(window, backdrop, chosen).await?; + (Backend::Metal(Box::new(surface)), renderer, adapter) + }; + Ok((Self { size, backend }, renderer, adapter)) + } + + /// Whether frames leave the system's backdrop showing. + pub fn translucent(&self) -> bool { + match &self.backend { + Backend::Metal(surface) => surface.translucent(), + Backend::Gl(surface) => surface.translucent(), + } + } + + pub fn configure(&mut self, renderer: &Renderer) { + match &mut self.backend { + Backend::Metal(surface) => { + surface.size = self.size; + surface.configure(renderer); + } + Backend::Gl(surface) => { + surface.size = self.size; + surface.configure(renderer); + } + } + } + + /// The window's next frame, or none to skip this one. + pub fn frame(&mut self, renderer: &Renderer) -> Result, Box> { + Ok(match &mut self.backend { + Backend::Metal(surface) => { + surface.size = self.size; + surface.frame(renderer)?.map(|frame| Frame { + target: frame.target, + texture: Some((frame.texture, frame.reconfigure)), + }) + } + Backend::Gl(surface) => { + surface.size = self.size; + surface.frame(renderer)?.map(|frame| Frame { + target: frame.target, + texture: None, + }) + } + }) + } + + pub fn present(&mut self, renderer: &Renderer, frame: Frame) { + match (&mut self.backend, frame.texture) { + (Backend::Metal(surface), Some((texture, reconfigure))) => { + let frame = metal::Frame { + target: frame.target, + texture, + reconfigure, + }; + surface.present(renderer, frame); + } + (Backend::Gl(surface), None) => surface.present( + renderer, + gl::Frame { + target: frame.target, + }, + ), + _ => unreachable!("Frames come from the surface's own backend"), + } + } + + /// A target the size of the window's frames that `read` reads back. + pub fn offscreen(&mut self, renderer: &Renderer) -> Result> { + match &mut self.backend { + Backend::Metal(surface) => { + surface.size = self.size; + let offscreen = surface.offscreen(renderer)?; + Ok(Offscreen { + target: offscreen.target, + texture: Some(offscreen.texture), + }) + } + Backend::Gl(surface) => { + surface.size = self.size; + Ok(Offscreen { + target: surface.offscreen(renderer)?.target, + texture: None, + }) + } + } + } + + /// The offscreen target's sRGB RGBA rows, top first. + pub fn read( + &self, + renderer: &Renderer, + offscreen: Offscreen, + ) -> Result, Box> { + match (&self.backend, offscreen.texture) { + (Backend::Metal(surface), Some(texture)) => surface.read( + renderer, + metal::Offscreen { + target: offscreen.target, + texture, + }, + ), + (Backend::Gl(surface), None) => surface.read( + renderer, + gl::Frame { + target: offscreen.target, + }, + ), + _ => unreachable!("Snapshots come from the surface's own backend"), + } + } +} diff --git a/crates/snowbound/src/surface_web.rs b/crates/snowbound/src/surface_web.rs new file mode 100644 index 0000000000000000000000000000000000000000..bbee54fd5aa85a42bdb0de3a55485674c51d9ee8 --- /dev/null +++ b/crates/snowbound/src/surface_web.rs @@ -0,0 +1,166 @@ +//! Frames reach the page through WebGPU or WebGL2, by wgpu, or else through its 2D canvas. +//! Each start takes a canvas of its own, as a canvas keeps the first kind of context it +//! hands out, which the page shows once `show` is called. + +#[path = "surface.rs"] +mod webgpu; + +use crate::{Window, platform, settings::Backend as Chosen}; +use draw::{Renderer, Target}; +use std::{error::Error, sync::Arc}; +use web_sys::HtmlCanvasElement; + +pub struct Surface { + /// Device pixels frames and snapshots are drawn at; `configure` gives the window it. + pub size: [u32; 2], + canvas: HtmlCanvasElement, + /// wgpu's surface, or none where the 2D canvas draws. + wgpu: Option>, +} + +pub struct Frame { + pub target: Target, + /// The canvas's texture, and whether it asks for reconfiguring, where wgpu draws. + texture: Option<(wgpu::SurfaceTexture, bool)>, +} + +pub struct Offscreen { + pub target: Target, + texture: Option, +} + +impl Surface { + /// Draws with `chosen` into a canvas of its own. Answers the adapter's name too. + pub async fn new( + window: Arc, + backdrop: bool, + chosen: Chosen, + ) -> Result<(Self, Renderer, String), Box> { + let canvas = platform::fresh_canvas()?; + let size = window.inner_size(); + let size = [size.width, size.height]; + if chosen == Chosen::Canvas2d { + Target::canvas2d(canvas.clone())?; + let surface = Self { + size, + canvas, + wgpu: None, + }; + return Ok((surface, Renderer::canvas2d()?, chosen.label().into())); + } + // wgpu's own errors say no more than this to someone choosing a renderer. + let offered = match chosen { + Chosen::Webgpu => web_sys::window().is_some_and(|window| { + js_sys::Reflect::has(&window.navigator(), &"gpu".into()).unwrap_or(false) + }), + // On a canvas of its own: a context keeps the settings it was first asked with. + _ => (platform::fresh_canvas()? + .get_context("webgl2") + .ok() + .flatten()) + .is_some(), + }; + if !offered { + return Err("Not in this browser".into()); + } + let (wgpu, renderer, adapter) = + webgpu::Surface::new(window, backdrop, chosen, canvas.clone()).await?; + let surface = Self { + size, + canvas, + wgpu: Some(Box::new(wgpu)), + }; + Ok((surface, renderer, adapter)) + } + + /// Puts the surface's canvas in the page in place of the one there. + pub fn show(&self) { + platform::adopt_canvas(&self.canvas); + } + + pub fn translucent(&self) -> bool { + false + } + + pub fn configure(&mut self, renderer: &Renderer) { + if let Some(surface) = &mut self.wgpu { + surface.size = self.size; + surface.configure(renderer); + } + } + + /// The page's next frame, or none to skip this one. + pub fn frame(&mut self, renderer: &Renderer) -> Result, Box> { + if self.size.contains(&0) { + return Ok(None); + } + Ok(match &mut self.wgpu { + Some(surface) => { + surface.size = self.size; + surface.frame(renderer)?.map(|frame| Frame { + target: frame.target, + texture: Some((frame.texture, frame.reconfigure)), + }) + } + None => Some(Frame { + target: Target::canvas2d(self.canvas.clone())?, + texture: None, + }), + }) + } + + /// Hands wgpu's frame to the canvas; the 2D canvas shows what was drawn once the frame's + /// task ends. + pub fn present(&mut self, renderer: &Renderer, frame: Frame) { + if let (Some(surface), Some((texture, reconfigure))) = (&mut self.wgpu, frame.texture) { + let frame = webgpu::Frame { + target: frame.target, + texture, + reconfigure, + }; + surface.present(renderer, frame); + } + } + + /// A target the size of the page's frames that `read` reads back. + pub fn offscreen(&mut self, renderer: &Renderer) -> Result> { + match &mut self.wgpu { + Some(surface) => { + surface.size = self.size; + let offscreen = surface.offscreen(renderer)?; + Ok(Offscreen { + target: offscreen.target, + texture: Some(offscreen.texture), + }) + } + None => { + let canvas = platform::fresh_canvas()?; + canvas.set_width(self.size[0]); + canvas.set_height(self.size[1]); + Ok(Offscreen { + target: Target::canvas2d(canvas)?, + texture: None, + }) + } + } + } + + /// The offscreen target's sRGB RGBA rows, top first. + pub fn read( + &self, + renderer: &Renderer, + offscreen: Offscreen, + ) -> Result, Box> { + match (&self.wgpu, offscreen.texture) { + (Some(surface), Some(texture)) => surface.read( + renderer, + webgpu::Offscreen { + target: offscreen.target, + texture, + }, + ), + (None, None) => Ok(renderer.read_pixels(&offscreen.target)?), + _ => unreachable!("Snapshots come from the surface's own backend"), + } + } +} diff --git a/crates/snowbound/src/surface_windows.rs b/crates/snowbound/src/surface_windows.rs index 5bd9c2e615ee42a438aad0dae4e436e90946dfe8..20fd5ddcb5eea49e54e6693ae47a9631a6e58f80 100644 --- a/crates/snowbound/src/surface_windows.rs +++ b/crates/snowbound/src/surface_windows.rs @@ -1,18 +1,17 @@ //! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), otherwise -//! through Direct3D 11, as on Windows 7, and failing both through OpenGL 2.1 on a WGL -//! context. The last two draw into an sRGB target, copied to the window's back buffer. -//! `SNOWBOUND_RENDERER` set to `d3d11` or `gl` picks one of those, and to anything else -//! insists on Direct3D 12. +//! through Direct3D 11, as on Windows 7, or OpenGL 2.1 on a WGL context. The last two draw +//! into an sRGB target, copied to the window's back buffer. #[path = "surface.rs"] mod webgpu; +use crate::settings::Backend as Chosen; use draw::{Renderer, Target}; use std::{error::Error, sync::Arc}; use windows_sys::Win32::{ Foundation::HWND, Graphics::{ - Gdi::{GetDC, HDC}, + Gdi::{GetDC, HDC, ReleaseDC}, OpenGL as gl, }, }; @@ -31,8 +30,9 @@ enum Backend { Wgpu(Box), /// Direct3D 11 or OpenGL, which present the renderer's own targets. Native { - /// For OpenGL, the window's device context, which the context draws to. - opengl: Option, + /// For OpenGL, the window, its device context, which the context draws to, and the + /// context. + opengl: Option<(HWND, HDC, gl::HGLRC)>, /// Frames leave the desktop's glass showing through their transparent pixels. translucent: bool, /// The frame target, kept between frames. @@ -53,47 +53,40 @@ pub struct Offscreen { } impl Surface { - /// With `backdrop`, frames leave the system's material showing through their - /// transparent pixels. + /// Draws with `chosen`; with `backdrop`, frames leave the system's material showing + /// through their transparent pixels. Answers the adapter's name too. pub async fn new( window: Arc, backdrop: bool, - ) -> Result<(Self, Renderer), Box> { - let forced = std::env::var("SNOWBOUND_RENDERER").ok(); - let forced = forced.as_deref(); + chosen: Chosen, + ) -> Result<(Self, Renderer, String), Box> { let size = window.inner_size(); let size = [size.width, size.height]; - let native = |opengl, translucent, renderer| { + let native = |opengl, translucent, renderer, adapter| { let backend = Backend::Native { opengl, translucent, target: None, }; - Ok((Self { size, backend }, renderer)) + Ok((Self { size, backend }, renderer, adapter)) }; - if !matches!(forced, Some("d3d11" | "gl")) { - match webgpu::Surface::new(window.clone(), backdrop).await { - Ok((surface, renderer)) => { - let backend = Backend::Wgpu(Box::new(surface)); - return Ok((Self { size, backend }, renderer)); - } - Err(error) if forced.is_some() => return Err(error), - Err(error) => eprintln!("No Direct3D 12 ({error})"), - } + if chosen == Chosen::D3d12 { + let (surface, renderer, adapter) = + webgpu::Surface::new(window.clone(), backdrop, chosen).await?; + let backend = Backend::Wgpu(Box::new(surface)); + return Ok((Self { size, backend }, renderer, adapter)); } let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else { unreachable!() }; let hwnd = handle.hwnd.get() as HWND; - if forced != Some("gl") { - match Renderer::direct3d11(hwnd) { - Ok((renderer, adapter)) => { - eprintln!("Canvas GPU: Direct3D 11 ({adapter})"); - return native(None, backdrop, renderer); - } - Err(error) if forced.is_some() => return Err(error.into()), - Err(error) => eprintln!("No Direct3D 11 ({error}); drawing with OpenGL"), - } + if chosen == Chosen::D3d11 { + let (renderer, adapter) = Renderer::direct3d11(hwnd)?; + eprintln!("Canvas GPU: Direct3D 11 ({adapter})"); + return native(None, backdrop, renderer, adapter); + } + if chosen != Chosen::Gl { + return Err(format!("Windows has no {}", chosen.label()).into()); } let device = unsafe { GetDC(hwnd) }; let request = |alpha| gl::PIXELFORMATDESCRIPTOR { @@ -123,16 +116,18 @@ impl Surface { }; let mut format = request(if backdrop { 8 } else { 0 }); let mut translucent = backdrop; - let mut chosen = accelerated(&format); - if chosen.is_none() && backdrop { + let mut picked = accelerated(&format); + if picked.is_none() && backdrop { eprintln!("No accelerated OpenGL format with alpha; drawing opaque"); format = request(0); translucent = false; - chosen = accelerated(&format); + picked = accelerated(&format); } - unsafe { - let chosen = chosen.unwrap_or_else(|| gl::ChoosePixelFormat(device, &format)); - if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 { + let context = unsafe { + // A window takes its pixel format once, so OpenGL started again keeps the first. + let set = gl::GetPixelFormat(device); + let picked = picked.unwrap_or_else(|| gl::ChoosePixelFormat(device, &format)); + if set == 0 && (picked == 0 || gl::SetPixelFormat(device, picked, &format) == 0) { return Err("No OpenGL pixel format".into()); } let context = gl::wglCreateContext(device); @@ -146,7 +141,8 @@ impl Surface { 1, ); } - } + context + }; let string = |name| unsafe { let text = gl::glGetString(name); if text.is_null() { @@ -170,7 +166,7 @@ impl Surface { the driver from your graphics card's maker. ({error})" ) })?; - native(Some(device), translucent, renderer) + native(Some((hwnd, device, context)), translucent, renderer, driver) } /// Whether frames leave the system's backdrop showing. @@ -234,7 +230,7 @@ impl Surface { None, ) => { renderer.present(&target, *translucent); - if let Some(device) = opengl { + if let Some((_, device, _)) = opengl { unsafe { gl::SwapBuffers(*device) }; } *kept = Some(target); @@ -276,3 +272,20 @@ impl Surface { } } } + +/// Gives the window back its device context, for a renderer started after this one. +impl Drop for Surface { + fn drop(&mut self) { + if let Backend::Native { + opengl: Some((window, device, context)), + .. + } = self.backend + { + unsafe { + gl::wglMakeCurrent(std::ptr::null_mut(), std::ptr::null_mut()); + gl::wglDeleteContext(context); + ReleaseDC(window, device); + } + } + } +} diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index a9572e02fa1195b5fcbe634d966aaeb433ff7dcc..60e80a6fc8ca3e227bb93b5f84e37ccf7e96cde3 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -1273,7 +1273,7 @@ mod tests { }); self.renderer .draw( - &texture.create_view(&Default::default()), + &texture.create_view(&Default::default()).into(), size, ui.theme.base, &layers, diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 4693e2476dac0db7d6932e164f156700959dc37e..ac0118491e8dbef01f07a4d6011ca61b5e2a3870 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -88,6 +88,10 @@ extern "C" { /// Keeps what `index.html`'s loading shell paints on the next visit. #[wasm_bindgen(js_name = keepLook)] fn keep_look(look: &str); + /// Puts `canvas` in the page in place of the canvas called page, which its input + /// follows. + #[wasm_bindgen(js_name = adoptCanvas)] + pub fn adopt_canvas(canvas: &web_sys::HtmlCanvasElement); } /// Where the browser keeps its notebooks, which a first visit makes one in. @@ -223,6 +227,16 @@ pub fn canvas() -> web_sys::HtmlCanvasElement { .expect("index.html has a canvas called page") } +/// A canvas like the page's, out of the page, with no context yet. +pub fn fresh_canvas() -> Result { + use wasm_bindgen::JsCast; + canvas() + .clone_node() + .map_err(|error| format!("{error:?}"))? + .dyn_into() + .map_err(|_| "A canvas's copy is a canvas".into()) +} + fn host(act: impl FnOnce(&mut Host) -> T) -> T { HOST.with_borrow_mut(act) } @@ -921,9 +935,17 @@ pub async fn start( notebook::fs::mount(&root); restore(folder.get(1).into()); } - let mut state = open(fonts) - .await - .map_err(|error| JsValue::from_str(&error.to_string()))?; + let renderer = location_renderer().map_err(|error| JsValue::from_str(&error))?; + let mut state = open(fonts, renderer).await.map_err(|error| { + // The page tells this apart from other failures: no way to draw at all. + if error.downcast_ref::().is_some() { + let thrown = js_sys::Error::new(&error.to_string()); + thrown.set_name("NoGraphicsError"); + return JsValue::from(thrown); + } + JsValue::from_str(&error.to_string()) + })?; + state.surface.show(); // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases // the rest in. state.full_page = true; @@ -1015,7 +1037,51 @@ fn store() { /// The window, as `launch` opens it with the notebooks the settings list, making the /// browser's first notebook on a first visit. -async fn open(fonts: Vec) -> Result> { +/// The renderer the page's address asks for with `?renderer=NAME`, over the settings', as +/// `--renderer` does on the desktop. +fn location_renderer() -> Result, String> { + let search = web_sys::window() + .and_then(|window| window.location().search().ok()) + .unwrap_or_default(); + search + .trim_start_matches('?') + .split('&') + .find_map(|pair| pair.strip_prefix("renderer=")) + .map(settings::Backend::named) + .transpose() +} + +/// Draws with `chosen` from now on: its surface starts on a canvas of its own while the +/// old one goes on drawing, then takes the old one's place. +pub fn switch_renderer(state: &mut State, chosen: settings::Backend) { + let window = state.window.clone(); + let mut unavailable = state.unavailable.clone(); + wasm_bindgen_futures::spawn_local(async move { + let started = crate::start_surface(&window, false, chosen, &mut unavailable).await; + STATE.with_borrow_mut(|slot| { + let Some(state) = slot else { + return; + }; + state.unavailable = unavailable; + match started { + Ok((surface, renderer, drawing)) => { + surface.show(); + state.surface = surface; + state.renderer = renderer; + state.drawing = drawing; + state.started_surface(chosen); + } + Err(error) => report(error), + } + }); + request_frame(); + }); +} + +async fn open( + fonts: Vec, + renderer: Option, +) -> Result> { let cache = PathBuf::from(CACHE); for folder in [NOTEBOOKS, CACHE, SETTINGS, CHOSEN] { notebook::fs::create_dir_all(folder)?; @@ -1031,7 +1097,12 @@ async fn open(fonts: Vec) -> Result> { locations: saved.notebooks.clone(), current: saved.current.clone(), }; - let launch = settings::Launch { file, saved, cache }; + let launch = settings::Launch { + file, + saved, + renderer, + cache, + }; // Where `State` reads them from, kept out of what IndexedDB stores. let substitutes: Vec = fonts .iter() diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index d8e7c73477d48a44d23318a0a4847e189b58be22..ea3338b54cc9bbc231238234ce8a5a8c569cc304 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -778,3 +778,41 @@ fn open_shared_notebook_is_offered_with_notebooks_open() { "{dialog}" ); } + +/// Switching between Metal and OpenGL, again and again, leaves the page, its edits and the +/// interface as they were. +#[test] +fn switching_renderers_keeps_the_window_as_it_was() { + let scratch = Scratch::new("renderer-switch"); + let notebook = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/search/notebook"); + let mut steps = vec![ + "move 600 400".to_owned(), + "press".to_owned(), + "release".to_owned(), + "type switching renderers ".to_owned(), + "settle".to_owned(), + "accessibility before".to_owned(), + ]; + for round in 0..5 { + for renderer in ["gl", "metal"] { + steps.push(format!("renderer {renderer}")); + steps.push("settle".to_owned()); + steps.push(format!("accessibility {renderer}-{round}")); + } + } + steps.extend([ + "type and after".to_owned(), + "accessibility after".to_owned(), + ]); + let steps: Vec<&str> = steps.iter().map(String::as_str).collect(); + let trees = replay(&scratch, Some(¬ebook), &steps); + let (before, after) = (&trees[0], &trees[trees.len() - 1]); + assert!(before.contains("switching renderers"), "{before}"); + for tree in &trees[1..trees.len() - 1] { + assert_eq!(tree, before, "a switch changed the window"); + } + assert!( + after.contains("switching renderers and after"), + "the caret stayed put: {after}" + ); +} diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index f081872e9f0fceb44befc7f896d88f911fe0e0cf..f86fd9096128ba927b1e3808af0631456f620680 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -9,6 +9,9 @@ let picker; let framePending = false; let wakeTimer; let storage; +// What `attach` hangs on the page's canvas, for `adoptCanvas` to move to the next one. +const canvasListeners = []; +let resizeObserver; /** The storage worker: the only place OPFS hands out synchronous handles, which write a * file's changed ranges in place. Runs as a worker of its own, from this source. */ @@ -508,6 +511,21 @@ function read(list) { const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i; +/** Puts `fresh` in the page in place of the canvas called page, as a renderer started on a + * canvas of its own needs; the page's input moves to it. */ +export function adoptCanvas(fresh) { + const old = document.getElementById("page"); + if (old !== fresh) old.replaceWith(fresh); + if (!canvas) return; + for (const [type, listener, options] of canvasListeners) { + canvas.removeEventListener(type, listener, options); + fresh.addEventListener(type, listener, options); + } + resizeObserver.unobserve(canvas); + canvas = fresh; + resizeObserver.observe(canvas); +} + /** Wires the page's canvas, text area and file input to `module`'s exports. */ export function attach(module) { wasm = module; @@ -519,6 +537,10 @@ export function attach(module) { return [event.clientX - rect.left, event.clientY - rect.top]; }; const pressure = (event) => (event.pointerType === "pen" ? event.pressure : NaN); + const listen = (type, listener, options) => { + canvasListeners.push([type, listener, options]); + canvas.addEventListener(type, listener, options); + }; // A finger pans the page or, with a second, pinches it; one that doesn't move taps it. const touches = new Map(); @@ -546,7 +568,7 @@ export function attach(module) { } }; - canvas.addEventListener("pointerdown", (event) => { + listen("pointerdown", (event) => { event.preventDefault(); input.focus({ preventScroll: true }); canvas.setPointerCapture(event.pointerId); @@ -559,7 +581,7 @@ export function attach(module) { wasm.pointer(0, ...point(event), -1, pressure(event), modifiers(event)); wasm.pointer(1, ...point(event), event.button, pressure(event), modifiers(event)); }); - canvas.addEventListener("pointermove", (event) => { + listen("pointermove", (event) => { if (event.pointerType === "touch") { if (touches.has(event.pointerId)) touchMoved(event); return; @@ -580,13 +602,13 @@ export function attach(module) { } wasm.pointer(2, ...point(event), event.button, pressure(event), modifiers(event)); }; - canvas.addEventListener("pointerup", up); - canvas.addEventListener("pointercancel", up); - canvas.addEventListener("pointerleave", (event) => { + listen("pointerup", up); + listen("pointercancel", up); + listen("pointerleave", (event) => { if (event.pointerType !== "touch") wasm.pointer(3, 0, 0, -1, NaN, modifiers(event)); }); - canvas.addEventListener("contextmenu", (event) => event.preventDefault()); - canvas.addEventListener( + listen("contextmenu", (event) => event.preventDefault()); + listen( "wheel", (event) => { event.preventDefault(); @@ -666,12 +688,13 @@ export function attach(module) { }; // Device pixels from the CSS size: emulated pixel ratios leave devicePixelContentBoxSize // at the CSS size. - new ResizeObserver(([entry]) => + resizeObserver = new ResizeObserver(([entry]) => resized( Math.round(entry.contentRect.width * devicePixelRatio), Math.round(entry.contentRect.height * devicePixelRatio), ), - ).observe(canvas); + ); + resizeObserver.observe(canvas); offerAccessibility(); input.focus({ preventScroll: true }); } diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html index f2e9d1945099d19ca3a6d8c499b80bd4ecfe77ab..e35d9f4c3de160806cc94b4fc26d9a991c5d1633 100644 --- a/crates/snowbound/web/index.html +++ b/crates/snowbound/web/index.html @@ -191,10 +191,6 @@ fail("Snowbound is open in another tab. Close it, then reload this one."); throw new Error("Snowbound is open in another tab"); } - if (!navigator.gpu && !document.createElement("canvas").getContext("webgl2")) { - fail("This browser can’t draw Snowbound: it has neither WebGPU nor WebGL 2.\nOpen it in a current Chrome, Edge, Safari or Firefox."); - throw new Error("No WebGPU or WebGL 2"); - } try { const [, fonts, dictionaries] = await Promise.all([ // Typed here, so the module compiles as it streams in from any server. @@ -207,9 +203,22 @@ ]); status.textContent = "Starting Snowbound"; await snowbound.start(snowbound, fonts, dictionaries); + sessionStorage.removeItem("snowbound.refetched"); } catch (error) { - // A download that failed rejects with a TypeError, or a status `counted` names. - fail(error instanceof TypeError || /: \d{3}$/.test(error.message) + // A module and JavaScript from different builds can't link: an old cached copy. Once, + // load the page afresh, which names the newest build's. + if (error instanceof WebAssembly.LinkError && !sessionStorage.getItem("snowbound.refetched")) { + sessionStorage.setItem("snowbound.refetched", "1"); + const fresh = new URL(location.href); + fresh.searchParams.set("fresh", Date.now()); + location.replace(fresh); + throw error; + } + // `start` rejects with a NoGraphicsError where not even the 2D canvas draws; a download + // that failed rejects with a TypeError, or a status `counted` names. + fail(error.name === "NoGraphicsError" + ? "This browser can’t draw Snowbound.\nOpen it in a current Chrome, Edge, Safari or Firefox." + : error instanceof TypeError || /: \d{3}$/.test(error.message) ? "Snowbound couldn’t download. Check your connection, then reload." : `Snowbound couldn’t start: ${error.message ?? error}`); throw error; diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs index fdedb66560fc50d6eef61b2191c00f88bc44e256..7f5414c18dc65c555cc0b93e3068bf5c1c226254 100644 --- a/crates/ui/examples/popups.rs +++ b/crates/ui/examples/popups.rs @@ -902,7 +902,7 @@ fn paint( }); renderer .draw( - &texture.create_view(&Default::default()), + &texture.create_view(&Default::default()).into(), size, ui.theme.base, &layers, diff --git a/tools/canvas/README.md b/tools/canvas/README.md index bc36e369787001d9109ff8255aed19ffa50267c9..3505c127f11c449d47a408d95ac566745e087b76 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -50,13 +50,13 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a `--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. -Launched 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`). +Launched 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`). As 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`). The `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. -A 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. +A 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. ```sh cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one