diff --git a/Cargo.lock b/Cargo.lock index e4b7b24..70f8608 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,12 @@ dependencies = [ "winit", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -543,6 +549,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -678,6 +690,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -846,12 +867,32 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -1341,6 +1382,21 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -1608,6 +1664,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -1620,6 +1696,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "naga" version = "30.0.1" @@ -2275,6 +2361,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "polling" version = "3.11.0" @@ -2351,6 +2450,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.41.0" @@ -2692,6 +2797,7 @@ dependencies = [ "etagere", "fontique", "icu_properties", + "image", "parley", "pastey", "pollster", @@ -2725,6 +2831,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -4227,12 +4339,33 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/crates/foreversight/README.md b/crates/foreversight/README.md index f0f26ba..f239b76 100644 --- a/crates/foreversight/README.md +++ b/crates/foreversight/README.md @@ -92,5 +92,4 @@ matter here: recorded. Results are capped at 500 for now, since every row is laid out and painted each frame. - **Text truncation**, for long item names in narrow rows. -- **Icons and images**, for item icons. - **Tooltips**, for item details on hover. diff --git a/crates/shikra/Cargo.toml b/crates/shikra/Cargo.toml index 8099469..1a41394 100644 --- a/crates/shikra/Cargo.toml +++ b/crates/shikra/Cargo.toml @@ -4,12 +4,14 @@ version = "0.1.0" edition = "2024" [features] -default = ["complex-scripts"] +default = ["complex-scripts", "image-decoding"] # Dictionary-based line breaking for scripts without spaces between words, like # Thai and Khmer, and better word boundaries for CJK. Adds about 4 MB. complex-scripts = ["parley/complex-scripts"] # Running apps without a platform, for tests. See `shikra::test`. test-support = [] +# Decoding PNG and JPEG images with `Image::decode`. +image-decoding = ["dep:image"] [dependencies] accesskit = "0.25" @@ -24,6 +26,7 @@ etagere = "0.3" # Load fontconfig at runtime, like winit and wgpu do with their system libraries. fontique = { version = "0.11", features = ["fontconfig-dlopen"] } icu_properties = "2" +image = { version = "0.25", default-features = false, features = ["png", "jpeg"], optional = true } parley = "0.11" pastey = "0.2" pollster = "1" @@ -36,3 +39,7 @@ winit = "0.30" [dev-dependencies] anyhow = "1" tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } + +[[example]] +name = "image" +required-features = ["image-decoding"] diff --git a/crates/shikra/README.md b/crates/shikra/README.md index d9e66e2..1e6994c 100644 --- a/crates/shikra/README.md +++ b/crates/shikra/README.md @@ -16,6 +16,7 @@ cargo run -p shikra --example clock cargo run -p shikra --example text cargo run -p shikra --example input cargo run -p shikra --example overlays +cargo run -p shikra --example image [-- path/to/image.png] cargo run -p shikra_headless --example primitives ``` diff --git a/crates/shikra/TODO.md b/crates/shikra/TODO.md index 5575547..7a48d35 100644 --- a/crates/shikra/TODO.md +++ b/crates/shikra/TODO.md @@ -20,7 +20,16 @@ end. - The glyph atlas doesn't grow or evict glyphs. Once it's full, new glyphs aren't drawn, and a warning is printed once (`atlas.rs`). -- No shadows, gradients, images, SVG, or icons. +- No shadows, gradients, SVG, or icons. +- Images are one 2048×2048 atlas texture. They stay in it until they're + dropped, and images that don't fit aren't drawn, with a warning printed once + (`atlas.rs`). +- Images are filtered without mipmaps, so shrinking one a lot aliases. +- `img` only stretches images to fill it, with no equivalent of CSS + `object-fit`, and ignores rounded corners. There's no image opacity. +- `Image::decode` only handles PNG and JPEG, and nothing loads images from + paths or URLs, or animates them. Decoding off the main thread is up to the + caller. - No element opacity, transforms, transitions, or animations. ## Layout and styling diff --git a/crates/shikra/examples/image.rs b/crates/shikra/examples/image.rs new file mode 100644 index 0000000..a320bbe --- /dev/null +++ b/crates/shikra/examples/image.rs @@ -0,0 +1,122 @@ +//! Draws images at their natural size, scaled up and down, and over a +//! background, to check their filtering and transparency. +//! +//! Pass the path to a PNG or JPEG to draw it too, decoded off the main thread: +//! +//! ```sh +//! cargo run --example image -- path/to/image.png +//! ``` + +use shikra::{ + Application, Context, Image, IntoElement, ParentElement, Render, Styled, Task, Window, + WindowOptions, div, img, px, rgb, size, +}; + +fn main() { + let path = std::env::args().nth(1); + Application::new().run(|cx| { + cx.open_window( + WindowOptions { + title: "Image".into(), + size: size(720., 480.), + }, + |_window, cx| cx.new(|cx| Gallery::new(path, cx)), + ); + }); +} + +struct Gallery { + checkerboard: Image, + gradient: Image, + decoded: Option, + _decode: Option>>, +} + +impl Gallery { + fn new(path: Option, cx: &mut Context) -> Self { + let decode = path.map(|path| { + let decoding = cx + .background_executor() + .spawn(async move { Image::decode(&std::fs::read(&path)?) }); + cx.spawn(async move |this, cx| { + let image = decoding.await?; + this.update(cx, |this, cx| { + this.decoded = Some(image); + cx.notify(); + }) + }) + }); + + Self { + checkerboard: checkerboard(), + gradient: gradient(), + decoded: None, + _decode: decode, + } + } +} + +impl Render for Gallery { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let row = || div().style(|s| s.flex().items_center().gap_4()); + div() + .style(|s| s.size_full().flex().flex_col().items_start().gap_6().p_6()) + .child( + row() + // Natural size. + .child(img(self.checkerboard.clone())) + // Scaled up, keeping the aspect ratio. + .child(img(self.checkerboard.clone()).style(|s| s.w(px(96.)))) + // Scaled down. + .child(img(self.checkerboard.clone()).style(|s| s.w(px(6.)))) + // Stretched. + .child(img(self.checkerboard.clone()).style(|s| s.w(px(96.)).h(px(24.)))), + ) + .child( + row() + .child(img(self.gradient.clone()).style(|s| s.w(px(128.)))) + .child( + div() + .style(|s| s.p_4().rounded_lg().bg(rgb(0x1e1e2e))) + .child(img(self.gradient.clone()).style(|s| s.w(px(128.)))), + ), + ) + .children( + self.decoded + .clone() + .map(|image| img(image).style(|s| s.max_w_full())), + ) + } +} + +/// An 8×8 checkerboard of orange and blue pixels. +fn checkerboard() -> Image { + let pixels = (0..8 * 8) + .flat_map(|index| { + if (index % 8 + index / 8) % 2 == 0 { + [0xfa, 0xb3, 0x87, 0xff] + } else { + [0x89, 0xb4, 0xfa, 0xff] + } + }) + .collect(); + Image::from_rgba(8, 8, pixels) +} + +/// Green fading from opaque on the left to transparent on the right, with +/// transparent pixels that are red, which should never show. +fn gradient() -> Image { + let (width, height) = (32, 8); + let pixels = (0..width * height) + .flat_map(|index| { + let x = index % width; + let alpha = 255 - x * 255 / (width - 1); + if alpha == 0 { + [0xff, 0, 0, 0] + } else { + [0xa6, 0xe3, 0xa1, alpha as u8] + } + }) + .collect(); + Image::from_rgba(width, height, pixels) +} diff --git a/crates/shikra/src/app.rs b/crates/shikra/src/app.rs index cfb2585..8e3427f 100644 --- a/crates/shikra/src/app.rs +++ b/crates/shikra/src/app.rs @@ -6,6 +6,7 @@ use std::rc::{Rc, Weak}; use anyhow::{Context as _, Result}; use tokio::runtime::{Handle, RuntimeFlavor}; +use crate::atlas::ImageAtlas; use crate::clipboard::Clipboard; use crate::entity::EntityMap; use crate::subscription::SubscriberSet; @@ -97,6 +98,7 @@ pub struct App { background_executor: BackgroundExecutor, pub(crate) entities: EntityMap, pub(crate) text_system: TextSystem, + pub(crate) image_atlas: ImageAtlas, observers: SubscriberSet, event_handlers: SubscriberSet, pending_effects: VecDeque, @@ -126,6 +128,7 @@ impl App { background_executor, entities: EntityMap::new(), text_system: TextSystem::new(), + image_atlas: ImageAtlas::new(), observers: SubscriberSet::new(), event_handlers: SubscriberSet::new(), pending_effects: VecDeque::new(), diff --git a/crates/shikra/src/atlas.rs b/crates/shikra/src/atlas.rs index a2cd28a..78cc4a6 100644 --- a/crates/shikra/src/atlas.rs +++ b/crates/shikra/src/atlas.rs @@ -1,11 +1,15 @@ -//! CPU-side bookkeeping for the glyph atlas textures. +//! CPU-side bookkeeping for the atlas textures. //! -//! Glyphs are rasterized while painting, allocated space in an atlas, and -//! queued for upload. The renderer uploads them to the GPU before drawing. +//! Glyphs are rasterized while painting, and images are first painted, then +//! allocated space in an atlas and queued for upload. The renderer uploads +//! them to the GPU before drawing. use std::collections::HashMap; +use std::sync::Weak; -use etagere::{BucketedAtlasAllocator, size2}; +use etagere::{AllocId, AtlasAllocator, BucketedAtlasAllocator, size2}; + +use crate::image::{Image, ImageData, ImageId}; /// The width and height of each atlas texture, in pixels. pub(crate) const ATLAS_SIZE: u32 = 2048; @@ -16,13 +20,15 @@ pub(crate) enum AtlasKind { Mono, /// Full-color glyphs, like emoji. Color, + /// Images, with premultiplied alpha so they can be filtered when scaled. + Image, } impl AtlasKind { pub(crate) fn bytes_per_pixel(self) -> u32 { match self { Self::Mono => 1, - Self::Color => 4, + Self::Color | Self::Image => 4, } } } @@ -102,6 +108,7 @@ impl GlyphAtlas { let allocator = match kind { AtlasKind::Mono => &mut self.mono, AtlasKind::Color => &mut self.color, + AtlasKind::Image => unreachable!("images go in the image atlas"), }; let Some(allocation) = allocator.allocate(size2(size[0] as i32, size[1] as i32)) else { // TODO: Grow the atlas or evict unused glyphs. @@ -137,3 +144,128 @@ impl GlyphAtlas { std::mem::take(&mut self.uploads) } } + +/// Where an image is in the image atlas. +struct ImageTile { + /// Lets the tile be freed once the image is dropped. + image: Weak, + allocation: AllocId, + origin: [u32; 2], +} + +/// The images that have been painted, which stay in the atlas until they're +/// dropped. +pub(crate) struct ImageAtlas { + allocator: AtlasAllocator, + tiles: HashMap, + uploads: Vec, + warned_full: bool, +} + +impl ImageAtlas { + pub(crate) fn new() -> Self { + Self { + allocator: AtlasAllocator::new(size2(ATLAS_SIZE as i32, ATLAS_SIZE as i32)), + tiles: HashMap::default(), + uploads: Vec::new(), + warned_full: false, + } + } + + /// Returns the top-left corner of an image in the atlas, allocating space + /// for it and queueing it for upload if needed. Returns `None` if it + /// doesn't fit. + pub(crate) fn get_or_insert(&mut self, image: &Image) -> Option<[u32; 2]> { + if let Some(tile) = self.tiles.get(&image.id()) { + return Some(tile.origin); + } + + let size = size2(image.width() as i32, image.height() as i32); + let allocation = match self.allocator.allocate(size) { + Some(allocation) => allocation, + None => { + self.free_dropped_images(); + let Some(allocation) = self.allocator.allocate(size) else { + // TODO: Evict images that haven't been painted recently, + // and use more than one atlas texture. + if !self.warned_full { + eprintln!("shikra: image atlas is full; some images won't be drawn"); + self.warned_full = true; + } + return None; + }; + allocation + } + }; + + let origin = [ + allocation.rectangle.min.x as u32, + allocation.rectangle.min.y as u32, + ]; + self.tiles.insert( + image.id(), + ImageTile { + image: image.downgrade(), + allocation: allocation.id, + origin, + }, + ); + self.uploads.push(AtlasUpload { + kind: AtlasKind::Image, + origin, + size: [image.width(), image.height()], + data: image.pixels().to_vec(), + }); + Some(origin) + } + + fn free_dropped_images(&mut self) { + self.tiles.retain(|_, tile| { + let alive = tile.image.strong_count() > 0; + if !alive { + self.allocator.deallocate(tile.allocation); + } + alive + }); + } + + pub(crate) fn take_uploads(&mut self) -> Vec { + std::mem::take(&mut self.uploads) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid_image(width: u32, height: u32) -> Image { + Image::from_rgba(width, height, vec![255; (width * height * 4) as usize]) + } + + #[test] + fn images_are_uploaded_once() { + let mut atlas = ImageAtlas::new(); + let image = solid_image(2, 3); + + let origin = atlas.get_or_insert(&image).unwrap(); + assert_eq!(atlas.get_or_insert(&image.clone()), Some(origin)); + + let uploads = atlas.take_uploads(); + assert_eq!(uploads.len(), 1); + assert_eq!(uploads[0].size, [2, 3]); + assert_eq!(uploads[0].data.len(), 2 * 3 * 4); + } + + #[test] + fn dropped_images_make_room() { + let mut atlas = ImageAtlas::new(); + let first = solid_image(ATLAS_SIZE, ATLAS_SIZE); + let second = solid_image(ATLAS_SIZE, ATLAS_SIZE); + + assert!(atlas.get_or_insert(&first).is_some()); + assert!(atlas.get_or_insert(&second).is_none()); + + drop(first); + assert!(atlas.get_or_insert(&second).is_some()); + } +} diff --git a/crates/shikra/src/image.rs b/crates/shikra/src/image.rs new file mode 100644 index 0000000..f5dd78a --- /dev/null +++ b/crates/shikra/src/image.rs @@ -0,0 +1,320 @@ +//! Images, and the element that draws them. + +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Weak}; + +use crate::{App, Bounds, Element, LayoutId, SharedString, Style, Styled, Window, size}; + +/// Identifies an image's pixels, for as long as the program runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct ImageId(u64); + +/// Decoded pixels that can be painted, such as with [`img`]. +/// +/// Cloning an image is cheap, and clones share their pixels. An image stays +/// on the GPU after it's first painted, until every clone of it is dropped, so +/// keep the image around rather than creating it again each frame. +/// +/// Images can be created on any thread, which is a good way to keep decoding +/// off the main thread. +#[derive(Clone)] +pub struct Image(Arc); + +pub(crate) struct ImageData { + id: ImageId, + width: u32, + height: u32, + /// sRGB, with premultiplied alpha. + pixels: Vec, +} + +impl Image { + /// Creates an image from sRGB pixels with straight (non-premultiplied) + /// alpha, four bytes each (red, green, blue, and alpha), row by row from + /// the top left. + /// + /// Panics if there aren't `width * height` pixels. + pub fn from_rgba(width: u32, height: u32, mut pixels: Vec) -> Self { + assert_eq!( + pixels.len(), + width as usize * height as usize * 4, + "expected {width}×{height} RGBA pixels" + ); + + // Premultiplying lets the GPU blend neighboring pixels when scaling + // without dark fringes around transparent ones. + for pixel in pixels.as_chunks_mut::<4>().0 { + let alpha = pixel[3] as u32; + for channel in &mut pixel[..3] { + *channel = ((*channel as u32 * alpha + 127) / 255) as u8; + } + } + + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + Self(Arc::new(ImageData { + id: ImageId(NEXT_ID.fetch_add(1, Ordering::Relaxed)), + width, + height, + pixels, + })) + } + + /// Decodes a PNG or JPEG image. + /// + /// Requires the `image-decoding` feature, which is on by default. + #[cfg(feature = "image-decoding")] + pub fn decode(bytes: &[u8]) -> anyhow::Result { + let image = image::load_from_memory(bytes)?.into_rgba8(); + let (width, height) = image.dimensions(); + Ok(Self::from_rgba(width, height, image.into_raw())) + } + + /// The width in pixels. + pub fn width(&self) -> u32 { + self.0.width + } + + /// The height in pixels. + pub fn height(&self) -> u32 { + self.0.height + } + + pub(crate) fn id(&self) -> ImageId { + self.0.id + } + + pub(crate) fn pixels(&self) -> &[u8] { + &self.0.pixels + } + + pub(crate) fn downgrade(&self) -> Weak { + Arc::downgrade(&self.0) + } +} + +impl fmt::Debug for Image { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Image") + .field("width", &self.width()) + .field("height", &self.height()) + .finish_non_exhaustive() + } +} + +/// An element that draws an image, stretched to fill its bounds. +/// +/// Like an `` on the web, it's as big as the image, with one image pixel +/// per logical pixel, unless its style sets its size. Setting only its width +/// or only its height keeps the image's aspect ratio. +/// +/// Only the layout parts of its style apply. Wrap it in a +/// [`div`](crate::div) for a background or border. +#[derive(Clone)] +pub struct Img { + image: Image, + style: Style, + alt: Option, +} + +/// Creates an element that draws `image`. See [`Img`]. +pub fn img(image: Image) -> Img { + Img { + image, + style: Style::default(), + alt: None, + } +} + +impl Img { + /// Describes the image to assistive technologies. Images without it are + /// treated as decorative and left out of the accessibility tree. + pub fn alt(mut self, alt: impl Into) -> Self { + self.alt = Some(alt.into()); + self + } +} + +impl Styled for Img { + fn style_mut(&mut self) -> &mut Style { + &mut self.style + } +} + +impl Element for Img { + type LayoutState = (); + + fn request_layout(&mut self, window: &mut Window, _cx: &mut App) -> (LayoutId, ()) { + let natural = size(self.image.width() as f32, self.image.height() as f32); + // Layout follows the aspect ratio when the style sets only one + // dimension, and so does measuring when layout knows only one. + if self.style.aspect_ratio.is_none() && natural.width > 0. && natural.height > 0. { + self.style.aspect_ratio = Some(natural.width / natural.height); + } + let aspect_ratio = self.style.aspect_ratio; + let layout_id = window.request_measured_layout(&self.style, move |known, _| { + match (known.width, known.height, aspect_ratio) { + (Some(width), Some(height), _) => size(width, height), + (Some(width), None, Some(ratio)) => size(width, width / ratio), + (None, Some(height), Some(ratio)) => size(height * ratio, height), + (width, height, _) => size( + width.unwrap_or(natural.width), + height.unwrap_or(natural.height), + ), + } + }); + (layout_id, ()) + } + + fn paint(&mut self, bounds: Bounds, _: &mut (), window: &mut Window, cx: &mut App) { + if let Some(alt) = &self.alt + && window.is_accessibility_active() + { + let id = window.with_element_id(None, |_, id| id); + let mut node = accesskit::Node::new(accesskit::Role::Image); + node.set_label(&**alt); + window.with_accessibility_node(id, bounds, node, |_, _| {}); + } + window.paint_image(bounds, &self.image, cx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::SpriteKind; + use crate::test::TestApp; + use crate::{Context, IntoElement, ParentElement, Render, div, px}; + + struct Images(Vec); + + impl Render for Images { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .style(|s| s.flex().items_start()) + .children(self.0.iter().cloned()) + } + } + + /// An image in a block container, rather than a flex one. + struct InBlock(Img); + + impl Render for InBlock { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child(self.0.clone()) + } + } + + fn solid(width: u32, height: u32) -> Image { + Image::from_rgba(width, height, vec![255; (width * height * 4) as usize]) + } + + /// The bounds of the images `view` paints, as origin and size. + fn painted(view: V) -> Vec<[f32; 4]> { + let app = TestApp::new(); + let window = app.open_window(|_, cx| cx.new(|_| view)); + window.update(|window, _| { + window + .scene + .sprites + .iter() + .filter(|sprite| sprite.kind == SpriteKind::Image as u32) + .map(|sprite| { + [ + sprite.origin[0], + sprite.origin[1], + sprite.size[0], + sprite.size[1], + ] + }) + .collect() + }) + } + + #[test] + fn premultiplies_pixels() { + let image = Image::from_rgba(1, 1, vec![255, 128, 0, 128]); + assert_eq!(image.pixels(), [128, 64, 0, 128]); + } + + #[cfg(feature = "image-decoding")] + #[test] + fn decodes_png() { + let mut bytes = Vec::new(); + image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 0]) + .unwrap() + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .unwrap(); + + let image = Image::decode(&bytes).unwrap(); + assert_eq!((image.width(), image.height()), (2, 1)); + assert_eq!(image.pixels(), [255, 0, 0, 255, 0, 0, 0, 0]); + assert!(Image::decode(b"not an image").is_err()); + } + + #[test] + fn sizes_images() { + let painted = painted(Images(vec![ + // Its natural size. + img(solid(20, 10)), + // A width, keeping the aspect ratio. + img(solid(20, 10)).style(|s| s.w(px(40.))), + // A height, keeping the aspect ratio. + img(solid(20, 10)).style(|s| s.h(px(5.))), + // Both, stretching it. + img(solid(20, 10)).style(|s| s.w(px(10.)).h(px(10.))), + ])); + assert_eq!( + painted, + [ + [0., 0., 20., 10.], + [20., 0., 40., 20.], + [60., 0., 10., 5.], + [70., 0., 10., 10.], + ] + ); + } + + #[test] + fn sizes_images_in_block_containers() { + let painted = painted(InBlock(img(solid(20, 10)).style(|s| s.w(px(40.))))); + assert_eq!(painted, [[0., 0., 40., 20.]]); + } + + #[test] + fn skips_images_outside_the_window() { + let app = TestApp::new(); + let offscreen = img(solid(10, 10)).style(|s| s.ml(px(1000.))); + let window = app.open_window(|_, cx| cx.new(|_| Images(vec![offscreen]))); + + let sprites = window.update(|window, _| window.scene.sprites.len()); + assert_eq!(sprites, 0); + // It isn't uploaded until it's visible. + let uploads = app.update(|cx| cx.image_atlas.take_uploads()); + assert!(uploads.is_empty()); + } + + #[test] + fn describes_images_with_alt_text() { + let app = TestApp::new(); + let window = app.open_window(|_, cx| { + cx.new(|_| { + Images(vec![ + img(solid(10, 10)).alt("Hearthstone"), + img(solid(10, 10)), + ]) + }) + }); + let tree = window.accessibility_tree(); + let labels: Vec<_> = tree + .nodes + .iter() + .filter(|(_, node)| node.role() == accesskit::Role::Image) + .map(|(_, node)| node.label().unwrap().to_string()) + .collect(); + assert_eq!(labels, ["Hearthstone"]); + } +} diff --git a/crates/shikra/src/layout.rs b/crates/shikra/src/layout.rs index f308eb5..27e187d 100644 --- a/crates/shikra/src/layout.rs +++ b/crates/shikra/src/layout.rs @@ -242,6 +242,7 @@ fn to_taffy(style: &Style, rem_size: f32) -> taffy::Style { .height .map_or(taffy::LengthPercentageAuto::auto(), length_percentage_auto), }, + aspect_ratio: style.aspect_ratio, max_size: taffy::Size { width: style .max_size diff --git a/crates/shikra/src/lib.rs b/crates/shikra/src/lib.rs index 6be71f0..864ea8d 100644 --- a/crates/shikra/src/lib.rs +++ b/crates/shikra/src/lib.rs @@ -11,6 +11,7 @@ mod entity; mod executor; mod focus; mod geometry; +mod image; mod input; mod layout; mod platform; @@ -38,6 +39,7 @@ pub use entity::*; pub use executor::*; pub use focus::{FocusHandle, FocusId}; pub use geometry::*; +pub use image::{Image, Img, img}; pub use input::*; pub use layout::{AvailableSpace, LayoutId}; pub use scroll::ScrollHandle; diff --git a/crates/shikra/src/platform.rs b/crates/shikra/src/platform.rs index a2c71d8..242b2c1 100644 --- a/crates/shikra/src/platform.rs +++ b/crates/shikra/src/platform.rs @@ -244,6 +244,7 @@ impl ApplicationHandler for Platform { &mut window.surface, &app_window.scene, &mut cx.text_system.atlas, + &mut cx.image_atlas, || window.window.pre_present_notify(), ); }); diff --git a/crates/shikra/src/renderer.rs b/crates/shikra/src/renderer.rs index 141f7d3..4f0477b 100644 --- a/crates/shikra/src/renderer.rs +++ b/crates/shikra/src/renderer.rs @@ -6,7 +6,7 @@ use bytemuck::{Pod, Zeroable}; use winit::event_loop::OwnedDisplayHandle; use crate::Rgba; -use crate::atlas::{ATLAS_SIZE, AtlasKind, GlyphAtlas}; +use crate::atlas::{ATLAS_SIZE, AtlasKind, AtlasUpload, GlyphAtlas, ImageAtlas}; use crate::scene::{PrimitiveKind, Quad, Scene, Sprite}; const INITIAL_INSTANCE_CAPACITY: usize = 1024; @@ -32,12 +32,20 @@ struct Gpu { globals_bind_group: wgpu::BindGroup, quads: InstanceBuffer, sprites: InstanceBuffer, - mono_atlas: wgpu::Texture, - color_atlas: wgpu::Texture, + atlases: Atlases, quads_bind_group: wgpu::BindGroup, sprites_bind_group: wgpu::BindGroup, } +/// The textures sprites come from. +struct Atlases { + mono: wgpu::Texture, + color: wgpu::Texture, + image: wgpu::Texture, + /// Samples images, which can be scaled, unlike glyphs. + image_sampler: wgpu::Sampler, +} + #[derive(Clone)] struct Pipelines { quads: wgpu::RenderPipeline, @@ -120,14 +128,16 @@ impl Renderer { &mut self, surface: &mut WindowSurface, scene: &Scene, - atlas: &mut GlyphAtlas, + glyph_atlas: &mut GlyphAtlas, + image_atlas: &mut ImageAtlas, pre_present: impl FnOnce(), ) { let Some(gpu) = &mut self.gpu else { return }; - // Upload new glyphs even if we end up skipping this frame, since - // they're already recorded as being in the atlas. - gpu.upload_glyphs(atlas); + // Upload new glyphs and images even if we end up skipping this frame, + // since they're already recorded as being in the atlases. + gpu.upload(glyph_atlas.take_uploads()); + gpu.upload(image_atlas.take_uploads()); let frame = match surface.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) => frame, @@ -239,7 +249,18 @@ impl Gpu { }); let sprites_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("sprites"), - entries: &[storage_entry(1), texture_entry(2), texture_entry(3)], + entries: &[ + storage_entry(1), + texture_entry(2, false), + texture_entry(3, false), + texture_entry(4, true), + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], }); let pipeline_layout = |label, layout| { device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -268,16 +289,20 @@ impl Gpu { let quads = InstanceBuffer::new::(&device, "quads", INITIAL_INSTANCE_CAPACITY); let sprites = InstanceBuffer::new::(&device, "sprites", INITIAL_INSTANCE_CAPACITY); - let mono_atlas = create_atlas_texture(&device, AtlasKind::Mono); - let color_atlas = create_atlas_texture(&device, AtlasKind::Color); + let atlases = Atlases { + mono: create_atlas_texture(&device, AtlasKind::Mono), + color: create_atlas_texture(&device, AtlasKind::Color), + image: create_atlas_texture(&device, AtlasKind::Image), + image_sampler: device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("images"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }), + }; let quads_bind_group = create_quads_bind_group(&device, &quads_layout, &quads); - let sprites_bind_group = create_sprites_bind_group( - &device, - &sprites_layout, - &sprites, - &mono_atlas, - &color_atlas, - ); + let sprites_bind_group = + create_sprites_bind_group(&device, &sprites_layout, &sprites, &atlases); Ok(Self { adapter, @@ -293,18 +318,18 @@ impl Gpu { globals_bind_group, quads, sprites, - mono_atlas, - color_atlas, + atlases, quads_bind_group, sprites_bind_group, }) } - fn upload_glyphs(&mut self, atlas: &mut GlyphAtlas) { - for upload in atlas.take_uploads() { + fn upload(&mut self, uploads: Vec) { + for upload in uploads { let texture = match upload.kind { - AtlasKind::Mono => &self.mono_atlas, - AtlasKind::Color => &self.color_atlas, + AtlasKind::Mono => &self.atlases.mono, + AtlasKind::Color => &self.atlases.color, + AtlasKind::Image => &self.atlases.image, }; self.queue.write_texture( wgpu::TexelCopyTextureInfo { @@ -348,8 +373,7 @@ impl Gpu { &self.device, &self.sprites_layout, &self.sprites, - &self.mono_atlas, - &self.color_atlas, + &self.atlases, ); } self.queue @@ -447,12 +471,12 @@ fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { } } -fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { +fn texture_entry(binding: u32, filterable: bool) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, + sample_type: wgpu::TextureSampleType::Float { filterable }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, @@ -465,6 +489,7 @@ fn create_atlas_texture(device: &wgpu::Device, kind: AtlasKind) -> wgpu::Texture label: Some(match kind { AtlasKind::Mono => "mono atlas", AtlasKind::Color => "color atlas", + AtlasKind::Image => "image atlas", }), size: wgpu::Extent3d { width: ATLAS_SIZE, @@ -478,6 +503,8 @@ fn create_atlas_texture(device: &wgpu::Device, kind: AtlasKind) -> wgpu::Texture AtlasKind::Mono => wgpu::TextureFormat::R8Unorm, // Glyph colors are sRGB, and the shader converts them if needed. AtlasKind::Color => wgpu::TextureFormat::Rgba8Unorm, + // Filtering happens in sRGB space, like blending does. + AtlasKind::Image => wgpu::TextureFormat::Rgba8Unorm, }, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], @@ -503,11 +530,15 @@ fn create_sprites_bind_group( device: &wgpu::Device, layout: &wgpu::BindGroupLayout, sprites: &InstanceBuffer, - mono_atlas: &wgpu::Texture, - color_atlas: &wgpu::Texture, + atlases: &Atlases, ) -> wgpu::BindGroup { - let mono_view = mono_atlas.create_view(&wgpu::TextureViewDescriptor::default()); - let color_view = color_atlas.create_view(&wgpu::TextureViewDescriptor::default()); + let view = + |texture: &wgpu::Texture| texture.create_view(&wgpu::TextureViewDescriptor::default()); + let (mono_view, color_view, image_view) = ( + view(&atlases.mono), + view(&atlases.color), + view(&atlases.image), + ); device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("sprites"), layout, @@ -524,6 +555,14 @@ fn create_sprites_bind_group( binding: 3, resource: wgpu::BindingResource::TextureView(&color_view), }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&image_view), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&atlases.image_sampler), + }, ], }) } diff --git a/crates/shikra/src/scene.rs b/crates/shikra/src/scene.rs index 0518876..00fabc3 100644 --- a/crates/shikra/src/scene.rs +++ b/crates/shikra/src/scene.rs @@ -75,7 +75,21 @@ pub(crate) struct Quad { pub(crate) clip: [f32; 4], } -/// An image from a glyph atlas, in device pixels. +/// Which texture a sprite comes from, and how it's drawn. +/// +/// Matches the `SPRITE_*` constants in `shaders.wgsl`. +#[repr(u32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpriteKind { + /// A coverage mask, tinted with the sprite's color. + Mono = 0, + /// A full-color glyph, drawn at its size in the atlas. + Color = 1, + /// An image, scaled to the sprite's size. + Image = 2, +} + +/// An image from an atlas, in device pixels. /// /// The layout matches the `Sprite` struct in `shaders.wgsl`. #[repr(C)] @@ -85,11 +99,14 @@ pub(crate) struct Sprite { pub(crate) size: [f32; 2], /// The top-left corner of the sprite in its atlas, in texels. pub(crate) atlas_origin: [f32; 2], - /// Whether this is a full-color sprite, rather than a tinted mask. - pub(crate) is_color: u32, - pub(crate) _pad: u32, + /// The size of the sprite in its atlas, in texels. Only images can be + /// scaled, so for glyphs it's the same as `size`. + pub(crate) atlas_size: [f32; 2], + /// A [`SpriteKind`]. + pub(crate) kind: u32, + pub(crate) _pad: [u32; 3], /// Straight (non-premultiplied) sRGB. Tints masks; only the alpha applies - /// to color sprites. + /// to color glyphs and images. pub(crate) color: [f32; 4], /// The area to draw inside, as left, top, right, and bottom edges. pub(crate) clip: [f32; 4], diff --git a/crates/shikra/src/shaders.wgsl b/crates/shikra/src/shaders.wgsl index b616b9c..fc71829 100644 --- a/crates/shikra/src/shaders.wgsl +++ b/crates/shikra/src/shaders.wgsl @@ -132,13 +132,21 @@ fn to_output(color: vec4) -> vec4 { return vec4(linear, color.a); } +// Matches `SpriteKind` in `scene.rs`. +const SPRITE_MONO: u32 = 0u; +const SPRITE_COLOR: u32 = 1u; +const SPRITE_IMAGE: u32 = 2u; + // Matches `Sprite` in `scene.rs`. Everything is in device pixels. struct Sprite { origin: vec2, size: vec2, atlas_origin: vec2, - is_color: u32, - _pad: u32, + atlas_size: vec2, + kind: u32, + _pad0: u32, + _pad1: u32, + _pad2: u32, color: vec4, // The area to draw inside: left, top, right, bottom. clip: vec4, @@ -147,10 +155,12 @@ struct Sprite { @group(1) @binding(1) var sprites: array; @group(1) @binding(2) var mono_atlas: texture_2d; @group(1) @binding(3) var color_atlas: texture_2d; +@group(1) @binding(4) var image_atlas: texture_2d; +@group(1) @binding(5) var image_sampler: sampler; struct SpriteVarying { @builtin(position) position: vec4, - // The texel under this fragment, in atlas coordinates. + // The point under this fragment, in atlas coordinates. @location(0) atlas_position: vec2, @location(1) @interpolate(flat) sprite_id: u32, } @@ -167,7 +177,8 @@ fn vs_sprite( var out: SpriteVarying; out.position = vec4(ndc, 0.0, 1.0); - out.atlas_position = sprite.atlas_origin + position - sprite.origin; + out.atlas_position = sprite.atlas_origin + + (position - sprite.origin) / sprite.size * sprite.atlas_size; out.sprite_id = sprite_id; return out; } @@ -175,11 +186,25 @@ fn vs_sprite( @fragment fn fs_sprite(input: SpriteVarying) -> @location(0) vec4 { let sprite = sprites[input.sprite_id]; - // Sprites are pixel-aligned, so we can load texels directly rather than + + if sprite.kind == SPRITE_IMAGE { + // Images are scaled, so sample them, keeping samples at least half a + // texel inside the image so they don't pick up its neighbors. + let point = clamp( + input.atlas_position, + sprite.atlas_origin + 0.5, + sprite.atlas_origin + sprite.atlas_size - 0.5, + ); + let uv = point / vec2(textureDimensions(image_atlas)); + let color = textureSampleLevel(image_atlas, image_sampler, uv, 0.0); + return premultiplied_to_output(color) * sprite.color.a; + } + + // Glyphs are pixel-aligned, so we can load texels directly rather than // sampling. let texel = vec2(floor(input.atlas_position)); - if sprite.is_color == 0u { + if sprite.kind == SPRITE_MONO { let coverage = textureLoad(mono_atlas, texel, 0).r; return premultiply(to_output(sprite.color)) * coverage; } @@ -188,6 +213,15 @@ fn fs_sprite(input: SpriteVarying) -> @location(0) vec4 { return color * sprite.color.a; } +// Converts a premultiplied sRGB color for the output, keeping it +// premultiplied. +fn premultiplied_to_output(color: vec4) -> vec4 { + if globals.linear_output == 0u || color.a == 0.0 { + return color; + } + return premultiply(to_output(vec4(color.rgb / color.a, color.a))); +} + // Moves `position` inside `bounds` (left, top, right, bottom). fn clip(position: vec2, bounds: vec4) -> vec2 { return clamp(position, bounds.xy, bounds.zw); diff --git a/crates/shikra/src/style.rs b/crates/shikra/src/style.rs index 7f93cda..c26fe61 100644 --- a/crates/shikra/src/style.rs +++ b/crates/shikra/src/style.rs @@ -92,6 +92,9 @@ pub struct Style { pub size: Size>, pub min_size: Size>, pub max_size: Size>, + /// Width divided by height, which the size follows when only one of its + /// dimensions is set. + pub aspect_ratio: Option, pub margin: Edges>, pub padding: Edges>, pub border_widths: Edges>, @@ -131,6 +134,7 @@ macro_rules! for_each_field { $f!(size.width, size.height); $f!(min_size.width, min_size.height); $f!(max_size.width, max_size.height); + $f!(aspect_ratio); $f!(margin.top, margin.right, margin.bottom, margin.left); $f!(padding.top, padding.right, padding.bottom, padding.left); $f!( @@ -657,6 +661,13 @@ impl Style { length_method!(max_w: Length => max_size.width); length_method!(max_h: Length => max_size.height); + /// Sets the width divided by the height, which the size follows when only + /// one of its dimensions is set. + pub fn aspect_ratio(mut self, ratio: f32) -> Self { + self.aspect_ratio = Some(ratio); + self + } + length_method!(inset: Length => inset.top, inset.right, inset.bottom, inset.left); length_method!(inset_x: Length => inset.left, inset.right); length_method!(inset_y: Length => inset.top, inset.bottom); diff --git a/crates/shikra/src/window.rs b/crates/shikra/src/window.rs index 54f84ac..bf85c6a 100644 --- a/crates/shikra/src/window.rs +++ b/crates/shikra/src/window.rs @@ -12,10 +12,10 @@ use anyhow::{Context as _, Result}; use crate::atlas::{AtlasKind, AtlasTile}; use crate::layout::LayoutEngine; -use crate::scene::{Quad, Scene, Sprite}; +use crate::scene::{Quad, Scene, Sprite, SpriteKind}; use crate::text::{TextLayout, TextLayoutCache}; use crate::{ - AnyView, App, AppContext, AvailableSpace, Bounds, Context, Corners, Edges, EntityId, + AnyView, App, AppContext, AvailableSpace, Bounds, Context, Corners, Edges, EntityId, Image, IntoElement, LayoutId, Point, Rgba, SharedString, Size, Style, TextStyle, TextStyleRefinement, TextSystem, px, size, }; @@ -244,13 +244,51 @@ impl Window { origin: [origin.x, origin.y], size, atlas_origin: [tile.origin[0] as f32, tile.origin[1] as f32], - is_color: (tile.kind == AtlasKind::Color) as u32, - _pad: 0, + atlas_size: size, + kind: match tile.kind { + AtlasKind::Mono => SpriteKind::Mono, + AtlasKind::Color => SpriteKind::Color, + AtlasKind::Image => unreachable!("glyphs aren't in the image atlas"), + } as u32, + _pad: [0; 3], color: [color.r, color.g, color.b, color.a], clip, }); } + /// Paints an image, stretched to fill `bounds`, snapping its edges to + /// device pixels. + pub fn paint_image(&mut self, bounds: Bounds, image: &Image, cx: &mut App) { + let scale = self.scale_factor; + let left = (bounds.origin.x * scale).round(); + let top = (bounds.origin.y * scale).round(); + let right = (bounds.right() * scale).round(); + let bottom = (bounds.bottom() * scale).round(); + let clip = self.device_content_mask(); + if right <= left + || bottom <= top + || image.width() == 0 + || image.height() == 0 + || !overlaps([left, top, right, bottom], clip) + { + return; + } + + let Some(atlas_origin) = cx.image_atlas.get_or_insert(image) else { + return; + }; + self.scene.push_sprite(Sprite { + origin: [left, top], + size: [right - left, bottom - top], + atlas_origin: [atlas_origin[0] as f32, atlas_origin[1] as f32], + atlas_size: [image.width() as f32, image.height() as f32], + kind: SpriteKind::Image as u32, + _pad: [0; 3], + color: [1., 1., 1., 1.], + clip, + }); + } + /// The [content mask](Self::content_mask) in device pixels, as left, top, /// right, and bottom edges, snapped like quads are. fn device_content_mask(&self) -> [f32; 4] {