From 933abaa5da4c0c40cc4d45c76e6c7d17e10fbed5 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Thu, 25 Apr 2024 19:33:36 +0200 Subject: [PATCH] Implement Web Renderer This adds a new renderer for the web that renders to a canvas, which can then be easily attached anywhere in the DOM with any size. This should allow the web LiveSplit One to replace its React / HTML based renderer, bringing the rendering more in line with the native renderer, with features like arbitrary resizing and horizontal splits. Additionally this should improve the performance a lot and help remove a lot of the code from the web LiveSplit One. --- .github/workflows/build.yml | 18 +- .github/workflows/test.sh | 4 +- Cargo.toml | 19 +- benches/scene_management.rs | 15 +- capi/Cargo.toml | 4 + capi/bind_gen/src/wasm_bindgen.rs | 30 +- capi/src/lib.rs | 17 +- capi/src/web_rendering.rs | 43 + src/component/title/mod.rs | 10 +- src/platform/math.rs | 2 +- .../default_text_engine/color_font/cpal.rs | 8 - src/rendering/icon.rs | 3 +- src/rendering/mod.rs | 23 +- src/rendering/resource/allocation.rs | 16 +- src/rendering/resource/handles.rs | 14 +- src/rendering/resource/mod.rs | 2 +- src/rendering/software.rs | 36 +- src/rendering/svg.rs | 29 +- src/rendering/web/bindings.rs | 71 ++ src/rendering/web/mod.rs | 897 ++++++++++++++++++ tests/layout_files/subsplits.lsl | 44 +- tests/rendering.rs | 6 +- 22 files changed, 1186 insertions(+), 125 deletions(-) create mode 100644 capi/src/web_rendering.rs create mode 100644 src/rendering/web/bindings.rs create mode 100644 src/rendering/web/mod.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 433e730c..e8bbfdd4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -239,6 +239,7 @@ jobs: - label: WebAssembly WASI target: wasm32-wasi + auto_splitting: skip cross: skip dylib: skip install_target: true @@ -411,14 +412,14 @@ jobs: # macOS - label: macOS aarch64 target: aarch64-apple-darwin - os: macos-14 + os: macos-latest cross: skip - install_target: true - label: macOS x86_64 target: x86_64-apple-darwin - os: macos-13 + os: macos-latest cross: skip + install_target: true # iOS - label: iOS aarch64 @@ -532,14 +533,14 @@ jobs: release: skip - label: macOS Beta - target: x86_64-apple-darwin + target: aarch64-apple-darwin os: macOS-latest toolchain: beta release: skip cross: skip - label: macOS Nightly - target: x86_64-apple-darwin + target: aarch64-apple-darwin os: macOS-latest toolchain: nightly release: skip @@ -601,6 +602,13 @@ jobs: SKIP_NETWORKING: ${{ matrix.networking }} SKIP_SOFTWARE_RENDERING: ${{ matrix.software_rendering }} + - name: Upload screenshots + if: matrix.tests == '' && (success() || failure()) + uses: actions/upload-artifact@v4 + with: + name: Screenshots ${{ matrix.label }} + path: target/renders + - name: Prepare Release if: startsWith(github.ref, 'refs/tags/') && matrix.release == '' shell: bash diff --git a/.github/workflows/test.sh b/.github/workflows/test.sh index d3c1110b..b74a5ed1 100644 --- a/.github/workflows/test.sh +++ b/.github/workflows/test.sh @@ -4,7 +4,7 @@ main() { local cargo=cross # all features except those that sometimes should be skipped. - local features="--features std,more-image-formats,image-shrinking,rendering,default-text-engine,wasm-web,font-loading" + local features="--features std,more-image-formats,image-shrinking,rendering,svg-rendering,default-text-engine,wasm-web,web-rendering,font-loading" if [ "$SKIP_CROSS" = "skip" ]; then cargo=cargo @@ -25,8 +25,6 @@ main() { if [ "$TARGET" = "wasm32-wasi" ]; then curl https://wasmtime.dev/install.sh -sSf | bash export PATH="$HOME/.wasmtime/bin:$PATH" - $cargo test -p livesplit-core --features software-rendering --target $TARGET - return fi $cargo test -p livesplit-core $features --target $TARGET diff --git a/Cargo.toml b/Cargo.toml index c86147eb..c56c7b7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,10 +99,11 @@ tokio = { version = "1.24.2", default-features = false, features = [ ], optional = true } log = { version = "0.4.14", default-features = false, optional = true } -[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] # WebAssembly in the Web js-sys = { version = "0.3.55", optional = true } wasm-bindgen = { version = "0.2.78", optional = true } +wasm-bindgen-futures = { version = "0.4.28", optional = true } web-sys = { version = "0.3.28", default-features = false, features = [ "Performance", "Window", @@ -125,7 +126,7 @@ seahash = "4.1.0" [target.'cfg(windows)'.dev-dependencies] sysinfo = { version = "0.30.0", default-features = false } -[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +[target.'cfg(not(target_family = "wasm"))'.dev-dependencies] criterion = "0.5.0" [features] @@ -165,6 +166,20 @@ default-text-engine = ["rendering", "cosmic-text"] font-loading = ["std", "default-text-engine"] software-rendering = ["default-text-engine", "tiny-skia", "tiny-skia-path"] svg-rendering = ["default-text-engine", "ahash"] +web-rendering = [ + "wasm-web", + "rendering", + "wasm-bindgen-futures", + "web-sys/Blob", + "web-sys/CanvasGradient", + "web-sys/Document", + "web-sys/DomRect", + "web-sys/Element", + "web-sys/HtmlCanvasElement", + "web-sys/ImageBitmap", + "web-sys/Path2d", + "web-sys/TextMetrics", +] wasm-web = [ "std", "cosmic-text?/wasm-web", diff --git a/benches/scene_management.rs b/benches/scene_management.rs index da985da8..8cba914f 100644 --- a/benches/scene_management.rs +++ b/benches/scene_management.rs @@ -4,7 +4,8 @@ cfg_if::cfg_if! { use livesplit_core::{ layout::{self, Layout}, rendering::{ - PathBuilder, ResourceAllocator, SceneManager, Label, FontKind, SharedOwnership, + PathBuilder, ResourceAllocator, SceneManager, + Image, Label, FontKind, SharedOwnership, }, run::parser::livesplit, settings::{Font, ImageCache}, @@ -31,15 +32,15 @@ cfg_if::cfg_if! { impl ResourceAllocator for Dummy { type PathBuilder = Dummy; type Path = (); - type Image = (); + type Image = Dummy; type Font = (); type Label = Dummy; fn path_builder(&mut self) -> Self::PathBuilder { Dummy } - fn create_image(&mut self, _: &[u8]) -> Option<(Self::Image, f32)> { - Some(((), 1.0)) + fn create_image(&mut self, _: &[u8]) -> Option { + Some(Dummy) } fn create_font(&mut self, _: Option<&Font>, _: FontKind) -> Self::Font {} fn create_label( @@ -68,6 +69,12 @@ cfg_if::cfg_if! { } } + impl Image for Dummy { + fn aspect_ratio(&self) -> f32 { + 1.0 + } + } + impl SharedOwnership for Dummy { fn share(&self) -> Self { Dummy diff --git a/capi/Cargo.toml b/capi/Cargo.toml index d62e596e..c8e88d1f 100644 --- a/capi/Cargo.toml +++ b/capi/Cargo.toml @@ -14,6 +14,9 @@ serde_json = { version = "1.0.8", default-features = false } time = { version = "0.3.4", default-features = false, features = ["formatting"] } simdutf8 = { git = "https://github.com/CryZe/simdutf8", branch = "wasm-ub-panic", default-features = false } +wasm-bindgen = { version = "0.2.78", optional = true } +web-sys = { version = "0.3.28", optional = true } + [features] default = ["image-shrinking"] image-shrinking = ["livesplit-core/image-shrinking"] @@ -21,3 +24,4 @@ software-rendering = ["livesplit-core/software-rendering"] wasm-web = ["livesplit-core/wasm-web"] auto-splitting = ["livesplit-core/auto-splitting"] assume-str-parameters-are-utf8 = [] +web-rendering = ["wasm-web", "livesplit-core/web-rendering", "wasm-bindgen", "web-sys"] diff --git a/capi/bind_gen/src/wasm_bindgen.rs b/capi/bind_gen/src/wasm_bindgen.rs index 7cc1ae46..35f2cde3 100644 --- a/capi/bind_gen/src/wasm_bindgen.rs +++ b/capi/bind_gen/src/wasm_bindgen.rs @@ -387,10 +387,8 @@ declare namespace TextEncoding { } } -const encoder = new TextEncoder("UTF-8"); -const decoder = new TextDecoder("UTF-8"); -const encodeUtf8: (str: string) => Uint8Array = (str) => encoder.encode(str); -const decodeUtf8: (data: Uint8Array) => string = (data) => decoder.decode(data); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); interface Slice { ptr: number, @@ -408,13 +406,12 @@ function allocUint8Array(src: Uint8Array): Slice { } function allocString(str: string): Slice { - const stringBuffer = encodeUtf8(str); - const len = stringBuffer.length + 1; + const len = 3 * str.length + 1; const ptr = wasm.alloc(len); const slice = new Uint8Array(wasm.memory.buffer, ptr, len); - slice.set(stringBuffer); - slice[len - 1] = 0; + const stats = encoder.encodeInto(str, slice); + slice[stats.written] = 0; return { ptr, len }; } @@ -431,7 +428,7 @@ function decodePtrLen(ptr: number, len: number): Uint8Array { } function decodeString(ptr: number): string { - return decodeUtf8(decodeSlice(ptr)); + return decoder.decode(decodeSlice(ptr)); } function dealloc(slice: Slice) { @@ -448,10 +445,8 @@ function dealloc(slice: Slice) { r#"import * as wasm from "./livesplit_core_bg.wasm"; import "./livesplit_core.js"; -const encoder = new TextEncoder("UTF-8"); -const decoder = new TextDecoder("UTF-8"); -const encodeUtf8 = (str) => encoder.encode(str); -const decodeUtf8 = (data) => decoder.decode(data); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); function allocUint8Array(src) { const len = src.length; @@ -464,13 +459,12 @@ function allocUint8Array(src) { } function allocString(str) { - const stringBuffer = encodeUtf8(str); - const len = stringBuffer.length + 1; + const len = 3 * str.length + 1; const ptr = wasm.alloc(len); const slice = new Uint8Array(wasm.memory.buffer, ptr, len); - slice.set(stringBuffer); - slice[len - 1] = 0; + const stats = encoder.encodeInto(str, slice); + slice[stats.written] = 0; return { ptr, len }; } @@ -487,7 +481,7 @@ function decodePtrLen(ptr, len) { } function decodeString(ptr) { - return decodeUtf8(decodeSlice(ptr)); + return decoder.decode(decodeSlice(ptr)); } function dealloc(slice) { diff --git a/capi/src/lib.rs b/capi/src/lib.rs index b2d1aa8a..92cfa05b 100644 --- a/capi/src/lib.rs +++ b/capi/src/lib.rs @@ -83,6 +83,8 @@ pub mod timer_write_lock; pub mod title_component; pub mod title_component_state; pub mod total_playtime_component; +#[cfg(all(target_family = "wasm", feature = "web-rendering"))] +pub mod web_rendering; use crate::{ run_metadata_custom_variable::RunMetadataCustomVariable, @@ -211,18 +213,19 @@ unsafe fn get_file(_: i64) -> ManuallyDrop { #[cfg(all(target_family = "wasm", not(target_os = "wasi")))] #[no_mangle] pub extern "C" fn alloc(size: usize) -> *mut u8 { - let mut buf = Vec::with_capacity(size); - let ptr = buf.as_mut_ptr(); - core::mem::forget(buf); - ptr + if size == 0 { + std::ptr::NonNull::dangling().as_ptr() + } else { + unsafe { std::alloc::alloc(std::alloc::Layout::from_size_align_unchecked(size, 1)) } + } } /// Deallocate memory. #[cfg(all(target_family = "wasm", not(target_os = "wasi")))] #[no_mangle] -pub extern "C" fn dealloc(ptr: *mut u8, cap: usize) { - unsafe { - let _buf = Vec::from_raw_parts(ptr, 0, cap); +pub unsafe extern "C" fn dealloc(ptr: *mut u8, cap: usize) { + if cap != 0 { + std::alloc::dealloc(ptr, std::alloc::Layout::from_size_align_unchecked(cap, 1)); } } diff --git a/capi/src/web_rendering.rs b/capi/src/web_rendering.rs new file mode 100644 index 00000000..987ad226 --- /dev/null +++ b/capi/src/web_rendering.rs @@ -0,0 +1,43 @@ +//! Provides a renderer for the web that renders into a canvas. The element can +//! then be attached anywhere in the DOM with any desired positioning and size. +//! +use livesplit_core::{layout::LayoutState, rendering::web, settings::ImageCache}; +use wasm_bindgen::prelude::*; +use web_sys::Element; + +/// The web renderer renders into a canvas element. The element can then be +/// attached anywhere in the DOM with any desired positioning and size. +#[wasm_bindgen] +pub struct CanvasRenderer { + inner: web::Renderer, +} + +#[wasm_bindgen] +impl CanvasRenderer { + /// Creates a new web renderer that renders into a canvas element. The + /// element can then be attached anywhere in the DOM with any desired + /// positioning and size. There are two CSS fonts that are used as the + /// default fonts. They are called "timer" and "fira". Make sure they are + /// fully loaded before creating the renderer as otherwise information about + /// a fallback font is cached instead. + #[allow(clippy::new_without_default)] + pub fn new() -> CanvasRenderer { + Self { + inner: web::Renderer::new(), + } + } + + /// Returns the HTML element. This can be attached anywhere in the DOM with + /// any desired positioning and size. + pub fn element(&self) -> Element { + self.inner.element().clone() + } + + /// Renders the layout state into the canvas. The image cache is used to + /// retrieve images that are used in the layout state. + pub unsafe fn render(&mut self, state: usize, image_cache: usize) { + let state = unsafe { core::mem::transmute::(state) }; + let image_cache = unsafe { core::mem::transmute::(image_cache) }; + self.inner.render(state, image_cache); + } +} diff --git a/src/component/title/mod.rs b/src/component/title/mod.rs index 9b0dd8ff..78e5e865 100644 --- a/src/component/title/mod.rs +++ b/src/component/title/mod.rs @@ -234,16 +234,10 @@ impl Component { let unchanged = catch! { let mut rem = &**state.line1.last()?; - let Some(rest) = rem.strip_prefix(game_name) else { - return None; - }; - rem = rest; + rem = rem.strip_prefix(game_name)?; if !game_name.is_empty() && !full_category_name.is_empty() { - let Some(rest) = rem.strip_prefix(" - ") else { - return None; - }; - rem = rest; + rem = rem.strip_prefix(" - ")?; } if rem != full_category_name { diff --git a/src/platform/math.rs b/src/platform/math.rs index c4cb8f79..8cf7d6e1 100644 --- a/src/platform/math.rs +++ b/src/platform/math.rs @@ -1,5 +1,5 @@ cfg_if::cfg_if! { - if #[cfg(feature = "std")] { + if #[cfg(all(feature = "std", not(test)))] { pub mod f32 { #[inline(always)] pub fn abs(x: f32) -> f32 { diff --git a/src/rendering/default_text_engine/color_font/cpal.rs b/src/rendering/default_text_engine/color_font/cpal.rs index f9503f3e..5c30293b 100644 --- a/src/rendering/default_text_engine/color_font/cpal.rs +++ b/src/rendering/default_text_engine/color_font/cpal.rs @@ -16,14 +16,6 @@ struct Header { color_records_array_offset: O32, } -#[derive(Debug, Copy, Clone, Pod, Zeroable)] -#[repr(C)] -struct HeaderV1Extra { - palette_types_array_offset: O32, - palette_labels_array_offset: O32, - palette_entry_labels_array_offset: O32, -} - #[derive(Debug, Copy, Clone, Pod, Zeroable)] #[repr(C)] pub struct ColorRecord { diff --git a/src/rendering/icon.rs b/src/rendering/icon.rs index 15b3d90d..47ba5662 100644 --- a/src/rendering/icon.rs +++ b/src/rendering/icon.rs @@ -7,16 +7,15 @@ pub struct CachedImage { pub image: Option>, } +// FIXME: Is this still useful? pub struct ImageHandle { pub handle: Handle, - pub aspect_ratio: f32, } impl SharedOwnership for ImageHandle { fn share(&self) -> Self { Self { handle: self.handle.share(), - aspect_ratio: self.aspect_ratio, } } } diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs index e371476b..0e292f13 100644 --- a/src/rendering/mod.rs +++ b/src/rendering/mod.rs @@ -85,6 +85,8 @@ pub mod default_text_engine; pub mod software; #[cfg(feature = "svg-rendering")] pub mod svg; +#[cfg(all(target_family = "wasm", feature = "web-rendering"))] +pub mod web; use self::{ consts::{ @@ -98,7 +100,7 @@ use self::{ use crate::{ layout::{LayoutDirection, LayoutState}, platform::prelude::*, - settings::{BackgroundImage, Color, Gradient, Image, ImageCache, ImageId, LayoutBackground}, + settings::{self, BackgroundImage, Color, Gradient, ImageCache, ImageId, LayoutBackground}, }; use alloc::borrow::Cow; use bytemuck_derive::{Pod, Zeroable}; @@ -108,7 +110,8 @@ pub use self::{ entity::Entity, font::{TEXT_FONT, TIMER_FONT}, resource::{ - FontKind, Handle, Label, LabelHandle, PathBuilder, ResourceAllocator, SharedOwnership, + FontKind, Handle, Image, Label, LabelHandle, PathBuilder, ResourceAllocator, + SharedOwnership, }, scene::{Layer, Scene}, }; @@ -464,11 +467,13 @@ impl RenderContext<'_, A> { let image = self.images.cache(id, || { let image = self .handles - .create_image(self.image_cache.lookup(id).unwrap_or(Image::EMPTY).data()) - .map(|(handle, aspect_ratio)| ImageHandle { - handle, - aspect_ratio, - }); + .create_image( + self.image_cache + .lookup(id) + .unwrap_or(settings::Image::EMPTY) + .data(), + ) + .map(|handle| ImageHandle { handle }); CachedImage { id: *id, image } }); @@ -506,7 +511,7 @@ impl RenderContext<'_, A> { image: ImageHandle, ) { let box_aspect_ratio = width / height; - let aspect_ratio_diff = box_aspect_ratio / image.aspect_ratio; + let aspect_ratio_diff = box_aspect_ratio / image.handle.aspect_ratio(); if aspect_ratio_diff > 1.0 { let new_width = width / aspect_ratio_diff; @@ -786,7 +791,7 @@ impl RenderContext<'_, A> { let image = self.create_image(&background_image.image)?; let box_aspect_ratio = width / height; - let aspect_ratio_diff = image.aspect_ratio / box_aspect_ratio; + let aspect_ratio_diff = image.handle.aspect_ratio() / box_aspect_ratio; let [mut x, mut y] = [0.0; 2]; if aspect_ratio_diff > 1.0 { diff --git a/src/rendering/resource/allocation.rs b/src/rendering/resource/allocation.rs index 08bb27a6..cb4dbf84 100644 --- a/src/rendering/resource/allocation.rs +++ b/src/rendering/resource/allocation.rs @@ -33,7 +33,7 @@ pub trait ResourceAllocator { /// The type the renderer uses for paths. type Path: SharedOwnership; /// The type the renderer uses for images. - type Image: SharedOwnership; + type Image: Image; /// The type the renderer uses for fonts. type Font; /// The type the renderer uses for text labels. @@ -82,9 +82,9 @@ pub trait ResourceAllocator { /// Creates an image out of the image data provided. The data represents the /// image in its original file format. It needs to be parsed in order to be - /// visualized. The parsed image as well as the aspect ratio (width / - /// height) are returned in case the image was parsed successfully. - fn create_image(&mut self, data: &[u8]) -> Option<(Self::Image, f32)>; + /// visualized. The parsed image is returned in case it was successfully + /// parsed. + fn create_image(&mut self, data: &[u8]) -> Option; /// Creates a font from the font description provided. It is expected that /// the the font description is used in a font matching algorithm to find @@ -126,6 +126,12 @@ pub trait ResourceAllocator { ); } +/// An image created by a [`ResourceAllocator`]. +pub trait Image: SharedOwnership { + /// The aspect ratio of the image. This is the width divided by the height. + fn aspect_ratio(&self) -> f32; +} + /// A text label created by a [`ResourceAllocator`]. pub trait Label: SharedOwnership { /// The width of the current text scaled by the scale factor provided. @@ -218,7 +224,7 @@ impl ResourceAllocator for &mut A { (*self).build_square() } - fn create_image(&mut self, data: &[u8]) -> Option<(Self::Image, f32)> { + fn create_image(&mut self, data: &[u8]) -> Option { (*self).create_image(data) } diff --git a/src/rendering/resource/handles.rs b/src/rendering/resource/handles.rs index 8ac84d17..9ffeddc5 100644 --- a/src/rendering/resource/handles.rs +++ b/src/rendering/resource/handles.rs @@ -5,7 +5,7 @@ use core::{ use crate::settings::Font; -use super::{Label, PathBuilder, ResourceAllocator, SharedOwnership}; +use super::{Image, Label, PathBuilder, ResourceAllocator, SharedOwnership}; pub struct Handles { next_id: usize, @@ -83,9 +83,9 @@ impl ResourceAllocator for Handles { self.next(square) } - fn create_image(&mut self, data: &[u8]) -> Option<(Self::Image, f32)> { - let (image, aspect_ratio) = self.allocator.create_image(data)?; - Some((self.next(image), aspect_ratio)) + fn create_image(&mut self, data: &[u8]) -> Option { + let image = self.allocator.create_image(data)?; + Some(self.next(image)) } fn create_font(&mut self, font: Option<&Font>, kind: super::FontKind) -> Self::Font { @@ -174,6 +174,12 @@ pub struct Handle { inner: T, } +impl Image for Handle { + fn aspect_ratio(&self) -> f32 { + self.inner.aspect_ratio() + } +} + impl SharedOwnership for Handle { fn share(&self) -> Self { Self { diff --git a/src/rendering/resource/mod.rs b/src/rendering/resource/mod.rs index 39d6f2e7..428b4f77 100644 --- a/src/rendering/resource/mod.rs +++ b/src/rendering/resource/mod.rs @@ -3,7 +3,7 @@ mod handles; mod shared_ownership; pub use self::{ - allocation::{FontKind, Label, PathBuilder, ResourceAllocator}, + allocation::{FontKind, Image, Label, PathBuilder, ResourceAllocator}, handles::*, shared_ownership::SharedOwnership, }; diff --git a/src/rendering/software.rs b/src/rendering/software.rs index b5acc5b1..da86244b 100644 --- a/src/rendering/software.rs +++ b/src/rendering/software.rs @@ -32,10 +32,21 @@ pub use image::{self, RgbaImage}; struct SkiaBuilder(PathBuilder); type SkiaPath = Option>; -type SkiaImage = UnsafeRc; +type SkiaImage = UnsafeRc; type SkiaFont = Font; type SkiaLabel = Label; +struct Image { + pixmap: Pixmap, + aspect_ratio: f32, +} + +impl resource::Image for SkiaImage { + fn aspect_ratio(&self) -> f32 { + self.aspect_ratio + } +} + impl resource::PathBuilder for SkiaBuilder { type Path = SkiaPath; @@ -94,7 +105,7 @@ impl ResourceAllocator for SkiaAllocator { path_builder() } - fn create_image(&mut self, _data: &[u8]) -> Option<(Self::Image, f32)> { + fn create_image(&mut self, _data: &[u8]) -> Option { #[cfg(feature = "image")] { let mut buf = image::load_from_memory(_data).ok()?.to_rgba8(); @@ -119,7 +130,10 @@ impl ResourceAllocator for SkiaAllocator { let pixmap = Pixmap::from_vec(buf.into_raw(), IntSize::from_wh(width, height)?)?; - Some((UnsafeRc::new(pixmap), width as f32 / height as f32)) + Some(UnsafeRc::new(Image { + pixmap, + aspect_ratio: width as f32 / height as f32, + })) } #[cfg(not(feature = "image"))] { @@ -455,13 +469,13 @@ fn render_layer( rectangle, &Paint { shader: Pattern::new( - image.as_ref(), + image.pixmap.as_ref(), SpreadMode::Pad, FilterQuality::Bilinear, 1.0, tiny_skia::Transform::from_scale( - 1.0 / image.width() as f32, - 1.0 / image.height() as f32, + 1.0 / image.pixmap.width() as f32, + 1.0 / image.pixmap.height() as f32, ), ), anti_alias: true, @@ -662,10 +676,10 @@ fn fill_background( .map(|(_, pixmap)| pixmap) .unwrap() } else { - &*image.image.0 + &image.image.pixmap }; #[cfg(not(feature = "image"))] - let pixmap = &*image.image.0; + let pixmap = &image.image.pixmap; let transform = convert_transform(transform); background_layer.fill_path( @@ -725,9 +739,9 @@ fn update_blurred_background_image( .is_some_and(|(key, _)| ¤t_key == key) { let original_image = ImageBuffer::, _>::from_raw( - image.image.width(), - image.image.height(), - image.image.data(), + image.image.pixmap.width(), + image.image.pixmap.height(), + image.image.pixmap.data(), ) .unwrap(); diff --git a/src/rendering/svg.rs b/src/rendering/svg.rs index fe7956f1..8915a690 100644 --- a/src/rendering/svg.rs +++ b/src/rendering/svg.rs @@ -19,8 +19,8 @@ use crate::{ use super::{ default_text_engine::{self, TextEngine}, - Background, Entity, FillShader, FontKind, ResourceAllocator, SceneManager, SharedOwnership, - Transform, + resource, Background, Entity, FillShader, FontKind, ResourceAllocator, SceneManager, + SharedOwnership, Transform, }; type SvgImage = Rc; @@ -528,6 +528,13 @@ struct Image { data: String, scale_x: f32, scale_y: f32, + aspect_ratio: f32, +} + +impl resource::Image for SvgImage { + fn aspect_ratio(&self) -> f32 { + self.aspect_ratio + } } impl SharedOwnership for SvgPath { @@ -690,7 +697,7 @@ impl ResourceAllocator for SvgAllocator { } } - fn create_image(&mut self, _data: &[u8]) -> Option<(Self::Image, f32)> { + fn create_image(&mut self, _data: &[u8]) -> Option { #[cfg(feature = "image")] { let format = image::guess_format(_data).ok()?; @@ -718,15 +725,13 @@ impl ResourceAllocator for SvgAllocator { buf.set_len(buf.len() + additional_len); } - Some(( - Rc::new(Image { - id: Cell::new(0), - data: buf, - scale_x: rwidth, - scale_y: rheight, - }), - width * rheight, - )) + Some(Rc::new(Image { + id: Cell::new(0), + data: buf, + scale_x: rwidth, + scale_y: rheight, + aspect_ratio: width * rheight, + })) } #[cfg(not(feature = "image"))] { diff --git a/src/rendering/web/bindings.rs b/src/rendering/web/bindings.rs new file mode 100644 index 00000000..77602428 --- /dev/null +++ b/src/rendering/web/bindings.rs @@ -0,0 +1,71 @@ +// Taken from web-sys, but optimized to take JsStrings. + +use js_sys::JsString; +use wasm_bindgen::prelude::*; +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(extends = ::js_sys::Object, js_name = CanvasRenderingContext2D, typescript_type = "CanvasRenderingContext2D")] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type CanvasRenderingContext2d; + #[wasm_bindgen(structural, method, setter, js_class = "CanvasRenderingContext2D", js_name = strokeStyle)] + pub fn set_stroke_style(this: &CanvasRenderingContext2d, value: &::wasm_bindgen::JsValue); + #[wasm_bindgen(structural, method, setter, js_class = "CanvasRenderingContext2D", js_name = fillStyle)] + pub fn set_fill_style(this: &CanvasRenderingContext2d, value: &::wasm_bindgen::JsValue); + #[wasm_bindgen(structural, method, setter, js_class = "CanvasRenderingContext2D", js_name = filter)] + pub fn set_filter(this: &CanvasRenderingContext2d, value: &JsString); + #[wasm_bindgen(structural, method, setter, js_class = "CanvasRenderingContext2D", js_name = lineWidth)] + pub fn set_line_width(this: &CanvasRenderingContext2d, value: f64); + #[wasm_bindgen(structural, method, setter, js_class = "CanvasRenderingContext2D", js_name = font)] + pub fn set_font(this: &CanvasRenderingContext2d, value: &js_sys::JsString); + #[wasm_bindgen(structural, method, setter, js_class = "CanvasRenderingContext2D", js_name = fontKerning)] + pub fn set_font_kerning(this: &CanvasRenderingContext2d, value: &JsString); + #[wasm_bindgen(catch, method, structural, js_class = "CanvasRenderingContext2D", js_name = drawImage)] + pub fn draw_image_with_image_bitmap_and_dw_and_dh( + this: &CanvasRenderingContext2d, + image: &web_sys::ImageBitmap, + dx: f64, + dy: f64, + dw: f64, + dh: f64, + ) -> Result<(), JsValue>; + #[wasm_bindgen(method, structural, js_class = "CanvasRenderingContext2D", js_name = fill)] + pub fn fill_with_path_2d(this: &CanvasRenderingContext2d, path: &web_sys::Path2d); + #[wasm_bindgen(method, structural, js_class = "CanvasRenderingContext2D", js_name = stroke)] + pub fn stroke_with_path(this: &CanvasRenderingContext2d, path: &web_sys::Path2d); + #[wasm_bindgen(method, structural, js_class = "CanvasRenderingContext2D", js_name = createLinearGradient)] + pub fn create_linear_gradient( + this: &CanvasRenderingContext2d, + x0: f64, + y0: f64, + x1: f64, + y1: f64, + ) -> web_sys::CanvasGradient; + #[wasm_bindgen(method, structural, js_class = "CanvasRenderingContext2D", js_name = clearRect)] + pub fn clear_rect(this: &CanvasRenderingContext2d, x: f64, y: f64, w: f64, h: f64); + #[wasm_bindgen(method, structural, js_class = "CanvasRenderingContext2D", js_name = fillRect)] + pub fn fill_rect(this: &CanvasRenderingContext2d, x: f64, y: f64, w: f64, h: f64); + #[wasm_bindgen(catch, method, structural, js_class = "CanvasRenderingContext2D", js_name = fillText)] + pub fn fill_text( + this: &CanvasRenderingContext2d, + text: &JsString, + x: f64, + y: f64, + ) -> Result<(), JsValue>; + #[wasm_bindgen(catch, method, structural, js_class = "CanvasRenderingContext2D", js_name = measureText)] + pub fn measure_text( + this: &CanvasRenderingContext2d, + text: &JsString, + ) -> Result; + #[wasm_bindgen(catch, method, structural, js_class = "CanvasRenderingContext2D", js_name = resetTransform)] + pub fn reset_transform(this: &CanvasRenderingContext2d) -> Result<(), JsValue>; + #[wasm_bindgen(catch, method, structural, js_class = "CanvasRenderingContext2D", js_name = setTransform)] + pub fn set_transform( + this: &CanvasRenderingContext2d, + a: f64, + b: f64, + c: f64, + d: f64, + e: f64, + f: f64, + ) -> Result<(), JsValue>; +} diff --git a/src/rendering/web/mod.rs b/src/rendering/web/mod.rs new file mode 100644 index 00000000..ee4a022e --- /dev/null +++ b/src/rendering/web/mod.rs @@ -0,0 +1,897 @@ +//! Provides a renderer for the web that renders into a canvas. The element can +//! then be attached anywhere in the DOM with any desired positioning and size. + +use bytemuck::cast; +use js_sys::{Array, JsString, Uint8Array}; +use std::{ + array, + cell::{Cell, RefCell}, + collections::HashMap, + f64::consts::TAU, + ops::Deref, + rc::Rc, + str, +}; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Blob, Element, HtmlCanvasElement, ImageBitmap, Path2d, Window}; + +use crate::{ + layout::LayoutState, + settings::{Font, FontStretch, FontStyle, FontWeight, ImageCache, BLUR_FACTOR}, +}; + +use self::bindings::CanvasRenderingContext2d; + +use super::{ + Background, Entity, FillShader, FontKind, Label, PathBuilder, ResourceAllocator, SceneManager, + SharedOwnership, Transform, +}; + +mod bindings; + +// FIXME: The fonts should really be sized 1px, because the actual positioning +// and scaling is done through the transformation matrix. However Safari and +// especially Firefox have a lot of issues rendering text that way. They assume +// the font is actually 1px and then scale it up, causing COLRv1 emojis to be +// rendered at 1x1 resolution. Additionally kerning might mess up on other +// instances of Firefox and Safari seems to force a minimum size on the emojis. +// This is why we specify the fonts to be at 100px, which is a decent resolution +// for the emojis and then scale everything back down. Check this fiddle to see +// if the problem still exists: (Firefox on Windows 11, iOS Safari) +// https://jsfiddle.net/7y4barmq/ +const FONT_SCALE_FACTOR: f32 = 0.01; + +struct CanvasPathBuilder { + path: Path2d, + min_x: f32, + max_x: f32, + min_y: f32, + max_y: f32, +} + +impl CanvasPathBuilder { + fn update_x(&mut self, x: f32) { + self.min_x = self.min_x.min(x); + self.max_x = self.max_x.max(x); + } + + fn update_y(&mut self, y: f32) { + self.min_y = self.min_y.min(y); + self.max_y = self.max_y.max(y); + } + + fn circle(&mut self, x: f32, y: f32, r: f32) { + self.update_x(x - r); + self.update_x(x + r); + self.update_y(y - r); + self.update_y(y + r); + let _ = self.path.arc(x as _, y as _, r as _, 0.0, TAU); + } +} + +struct CanvasAllocator { + window: Window, + force_redraw_all: Rc>, + ctx_bottom: CanvasRenderingContext2d, + ctx_top: CanvasRenderingContext2d, + digits: [JsString; 10], +} + +#[derive(Clone)] +struct Path(Rc); + +#[derive(Clone)] +struct Image(Rc>>); + +impl super::Image for Image { + fn aspect_ratio(&self) -> f32 { + self.0.borrow().as_ref().map_or(1.0, |(_, ratio)| *ratio) + } +} + +impl SharedOwnership for Image { + fn share(&self) -> Self { + self.clone() + } +} + +impl Deref for Path { + type Target = Rc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl SharedOwnership for Path { + fn share(&self) -> Self { + self.clone() + } +} + +impl PathBuilder for CanvasPathBuilder { + type Path = Path; + + fn move_to(&mut self, x: f32, y: f32) { + self.update_x(x); + self.update_y(y); + self.path.move_to(x as _, y as _); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.update_x(x); + self.update_y(y); + self.path.line_to(x as _, y as _); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + // FIXME: Calculate the actual bezier curve's bounds. Should affect the + // other renderers too. + self.update_x(x1); + self.update_y(y1); + self.update_x(x); + self.update_y(y); + self.path + .quadratic_curve_to(x1 as _, y1 as _, x as _, y as _); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.update_x(x1); + self.update_y(y1); + self.update_x(x2); + self.update_y(y2); + self.update_x(x); + self.update_y(y); + self.path + .bezier_curve_to(x1 as _, y1 as _, x2 as _, y2 as _, x as _, y as _); + } + + fn close(&mut self) { + self.path.close_path(); + } + + fn finish(self) -> Self::Path { + Path(Rc::new(self)) + } +} + +struct CanvasLabelInner { + font: Rc, + shape: LabelShape, + width: f32, + width_without_max_width: f32, +} + +impl Default for CanvasLabelInner { + fn default() -> Self { + Self { + font: Rc::new(CanvasFont { + descriptor: "".into(), + font_handling: FontHandling::Normal, + font_kerning: "".into(), + top: 0.0, + bottom: 0.0, + }), + shape: LabelShape::Normal("".into()), + width: 0.0, + width_without_max_width: 0.0, + } + } +} + +#[derive(Default)] +struct CanvasLabel(Rc>); + +impl SharedOwnership for CanvasLabel { + fn share(&self) -> Self { + Self(self.0.share()) + } +} + +impl Label for CanvasLabel { + fn width(&self, scale: f32) -> f32 { + self.0.borrow().width * scale + } + + fn width_without_max_width(&self, scale: f32) -> f32 { + self.0.borrow().width_without_max_width * scale + } +} + +struct CanvasFont { + descriptor: JsString, + font_kerning: JsString, + font_handling: FontHandling, + top: f32, + bottom: f32, +} + +enum FontHandling { + Normal, + MonospaceEmulation(MonospaceInfo), +} + +struct MonospaceInfo { + digit_offsets: [f32; 10], + digit_width: f32, +} + +impl ResourceAllocator for CanvasAllocator { + type PathBuilder = CanvasPathBuilder; + type Path = Path; + type Image = Image; + type Font = Rc; + type Label = CanvasLabel; + + fn path_builder(&mut self) -> Self::PathBuilder { + CanvasPathBuilder { + path: Path2d::new().unwrap(), + min_x: f32::INFINITY, + max_x: f32::NEG_INFINITY, + min_y: f32::INFINITY, + max_y: f32::NEG_INFINITY, + } + } + + fn create_image(&mut self, data: &[u8]) -> Option { + if data.is_empty() { + return None; + } + let slot = Rc::new(RefCell::new(None)); + + // SAFETY: There is no allocation happening that would invalidate the + // view. The view is immediately given to the blob, which creates an + // internal copy of the data. + let blob = unsafe { + let parts = Array::of1(&Uint8Array::view(data).into()); + Blob::new_with_u8_array_sequence(&parts).ok()? + }; + + if let Ok(promise) = self.window.create_image_bitmap_with_blob(&blob) { + let future = JsFuture::from(promise); + let slot = slot.clone(); + let force_redraw_all = self.force_redraw_all.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(image) = future.await { + let image: ImageBitmap = image.unchecked_into(); + let aspect_ratio = image.width() as f32 / image.height() as f32; + *slot.borrow_mut() = Some((image, aspect_ratio)); + force_redraw_all.set(true); + } + }); + } + Some(Image(slot)) + } + + fn create_font(&mut self, font: Option<&Font>, kind: FontKind) -> Self::Font { + let mut descriptor = String::new(); + if let Some(font) = font { + match font.style { + FontStyle::Normal => {} + FontStyle::Italic => descriptor.push_str("italic "), + FontStyle::Oblique => descriptor.push_str("oblique "), + } + if font.weight != FontWeight::Normal { + descriptor.push_str(weight_as_css_str(font.weight)); + descriptor.push(' '); + } + if font.stretch != FontStretch::Normal { + descriptor.push_str(stretch_as_css_str(font.stretch)); + descriptor.push(' '); + } + descriptor.push_str("100px \""); + descriptor.push_str(&font.family); + descriptor.push_str("\", "); + } else { + if kind == FontKind::Times { + descriptor.push_str("700 "); + } + descriptor.push_str("100px "); + } + match kind { + FontKind::Timer => descriptor.push_str("\"timer\", monospace"), + FontKind::Text => descriptor.push_str("\"fira\", sans-serif"), + FontKind::Times => descriptor.push_str("\"fira\", monospace"), + } + + let is_monospaced = kind.is_monospaced(); + let font_kerning = JsString::from(if is_monospaced { "none" } else { "auto" }); + let descriptor = JsString::from(descriptor); + self.ctx_top.set_font(&descriptor); + self.ctx_top.set_font_kerning(&font_kerning); + + // FIXME: We query this to position a gradient from the top to the + // bottom of the font. Is the ascent and descent what we want here? + // That's not what we do in our default text engine. + let metrics = self.ctx_top.measure_text(&JsString::from("")).unwrap(); + let top = -metrics.font_bounding_box_ascent() as f32; + let bottom = metrics.font_bounding_box_descent() as f32; + + let font_handling = if is_monospaced { + let mut digit_offsets = [0.0; 10]; + let mut digit_width = 0.0; + + for (digit, glyph) in digit_offsets.iter_mut().enumerate() { + let metrics = self.ctx_top.measure_text(&self.digits[digit]).unwrap(); + let width = metrics.width() as f32; + *glyph = width; + if width > digit_width { + digit_width = width; + } + } + + if digit_offsets.iter().all(|&v| v == digit_width) { + // If all digits have the same width, there's no need to emulate + // monospacing. + FontHandling::Normal + } else { + for digit_offset in &mut digit_offsets { + *digit_offset = 0.5 * (digit_width - *digit_offset); + } + FontHandling::MonospaceEmulation(MonospaceInfo { + digit_offsets, + digit_width, + }) + } + } else { + FontHandling::Normal + }; + + Rc::new(CanvasFont { + descriptor, + font_kerning, + font_handling, + top, + bottom, + }) + } + + fn create_label( + &mut self, + text: &str, + font: &mut Self::Font, + max_width: Option, + ) -> Self::Label { + let mut label = CanvasLabel::default(); + self.update_label(&mut label, text, font, max_width); + label + } + + fn update_label( + &mut self, + label: &mut Self::Label, + text: &str, + font: &mut Self::Font, + max_width: Option, + ) { + let mut label = label.0.borrow_mut(); + let label = &mut *label; + set_font(&self.ctx_top, font); + + let (shape, width) = font.font_handling.shape(text, &self.ctx_top); + label.width_without_max_width = width; + label.width = label.width_without_max_width; + label.shape = shape; + + // FIXME: Pop from the existing shape instead. + if let Some(max_width) = max_width { + if label.width > max_width { + let mut text = text.to_owned(); + while let Some((drain_index, _)) = text.char_indices().next_back() { + text.drain(drain_index..); + text.push('…'); + let (shape, width) = font.font_handling.shape(&text, &self.ctx_top); + label.shape = shape; + label.width = width; + if label.width <= max_width { + break; + } else { + const ELLIPSIS_LEN: usize = '…'.len_utf8(); + text.drain(text.len() - ELLIPSIS_LEN..); + } + } + } + } + + label.font = font.clone(); + } + + fn build_circle(&mut self, x: f32, y: f32, r: f32) -> Self::Path { + let mut builder = self.path_builder(); + builder.circle(x, y, r); + builder.finish() + } +} + +enum MonospacePiece { + Digit { digit: u8, offset: f32 }, + Chunk { chunk: JsString, offset: f32 }, +} + +enum LabelShape { + Normal(JsString), + // FIXME: Switch to tabular-nums if that ever becomes a thing for Canvas. + MonospaceEmulation(Vec), +} + +impl FontHandling { + fn shape(&self, text: &str, ctx: &CanvasRenderingContext2d) -> (LabelShape, f32) { + match self { + FontHandling::Normal => { + let text = JsString::from(text); + let metrics = ctx.measure_text(&text).unwrap(); + let width = metrics.width() as f32; + (LabelShape::Normal(text), width * FONT_SCALE_FACTOR) + } + FontHandling::MonospaceEmulation(info) => { + let mut shaped = Vec::new(); + let mut rem = text; + let mut offset = 0.0; + while !rem.is_empty() { + let digit_pos = rem + .bytes() + .position(|b| b.is_ascii_digit()) + .unwrap_or(rem.len()); + + if digit_pos > 0 { + let chunk = JsString::from(&rem[..digit_pos]); + let metrics = ctx.measure_text(&chunk).unwrap(); + let width = metrics.width() as f32; + shaped.push(MonospacePiece::Chunk { chunk, offset }); + offset += width; + } + + if digit_pos < rem.len() { + let digit = rem.as_bytes()[digit_pos]; + let digit = digit - b'0'; + shaped.push(MonospacePiece::Digit { + digit, + offset: offset + info.digit_offsets[digit as usize], + }); + offset += info.digit_width; + rem = &rem[digit_pos + 1..]; + } else { + rem = ""; + } + } + + ( + LabelShape::MonospaceEmulation(shaped), + offset * FONT_SCALE_FACTOR, + ) + } + } + } +} + +/// The web renderer renders into a canvas element. The element can then be +/// attached anywhere in the DOM with any desired positioning and size. +pub struct Renderer { + manager: SceneManager, CanvasLabel>, + div: Element, + allocator: CanvasAllocator, + canvas_bottom: HtmlCanvasElement, + canvas_top: HtmlCanvasElement, + str_buf: String, + top_layer_is_cleared: bool, + cache: HashMap, +} + +impl Default for Renderer { + fn default() -> Self { + Self::new() + } +} + +impl Renderer { + /// Creates a new web renderer that renders into a canvas element. The + /// element can then be attached anywhere in the DOM with any desired + /// positioning and size. There are two CSS fonts that are used as the + /// default fonts. They are called "timer" and "fira". Make sure they are + /// fully loaded before creating the renderer as otherwise information about + /// a fallback font is cached instead. + pub fn new() -> Self { + let window = web_sys::window().unwrap(); + let document = window.document().unwrap(); + + let div = document.create_element("div").unwrap(); + + let canvas_bottom: HtmlCanvasElement = + document.create_element("canvas").unwrap().unchecked_into(); + + let canvas_top: HtmlCanvasElement = + document.create_element("canvas").unwrap().unchecked_into(); + + canvas_bottom + .set_attribute( + "style", + "position: absolute; width: inherit; height: inherit;", + ) + .unwrap(); + canvas_top + .set_attribute( + "style", + "position: absolute; width: inherit; height: inherit;", + ) + .unwrap(); + + div.append_with_node_2(&canvas_bottom, &canvas_top).unwrap(); + + let ctx_bottom: CanvasRenderingContext2d = canvas_bottom + .get_context("2d") + .unwrap() + .unwrap() + .unchecked_into(); + + let ctx_top: CanvasRenderingContext2d = canvas_top + .get_context("2d") + .unwrap() + .unwrap() + .unchecked_into(); + + let force_redraw_all = Rc::new(Cell::new(false)); + + let mut allocator = CanvasAllocator { + window, + force_redraw_all, + ctx_bottom, + ctx_top, + digits: array::from_fn(|digit| JsString::from((digit as u8 + b'0') as char)), + }; + + Self { + manager: SceneManager::new(&mut allocator), + div, + allocator, + canvas_bottom, + canvas_top, + str_buf: String::new(), + top_layer_is_cleared: true, + cache: HashMap::new(), + } + } + + /// Returns the HTML element. This can be attached anywhere in the DOM with + /// any desired positioning and size. + pub const fn element(&self) -> &Element { + &self.div + } + + /// Renders the layout state into the canvas. The image cache is used to + /// retrieve images that are used in the layout state. + pub fn render(&mut self, state: &LayoutState, image_cache: &ImageCache) { + // Scaling is based on: + // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html + + let ratio = self.allocator.window.device_pixel_ratio(); + let bounding_rect = self.canvas_bottom.get_bounding_client_rect(); + + let (width, height) = ( + (ratio * bounding_rect.width()).round(), + (ratio * bounding_rect.height()).round(), + ); + + if (self.canvas_bottom.width(), self.canvas_bottom.height()) != (width as _, height as _) { + self.canvas_bottom.set_width(width as _); + self.canvas_bottom.set_height(height as _); + self.canvas_top.set_width(width as _); + self.canvas_top.set_height(height as _); + } + + self.manager.update_scene( + &mut self.allocator, + [width as _, height as _], + state, + image_cache, + ); + + let scene = self.manager.scene(); + let str_buf = &mut self.str_buf; + + if scene.bottom_layer_changed() || self.allocator.force_redraw_all.take() { + let ctx = &mut self.allocator.ctx_bottom; + let _ = ctx.reset_transform(); + ctx.clear_rect(0.0, 0.0, width, height); + if let Some(background) = scene.background() { + match background { + Background::Shader(shader) => { + set_fill_style(shader, ctx, &mut self.cache, str_buf, &*scene.rectangle()); + ctx.fill_rect(0.0, 0.0, width, height); + } + Background::Image(background_image, transform) => { + let image = background_image.image.0.borrow(); + if let Some((image, _)) = &*image { + str_buf.clear(); + use std::fmt::Write; + if background_image.brightness != 0.0 { + let _ = write!( + str_buf, + "brightness({}%)", + 100.0 * background_image.brightness + ); + } + if background_image.opacity != 1.0 { + if !str_buf.is_empty() { + str_buf.push(' '); + } + let _ = write!( + str_buf, + "opacity({}%)", + 100.0 * background_image.opacity + ); + } + if background_image.blur != 0.0 { + if !str_buf.is_empty() { + str_buf.push(' '); + } + let _ = write!( + str_buf, + "blur({}px)", + (BLUR_FACTOR as f64) + * background_image.blur as f64 + * width.max(height) + ); + } + if !str_buf.is_empty() { + // FIXME: Cache the string (and below for the none). + ctx.set_filter(&JsString::from(str_buf.as_str())); + } + let _ = ctx.draw_image_with_image_bitmap_and_dw_and_dh( + image, + transform.x as _, + transform.y as _, + transform.scale_x as _, + transform.scale_y as _, + ); + if !str_buf.is_empty() { + ctx.set_filter(&JsString::from("none")); + } + } + } + } + } + + render_layer( + ctx, + &mut self.cache, + str_buf, + scene.bottom_layer(), + &self.allocator.digits, + ); + } + + let layer = scene.top_layer(); + let ctx = &mut self.allocator.ctx_top; + + if layer.is_empty() <= !self.top_layer_is_cleared { + let _ = ctx.reset_transform(); + ctx.clear_rect(0.0, 0.0, width, height); + } + self.top_layer_is_cleared = layer.is_empty(); + render_layer(ctx, &mut self.cache, str_buf, layer, &self.allocator.digits); + } +} + +#[derive(PartialEq, Eq, Hash)] +enum HashShader { + SolidColor([u32; 4]), + VerticalGradient([u32; 4], [u32; 4], [u32; 2]), + HorizontalGradient([u32; 4], [u32; 4], [u32; 2]), +} + +trait HasBounds { + fn bounds_x(&self) -> [f32; 2]; + fn bounds_y(&self) -> [f32; 2]; +} + +impl HasBounds for Path { + fn bounds_x(&self) -> [f32; 2] { + [self.min_x, self.max_x] + } + + fn bounds_y(&self) -> [f32; 2] { + [self.min_y, self.max_y] + } +} + +impl HasBounds for CanvasLabel { + fn bounds_x(&self) -> [f32; 2] { + let label = self.0.borrow(); + [0.0, label.width] + } + + fn bounds_y(&self) -> [f32; 2] { + let label = self.0.borrow(); + [label.font.top, label.font.bottom] + } +} + +fn set_stroke_style( + c: &[f32; 4], + ctx: &CanvasRenderingContext2d, + str_buf: &mut String, + cache: &mut HashMap, +) { + let hash_shader = HashShader::SolidColor(cast(*c)); + let style = cache + .entry(hash_shader) + .or_insert_with(|| JsValue::from_str(color(str_buf, c))); + ctx.set_stroke_style(style); +} + +fn set_fill_style( + shader: &FillShader, + ctx: &CanvasRenderingContext2d, + cache: &mut HashMap, + str_buf: &mut String, + handle: &impl HasBounds, +) { + let hash_shader = match *shader { + FillShader::SolidColor(c) => HashShader::SolidColor(cast(c)), + FillShader::VerticalGradient(t, b) => { + HashShader::VerticalGradient(cast(t), cast(b), cast(handle.bounds_y())) + } + FillShader::HorizontalGradient(l, r) => { + HashShader::HorizontalGradient(cast(l), cast(r), cast(handle.bounds_x())) + } + }; + let style = cache.entry(hash_shader).or_insert_with(|| match shader { + FillShader::SolidColor(c) => JsValue::from_str(color(str_buf, c)), + FillShader::VerticalGradient(t, b) => { + let [min_y, max_y] = handle.bounds_y(); + let gradient = ctx.create_linear_gradient(0.0, min_y as _, 0.0, max_y as _); + let _ = gradient.add_color_stop(0.0, color(str_buf, t)); + let _ = gradient.add_color_stop(1.0, color(str_buf, b)); + gradient.unchecked_into() + } + FillShader::HorizontalGradient(l, r) => { + let [min_x, max_x] = handle.bounds_x(); + let gradient = ctx.create_linear_gradient(min_x as _, 0.0, max_x as _, 0.0); + let _ = gradient.add_color_stop(0.0, color(str_buf, l)); + let _ = gradient.add_color_stop(1.0, color(str_buf, r)); + gradient.unchecked_into() + } + }); + ctx.set_fill_style(style); +} + +fn render_layer( + ctx: &CanvasRenderingContext2d, + cache: &mut HashMap, + str_buf: &mut String, + layer: &[Entity], + digits: &[JsString; 10], +) { + for entity in layer { + match entity { + Entity::FillPath(path, shader, transform) => { + set_fill_style(shader, ctx, cache, str_buf, &**path); + set_transform(ctx, transform); + ctx.fill_with_path_2d(&path.path); + } + Entity::StrokePath(path, stroke_width, color, transform) => { + set_fill_style( + &FillShader::SolidColor([0.0; 4]), + ctx, + cache, + str_buf, + &**path, + ); + ctx.set_line_width(*stroke_width as f64); + set_stroke_style(color, ctx, str_buf, cache); + set_transform(ctx, transform); + ctx.stroke_with_path(&path.path); + } + Entity::Image(image, transform) => { + let image = image.0.borrow(); + if let Some((image, _)) = &*image { + let _ = ctx.reset_transform(); + let _ = ctx.draw_image_with_image_bitmap_and_dw_and_dh( + image, + transform.x as _, + transform.y as _, + transform.scale_x as _, + transform.scale_y as _, + ); + } + } + Entity::Label(label, shader, transform) => { + set_fill_style(shader, ctx, cache, str_buf, &**label); + let label = label.0.borrow(); + let label = &*label; + set_font(ctx, &label.font); + + set_transform( + ctx, + &transform.pre_scale(FONT_SCALE_FACTOR, FONT_SCALE_FACTOR), + ); + + match &label.shape { + LabelShape::Normal(text) => { + let _ = ctx.fill_text(text, 0.0, 0.0); + } + LabelShape::MonospaceEmulation(pieces) => { + for piece in pieces { + match piece { + MonospacePiece::Digit { digit, offset } => { + let _ = ctx.fill_text( + &digits[*digit as usize], + *offset as f64, + 0.0, + ); + } + MonospacePiece::Chunk { chunk, offset } => { + let _ = ctx.fill_text(chunk, *offset as f64, 0.0); + } + } + } + } + } + } + } + } +} + +fn set_font(ctx: &CanvasRenderingContext2d, font: &CanvasFont) { + ctx.set_font(&font.descriptor); + ctx.set_font_kerning(&font.font_kerning); +} + +fn set_transform(ctx: &CanvasRenderingContext2d, transform: &Transform) { + let (sx, sy, tx, ty, ky, kx) = ( + transform.scale_x as f64, + transform.scale_y as f64, + transform.x as f64, + transform.y as f64, + 0.0, + 0.0, + ); + + let _ = ctx.set_transform(sx, ky, kx, sy, tx, ty); +} + +fn color<'b>(buf: &'b mut String, &[r, g, b, a]: &[f32; 4]) -> &'b str { + use core::fmt::Write; + + buf.clear(); + let _ = write!( + buf, + "rgba({}, {}, {}, {})", + 255.0 * r, + 255.0 * g, + 255.0 * b, + a + ); + buf +} + +const fn weight_as_css_str(weight: FontWeight) -> &'static str { + match weight { + FontWeight::Thin => "100", + FontWeight::ExtraLight => "200", + FontWeight::Light => "300", + FontWeight::SemiLight => "350", + FontWeight::Normal => "400", + FontWeight::Medium => "500", + FontWeight::SemiBold => "600", + FontWeight::Bold => "700", + FontWeight::ExtraBold => "800", + FontWeight::Black => "900", + FontWeight::ExtraBlack => "950", + } +} + +const fn stretch_as_css_str(stretch: FontStretch) -> &'static str { + match stretch { + FontStretch::UltraCondensed => "ultra-condensed", + FontStretch::ExtraCondensed => "extra-condensed", + FontStretch::Condensed => "condensed", + FontStretch::SemiCondensed => "semi-condensed", + FontStretch::Normal => "normal", + FontStretch::SemiExpanded => "semi-expanded", + FontStretch::Expanded => "expanded", + FontStretch::ExtraExpanded => "extra-expanded", + FontStretch::UltraExpanded => "ultra-expanded", + } +} diff --git a/tests/layout_files/subsplits.lsl b/tests/layout_files/subsplits.lsl index c26f2cbd..298adde5 100644 --- a/tests/layout_files/subsplits.lsl +++ b/tests/layout_files/subsplits.lsl @@ -12,7 +12,7 @@ FF0F0F0F 00000000 03FFFFFF - 642580FF + FF2580FF FF16A6FF FF00CC36 FF52CC73 @@ -52,8 +52,8 @@ False False FFFFFFFF - 6400FFFF - 640000FF + FF00003F + FF0000FF Vertical True False @@ -69,7 +69,7 @@ False FFFFFFFF False - 640000FF + FF00003F 00FFFFFF Plain False @@ -87,8 +87,8 @@ False FFFFFFFF False - 640000FF - 646400FF + FF00003F + FF3F003F Vertical @@ -101,8 +101,8 @@ LiveSplit.Subsplits.dll 1.7 - 966400FF - 9600FFFF + FF640000 + FF006464 21 7 0 @@ -124,8 +124,8 @@ True 5.8 Horizontal - 646400FF - 6400FFFF + FF200000 + FF002020 Horizontal True Tenths @@ -205,8 +205,8 @@ 40 False FFFFFFFF - 966400FF - 9600FFFF + FF6400FF + FF00FFFF Horizontal Current Comparison Best Segments @@ -225,8 +225,8 @@ FFFFFFFF False Tenths - 966400FF - 9600FFFF + FF6400FF + FF00FFFF Horizontal Current Comparison False @@ -239,8 +239,8 @@ 1.6 FFFFFFFF False - 644B00FF - 6400FFFF + FF4B00FF + FF00FFFF Horizontal Tenths True @@ -257,8 +257,8 @@ FFFFFFFF False Tenths - 644B00FF - 6400FFFF + FF4B00FF + FF00FFFF Horizontal Best Split Times False @@ -274,8 +274,8 @@ FFFFFFFF False Seconds - 644B00FF - 6400FFFF + FF4B00FF + FF00FFFF Horizontal False @@ -289,8 +289,8 @@ FFFFFFFF False Seconds - 644B00FF - 6400FFFF + FF4B00FF + FF00FFFF Horizontal Best Segments False diff --git a/tests/rendering.rs b/tests/rendering.rs index f6b69ef9..711a8658 100644 --- a/tests/rendering.rs +++ b/tests/rendering.rs @@ -289,8 +289,8 @@ fn subsplits_layout() { &layout.state(&mut image_cache, &timer.snapshot()), &image_cache, [300, 800], - "8694d76628ff63f8", - "f2bcd20608fb35df", + "78250961341ef747", + "95ba6dcf34c60078", "subsplits_layout", ); } @@ -314,7 +314,7 @@ fn background_image() { &image_cache, [300, 300], "efc369e681d98dfe", - "f2bcd20608fb35df", + "f0ec188c38f0e26d", "background_image", ); }