diff --git a/Cargo.lock b/Cargo.lock index 9d6d06f..3c56109 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,37 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -882,12 +913,9 @@ dependencies = [ name = "fvi" version = "0.1.0" dependencies = [ - "bytemuck", "fvi-core", "fvi-render", - "tokio", "tracing", - "tracing-subscriber", "winit", ] @@ -905,9 +933,12 @@ version = "0.1.0" dependencies = [ "bytemuck", "etagere", + "fontdb", "fvi-core", + "harfrust", "hashbrown 0.17.1", "nalgebra", + "swash", "tracing", "wgpu", "wgsl_bindgen", @@ -1038,6 +1069,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1199,12 +1243,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.189" @@ -1436,15 +1474,6 @@ dependencies = [ "jni-sys 0.3.1", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num-bigint" version = "0.4.8" @@ -2180,6 +2209,28 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2242,6 +2293,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2393,15 +2450,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -2436,6 +2484,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -2546,6 +2604,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + [[package]] name = "syn" version = "2.0.119" @@ -2630,15 +2699,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - [[package]] name = "tiny-skia" version = "0.11.4" @@ -2675,13 +2735,10 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.53.1" +name = "tinyvec" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "pin-project-lite", -] +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "toml_datetime" @@ -2742,32 +2799,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", ] [[package]] @@ -2818,12 +2849,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "version_check" version = "0.9.5" @@ -3623,6 +3648,18 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.8.57" diff --git a/Cargo.toml b/Cargo.toml index 227e4fc..46207b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,11 @@ fvi-render = { path = "crates/render" } bytemuck = "1.25" etagere = "0.3" +fontdb = "0.24" hashbrown = "0.17" +harfrust = "0.13" nalgebra = "0.35" -tokio = { version = "1", features = ["rt", "sync"] } +swash = "0.2" tracing = "0.1" tracing-subscriber = "0.3" wgpu = "30.0" diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 0bd49c6..5d2f4ce 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -9,14 +9,8 @@ license.workspace = true fvi-core.workspace = true fvi-render.workspace = true -bytemuck.workspace = true -tokio.workspace = true tracing.workspace = true -tracing-subscriber.workspace = true winit.workspace = true -[target.'cfg(not(target_family = "wasm"))'.dependencies] -tokio = { workspace = true, features = ["rt-multi-thread"] } - [lints] workspace = true diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 08f7afc..3976c02 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -10,8 +10,11 @@ fvi-core.workspace = true bytemuck.workspace = true etagere.workspace = true +fontdb.workspace = true hashbrown.workspace = true +harfrust.workspace = true nalgebra.workspace = true +swash.workspace = true tracing.workspace = true wgpu.workspace = true winit.workspace = true diff --git a/crates/render/src/cache/mod.rs b/crates/render/src/cache/mod.rs index 6001afe..0a2e993 100644 --- a/crates/render/src/cache/mod.rs +++ b/crates/render/src/cache/mod.rs @@ -1,4 +1,4 @@ -use hashbrown::hash_table::OccupiedEntry; +use hashbrown::hash_table::{Entry, OccupiedEntry}; use hashbrown::{DefaultHashBuilder, HashTable}; use std::hash::{BuildHasher, Hash}; @@ -39,7 +39,7 @@ impl Segment { } } - pub fn is_sweep_required(&self) -> bool { + pub const fn is_sweep_required(&self) -> bool { self.items.len() > self.max_len } } @@ -68,10 +68,56 @@ impl Cache { } pub fn clear(&mut self) { - self.segments.clear(); + for segment in &mut self.segments { + segment.items.clear(); + } self.table.clear(); } + fn try_fetch_entry( + &mut self, + key: K, + default: impl FnOnce(&K) -> Result, + ) -> Result { + let hash = self.hasher.hash_one(&key); + + let entry = self.table.entry( + hash, + |&other| { + let segment = &self.segments[other.loc.segment as usize]; + segment.items[other.loc.item as usize].key == key + }, + |old| old.hash, + ); + + let &entry = match entry { + Entry::Occupied(entry) => entry, + Entry::Vacant(vacant) => { + let value = default(&key)?; + + let index = self.segments[0].items.len(); + self.segments[0].items.push(Item { + epoch: self.now, + key, + value, + }); + + vacant.insert(TableEntry { + loc: ItemLoc { + segment: 0, + item: index.try_into().expect("insufficient address range"), + }, + hash, + }) + } + } + .get(); + + self.segments[entry.loc.segment as usize].items[entry.loc.item as usize].epoch = self.now; + + Ok(entry) + } + fn fetch_entry(&mut self, key: K, default: impl FnOnce(&K) -> V) -> TableEntry { let hash = self.hasher.hash_one(&key); @@ -148,6 +194,15 @@ impl Cache { (occupied, removed) } + pub fn try_fetch( + &mut self, + key: K, + default: impl FnOnce(&K) -> Result, + ) -> Result<&V, E> { + let entry = self.try_fetch_entry(key, default)?; + Ok(&self.segments[entry.loc.segment as usize].items[entry.loc.item as usize].value) + } + pub fn fetch(&mut self, key: K, default: impl FnOnce(&K) -> V) -> &V { let entry = self.fetch_entry(key, default); &self.segments[entry.loc.segment as usize].items[entry.loc.item as usize].value diff --git a/crates/render/src/context/adapter.rs b/crates/render/src/context/adapter.rs index 0f41aa7..7495d7e 100644 --- a/crates/render/src/context/adapter.rs +++ b/crates/render/src/context/adapter.rs @@ -8,11 +8,12 @@ fn is_compatible(adapter: &Adapter, surface: &Surface, config: &RenderConfig) -> let mut is_compat = true; let limits = adapter.limits(); - let limits_mask = config.limits.clone().or_better_values_from(&limits); - limits.check_limits_with_fail_fn(&limits_mask, false, |limit_name, actual, req| { - is_compat = false; - trace!("{name}: {limit_name}={actual}, {req} required"); - }); + config + .limits + .check_limits_with_fail_fn(&limits, false, |limit_name, req, actual| { + is_compat = false; + trace!("{name}: {limit_name}={actual}, {req} required"); + }); let feat_diff = config.features.difference(adapter.features()); if !feat_diff.is_empty() { diff --git a/crates/render/src/context/mod.rs b/crates/render/src/context/mod.rs index 280c69d..cb7155d 100644 --- a/crates/render/src/context/mod.rs +++ b/crates/render/src/context/mod.rs @@ -8,7 +8,7 @@ use winit::dpi::PhysicalSize; use winit::window::Window; use crate::gfx::blit; -use crate::text::AtlasSet; +use crate::text::GlyphContext; use crate::{Frame, InitError, RenderError, label}; use crate::{FrameDescriptor, config::*}; @@ -33,7 +33,7 @@ pub struct RenderContext { surface: Surface<'static>, pub(crate) device: RenderDevice, pub(crate) texture_map: TextureMap, - pub(crate) atlas_set: AtlasSet, + pub(crate) glyph_cx: GlyphContext, format: TextureFormat, pipeline: RenderPipeline, } @@ -103,11 +103,7 @@ impl RenderContext { cache: None, }); - #[expect( - clippy::cast_possible_truncation, - reason = "that much precision is unnecessary for glyph rasterization" - )] - let atlas_set = AtlasSet::new(window.scale_factor() as f32); + let glyph_cx = GlyphContext::new(); let this = Self { config, @@ -118,7 +114,7 @@ impl RenderContext { surface, device, texture_map, - atlas_set, + glyph_cx, format, pipeline, }; @@ -206,11 +202,6 @@ impl RenderContext { self.configure_surface(); } - /// Rescales as given. - pub fn rescale(&mut self, scale: f32) { - self.atlas_set.rescale(scale); - } - /// Creates a new [`Frame`] with its descriptor. pub fn create_frame(&self, desc: &FrameDescriptor) -> Frame { Frame::new(&self.device, desc) @@ -349,6 +340,6 @@ impl RenderContext { /// Advances a tick to manage resources. pub fn update(&mut self) { - self.atlas_set.update(); + self.glyph_cx.update(); } } diff --git a/crates/render/src/context/texture.rs b/crates/render/src/context/texture.rs index 8c2c298..50c7a53 100644 --- a/crates/render/src/context/texture.rs +++ b/crates/render/src/context/texture.rs @@ -7,7 +7,6 @@ use wgpu::util::*; use wgpu::*; use crate::label; -use crate::text::AtlasId; /// A key for internal textures. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -23,8 +22,6 @@ impl InternalTextureId { pub enum TextureId { /// An internal texture. Internal(InternalTextureId), - /// An atlas texture. - Atlas(AtlasId), } impl From for TextureId { @@ -33,12 +30,6 @@ impl From for TextureId { } } -impl From for TextureId { - fn from(value: AtlasId) -> Self { - Self::Atlas(value) - } -} - /// An opaque reference to texture. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct TextureRef(u32); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4b8fd0e..a76acaa 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod frame; mod id; mod raw; pub mod text; +mod theme; mod unit; pub(crate) use cache::*; @@ -17,6 +18,7 @@ pub use canvas::*; pub use error::*; pub use frame::*; pub use id::*; +pub use theme::*; pub use unit::*; #[path = "gfx.g.rs"] diff --git a/crates/render/src/text/atlas.rs b/crates/render/src/text/atlas.rs new file mode 100644 index 0000000..0135466 --- /dev/null +++ b/crates/render/src/text/atlas.rs @@ -0,0 +1,180 @@ +use etagere::euclid::Size2D; +use etagere::{AllocId, AtlasAllocator}; +use std::fmt::Debug; +use tracing::warn; +use wgpu::*; + +use crate::label; +use crate::text::Image; + +const PAGE_SIZE: u32 = 2048; + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct AtlasView { + page: usize, + id: AllocId, + size: [u32; 2], +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct AtlasPart { + pub tex: TextureView, + pub pos: [u32; 2], + pub size: [u32; 2], +} + +#[derive(Debug)] +pub struct Atlas { + size: u32, + colored: bool, + pages: Vec, +} + +impl Atlas { + pub const fn new(colored: bool) -> Self { + Self::with_size(PAGE_SIZE, colored) + } + + const fn with_size(size: u32, colored: bool) -> Self { + Self { + size, + colored, + pages: Vec::new(), + } + } + + pub fn read(&self, view: &AtlasView) -> AtlasPart { + let rect = self.pages[view.page].alloc.get(view.id); + + let size = rect.size().to_u32().to_array(); + + debug_assert!(size[0] >= view.size[0]); + debug_assert!(size[1] >= view.size[1]); + + AtlasPart { + tex: self.pages[view.page] + .tex + .create_view(&TextureViewDescriptor { + usage: Some(TextureUsages::TEXTURE_BINDING), + ..Default::default() + }), + pos: rect.min.to_u32().to_array(), + size: view.size, + } + } + + pub fn store(&mut self, device: &Device, queue: &Queue, image: &Image) -> AtlasView { + assert!(!image.is_empty(), "cannot store empty image in atlas"); + + for (i, page) in self.pages.iter_mut().enumerate() { + if let Some(alloc) = page.store(queue, image) { + return AtlasView { + page: i, + id: alloc.id, + size: [image.width, image.height], + }; + } + } + + let size = self.size.max(image.width).max(image.height); + if size != self.size { + warn!( + "too large image requested ({}x{}; {}x{} preferred)", + image.width, image.height, self.size, self.size, + ); + } + + let page_id = self.pages.len(); + let page = self.pages.push_mut(Page::new(device, size, self.colored)); + let alloc = page.store(queue, image).unwrap(); + + AtlasView { + page: page_id, + id: alloc.id, + size: [image.width, image.height], + } + } + + #[expect(clippy::needless_pass_by_value, reason = "intended lifetime")] + pub fn remove(&mut self, view: AtlasView) { + self.pages[view.page].remove(view.id); + } +} + +#[derive(Debug, PartialEq, Eq, Hash)] +struct PageAlloc { + id: AllocId, +} + +struct Page { + tex: Texture, + view: TextureView, + alloc: AtlasAllocator, +} + +impl Page { + pub fn new(device: &Device, size: u32, colored: bool) -> Self { + let tex = device.create_texture(&TextureDescriptor { + label: label!("tex"), + size: Extent3d { + width: size, + height: size, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: if colored { + TextureFormat::Rgba8Unorm + } else { + TextureFormat::R8Unorm + }, + usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let view = tex.create_view(&TextureViewDescriptor { + label: label!("tex"), + ..Default::default() + }); + + let alloc = AtlasAllocator::new(Size2D::new(size, size).to_i32()); + + Self { tex, view, alloc } + } + + pub fn store(&mut self, queue: &Queue, image: &Image) -> Option { + let alloc = self + .alloc + .allocate(Size2D::new(image.width, image.height).to_i32())?; + + let pos = alloc.rectangle.min.to_u32(); + + image.write_to( + queue, + &self.tex, + Origin3d { + x: pos.x, + y: pos.y, + z: 0, + }, + ); + + Some(PageAlloc { id: alloc.id }) + } + + pub fn remove(&mut self, alloc: AllocId) { + self.alloc.deallocate(alloc); + } +} + +impl Debug for Page { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Atlas") + .field("tex", &self.tex) + .field("view", &self.view) + .field("used", &self.alloc.allocated_space()) + .field("free", &self.alloc.free_space()) + .finish_non_exhaustive() + } +} diff --git a/crates/render/src/text/cache.rs b/crates/render/src/text/cache.rs new file mode 100644 index 0000000..4958137 --- /dev/null +++ b/crates/render/src/text/cache.rs @@ -0,0 +1,157 @@ +use wgpu::*; + +use crate::text::*; +use crate::{Cache, Theme}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlyphReq { + pub glyph: u16, + pub x_fract: Unit, + pub y_fract: Unit, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlyphStyle { + pub size: Unit, + pub weight: Unit, + pub italic: bool, + pub hint: bool, + pub theme: Option, +} + +#[derive(Debug, PartialEq, Eq, Hash)] +struct GlyphKey { + font: FontId, + req: GlyphReq, + style: GlyphStyle, +} + +#[derive(Debug)] +struct GlyphData { + colored: bool, + view: AtlasView, +} + +#[derive(Debug)] +pub struct GlyphCache { + cache: Cache>, + atlas: Atlas, + colored_atlas: Atlas, + rcx: RasterContext, +} + +impl GlyphCache { + pub fn new() -> Self { + Self { + cache: Cache::new(), + atlas: Atlas::new(false), + colored_atlas: Atlas::new(true), + rcx: RasterContext::new(), + } + } + + pub fn fetch_batch( + &mut self, + device: &Device, + queue: &Queue, + font_map: &FontMap, + font_id: FontId, + style: GlyphStyle, + reqs: impl Iterator, + ) -> Result, RasterizationError> { + let mut buf = Vec::new(); + self.fetch_batch_into(device, queue, font_map, font_id, style, &mut buf, reqs)?; + Ok(buf) + } + + pub fn fetch_batch_into( + &mut self, + device: &Device, + queue: &Queue, + font_map: &FontMap, + font_id: FontId, + style: GlyphStyle, + buf: &mut Vec, + reqs: impl Iterator, + ) -> Result<(), RasterizationError> { + font_map.with(font_id, |font| { + self.fetch_batch_into_unchecked(device, queue, font, font_id, style, buf, reqs) + }) + } + + fn fetch_batch_into_unchecked( + &mut self, + device: &Device, + queue: &Queue, + font: &FontRef, + font_id: FontId, + style: GlyphStyle, + buf: &mut Vec, + reqs: impl Iterator, + ) -> Result<(), RasterizationError> { + buf.reserve(reqs.size_hint().0); + + let mut scope = self.rcx.open(RasterStyle { + font: font.clone(), + size: style.size, + weight: style.weight, + italic: style.italic, + hint: style.hint, + theme: style.theme, + }); + + for req in reqs { + let data = self.cache.try_fetch( + GlyphKey { + font: font_id, + req, + style, + }, + |key| { + let image = scope.rasterize(key.req.glyph, key.req.x_fract, key.req.y_fract)?; + + if image.is_empty() { + return Ok(None); + } + + let view = if image.colored { + self.colored_atlas.store(device, queue, &image) + } else { + self.atlas.store(device, queue, &image) + }; + + Ok(Some(GlyphData { + colored: image.colored, + view, + })) + }, + )?; + + if let Some(data) = data { + let part = if data.colored { + self.colored_atlas.read(&data.view) + } else { + self.atlas.read(&data.view) + }; + + buf.push(part); + } + } + + Ok(()) + } + + pub fn update(&mut self) { + for (_, data) in self.cache.sweep() { + let Some(data) = data else { continue }; + + if data.colored { + self.colored_atlas.remove(data.view); + } else { + self.atlas.remove(data.view); + } + } + + self.cache.tick(); + } +} diff --git a/crates/render/src/text/error.rs b/crates/render/src/text/error.rs index eab36fa..be2e603 100644 --- a/crates/render/src/text/error.rs +++ b/crates/render/src/text/error.rs @@ -3,6 +3,49 @@ use std::fmt::Display; use crate::context::InsertionError; +/// An error during text layout. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LayoutError { + /// A dimension is negative or non-finite, or the font scale is not positive. + InvalidDimensions, + /// The input exceeds the shaping engine's byte-offset limit. + TextTooLong, + /// Text requires bidirectional resolution, which is not supported yet. + UnsupportedBidi, + /// A style changes inside a shaping cluster at this byte offset. + StyleInsideCluster(usize), + /// None of the style's fonts are available for this original byte range. + MissingFont(std::ops::Range), +} + +impl Display for LayoutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidDimensions => write!(f, "invalid text layout dimensions"), + Self::TextTooLong => write!(f, "text exceeds the shaping byte-offset limit"), + Self::UnsupportedBidi => write!(f, "bidirectional text layout is not supported"), + Self::StyleInsideCluster(offset) => { + write!(f, "style changes inside a cluster at byte {offset}") + } + Self::MissingFont(range) => { + write!(f, "no usable font for bytes {}..{}", range.start, range.end) + } + } + } +} + +impl Error for LayoutError {} + +/// An error during rasterization. +#[derive(Debug)] +pub struct RasterizationError; + +impl Display for RasterizationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("failed to rasterize glyph") + } +} + /// An error from atlas. #[derive(Debug)] pub enum AtlasError { diff --git a/crates/render/src/text/font.rs b/crates/render/src/text/font.rs index fb262f9..b2c95ff 100644 --- a/crates/render/src/text/font.rs +++ b/crates/render/src/text/font.rs @@ -1,9 +1,186 @@ -use std::rc::Rc; +use std::fmt::Debug; +use std::sync::Arc; +use swash::text::cluster::{CharCluster, Status}; -/// A reference of font. +#[expect( + clippy::redundant_pub_crate, + reason = "it's crate-scoped although parent is pub" +)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct FontId(fontdb::ID); + +#[expect( + clippy::redundant_pub_crate, + reason = "it's crate-scoped although parent is pub" +)] +#[derive(Clone)] +pub(crate) struct FontRef<'a> { + harfrust: harfrust::FontRef<'a>, + swash: swash::FontRef<'a>, +} + +impl<'a> FontRef<'a> { + pub fn from_index(data: &'a [u8], index: u32) -> Option { + Some(Self { + harfrust: harfrust::FontRef::from_index(data, index).ok()?, + swash: swash::FontRef::from_index(data, index as usize)?, + }) + } +} + +impl Debug for FontRef<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FontRef") + .field("data", &self.swash.data) + .field("offset", &self.swash.offset) + .finish_non_exhaustive() + } +} + +impl<'a> AsRef> for FontRef<'a> { + fn as_ref(&self) -> &harfrust::FontRef<'a> { + &self.harfrust + } +} + +impl<'a> AsRef> for FontRef<'a> { + fn as_ref(&self) -> &swash::FontRef<'a> { + &self.swash + } +} + +impl<'a> From> for harfrust::FontRef<'a> { + fn from(value: FontRef<'a>) -> Self { + value.harfrust + } +} + +impl<'a> From> for swash::FontRef<'a> { + fn from(value: FontRef<'a>) -> Self { + value.swash + } +} + +/// A set of fonts. #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct FontRef(Rc); +pub struct Fonts { + fonts: Arc<[fontdb::ID]>, +} + +impl Fonts { + pub(crate) fn resolve(&self, map: &FontMap, cluster: &mut CharCluster) -> Option { + let mut best = None; + for &font in &*self.fonts { + match map + .db + .with_face_data(font, |data, index| { + let Some(font) = swash::FontRef::from_index(data, index as usize) else { + return Status::Discard; + }; + + let charmap = font.charmap(); + cluster.map(|ch| charmap.map(ch)) + }) + .unwrap_or(Status::Discard) + { + Status::Complete => return Some(FontId(font)), + Status::Keep => best = Some(FontId(font)), + Status::Discard => {} + } + } + + best + } +} + +/// A style of the font. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct FontStyle { + /// Font weight. + pub weight: u16, + /// Whether to set italic. + pub italic: bool, +} + +/// A descriptor of [`Fonts`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct FontsDescriptor<'a> { + /// Names of fonts. + pub names: &'a [&'a str], + /// Font style. + pub style: FontStyle, +} + +impl Fonts { + fn new(map: &FontMap, desc: &FontsDescriptor) -> Self { + let mut fonts = Vec::with_capacity(desc.names.len()); + for name in desc.names { + #[rustfmt::skip] + let family = match *name { + "serif" => fontdb::Family::Serif, + "sans-serif" => fontdb::Family::SansSerif, + "monospace" => fontdb::Family::Monospace, + "cursive" => fontdb::Family::Cursive, + "fantasy" => fontdb::Family::Fantasy, + name => fontdb::Family::Name(name), + }; + + let Some(font_id) = map.db.query(&fontdb::Query { + families: &[family], + weight: fontdb::Weight(desc.style.weight), + stretch: fontdb::Stretch::Normal, + style: if desc.style.italic { + fontdb::Style::Italic + } else { + fontdb::Style::Normal + }, + }) else { + continue; + }; + + fonts.push(font_id); + } + + Self { + fonts: fonts.into_boxed_slice().into(), + } + } +} + +/// A map of available fonts. +#[derive(Debug)] +pub struct FontMap { + db: fontdb::Database, +} + +impl FontMap { + pub(crate) fn new() -> Self { + Self { + db: fontdb::Database::new(), + } + } + + /// Resolves a [`FontsDescriptor`] to [`Fonts`]. + #[must_use] + pub fn resolve(&self, desc: &FontsDescriptor) -> Fonts { + Fonts::new(self, desc) + } + + pub(crate) fn with(&self, id: FontId, f: F) -> T + where + F: FnOnce(&FontRef) -> T, + { + self.db + .with_face_data(id.0, |data, index| { + let font_ref = FontRef::from_index(data, index).unwrap(); + f(&font_ref) + }) + .expect("id is not from self") + } +} -/// A font. -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct Font {} +impl Default for FontMap { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/render/src/text/image.rs b/crates/render/src/text/image.rs new file mode 100644 index 0000000..bf232fe --- /dev/null +++ b/crates/render/src/text/image.rs @@ -0,0 +1,74 @@ +use swash::scale::image::Content; +use wgpu::*; + +use super::SwashImage; + +#[derive(Clone, Debug)] +pub struct Image<'a> { + pub colored: bool, + pub width: u32, + pub height: u32, + pub data: &'a [u8], +} + +impl<'a> Image<'a> { + pub fn from(value: &'a SwashImage) -> Self { + Self { + colored: match value.content { + Content::Mask => false, + Content::SubpixelMask | Content::Color => true, + }, + width: value.placement.width, + height: value.placement.height, + data: &value.data, + } + } + + #[must_use] + pub const fn is_empty(&self) -> bool { + self.width == 0 || self.height == 0 || self.data.is_empty() + } + + const fn px_size(&self) -> u32 { + if self.colored { 4 } else { 1 } + } + + pub fn write_to(&self, queue: &Queue, dst: &Texture, origin: Origin3d) { + debug_assert_eq!( + (self.width * self.height * self.px_size()) as usize, + self.data.len() + ); + debug_assert_eq!( + if self.colored { + TextureFormat::Rgba8Unorm + } else { + TextureFormat::R8Unorm + }, + dst.format() + ); + + if self.is_empty() { + return; + } + + queue.write_texture( + TexelCopyTextureInfo { + texture: dst, + mip_level: 0, + origin, + aspect: TextureAspect::All, + }, + self.data, + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(self.width * self.px_size()), + rows_per_image: Some(self.height), + }, + Extent3d { + width: self.width, + height: self.height, + depth_or_array_layers: 1, + }, + ); + } +} diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs new file mode 100644 index 0000000..af475ca --- /dev/null +++ b/crates/render/src/text/layout.rs @@ -0,0 +1,311 @@ +use std::ops::Range; + +use swash::text::cluster::Boundary; + +use super::shape::{self, Cluster, Fragment, Kind, PositionedRun}; +use super::{FontMap, LayoutError, Text}; +use crate::Dp; + +/// Additional line breaking within a paragraph. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Wrap { + /// Only mandatory breaks produce new lines. + None, + /// Break at Unicode line opportunities; oversized words may overflow. + Word, + /// Also break oversized words at shaping cluster boundaries. + #[default] + WordOrCluster, +} + +/// Horizontal alignment of each line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Alignment { + /// Align at the left edge. + #[default] + Start, + /// Center within the available width. + Center, + /// Align at the right edge. + End, +} + +/// Options for CPU text layout, independent of rasterization scale. +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutDescriptor { + /// Available width. + pub width: Option, + /// Soft wrapping behavior. + pub wrap: Wrap, + /// Layout alignment. + pub alignment: Alignment, + /// Minimum line height. + pub min_line_height: Option, + /// Accessibility font scale. See also [`Sp::to_dp`](`crate::Sp::to_dp`). + pub font_scale: f32, +} + +impl Default for LayoutDescriptor { + fn default() -> Self { + Self { + width: None, + wrap: Wrap::WordOrCluster, + alignment: Alignment::Start, + min_line_height: None, + font_scale: 1.0, + } + } +} + +/// The layout box. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LayoutBounds { + /// Alignment width. + pub width: Dp, + /// Sum of the line heights. + pub height: Dp, +} + +/// Metrics for one line. +#[derive(Clone, Debug, PartialEq)] +pub struct LineMetrics { + /// Original UTF-8 byte range. + pub range: Range, + /// Original byte range of the mandatory break. + pub break_range: Option>, + /// Horizontal alignment offset. + pub x: Dp, + /// Top of the line box. + pub y: Dp, + /// Advance width excluding trailing breakable spaces. + pub width: Dp, + /// Full advance width. + pub advance: Dp, + /// Full line height. + pub height: Dp, + /// Baseline position relative to the layout origin. + pub baseline: Dp, + /// Maximum font ascent among the participating runs. + pub ascent: Dp, + /// Maximum font descent among the participating runs. + pub descent: Dp, + /// Maximum font leading among the participating runs. + pub leading: Dp, +} + +/// An owned layout. +#[derive(Clone, Debug)] +pub struct Layout { + /// Logical bounds. + pub bounds: LayoutBounds, + /// Lines in source order. + pub lines: Vec, + pub(crate) runs: Vec, +} + +pub(super) fn layout( + text: &Text, + fonts: &FontMap, + desc: &LayoutDescriptor, +) -> Result { + validate(text, desc)?; + let clusters = shape::prepare(text, fonts)?; + let mut layout = Layout { + bounds: LayoutBounds { + width: Dp(0.0), + height: Dp(0.0), + }, + lines: Vec::new(), + runs: Vec::new(), + }; + let mut run_ranges = Vec::new(); + let mut start = 0; + + while start < clusters.len() { + let end = clusters[start..] + .iter() + .position(|cluster| cluster.kind == Kind::Break) + .map_or(clusters.len(), |offset| start + offset); + let (mut next, mut fragment) = choose_line(text, fonts, &clusters, start..end, desc)?; + let mut break_range = None; + + if next == end + && let Some(hard_break) = clusters.get(end) + { + break_range = Some(hard_break.range.clone()); + + let metrics = shape::fragment(text, fonts, &clusters[end..=end], desc.font_scale)?; + fragment.ascent = fragment.ascent.max(metrics.ascent); + fragment.descent = fragment.descent.max(metrics.descent); + fragment.leading = fragment.leading.max(metrics.leading); + next += 1; + } + + let content_end = break_range + .as_ref() + .map_or_else(|| clusters[next - 1].range.end, |range| range.start); + run_ranges.push(append_line( + &mut layout, + desc, + fragment, + clusters[start].range.start..content_end, + break_range, + )); + start = next; + } + + if let Some(last) = clusters.last() + && last.kind == Kind::Break + { + let fragment = shape::fragment(text, fonts, std::slice::from_ref(last), desc.font_scale)?; + run_ranges.push(append_line( + &mut layout, + desc, + fragment, + text.string.len()..text.string.len(), + None, + )); + } + + let measured = layout + .lines + .iter() + .map(|line| line.width.0) + .fold(0.0_f32, f32::max); + let width = desc.width.map_or(measured, |width| width.0); + if !layout.lines.is_empty() { + layout.bounds.width = Dp(width.max(measured)); + } + + for (line, runs) in layout.lines.iter_mut().zip(run_ranges) { + let extra = (width - line.width.0).max(0.0); + line.x = Dp(match desc.alignment { + Alignment::Start => 0.0, + Alignment::Center => extra * 0.5, + Alignment::End => extra, + }); + + for run in &mut layout.runs[runs] { + for glyph in &mut run.glyphs { + glyph.x += line.x; + glyph.y += line.baseline; + } + } + } + + Ok(layout) +} + +fn validate(text: &Text, desc: &LayoutDescriptor) -> Result<(), LayoutError> { + let nonnegative = |value: f32| value.is_finite() && value >= 0.0; + if desc.width.is_some_and(|width| !nonnegative(width.0)) + || desc + .min_line_height + .is_some_and(|height| !nonnegative(height.0)) + || !desc.font_scale.is_finite() + || desc.font_scale <= 0.0 + || text.spans.iter().any(|span| { + !nonnegative(span.style.size.0) + || !nonnegative(span.style.size.to_dp(desc.font_scale).0) + }) + { + return Err(LayoutError::InvalidDimensions); + } + + Ok(()) +} + +fn choose_line( + text: &Text, + fonts: &FontMap, + clusters: &[Cluster], + range: Range, + desc: &LayoutDescriptor, +) -> Result<(usize, Fragment), LayoutError> { + let shape = |end| shape::fragment(text, fonts, &clusters[range.start..end], desc.font_scale); + let Some(width) = desc.width.filter(|_| desc.wrap != Wrap::None) else { + return Ok((range.end, shape(range.end)?)); + }; + if range.is_empty() { + return Ok((range.end, Fragment::default())); + } + + let mut best = None; + let breaks = (range.start + 1..range.end) + .filter(|&end| clusters[end].boundary >= Boundary::Line) + .chain(std::iter::once(range.end)); + + for end in breaks { + let fragment = shape(end)?; + if fragment.width <= width.0 { + best = Some((end, fragment)); + continue; + } + if let Some(best) = best { + return Ok(best); + } + if desc.wrap == Wrap::Word { + return Ok((end, fragment)); + } + + let mut fallback = None; + for split in range.start + 1..=end { + let mut candidate = shape(split)?; + if candidate.width > width.0 { + if let Some(fallback) = fallback { + return Ok(fallback); + } + + let mut next = split; + while next < end && clusters[next].space { + next += 1; + } + if next != split { + candidate = shape(next)?; + } + + return Ok((next, candidate)); + } + + fallback = Some((split, candidate)); + } + + return Ok(fallback.unwrap()); + } + + Ok(best.unwrap()) +} + +fn append_line( + layout: &mut Layout, + desc: &LayoutDescriptor, + fragment: Fragment, + range: Range, + break_range: Option>, +) -> Range { + let natural = fragment.ascent + fragment.descent + fragment.leading; + let height = desc + .min_line_height + .map_or(natural, |height| height.0.max(natural)); + let y = layout.bounds.height; + let baseline = (fragment.leading + height - natural).mul_add(0.5, y.0 + fragment.ascent); + + layout.lines.push(LineMetrics { + range, + break_range, + x: Dp(0.0), + y, + width: Dp(fragment.width), + advance: Dp(fragment.advance), + height: Dp(height), + baseline: Dp(baseline), + ascent: Dp(fragment.ascent), + descent: Dp(fragment.descent), + leading: Dp(fragment.leading), + }); + layout.bounds.height += Dp(height); + + let start = layout.runs.len(); + layout.runs.extend(fragment.runs); + start..layout.runs.len() +} diff --git a/crates/render/src/text/mod.rs b/crates/render/src/text/mod.rs index 6a19821..dd48e67 100644 --- a/crates/render/src/text/mod.rs +++ b/crates/render/src/text/mod.rs @@ -1,276 +1,105 @@ //! A module for text system. -use etagere::{AllocId, AtlasAllocator}; -use std::fmt::Debug; -use tracing::warn; -use wgpu::*; - -use crate::context::{TextureMap, TextureRef}; -use crate::label; -use crate::{Cache, Dp}; - +mod atlas; +mod cache; mod error; mod font; +mod image; +mod layout; +mod raster; +mod shape; mod style; +mod unit; +use atlas::*; +use cache::*; pub use error::*; pub use font::*; +use image::*; +pub use layout::*; +use raster::*; pub use style::*; +use unit::*; -/// An identifier of a styled glyph. -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct GlyphKey { - id: u16, - style: TextStyle, -} - -/// A glyph. -#[derive(Clone, Debug, PartialEq)] -pub struct Glyph { - /// Atlas texture of glyph. - pub tex: TextureRef, - /// Origin coord on atlas texture, in px. - pub origin: [u32; 2], - /// Size of glyph, in dp. - pub size: [Dp; 2], - /// Offset of glyph from baseline, in dp. - pub off: [Dp; 2], - atlas: AtlasId, - alloc: AllocId, -} +use std::ops::Range; +use swash::scale::image::Image as SwashImage; -#[derive(Clone, Debug, PartialEq)] -struct GlyphData { - size: [u32; 2], - off: [f32; 2], - data: Vec, -} - -/// An identifier of an atlas. -#[repr(transparent)] -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct AtlasId(u32); - -struct Atlas { - id: AtlasId, - alloc: AtlasAllocator, - tex_ref: TextureRef, - tex: TextureView, +#[derive(Debug)] +pub(crate) struct GlyphContext { + cache: GlyphCache, + fonts: FontMap, } -impl Atlas { - pub fn new( - device: &Device, - tex_map: &mut TextureMap, - id: AtlasId, - size: [u32; 2], - ) -> Result { - let alloc = AtlasAllocator::new( - [ - size[0].try_into().expect("too large width"), - size[1].try_into().expect("too large height"), - ] - .into(), - ); - - let tex = device - .create_texture(&TextureDescriptor { - label: label!("tex"), - size: Extent3d { - width: size[0], - height: size[1], - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: TextureDimension::D2, - format: TextureFormat::Rgba8Unorm, - usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - .create_view(&TextureViewDescriptor::default()); - - let tex_ref = tex_map.insert(id.into(), tex.clone())?; - - Ok(Self { - id, - alloc, - tex_ref, - tex, - }) - } - - fn dealloc(&mut self, alloc: AllocId) { - self.alloc.deallocate(alloc); - } - - fn try_alloc(&mut self, scale: f32, data: &GlyphData) -> Option { - let size_signed = [ - data.size[0].try_into().expect("too large width"), - data.size[1].try_into().expect("too large height"), - ]; - - let alloc = self.alloc.allocate(size_signed.into())?; - let rect = alloc.rectangle.to_u32(); - Some(Glyph { - tex: self.tex_ref, - origin: rect.min.to_array(), - #[expect(clippy::cast_precision_loss, reason = "size may not exceed 2^23")] - size: *data - .size - .map(|px| Dp::from_px(px as f32, scale)) - .as_array::<2>() - .unwrap(), - off: *data - .off - .map(|px| Dp::from_px(px, scale)) - .as_array::<2>() - .unwrap(), - atlas: self.id, - alloc: alloc.id, - }) +impl GlyphContext { + pub fn new() -> Self { + Self { + cache: GlyphCache::new(), + fonts: FontMap::new(), + } } - fn clear(&mut self) { - self.alloc.clear(); + pub fn update(&mut self) { + self.cache.update(); } } -impl Debug for Atlas { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Atlas") - .field("id", &self.id) - .field("tex_ref", &self.tex_ref) - .field("tex", &self.tex) - .finish_non_exhaustive() - } +/// A text element. +#[derive(Clone, Debug)] +pub struct Text { + string: String, + spans: Vec, } -/// A set of glyph atlases. -#[derive(Debug)] -pub struct AtlasSet { - rasterizer: Rasterizer, - cache: Cache, - atlases: Vec, - atlas_size: [u32; 2], - scale: f32, +#[derive(Clone, Debug)] +struct Span { + range: Range, + style: Style, } -impl AtlasSet { - pub(crate) fn new(scale: f32) -> Self { +impl Text { + /// Creates a new [`Text`]. + #[must_use] + pub fn new() -> Self { Self { - rasterizer: Rasterizer::new(), - cache: Cache::new(), - atlases: Vec::new(), - atlas_size: [2048; 2], - scale, + string: String::new(), + spans: Vec::new(), } } - pub(crate) fn rescale(&mut self, scale: f32) { - if (scale - self.scale).abs() <= 0.01 { - warn!("atlas rescaling ignored"); + /// Appends text with the given style, merging adjacent equal styles. + pub fn push(&mut self, string: &str, style: &Style) { + if string.is_empty() { return; } - self.scale = scale; - self.cache.clear(); - - for atlas in &mut self.atlases { - atlas.clear(); + let start = self.string.len(); + self.string.push_str(string); + + if let Some(span) = self.spans.last_mut() + && span.style == *style + { + span.range.end = self.string.len(); + } else { + self.spans.push(Span { + range: start..self.string.len(), + style: style.clone(), + }); } } - /// Fetches a glyph from given key. - /// - /// # Panics - /// - /// This function may panic if any of conditions below met. - /// - /// - New texture failed to be allocated on device. - /// - Glyph is too big to be drawn on atlas. - pub fn fetch( - &mut self, - device: &Device, - queue: &Queue, - tex_map: &mut TextureMap, - key: GlyphKey, - ) -> &Glyph { - self.cache.fetch(key, |key| { - let data = self.rasterizer.rasterize(key); - - let mut opt = None; - for atlas in &mut self.atlases { - opt = atlas - .try_alloc(self.scale, &data) - .map(|glyph| (atlas.tex_ref, glyph)); - if opt.is_some() { - break; - } - } - let (tex_ref, glyph) = if let Some(pair) = opt { - pair - } else { - let id = AtlasId(self.atlases.len().try_into().expect("too many atlas")); - - let mut atlas = Atlas::new(device, tex_map, id, self.atlas_size) - .expect("couldn't allocate atlas"); - let tex_ref = atlas.tex_ref; - - let glyph = atlas - .try_alloc(self.scale, &data) - .expect("couldn't allocate glyph"); - self.atlases.push(atlas); - (tex_ref, glyph) - }; - - let tex = tex_map.get(tex_ref); - - queue.write_texture( - TexelCopyTextureInfo { - texture: tex.texture(), - mip_level: 0, - origin: Origin3d { - x: glyph.origin[0], - y: glyph.origin[1], - z: 0, - }, - aspect: TextureAspect::All, - }, - &data.data, - TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(data.size[0]), - rows_per_image: Some(data.size[1]), - }, - Extent3d { - width: data.size[0], - height: data.size[1], - depth_or_array_layers: 1, - }, - ); - - glyph - }) + /// Removes all text and styles, retaining allocated storage. + pub fn clear(&mut self) { + self.string.clear(); + self.spans.clear(); } - pub(crate) fn update(&mut self) { - for (_, glyph) in self.cache.sweep() { - self.atlases[glyph.atlas.0 as usize].dealloc(glyph.alloc); - } - self.cache.tick(); - } -} - -#[derive(Debug)] -struct Rasterizer {} - -impl Rasterizer { - pub fn new() -> Self { - todo!() - } - - fn rasterize(&mut self, key: &GlyphKey) -> GlyphData { - todo!() + /// Measures and positions left-to-right text in density independent pixels. + /// Unsupported characters use a replacement or missing-glyph symbol. + /// + /// # Errors + /// Returns an error for invalid dimensions, unavailable fonts, bidi text, or + /// style changes inside a shaping cluster. + pub fn layout(&self, fonts: &FontMap, desc: &LayoutDescriptor) -> Result { + layout::layout(self, fonts, desc) } } diff --git a/crates/render/src/text/raster.rs b/crates/render/src/text/raster.rs new file mode 100644 index 0000000..80b921a --- /dev/null +++ b/crates/render/src/text/raster.rs @@ -0,0 +1,115 @@ +use std::fmt::Debug; +use swash::Usability; +use swash::scale::{Render, ScaleContext, Scaler, Source, StrikeWith}; +use swash::zeno::Vector; + +use super::SwashImage; +use crate::Theme; +use crate::text::{FontRef, Image, RasterizationError, Unit}; + +#[derive(Clone, Debug)] +pub struct RasterStyle<'a> { + pub font: FontRef<'a>, + pub size: Unit, + pub weight: Unit, + pub italic: bool, + pub hint: bool, + pub theme: Option, +} + +pub struct RasterContext { + context: ScaleContext, + buffer: SwashImage, +} + +impl RasterContext { + pub fn new() -> Self { + Self { + context: ScaleContext::new(), + buffer: SwashImage::new(), + } + } + + pub fn open<'a>(&'a mut self, style: RasterStyle<'a>) -> RasterScope<'a> { + RasterScope::new(self, style) + } +} + +impl Debug for RasterContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RasterContext").finish_non_exhaustive() + } +} + +pub struct RasterScope<'a> { + palette: Option, + scaler: Scaler<'a>, + buffer: &'a mut SwashImage, +} + +impl<'a> RasterScope<'a> { + fn new(rcx: &'a mut RasterContext, style: RasterStyle<'a>) -> Self { + Self { + palette: query_palette(&style.font, style.theme), + scaler: rcx + .context + .builder(style.font) + .size(style.size.into()) + .variations(&[("wght", style.weight.into()), ("ital", style.italic.into())]) + .hint(style.hint) + .build(), + buffer: &mut rcx.buffer, + } + } + + pub fn rasterize( + &mut self, + glyph: u16, + x_fract: Unit, + y_fract: Unit, + ) -> Result, RasterizationError> { + debug_assert_eq!(x_fract.fract(), x_fract); + debug_assert_eq!(y_fract.fract(), y_fract); + + let sources_base: [Source; 4] = [ + Source::ColorOutline(self.palette.unwrap_or(0)), + Source::Outline, + Source::ColorBitmap(StrikeWith::BestFit), + Source::Bitmap(StrikeWith::BestFit), + ]; + let sources = if self.palette.is_some() { + &sources_base + } else { + &sources_base[1..] + }; + + self.buffer.clear(); + + if !Render::new(sources) + .offset(Vector::new(x_fract.into(), y_fract.into())) + .render_into(&mut self.scaler, glyph, self.buffer) + { + return Err(RasterizationError); + } + + Ok(Image::from(self.buffer)) + } +} + +fn query_palette(font: &FontRef, theme: Option) -> Option { + let font: &swash::FontRef = font.as_ref(); + + let mut best = None; + for palette in font.color_palettes() { + match (palette.usability(), theme) { + (_, None) + | (Some(Usability::Both), _) + | (Some(Usability::Light), Some(Theme::Light)) + | (Some(Usability::Dark), Some(Theme::Dark)) => return Some(palette.index()), + (None, Some(_)) => best = Some(palette.index()), + _ => continue, + } + } + + best +} diff --git a/crates/render/src/text/shape.rs b/crates/render/src/text/shape.rs new file mode 100644 index 0000000..a044fad --- /dev/null +++ b/crates/render/src/text/shape.rs @@ -0,0 +1,485 @@ +use harfrust::{BufferFlags, Direction, ShaperData, ShaperInstance, UnicodeBuffer}; +use std::ops::Range; +use swash::text::cluster::{Boundary, CharCluster, CharInfo, Parser, Token}; +use swash::text::{ClusterBreak, Codepoint, LineBreak, Script, analyze}; + +use super::{FontId, FontMap, FontRef, LayoutError, Style, Text}; +use crate::Dp; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Kind { + Text, + Tab, + Break, +} + +pub(super) struct Cluster { + pub range: Range, + pub style: usize, + pub script: Script, + pub boundary: Boundary, + pub kind: Kind, + pub space: bool, + pub font: Option, + replacement: bool, + chars: CharCluster, +} + +#[derive(Clone, Debug)] +#[expect( + clippy::redundant_pub_crate, + reason = "positioned glyphs remain crate-private" +)] +#[expect(dead_code, reason = "glyph IDs are retained for future rendering")] +pub(crate) struct PositionedGlyph { + pub id: u16, + pub range: Range, + pub x: Dp, + pub y: Dp, + pub advance: Dp, +} + +#[derive(Clone, Debug)] +#[expect( + clippy::redundant_pub_crate, + reason = "positioned runs remain crate-private" +)] +#[expect( + dead_code, + reason = "font handles and styles are retained for future rendering" +)] +pub(crate) struct PositionedRun { + pub font: FontId, + pub style: Style, + pub range: Range, + pub glyphs: Vec, +} + +#[derive(Debug, Default)] +pub(super) struct Fragment { + pub runs: Vec, + pub advance: f32, + pub width: f32, + pub ascent: f32, + pub descent: f32, + pub leading: f32, +} + +impl Fragment { + const fn include_metrics(&mut self, metrics: swash::Metrics) { + self.ascent = self.ascent.max(metrics.ascent); + self.descent = self.descent.max(metrics.descent); + self.leading = self.leading.max(metrics.leading); + } +} + +const fn kind(ch: char) -> Kind { + match ch { + '\r' | '\n' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' => Kind::Break, + '\t' => Kind::Tab, + _ => Kind::Text, + } +} + +const fn neutral(script: Script) -> bool { + matches!(script, Script::Common | Script::Inherited | Script::Unknown) +} + +fn analyze_text(text: &Text) -> Result, LayoutError> { + u32::try_from(text.string.len()).map_err(|_| LayoutError::TextTooLong)?; + + let mut analysis = analyze(text.string.chars()); + let mut tokens = Vec::new(); + let mut scripts = Vec::new(); + for ((offset, ch), (properties, boundary)) in text.string.char_indices().zip(&mut analysis) { + tokens.push(Token { + ch, + offset: u32::try_from(offset).map_err(|_| LayoutError::TextTooLong)?, + len: u8::try_from(ch.len_utf8()).unwrap(), + info: CharInfo::new(properties, boundary), + data: 0, + }); + scripts.push(match properties.cluster_break() { + ClusterBreak::EX | ClusterBreak::SM | ClusterBreak::ZWJ => Script::Inherited, + _ => properties.script(), + }); + } + + if analysis.needs_bidi_resolution() { + return Err(LayoutError::UnsupportedBidi); + } + + resolve_scripts(&tokens, &mut scripts); + + let mut clusters: Vec = Vec::new(); + let mut start = 0; + while start < tokens.len() { + let token = tokens[start]; + let token_kind = kind(token.ch); + let mut end = start + 1; + + if token_kind == Kind::Break && token.ch == '\r' { + if tokens.get(end).is_some_and(|next| next.ch == '\n') { + end += 1; + } + } else if token_kind == Kind::Text { + while end < tokens.len() + && kind(tokens[end].ch) == Kind::Text + && scripts[end] == scripts[start] + { + end += 1; + } + } + + let mut parser = Parser::new(scripts[start], tokens[start..end].iter().copied()); + let mut chars = CharCluster::new(); + while parser.next(&mut chars) { + let range = chars.range().to_range(); + let source = &text.string[range.clone()]; + + // swash's fixed-capacity parser overlaps chunks of long clusters. + // its simple parser also emits regional indicators individually. + if let Some(last) = clusters.last_mut() { + let previous = &text.string[last.range.clone()]; + let flag = previous.chars().count() == 1 + && previous.chars().all(regional_indicator) + && source.chars().all(regional_indicator); + + if range.start < last.range.end || flag { + last.range.end = range.end; + continue; + } + } + + clusters.push(Cluster { + range, + style: 0, + script: scripts[start], + boundary: chars.info().boundary(), + kind: token_kind, + space: source.chars().all(|ch| { + ch.is_whitespace() && !matches!(ch.line_break(), LineBreak::GL | LineBreak::WJ) + }), + font: None, + replacement: false, + chars, + }); + } + start = end; + } + + let mut style = 0; + for cluster in &mut clusters { + while text.spans[style].range.end <= cluster.range.start { + style += 1; + } + + cluster.style = style; + if cluster.kind != Kind::Break && text.spans[style].range.end < cluster.range.end { + return Err(LayoutError::StyleInsideCluster(text.spans[style].range.end)); + } + } + + Ok(clusters) +} + +fn resolve_scripts(tokens: &[Token], scripts: &mut [Script]) { + // prefer the preceding script, then the following script within the paragraph. + let mut previous = Script::Common; + for (token, script) in tokens.iter().zip(scripts.iter_mut()) { + if kind(token.ch) == Kind::Break { + previous = Script::Common; + } else if neutral(*script) { + *script = previous; + } else { + previous = *script; + } + } + + let mut following = Script::Common; + for (token, script) in tokens.iter().zip(scripts.iter_mut()).rev() { + if kind(token.ch) == Kind::Break { + following = Script::Common; + } else if neutral(*script) { + *script = following; + } else { + following = *script; + } + } +} + +const fn regional_indicator(ch: char) -> bool { + matches!(ch, '\u{1f1e6}'..='\u{1f1ff}') +} + +fn char_cluster(ch: char) -> CharCluster { + let token = Token { + ch, + len: u8::try_from(ch.len_utf8()).unwrap(), + info: ch.into(), + ..Token::default() + }; + let mut cluster = CharCluster::new(); + Parser::new(Script::Common, std::iter::once(token)).next(&mut cluster); + cluster +} + +fn resolve_font(fonts: &FontMap, style: &Style, chars: &mut CharCluster) -> Option<(FontId, bool)> { + if let Some(font) = style.fonts.resolve(fonts, chars) { + return Some((font, false)); + } + + let font = style + .fonts + .resolve(fonts, &mut char_cluster('\u{fffd}')) + .or_else(|| style.fonts.resolve(fonts, &mut CharCluster::new()))?; + Some((font, true)) +} + +pub(super) fn prepare(text: &Text, fonts: &FontMap) -> Result, LayoutError> { + let mut clusters = analyze_text(text)?; + for cluster in &mut clusters { + let mut chars = if cluster.kind == Kind::Text { + cluster.chars + } else { + char_cluster(' ') + }; + let style = &text.spans[cluster.style].style; + let (font, replacement) = resolve_font(fonts, style, &mut chars) + .ok_or_else(|| LayoutError::MissingFont(cluster.range.clone()))?; + cluster.font = Some(font); + cluster.replacement = replacement; + } + + Ok(clusters) +} + +fn metrics(font: &FontRef, style: &Style, scale: f32) -> swash::Metrics { + let font: &swash::FontRef = font.as_ref(); + let coords: Vec<_> = font + .variations() + .normalized_coords([ + ("wght", f32::from(style.weight)), + ("ital", f32::from(style.italic)), + ]) + .collect(); + font.metrics(&coords).scale(style.size.to_dp(scale).0) +} + +#[expect( + clippy::cast_precision_loss, + reason = "font design units are scaled to f32 layout coordinates" +)] +fn shape_run( + font: &FontRef, + style: &Style, + scale: f32, + source: &str, + range: Range, + context: Range, + script: Script, +) -> Vec { + let face: &harfrust::FontRef = font.as_ref(); + let data = ShaperData::new(face); + let instance = ShaperInstance::from_variations( + face, + [ + (harfrust::Tag::new(b"wght"), f32::from(style.weight)), + (harfrust::Tag::new(b"ital"), f32::from(style.italic)), + ], + ); + let shaper = data.shaper(face).instance(Some(&instance)).build(); + let factor = style.size.to_dp(scale).0 / shaper.units_per_em().max(1) as f32; + + let mut buffer = UnicodeBuffer::new(); + for (offset, ch) in source[range.clone()].char_indices() { + buffer.add(ch, u32::try_from(range.start + offset).unwrap()); + } + + buffer.set_pre_context(&source[context.start..range.start]); + buffer.set_post_context(&source[range.end..context.end]); + buffer.set_direction(Direction::LeftToRight); + buffer.set_script(harfrust_script(script)); + + let mut flags = BufferFlags::empty(); + if range.start == context.start { + flags |= BufferFlags::BEGINNING_OF_TEXT; + } + if range.end == context.end { + flags |= BufferFlags::END_OF_TEXT; + } + buffer.set_flags(flags); + + let buffer = shaper.shape(buffer, harfrust::ShapeOptions::default()); + let infos = buffer.glyph_infos(); + let mut boundaries: Vec<_> = infos.iter().map(|info| info.cluster as usize).collect(); + boundaries.push(range.end); + boundaries.sort_unstable(); + boundaries.dedup(); + + let mut x = 0.0; + let mut y = 0.0; + infos + .iter() + .zip(buffer.glyph_positions()) + .map(|(info, position)| { + let start = info.cluster as usize; + let end = boundaries[boundaries.partition_point(|&offset| offset <= start)]; + let glyph = PositionedGlyph { + id: u16::try_from(info.glyph_id).unwrap(), + range: start..end, + x: Dp((position.x_offset as f32).mul_add(factor, x)), + y: Dp((position.y_offset as f32).mul_add(-factor, y)), + advance: Dp(position.x_advance as f32 * factor), + }; + + x += glyph.advance.0; + y = (position.y_advance as f32).mul_add(-factor, y); + glyph + }) + .collect() +} + +fn harfrust_script(script: Script) -> harfrust::Script { + // these tags differ from their ISO 15924 names. + let tag = match script { + Script::Bengali => *b"Beng", + Script::Devanagari => *b"Deva", + Script::Gujarati => *b"Gujr", + Script::Gurmukhi => *b"Guru", + Script::Kannada => *b"Knda", + Script::Malayalam => *b"Mlym", + Script::Myanmar => *b"Mymr", + Script::Oriya => *b"Orya", + Script::Tamil => *b"Taml", + _ if neutral(script) => return harfrust::script::COMMON, + _ => script.to_opentype().to_be_bytes(), + }; + harfrust::Script::from_iso15924_tag(harfrust::Tag::new(&tag)) + .unwrap_or(harfrust::script::UNKNOWN) +} + +fn space_width(fonts: &FontMap, style: &Style, scale: f32, script: Script) -> Option { + let (id, replacement) = resolve_font(fonts, style, &mut char_cluster(' '))?; + let source = if replacement { "\u{fffd}" } else { " " }; + let range = 0..source.len(); + let glyphs = fonts.with(id, |font| { + shape_run(font, style, scale, source, range.clone(), range, script) + }); + Some(glyphs.iter().map(|glyph| glyph.advance.0).sum()) +} + +pub(super) fn fragment( + text: &Text, + fonts: &FontMap, + clusters: &[Cluster], + scale: f32, +) -> Result { + let mut fragment = Fragment::default(); + let Some(first) = clusters.first() else { + return Ok(fragment); + }; + + let end = clusters + .iter() + .rfind(|cluster| cluster.kind != Kind::Break) + .map_or(first.range.start, |cluster| cluster.range.end); + let context = first.range.start..end; + let visible_end = clusters + .iter() + .rfind(|cluster| !cluster.space && cluster.kind != Kind::Break) + .map_or(context.start, |cluster| cluster.range.end); + let mut tab_width = None; + let mut start = 0; + + while start < clusters.len() { + let cluster = &clusters[start]; + let style = &text.spans[cluster.style].style; + let font = cluster.font.unwrap(); + fragment.include_metrics(fonts.with(font, |font| metrics(font, style, scale))); + + if cluster.kind == Kind::Tab { + let stop = if let Some(width) = tab_width { + width + } else { + let initial_style = &text.spans[first.style].style; + let width = space_width(fonts, initial_style, scale, first.script) + .ok_or_else(|| LayoutError::MissingFont(first.range.clone()))? + * 4.0; + tab_width = Some(width); + width + }; + + if stop > 0.0 { + fragment.advance = (fragment.advance / stop).floor().mul_add(stop, stop); + } + if cluster.range.end <= visible_end { + fragment.width = fragment.advance; + } + start += 1; + continue; + } + + if cluster.kind == Kind::Break { + start += 1; + continue; + } + + let mut end = start + 1; + while !cluster.replacement + && end < clusters.len() + && !clusters[end].replacement + && clusters[end].kind == Kind::Text + && clusters[end].style == cluster.style + && clusters[end].font == cluster.font + && clusters[end].script == cluster.script + { + end += 1; + } + + let range = cluster.range.start..clusters[end - 1].range.end; + let (source, source_range, run_context, script) = if cluster.replacement { + ("\u{fffd}", 0..3, 0..3, Script::Common) + } else { + ( + text.string.as_str(), + range.clone(), + context.clone(), + cluster.script, + ) + }; + let mut glyphs = fonts.with(font, |font| { + shape_run( + font, + style, + scale, + source, + source_range, + run_context, + script, + ) + }); + + let origin = fragment.advance; + for glyph in &mut glyphs { + if cluster.replacement { + glyph.range = range.clone(); + } + glyph.x.0 += origin; + fragment.advance += glyph.advance.0; + if glyph.range.start < visible_end { + fragment.width = fragment.advance; + } + } + + fragment.runs.push(PositionedRun { + font, + style: style.clone(), + range, + glyphs, + }); + start = end; + } + + Ok(fragment) +} diff --git a/crates/render/src/text/style.rs b/crates/render/src/text/style.rs index 41b6959..45170f3 100644 --- a/crates/render/src/text/style.rs +++ b/crates/render/src/text/style.rs @@ -1,95 +1,29 @@ -use crate::Sp; -use crate::text::FontRef; - -/// A builder type of [`TextStyle`]. -#[derive(Debug)] -pub struct TextStyleBuilder { - font: FontRef, - weight: f32, - italic: bool, - size: Sp, +use crate::text::Fonts; +use crate::{Color, Sp}; + +/// A style of text. +#[derive(Clone, Debug)] +pub struct Style { + /// A set of fonts. + pub fonts: Fonts, + /// An overlay color. + pub color: Color, + /// Font weight. + pub weight: u16, + /// Whether to set italic. + pub italic: bool, + /// Font size. + pub size: Sp, } -impl TextStyleBuilder { - const fn new(font: FontRef) -> Self { - Self { - font, - weight: 400.0, - italic: false, - size: Sp(12.0), - } - } - - /// Sets weight as given (default: 400.0). - #[must_use] - pub const fn weight(mut self, value: f32) -> Self { - self.weight = value; - self +impl PartialEq for Style { + fn eq(&self, other: &Self) -> bool { + self.fonts == other.fonts + && self.color.to_bits() == other.color.to_bits() + && self.weight == other.weight + && self.italic == other.italic + && self.size.0.to_bits() == other.size.0.to_bits() } - - /// Sets italic as given (default: false). - #[must_use] - pub const fn italic(mut self, value: bool) -> Self { - self.italic = value; - self - } - - /// Sets size as given (default: 12.0). - #[must_use] - pub const fn size(mut self, size: Sp) -> Self { - self.size = size; - self - } - - /// Builds [`TextStyle`]. - #[must_use] - pub fn build(self) -> TextStyle { - TextStyle { - font: self.font, - weight: self.weight.to_ne_bytes(), - italic: self.italic, - size: self.size.0.to_ne_bytes(), - } - } -} - -/// A style of glyph. -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct TextStyle { - font: FontRef, - weight: [u8; 4], - italic: bool, - size: [u8; 4], } -impl TextStyle { - /// Creates a builder of [`TextStyle`]. - #[must_use] - pub const fn builder(font: FontRef) -> TextStyleBuilder { - TextStyleBuilder::new(font) - } - - /// Returns font set of `self`. - #[must_use] - pub const fn font(&self) -> &FontRef { - &self.font - } - - /// Returns weight value. - #[must_use] - pub const fn weight(&self) -> f32 { - f32::from_ne_bytes(self.weight) - } - - /// Returns whether the italic is set. - #[must_use] - pub const fn italic(&self) -> bool { - self.italic - } - - /// Returns font size. - #[must_use] - pub const fn size(&self) -> Sp { - Sp(f32::from_ne_bytes(self.size)) - } -} +impl Eq for Style {} diff --git a/crates/render/src/text/unit.rs b/crates/render/src/text/unit.rs new file mode 100644 index 0000000..c1e77f0 --- /dev/null +++ b/crates/render/src/text/unit.rs @@ -0,0 +1,47 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Unit(i32); + +impl Unit { + // 4 unit per 1 physical pixel + const PRECI: i32 = 4; + const PRECF: f32 = 4.0; + + pub const fn fract(self) -> Self { + Self(self.0 % Self::PRECI) + } +} + +impl From for Unit { + fn from(value: f32) -> Self { + #[expect(clippy::cast_possible_truncation, reason = "created from i32")] + Self((value * Self::PRECF).round() as i32) + } +} + +impl From for f32 { + fn from(value: Unit) -> Self { + #![expect( + clippy::cast_precision_loss, + reason = r" + precision loss is acceptable for glyph cache keys; + if a property goes larger than 2^23, difference will not be seen by user + " + )] + let fract = (value.0 % Unit::PRECI) as Self; + let int = (value.0 / Unit::PRECI) as Self; + fract / Unit::PRECF + int + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_samples() { + assert_eq!(Unit::from(0.1), Unit(0)); + assert_eq!(Unit::from(1.1), Unit(4)); + assert_eq!(Unit::from(0.2), Unit(1)); + assert_eq!(Unit::from(4.4), Unit(18)); + } +} diff --git a/crates/render/src/theme.rs b/crates/render/src/theme.rs new file mode 100644 index 0000000..e3a0142 --- /dev/null +++ b/crates/render/src/theme.rs @@ -0,0 +1,8 @@ +/// A theme. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Theme { + /// Light mode. + Light, + /// Dark mode. + Dark, +} diff --git a/crates/render/src/unit.rs b/crates/render/src/unit.rs index 53118f1..d8ca46a 100644 --- a/crates/render/src/unit.rs +++ b/crates/render/src/unit.rs @@ -157,3 +157,52 @@ impl Sp { Dp(dp.copysign(self.0)) } } + +/// A straight RGBA32 color. +#[repr(C, align(16))] +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Zeroable, Pod)] +pub struct Color { + /// Red channel of the color. + pub r: f32, + /// Green channel of the color. + pub g: f32, + /// Blue channel of the color. + pub b: f32, + /// Alpha channel of the color. + pub a: f32, +} + +impl Color { + /// Raw transmutation to `u128`. + #[must_use] + pub fn to_bits(&self) -> u128 { + let r = u128::from(self.r.to_bits()) << 96; + let g = u128::from(self.g.to_bits()) << 64; + let b = u128::from(self.b.to_bits()) << 32; + let a = u128::from(self.a.to_bits()); + r | g | b | a + } +} + +impl From for Color { + #[expect(clippy::cast_possible_truncation, reason = "intended loss")] + fn from(value: wgpu::Color) -> Self { + Self { + r: value.r as f32, + g: value.g as f32, + b: value.b as f32, + a: value.a as f32, + } + } +} + +impl From for wgpu::Color { + fn from(value: Color) -> Self { + Self { + r: value.r.into(), + g: value.g.into(), + b: value.b.into(), + a: value.a.into(), + } + } +}