diff --git a/Cargo.lock b/Cargo.lock index 9d7c11a194d..53dcc05ce73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.6.0" @@ -603,7 +609,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d59b4c170e16f0405a2e95aff44432a0d41aa97675f3d52623effe95792a037" dependencies = [ - "objc2 0.6.0", + "objc2 0.6.1", ] [[package]] @@ -619,17 +625,6 @@ dependencies = [ "piper", ] -[[package]] -name = "bstr" -version = "1.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - [[package]] name = "bumpalo" version = "3.16.0" @@ -638,9 +633,9 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" dependencies = [ "bytemuck_derive", ] @@ -836,6 +831,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ae467d04a8a8aea5d9a49018a6ade2e4221d92968e8ce55a48c0b1164e5f698" + [[package]] name = "color-hex" version = "0.2.0" @@ -885,18 +886,6 @@ dependencies = [ "env_logger", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "windows-sys 0.59.0", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1151,7 +1140,17 @@ dependencies = [ "bitflags 2.9.0", "block2 0.6.0", "libc", - "objc2 0.6.0", + "objc2 0.6.1", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.0", + "objc2 0.6.1", ] [[package]] @@ -1345,7 +1344,6 @@ dependencies = [ "egui_kittest", "image", "mimalloc", - "rand", "serde", "unicode_names2", ] @@ -1445,12 +1443,6 @@ dependencies = [ "serde", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "endi" version = "1.1.0" @@ -1536,7 +1528,7 @@ dependencies = [ name = "epaint" version = "0.31.1" dependencies = [ - "ab_glyph", + "accesskit", "ahash", "backtrace", "bytemuck", @@ -1549,10 +1541,11 @@ dependencies = [ "mimalloc", "nohash-hasher", "parking_lot", + "parley", "profiling", "rayon", "serde", - "similar-asserts", + "swash 0.2.2", ] [[package]] @@ -1684,6 +1677,34 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +[[package]] +name = "font-types" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-cache-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +dependencies = [ + "bytemuck", + "thiserror 1.0.66", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -1706,6 +1727,28 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "fontique" +version = "0.4.0" +source = "git+https://github.com/valadaptive/parley?branch=egui#ff827afca82ed0d5266cdb5c96f330d39856cd25" +dependencies = [ + "bytemuck", + "fontconfig-cache-parser", + "hashbrown", + "icu_locid", + "memmap2", + "objc2 0.6.1", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.1", + "peniko", + "read-fonts 0.29.0", + "roxmltree", + "smallvec", + "windows 0.58.0", + "windows-core 0.58.0", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2020,10 +2063,12 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2430,9 +2475,9 @@ dependencies = [ [[package]] name = "kurbo" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89234b2cc610a7dd927ebde6b41dd1a5d4214cffaef4cf1fb2195d592f92518f" +checksum = "1077d333efea6170d9ccb96d3c3026f300ca0773da4938cc4c811daa6df68b0c" dependencies = [ "arrayvec", "smallvec", @@ -2788,9 +2833,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" dependencies = [ "objc2-encode", ] @@ -2819,8 +2864,8 @@ checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb" dependencies = [ "bitflags 2.9.0", "block2 0.6.0", - "objc2 0.6.0", - "objc2-foundation 0.3.0", + "objc2 0.6.1", + "objc2-foundation 0.3.1", ] [[package]] @@ -2861,12 +2906,13 @@ dependencies = [ [[package]] name = "objc2-core-foundation" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925" +checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", + "dispatch2 0.3.0", + "objc2 0.6.1", ] [[package]] @@ -2893,6 +2939,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ba833d4a1cb1aac330f8c973fd92b6ff1858e4aef5cdd00a255eefb28022fb5" +dependencies = [ + "bitflags 2.9.0", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2914,12 +2970,12 @@ dependencies = [ [[package]] name = "objc2-foundation" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.9.0", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-core-foundation", ] @@ -3108,12 +3164,37 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parley" +version = "0.4.0" +source = "git+https://github.com/valadaptive/parley?branch=egui#ff827afca82ed0d5266cdb5c96f330d39856cd25" +dependencies = [ + "accesskit", + "fontique", + "hashbrown", + "peniko", + "serde", + "skrifa 0.31.0", + "swash 0.2.4", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peniko" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f9529efd019889b2a205193c14ffb6e2839b54ed9d2720674f10f4b04d87ac9" +dependencies = [ + "color", + "kurbo", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3449,6 +3530,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f9e8a4f503e5c8750e4cd3b32a4e090035c46374b305a15c70bad833dca05f" +dependencies = [ + "bytemuck", + "font-types 0.8.4", +] + +[[package]] +name = "read-fonts" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce8e2ca6b24313587a03ca61bb74c384e2a815bd90cf2866cfc9f5fb7a11fa0" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -3535,13 +3636,13 @@ checksum = "80c844748fdc82aae252ee4594a89b6e7ebef1063de7951545564cbc4e57075d" dependencies = [ "ashpd", "block2 0.6.0", - "dispatch2", + "dispatch2 0.2.0", "js-sys", "log", - "objc2 0.6.0", + "objc2 0.6.1", "objc2-app-kit 0.3.0", "objc2-core-foundation", - "objc2-foundation 0.3.0", + "objc2-foundation 0.3.1", "pollster", "raw-window-handle 0.6.2", "urlencoding", @@ -3797,39 +3898,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] -name = "similar" -version = "2.7.0" +name = "simplecss" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +checksum = "a11be7c62927d9427e9f40f3444d5499d868648e2edbc4e2116de69e7ec0e89d" dependencies = [ - "bstr", - "unicode-segmentation", + "log", ] [[package]] -name = "similar-asserts" -version = "1.7.0" +name = "siphasher" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b441962c817e33508847a22bd82f03a30cff43642dc2fae8b050566121eb9a" -dependencies = [ - "console", - "similar", -] +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] -name = "simplecss" -version = "0.2.1" +name = "skrifa" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a11be7c62927d9427e9f40f3444d5499d868648e2edbc4e2116de69e7ec0e89d" +checksum = "8cc1aa86c26dbb1b63875a7180aa0819709b33348eb5b1491e4321fae388179d" dependencies = [ - "log", + "bytemuck", + "read-fonts 0.25.3", ] [[package]] -name = "siphasher" -version = "1.0.1" +name = "skrifa" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "bbe6666ab11018ab91ff7b03f1a3b9fdbecfb610848436fefa5ce50343d3d913" +dependencies = [ + "bytemuck", + "read-fonts 0.29.0", +] [[package]] name = "slab" @@ -3851,9 +3952,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" [[package]] name = "smithay-client-toolkit" @@ -3978,6 +4079,27 @@ dependencies = [ "siphasher", ] +[[package]] +name = "swash" +version = "0.2.2" +source = "git+https://github.com/valadaptive/swash?branch=tight-bounds#2884db1436e05256246047dc4a41b970afb7a74a" +dependencies = [ + "skrifa 0.26.6", + "yazi", + "zeno 0.3.2", +] + +[[package]] +name = "swash" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dce3f0af95643c855cdc449fbaa17d8c2cd08e0b00a49a6babcbe6e71667f3d" +dependencies = [ + "skrifa 0.31.0", + "yazi", + "zeno 0.3.3", +] + [[package]] name = "syn" version = "2.0.96" @@ -5490,6 +5612,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + [[package]] name = "yoke" version = "0.7.5" @@ -5614,6 +5742,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zeno" +version = "0.3.2" +source = "git+https://github.com/valadaptive/zeno?branch=tight-bounds#696f94184a1af5c0411c282045549264236ff881" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index d1efd4aeecc..2888e1efc86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,7 +97,6 @@ puffin_http = "0.16" raw-window-handle = "0.6.0" ron = "0.10.1" serde = { version = "1", features = ["derive"] } -similar-asserts = "1.4.2" thiserror = "1.0.37" type-map = "0.5.0" unicode-segmentation = "1.12.0" diff --git a/README.md b/README.md index 4f1e62c4ee5..6490e95aa9f 100644 --- a/README.md +++ b/README.md @@ -145,11 +145,11 @@ Light Theme: ## Dependencies `egui` has a minimal set of default dependencies: -* [`ab_glyph`](https://crates.io/crates/ab_glyph) * [`ahash`](https://crates.io/crates/ahash) * [`bitflags`](https://crates.io/crates/bitflags) * [`nohash-hasher`](https://crates.io/crates/nohash-hasher) * [`parking_lot`](https://crates.io/crates/parking_lot) +* [`parley`](https://crates.io/crates/parley) Heavier dependencies are kept out of `egui`, even as opt-in. All code in `egui` is Wasm-friendly (even outside a browser). diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs index 7badefda479..7f696fb0be5 100644 --- a/crates/egui-wgpu/src/renderer.rs +++ b/crates/egui-wgpu/src/renderer.rs @@ -564,15 +564,6 @@ impl Renderer { ); Cow::Borrowed(&image.pixels) } - epaint::ImageData::Font(image) => { - assert_eq!( - width as usize * height as usize, - image.pixels.len(), - "Mismatch between texture size and texel count" - ); - profiling::scope!("font -> sRGBA"); - Cow::Owned(image.srgba_pixels(None).collect::>()) - } }; let data_bytes: &[u8] = bytemuck::cast_slice(data_color32.as_slice()); diff --git a/crates/egui/Cargo.toml b/crates/egui/Cargo.toml index e7641ef315b..6058f666853 100644 --- a/crates/egui/Cargo.toml +++ b/crates/egui/Cargo.toml @@ -28,7 +28,7 @@ default = ["default_fonts"] ## Exposes detailed accessibility implementation required by platform ## accessibility APIs. Also requires support in the egui integration. -accesskit = ["dep:accesskit"] +accesskit = ["dep:accesskit", "epaint/accesskit"] ## [`bytemuck`](https://docs.rs/bytemuck) enables you to cast [`epaint::Vertex`], [`emath::Vec2`] etc to `&[u8]`. bytemuck = ["epaint/bytemuck"] diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 3204927b8df..328273f5866 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -2,15 +2,15 @@ use std::{borrow::Cow, cell::RefCell, panic::Location, sync::Arc, time::Duration}; -use emath::{GuiRounding as _, OrderedFloat}; +use emath::GuiRounding as _; use epaint::{ emath::{self, TSTransform}, mutex::RwLock, stats::PaintStats, tessellator, - text::{FontInsert, FontPriority, Fonts}, - vec2, ClippedPrimitive, ClippedShape, Color32, ImageData, ImageDelta, Pos2, Rect, StrokeKind, - TessellationOptions, TextureAtlas, TextureId, Vec2, + text::{FontInsert, FontPriority, FontStore, Fonts}, + vec2, ClippedPrimitive, ClippedShape, Color32, ImageData, Pos2, Rect, StrokeKind, + TessellationOptions, TextureId, Vec2, }; use crate::{ @@ -77,7 +77,7 @@ impl Default for WrappedTextureManager { // Will be filled in later let font_id = tex_mngr.alloc( "egui_font_texture".into(), - epaint::FontImage::new([0, 0]).into(), + epaint::ColorImage::filled([0, 0], Color32::TRANSPARENT).into(), Default::default(), ); assert_eq!( @@ -395,13 +395,7 @@ impl ViewportRepaintInfo { #[derive(Default)] struct ContextImpl { - /// Since we could have multiple viewports across multiple monitors with - /// different `pixels_per_point`, we need a `Fonts` instance for each unique - /// `pixels_per_point`. - /// This is because the `Fonts` depend on `pixels_per_point` for the font atlas - /// as well as kerning, font sizes, etc. - fonts: std::collections::BTreeMap, Fonts>, - font_definitions: FontDefinitions, + fonts: Option, memory: Memory, animation_manager: AnimationManager, @@ -559,33 +553,43 @@ impl ContextImpl { fn update_fonts_mut(&mut self) { profiling::function_scope!(); let input = &self.viewport().input; - let pixels_per_point = input.pixels_per_point(); let max_texture_side = input.max_texture_side; - if let Some(font_definitions) = self.memory.new_font_definitions.take() { - // New font definition loaded, so we need to reload all fonts. - self.fonts.clear(); - self.font_definitions = font_definitions; - #[cfg(feature = "log")] - log::trace!("Loading new font definitions"); - } + let mut new_font_definitions = + if let Some(font_definitions) = self.memory.new_font_definitions.take() { + Cow::Owned(font_definitions) + } else if let Some(fonts) = &self.fonts { + Cow::Borrowed(fonts.definitions()) + } else { + Cow::Owned(FontDefinitions::default()) + }; if !self.memory.add_fonts.is_empty() { let fonts = self.memory.add_fonts.drain(..); + let font_definitions = new_font_definitions.to_mut(); for font in fonts { - self.fonts.clear(); // recreate all the fonts for family in font.families { - let fam = self - .font_definitions - .families - .entry(family.family) - .or_default(); + let fam = font_definitions.families.entry(family.family).or_default(); match family.priority { FontPriority::Highest => fam.insert(0, font.name.clone()), FontPriority::Lowest => fam.push(font.name.clone()), + FontPriority::Above(name) => { + let position = fam + .iter() + .position(|f| f == name.as_ref()) + .unwrap_or(fam.len()); + fam.insert(position, font.name.clone()); + } + FontPriority::Below(name) => { + let position = fam + .iter() + .position(|f| f == name.as_ref()) + .unwrap_or(fam.len() - 1); + fam.insert(position + 1, font.name.clone()); + } } } - self.font_definitions + font_definitions .font_data .insert(font.name, Arc::new(font.data)); } @@ -594,35 +598,47 @@ impl ContextImpl { log::trace!("Adding new fonts"); } - let mut is_new = false; + let mut did_change_fonts = false; - let fonts = self - .fonts - .entry(pixels_per_point.into()) - .or_insert_with(|| { - #[cfg(feature = "log")] - log::trace!("Creating new Fonts for pixels_per_point={pixels_per_point}"); + // If we changed the font definitions this frame, or self.fonts previously did not exist, new_font_definitions + // must be Cow::Owned. Set the font definitions. + let fonts = if let Cow::Owned(new_font_definitions) = new_font_definitions { + did_change_fonts = true; + if let Some(fonts) = self.fonts.as_mut() { + fonts.set_definitions(new_font_definitions); + did_change_fonts = true; + fonts + } else { + self.fonts.get_or_insert_with(|| { + #[cfg(feature = "log")] + log::trace!("Creating new FontStore"); - is_new = true; - profiling::scope!("Fonts::new"); - Fonts::new( - pixels_per_point, - max_texture_side, - self.font_definitions.clone(), - ) - }); + did_change_fonts = true; + profiling::scope!("FontStore::new"); + FontStore::new(max_texture_side, new_font_definitions) + }) + } + } else { + self.fonts + .as_mut() + .expect("new_font_definitions is borrowed, but we had nowhere to borrow it from") + }; + + if let Some(new_font_hinting) = self.memory.new_font_hinting.take() { + fonts.set_hinting_enabled(new_font_hinting); + } { - profiling::scope!("Fonts::begin_pass"); - fonts.begin_pass(pixels_per_point, max_texture_side); + profiling::scope!("FontStore::begin_pass"); + fonts.begin_pass(max_texture_side); } - if is_new && self.memory.options.preload_font_glyphs { + if did_change_fonts && self.memory.options.preload_font_glyphs { profiling::scope!("preload_font_glyphs"); // Preload the most common characters for the most common fonts. // This is not very important to do, but may save a few GPU operations. - for font_id in self.memory.options.style().text_styles.values() { - fonts.lock().fonts.font(font_id).preload_common_characters(); + for font_style in self.memory.options.style().text_styles.values() { + fonts.preload_common_characters(font_style); } } } @@ -1020,18 +1036,20 @@ impl Context { self.write(move |ctx| reader(&ctx.viewport().prev_pass)) } - /// Read-only access to [`Fonts`]. + /// Read-write access to [`Fonts`]. /// /// Not valid until first call to [`Context::run()`]. /// That's because since we don't know the proper `pixels_per_point` until then. #[inline] - pub fn fonts(&self, reader: impl FnOnce(&Fonts) -> R) -> R { + pub fn fonts(&self, reader: impl FnOnce(&mut Fonts<'_>) -> R) -> R { self.write(move |ctx| { let pixels_per_point = ctx.pixels_per_point(); reader( - ctx.fonts - .get(&pixels_per_point.into()) - .expect("No fonts available until first call to Context::run()"), + &mut ctx + .fonts + .as_mut() + .expect("No fonts available until first call to Context::run()") + .with_pixels_per_point(pixels_per_point), ) }) } @@ -1494,12 +1512,10 @@ impl Context { let font_id = TextStyle::Body.resolve(&self.style()); self.fonts(|f| { - let mut lock = f.lock(); - let font = lock.fonts.font(&font_id); - font.has_glyphs(alt) - && font.has_glyphs(ctrl) - && font.has_glyphs(shift) - && font.has_glyphs(mac_cmd) + f.has_glyphs_for(&font_id, alt) + && f.has_glyphs_for(&font_id, ctrl) + && f.has_glyphs_for(&font_id, shift) + && f.has_glyphs_for(&font_id, mac_cmd) }) } @@ -1801,17 +1817,12 @@ impl Context { pub fn set_fonts(&self, font_definitions: FontDefinitions) { profiling::function_scope!(); - let pixels_per_point = self.pixels_per_point(); - - let mut update_fonts = true; - - self.read(|ctx| { - if let Some(current_fonts) = ctx.fonts.get(&pixels_per_point.into()) { - // NOTE: this comparison is expensive since it checks TTF data for equality - if current_fonts.lock().fonts.definitions() == &font_definitions { - update_fonts = false; // no need to update - } - } + let update_fonts = self.read(|ctx| { + // NOTE: this comparison is expensive since it checks TTF data for equality + // TODO(valadaptive): add_font only checks the *names* for equality. Change this? + ctx.fonts + .as_ref() + .is_none_or(|fonts| fonts.definitions() != &font_definitions) }); if update_fonts { @@ -1829,22 +1840,10 @@ impl Context { pub fn add_font(&self, new_font: FontInsert) { profiling::function_scope!(); - let pixels_per_point = self.pixels_per_point(); - - let mut update_fonts = true; - - self.read(|ctx| { - if let Some(current_fonts) = ctx.fonts.get(&pixels_per_point.into()) { - if current_fonts - .lock() - .fonts - .definitions() - .font_data - .contains_key(&new_font.name) - { - update_fonts = false; // no need to update - } - } + let update_fonts = self.read(|ctx| { + ctx.fonts + .as_ref() + .is_none_or(|fonts| !fonts.definitions().font_data.contains_key(&new_font.name)) }); if update_fonts { @@ -1852,6 +1851,26 @@ impl Context { } } + /// Set whether font hinting (pixel snapping for clearer fonts) is enabled. + /// + /// By default, hinting is enabled. You can override this per-font with + /// [`crate::FontTweak::hinting_enabled`]. + /// + /// The new font hinting setting will become active at the start of the next pass. + pub fn set_font_hinting_enabled(&self, hinting_enabled: bool) { + profiling::function_scope!(); + + let update_hinting = self.read(|ctx| { + ctx.fonts + .as_ref() + .is_none_or(|fonts| fonts.hinting_enabled() != hinting_enabled) + }); + + if update_hinting { + self.memory_mut(|mem| mem.new_font_hinting = Some(hinting_enabled)); + } + } + /// Does the OS use dark or light mode? /// This is used when the theme preference is set to [`crate::ThemePreference::System`]. pub fn system_theme(&self) -> Option { @@ -2325,30 +2344,12 @@ impl ContextImpl { self.memory.end_pass(&viewport.this_pass.used_ids); - if let Some(fonts) = self.fonts.get(&pixels_per_point.into()) { + if let Some(fonts) = self.fonts.as_mut() { let tex_mngr = &mut self.tex_manager.0.write(); if let Some(font_image_delta) = fonts.font_image_delta() { // A partial font atlas update, e.g. a new glyph has been entered. tex_mngr.set(TextureId::default(), font_image_delta); } - - if 1 < self.fonts.len() { - // We have multiple different `pixels_per_point`, - // e.g. because we have many viewports spread across - // monitors with different DPI scaling. - // All viewports share the same texture namespace and renderer, - // so the all use `TextureId::default()` for the font texture. - // This is a problem. - // We solve this with a hack: we always upload the full font atlas - // every frame, for all viewports. - // This ensures it is up-to-date, solving - // https://github.com/emilk/egui/issues/3664 - // at the cost of a lot of performance. - // (This will override any smaller delta that was uploaded above.) - profiling::scope!("full_font_atlas_update"); - let full_delta = ImageDelta::full(fonts.image(), TextureAtlas::texture_options()); - tex_mngr.set(TextureId::default(), full_delta); - } } // Inform the backend of all textures that have been updated (including font atlas). @@ -2486,24 +2487,6 @@ impl ContextImpl { self.memory.set_viewport_id(viewport_id); } - let active_pixels_per_point: std::collections::BTreeSet> = self - .viewports - .values() - .map(|v| v.input.pixels_per_point.into()) - .collect(); - self.fonts.retain(|pixels_per_point, _| { - if active_pixels_per_point.contains(pixels_per_point) { - true - } else { - #[cfg(feature = "log")] - log::trace!( - "Freeing Fonts with pixels_per_point={} because it is no longer needed", - pixels_per_point.into_inner() - ); - false - } - }); - platform_output.num_completed_passes += 1; FullOutput { @@ -2535,22 +2518,19 @@ impl Context { self.write(|ctx| { let tessellation_options = ctx.memory.options.tessellation_options; - let texture_atlas = if let Some(fonts) = ctx.fonts.get(&pixels_per_point.into()) { + let texture_atlas = if let Some(fonts) = ctx.fonts.as_mut() { fonts.texture_atlas() } else { #[cfg(feature = "log")] log::warn!("No font size matching {pixels_per_point} pixels per point found."); ctx.fonts - .iter() + .iter_mut() .next() .expect("No fonts loaded") - .1 .texture_atlas() }; - let (font_tex_size, prepared_discs) = { - let atlas = texture_atlas.lock(); - (atlas.size(), atlas.prepared_discs()) - }; + let (font_tex_size, prepared_discs) = + { (texture_atlas.size(), texture_atlas.prepared_discs()) }; let paint_stats = PaintStats::from_shapes(&shapes); let clipped_primitives = { @@ -2959,7 +2939,7 @@ impl Context { } fn fonts_tweak_ui(&self, ui: &mut Ui) { - let mut font_definitions = self.write(|ctx| ctx.font_definitions.clone()); + let mut font_definitions = self.fonts(|fonts| fonts.definitions().clone()); let mut changed = false; for (name, data) in &mut font_definitions.font_data { diff --git a/crates/egui/src/debug_text.rs b/crates/egui/src/debug_text.rs index bb9487bd32f..573bfa170da 100644 --- a/crates/egui/src/debug_text.rs +++ b/crates/egui/src/debug_text.rs @@ -6,7 +6,8 @@ //! to get callbacks on certain events ([`Context::on_begin_pass`], [`Context::on_end_pass`]). use crate::{ - text, Align, Align2, Color32, Context, FontFamily, FontId, Id, Rect, Shape, Vec2, WidgetText, + text::{self, style::FontId}, + Align, Align2, Color32, Context, Id, Rect, Shape, Vec2, WidgetText, }; /// Register this plugin on the given egui context, @@ -92,7 +93,7 @@ impl State { let mut bounding_rect = Rect::from_points(&[pos]); let color = Color32::GRAY; - let font_id = FontId::new(10.0, FontFamily::Proportional); + let font_id = FontId::system_ui(10.0); for Entry { location, text } in entries { { diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index c871a84520e..7f27246ce89 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -1482,7 +1482,7 @@ impl InputState { .text_styles .get_mut(&crate::TextStyle::Body) .unwrap() - .family = crate::FontFamily::Monospace; + .family = crate::text::style::GenericFamily::Monospace.into(); ui.collapsing("Raw Input", |ui| raw.ui(ui)); diff --git a/crates/egui/src/introspection.rs b/crates/egui/src/introspection.rs index 8826eca79bf..251bc9ad903 100644 --- a/crates/egui/src/introspection.rs +++ b/crates/egui/src/introspection.rs @@ -1,27 +1,32 @@ //! Showing UI:s for egui/epaint types. + +use epaint::text::style::GenericFamily; + use crate::{ - epaint, memory, pos2, remap_clamp, vec2, Color32, CursorIcon, FontFamily, FontId, Label, Mesh, - NumExt as _, Rect, Response, Sense, Shape, Slider, TextStyle, TextWrapMode, Ui, Widget, + epaint, memory, pos2, remap_clamp, text::style::FontId, vec2, Color32, ComboBox, CursorIcon, + Label, Mesh, NumExt as _, Rect, Response, Sense, Shape, Slider, TextStyle, TextWrapMode, Ui, + Widget, }; -pub fn font_family_ui(ui: &mut Ui, font_family: &mut FontFamily) { - let families = ui.fonts(|f| f.families()); - ui.horizontal(|ui| { - for alternative in families { - let text = alternative.to_string(); - ui.radio_value(font_family, alternative, text); - } - }); -} - pub fn font_id_ui(ui: &mut Ui, font_id: &mut FontId) { - let families = ui.fonts(|f| f.families()); - ui.horizontal(|ui| { + let families = ui.fonts(|f| f.families().to_owned()); + ui.horizontal_wrapped(|ui| { ui.add(Slider::new(&mut font_id.size, 4.0..=40.0).max_decimals(1)); - for alternative in families { - let text = alternative.to_string(); - ui.radio_value(&mut font_id.family, alternative, text); - } + ComboBox::from_id_salt(ui.next_auto_id()) + .selected_text(font_id.family.first_family().to_string()) + .show_ui(ui, |ui| { + for generic in GenericFamily::ALL { + let text = generic.to_string(); + ui.selectable_value(&mut font_id.family, generic.into(), text); + } + + ui.separator(); + + for alternative in families { + let text = alternative.to_string(); + ui.selectable_value(&mut font_id.family, alternative.into(), text); + } + }); }); } diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 6d42a230b08..821299df32f 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -467,17 +467,18 @@ pub use emath::{ }; pub use epaint::{ mutex, - text::{FontData, FontDefinitions, FontFamily, FontId, FontTweak}, + text::{FontData, FontDefinitions, FontTweak}, textures::{TextureFilter, TextureOptions, TextureWrapMode, TexturesDelta}, - ClippedPrimitive, ColorImage, CornerRadius, FontImage, ImageData, Margin, Mesh, PaintCallback, + ClippedPrimitive, ColorImage, CornerRadius, ImageData, Margin, Mesh, PaintCallback, PaintCallbackInfo, Shadow, Shape, Stroke, StrokeKind, TextureHandle, TextureId, }; pub mod text { - pub use crate::text_selection::CCursorRange; pub use epaint::text::{ - cursor::CCursor, FontData, FontDefinitions, FontFamily, Fonts, Galley, LayoutJob, - LayoutSection, TextFormat, TextWrapping, TAB_SIZE, + cursor::{ByteCursor, Selection}, + style::{self, TextFormat}, + FontData, FontDefinitions, FontStore, Galley, LayoutJob, LayoutSection, TextWrapping, + TAB_SIZE, }; } diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 9bf482b830b..da8423ecf87 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -79,6 +79,10 @@ pub struct Memory { #[cfg_attr(feature = "persistence", serde(skip))] pub(crate) new_font_definitions: Option, + /// new font hinting setting that will be applied at the start of the next frame + #[cfg_attr(feature = "persistence", serde(skip))] + pub(crate) new_font_hinting: Option, + /// add new font that will be applied at the start of the next frame #[cfg_attr(feature = "persistence", serde(skip))] pub(crate) add_fonts: Vec, @@ -125,6 +129,7 @@ impl Default for Memory { data: Default::default(), caches: Default::default(), new_font_definitions: Default::default(), + new_font_hinting: Default::default(), interactions: Default::default(), focus: Default::default(), viewport_id: Default::default(), @@ -275,8 +280,12 @@ pub struct Options { /// /// Only the fonts in [`Style::text_styles`] will be pre-cached. /// - /// This can lead to fewer texture operations, but may use up the texture atlas quicker - /// if you are changing [`Style::text_styles`], or have a lot of text styles. + /// This can lead to fewer texture operations, but may use up the texture atlas quicker if you are changing + /// [`Style::text_styles`], or have a lot of text styles. + /// + /// TODO(valadaptive): `preload_font_glyphs` used to do something, but the new text layout code rasterizes at + /// subpixel offsets, and I don't feel like rasterizing all 4 offsets for every glyph ahead of time. Is + /// `preload_font_glyphs` actually useful or just a placebo? pub preload_font_glyphs: bool, /// Check reusing of [`Id`]s, and show a visual warning on screen when one is found. diff --git a/crates/egui/src/painter.rs b/crates/egui/src/painter.rs index c17fbb272ef..7d55458f912 100644 --- a/crates/egui/src/painter.rs +++ b/crates/egui/src/painter.rs @@ -2,14 +2,14 @@ use std::sync::Arc; use emath::GuiRounding as _; use epaint::{ - text::{Fonts, Galley, LayoutJob}, + text::{style::FontId, Fonts, Galley, LayoutJob}, CircleShape, ClippedShape, CornerRadius, PathStroke, RectShape, Shape, Stroke, StrokeKind, }; use crate::{ emath::{Align2, Pos2, Rangef, Rect, Vec2}, layers::{LayerId, PaintList, ShapeIdx}, - Color32, Context, FontId, + Color32, Context, }; /// Helper to paint shapes and text to a specific region on a specific layer. @@ -140,11 +140,11 @@ impl Painter { self.pixels_per_point } - /// Read-only access to the shared [`Fonts`]. + /// Read-write access to the shared [`Fonts`]. /// /// See [`Context`] documentation for how locks work. #[inline] - pub fn fonts(&self, reader: impl FnOnce(&Fonts) -> R) -> R { + pub fn fonts(&self, reader: impl FnOnce(&mut Fonts<'_>) -> R) -> R { self.ctx.fonts(reader) } diff --git a/crates/egui/src/pass_state.rs b/crates/egui/src/pass_state.rs index 1f629253c4b..bc011f9683b 100644 --- a/crates/egui/src/pass_state.rs +++ b/crates/egui/src/pass_state.rs @@ -1,9 +1,12 @@ use ahash::HashMap; -use crate::{id::IdSet, style, Align, Id, IdMap, LayerId, Rangef, Rect, Vec2, WidgetRects}; +use crate::{ + id::IdSet, style, text::style::FontId, Align, Id, IdMap, LayerId, Rangef, Rect, Vec2, + WidgetRects, +}; #[cfg(debug_assertions)] -use crate::{pos2, Align2, Color32, FontId, NumExt as _, Painter}; +use crate::{pos2, Align2, Color32, NumExt as _, Painter}; /// Reset at the start of each frame. #[derive(Clone, Debug, Default)] diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 7b73edbb9ad..f8283e6f119 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -3,14 +3,19 @@ #![allow(clippy::if_same_then_else)] use emath::Align; -use epaint::{text::FontTweak, CornerRadius, Shadow, Stroke}; +use epaint::{ + text::{ + style::{FontId, GenericFamily}, + FontTweak, + }, + CornerRadius, Shadow, Stroke, +}; use std::{collections::BTreeMap, ops::RangeInclusive, sync::Arc}; use crate::{ ecolor::Color32, emath::{pos2, vec2, Rangef, Rect, Vec2}, - ComboBox, CursorIcon, FontFamily, FontId, Grid, Margin, Response, RichText, TextWrapMode, - WidgetText, + ComboBox, CursorIcon, Grid, Margin, Response, RichText, TextWrapMode, WidgetText, }; /// How to format numbers in e.g. a [`crate::DragValue`]. @@ -125,7 +130,7 @@ pub enum FontSelection { /// [`Style::override_font_id`] or [`Style::override_text_style`] is set. Default, - /// Directly select size and font family + /// Directly select font FontId(FontId), /// Use a [`TextStyle`] to look up the [`FontId`] in [`Style::text_styles`]. @@ -1247,14 +1252,14 @@ impl Default for DebugOptions { /// The default text styles of the default egui theme. pub fn default_text_styles() -> BTreeMap { - use FontFamily::{Monospace, Proportional}; + use GenericFamily::{Monospace, SystemUi}; [ - (TextStyle::Small, FontId::new(9.0, Proportional)), - (TextStyle::Body, FontId::new(12.5, Proportional)), - (TextStyle::Button, FontId::new(12.5, Proportional)), - (TextStyle::Heading, FontId::new(18.0, Proportional)), - (TextStyle::Monospace, FontId::new(12.0, Monospace)), + (TextStyle::Small, FontId::simple(9.0, SystemUi)), + (TextStyle::Body, FontId::simple(12.5, SystemUi)), + (TextStyle::Button, FontId::simple(12.5, SystemUi)), + (TextStyle::Heading, FontId::simple(18.0, SystemUi)), + (TextStyle::Monospace, FontId::simple(12.0, Monospace)), ] .into() } @@ -2622,6 +2627,7 @@ impl Widget for &mut FontTweak { y_offset_factor, y_offset, baseline_offset_factor, + hinting_override, } = self; ui.label("Scale"); @@ -2641,6 +2647,19 @@ impl Widget for &mut FontTweak { ui.add(DragValue::new(baseline_offset_factor).speed(-0.0025)); ui.end_row(); + ui.label("hinting_override"); + ComboBox::from_id_salt("hinting_override") + .selected_text(match hinting_override { + None => "None", + Some(true) => "Enable", + Some(false) => "Disable", + }) + .show_ui(ui, |ui| { + ui.selectable_value(hinting_override, None, "None"); + ui.selectable_value(hinting_override, Some(true), "Enable"); + ui.selectable_value(hinting_override, Some(false), "Disable"); + }); + if ui.button("Reset").clicked() { *self = Default::default(); } diff --git a/crates/egui/src/text_selection/accesskit_text.rs b/crates/egui/src/text_selection/accesskit_text.rs index e04a54d1824..33b38052c98 100644 --- a/crates/egui/src/text_selection/accesskit_text.rs +++ b/crates/egui/src/text_selection/accesskit_text.rs @@ -1,34 +1,27 @@ use emath::TSTransform; -use crate::{Context, Galley, Id}; - -use super::{text_cursor_state::is_word_char, CCursorRange}; +use crate::{text::Selection, Context, Galley, Id}; /// Update accesskit with the current text state. pub fn update_accesskit_for_text_widget( ctx: &Context, widget_id: Id, - cursor_range: Option, + selection: Option, role: accesskit::Role, global_from_galley: TSTransform, galley: &Galley, ) { + let map_id = |parent_id: Id, node_id: accesskit::NodeId| parent_id.with(node_id.0); let parent_id = ctx.accesskit_node_builder(widget_id, |builder| { let parent_id = widget_id; - if let Some(cursor_range) = &cursor_range { - let anchor = galley.layout_from_cursor(cursor_range.secondary); - let focus = galley.layout_from_cursor(cursor_range.primary); - builder.set_text_selection(accesskit::TextSelection { - anchor: accesskit::TextPosition { - node: parent_id.with(anchor.row).accesskit_id(), - character_index: anchor.column, - }, - focus: accesskit::TextPosition { - node: parent_id.with(focus.row).accesskit_id(), - character_index: focus.column, - }, - }); + if let Some(mut selection) = selection + .as_ref() + .and_then(|selection| galley.selection(|s| s.to_accesskit_selection(selection))) + { + selection.anchor.node = map_id(parent_id, selection.anchor.node).accesskit_id(); + selection.focus.node = map_id(parent_id, selection.focus.node).accesskit_id(); + builder.set_text_selection(selection); } builder.set_role(role); @@ -40,59 +33,26 @@ pub fn update_accesskit_for_text_widget( return; }; + // TODO(valadaptive): mostly untested ctx.with_accessibility_parent(parent_id, || { - for (row_index, row) in galley.rows.iter().enumerate() { - let row_id = parent_id.with(row_index); - ctx.accesskit_node_builder(row_id, |builder| { - builder.set_role(accesskit::Role::TextRun); - let rect = global_from_galley * row.rect_without_leading_space(); - builder.set_bounds(accesskit::Rect { - x0: rect.min.x.into(), - y0: rect.min.y.into(), - x1: rect.max.x.into(), - y1: rect.max.y.into(), - }); - builder.set_text_direction(accesskit::TextDirection::LeftToRight); - // TODO(mwcampbell): Set more node fields for the row - // once AccessKit adapters expose text formatting info. - - let glyph_count = row.glyphs.len(); - let mut value = String::new(); - value.reserve(glyph_count); - let mut character_lengths = Vec::::with_capacity(glyph_count); - let mut character_positions = Vec::::with_capacity(glyph_count); - let mut character_widths = Vec::::with_capacity(glyph_count); - let mut word_lengths = Vec::::new(); - let mut was_at_word_end = false; - let mut last_word_start = 0usize; - - for glyph in &row.glyphs { - let is_word_char = is_word_char(glyph.chr); - if is_word_char && was_at_word_end { - word_lengths.push((character_lengths.len() - last_word_start) as _); - last_word_start = character_lengths.len(); - } - was_at_word_end = !is_word_char; - let old_len = value.len(); - value.push(glyph.chr); - character_lengths.push((value.len() - old_len) as _); - character_positions.push(glyph.pos.x - row.pos.x); - character_widths.push(glyph.advance_width); + for (node_id, node) in &galley.accessibility().nodes { + let row_id = map_id(parent_id, *node_id); + ctx.accesskit_node_builder(row_id, |dest_node| { + *dest_node = node.clone(); + // Transform the node bounds + if let Some(bounds) = node.bounds() { + let new_bounds = global_from_galley + * emath::Rect { + min: emath::Pos2::new(bounds.x0 as f32, bounds.y0 as f32), + max: emath::Pos2::new(bounds.x1 as f32, bounds.y1 as f32), + }; + dest_node.set_bounds(accesskit::Rect { + x0: new_bounds.min.x.into(), + y0: new_bounds.min.y.into(), + x1: new_bounds.max.x.into(), + y1: new_bounds.max.y.into(), + }); } - - if row.ends_with_newline { - value.push('\n'); - character_lengths.push(1); - character_positions.push(row.size.x); - character_widths.push(0.0); - } - word_lengths.push((character_lengths.len() - last_word_start) as _); - - builder.set_value(value); - builder.set_character_lengths(character_lengths); - builder.set_character_positions(character_positions); - builder.set_character_widths(character_widths); - builder.set_word_lengths(word_lengths); }); } }); diff --git a/crates/egui/src/text_selection/cursor_range.rs b/crates/egui/src/text_selection/cursor_range.rs deleted file mode 100644 index 05351e0ac8e..00000000000 --- a/crates/egui/src/text_selection/cursor_range.rs +++ /dev/null @@ -1,329 +0,0 @@ -use epaint::{text::cursor::CCursor, Galley}; - -use crate::{os::OperatingSystem, Event, Id, Key, Modifiers}; - -use super::text_cursor_state::{ccursor_next_word, ccursor_previous_word, slice_char_range}; - -/// A selected text range (could be a range of length zero). -/// -/// The selection is based on character count (NOT byte count!). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct CCursorRange { - /// When selecting with a mouse, this is where the mouse was released. - /// When moving with e.g. shift+arrows, this is what moves. - /// Note that the two ends can come in any order, and also be equal (no selection). - pub primary: CCursor, - - /// When selecting with a mouse, this is where the mouse was first pressed. - /// This part of the cursor does not move when shift is down. - pub secondary: CCursor, - - /// Saved horizontal position of the cursor. - pub h_pos: Option, -} - -impl CCursorRange { - /// The empty range. - #[inline] - pub fn one(ccursor: CCursor) -> Self { - Self { - primary: ccursor, - secondary: ccursor, - h_pos: None, - } - } - - #[inline] - pub fn two(min: impl Into, max: impl Into) -> Self { - Self { - primary: max.into(), - secondary: min.into(), - h_pos: None, - } - } - - /// Select all the text in a galley - pub fn select_all(galley: &Galley) -> Self { - Self::two(galley.begin(), galley.end()) - } - - /// The range of selected character indices. - pub fn as_sorted_char_range(&self) -> std::ops::Range { - let [start, end] = self.sorted_cursors(); - std::ops::Range { - start: start.index, - end: end.index, - } - } - - /// True if the selected range contains no characters. - #[inline] - pub fn is_empty(&self) -> bool { - self.primary == self.secondary - } - - /// Is `self` a super-set of the other range? - pub fn contains(&self, other: Self) -> bool { - let [self_min, self_max] = self.sorted_cursors(); - let [other_min, other_max] = other.sorted_cursors(); - self_min.index <= other_min.index && other_max.index <= self_max.index - } - - /// If there is a selection, None is returned. - /// If the two ends are the same, that is returned. - pub fn single(&self) -> Option { - if self.is_empty() { - Some(self.primary) - } else { - None - } - } - - #[inline] - pub fn is_sorted(&self) -> bool { - let p = self.primary; - let s = self.secondary; - (p.index, p.prefer_next_row) <= (s.index, s.prefer_next_row) - } - - /// returns the two ends ordered - #[inline] - pub fn sorted_cursors(&self) -> [CCursor; 2] { - if self.is_sorted() { - [self.primary, self.secondary] - } else { - [self.secondary, self.primary] - } - } - - #[inline] - #[deprecated = "Use `self.sorted_cursors` instead."] - pub fn sorted(&self) -> [CCursor; 2] { - self.sorted_cursors() - } - - pub fn slice_str<'s>(&self, text: &'s str) -> &'s str { - let [min, max] = self.sorted_cursors(); - slice_char_range(text, min.index..max.index) - } - - /// Check for key presses that are moving the cursor. - /// - /// Returns `true` if we did mutate `self`. - pub fn on_key_press( - &mut self, - os: OperatingSystem, - galley: &Galley, - modifiers: &Modifiers, - key: Key, - ) -> bool { - match key { - Key::A if modifiers.command => { - *self = Self::select_all(galley); - true - } - - Key::ArrowLeft | Key::ArrowRight if modifiers.is_none() && !self.is_empty() => { - if key == Key::ArrowLeft { - *self = Self::one(self.sorted_cursors()[0]); - } else { - *self = Self::one(self.sorted_cursors()[1]); - } - true - } - - Key::ArrowLeft - | Key::ArrowRight - | Key::ArrowUp - | Key::ArrowDown - | Key::Home - | Key::End => { - move_single_cursor( - os, - &mut self.primary, - &mut self.h_pos, - galley, - key, - modifiers, - ); - if !modifiers.shift { - self.secondary = self.primary; - } - true - } - - Key::P | Key::N | Key::B | Key::F | Key::A | Key::E - if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift => - { - move_single_cursor( - os, - &mut self.primary, - &mut self.h_pos, - galley, - key, - modifiers, - ); - self.secondary = self.primary; - true - } - - _ => false, - } - } - - /// Check for events that modify the cursor range. - /// - /// Returns `true` if such an event was found and handled. - pub fn on_event( - &mut self, - os: OperatingSystem, - event: &Event, - galley: &Galley, - _widget_id: Id, - ) -> bool { - match event { - Event::Key { - modifiers, - key, - pressed: true, - .. - } => self.on_key_press(os, galley, modifiers, *key), - - #[cfg(feature = "accesskit")] - Event::AccessKitActionRequest(accesskit::ActionRequest { - action: accesskit::Action::SetTextSelection, - target, - data: Some(accesskit::ActionData::SetTextSelection(selection)), - }) => { - if _widget_id.accesskit_id() == *target { - let primary = - ccursor_from_accesskit_text_position(_widget_id, galley, &selection.focus); - let secondary = - ccursor_from_accesskit_text_position(_widget_id, galley, &selection.anchor); - if let (Some(primary), Some(secondary)) = (primary, secondary) { - *self = Self { - primary, - secondary, - h_pos: None, - }; - return true; - } - } - false - } - - _ => false, - } - } -} - -// ---------------------------------------------------------------------------- - -#[cfg(feature = "accesskit")] -fn ccursor_from_accesskit_text_position( - id: Id, - galley: &Galley, - position: &accesskit::TextPosition, -) -> Option { - let mut total_length = 0usize; - for (i, row) in galley.rows.iter().enumerate() { - let row_id = id.with(i); - if row_id.accesskit_id() == position.node { - return Some(CCursor { - index: total_length + position.character_index, - prefer_next_row: !(position.character_index == row.glyphs.len() - && !row.ends_with_newline - && (i + 1) < galley.rows.len()), - }); - } - total_length += row.glyphs.len() + (row.ends_with_newline as usize); - } - None -} - -// ---------------------------------------------------------------------------- - -/// Move a text cursor based on keyboard -fn move_single_cursor( - os: OperatingSystem, - cursor: &mut CCursor, - h_pos: &mut Option, - galley: &Galley, - key: Key, - modifiers: &Modifiers, -) { - let (new_cursor, new_h_pos) = - if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift { - match key { - Key::A => (galley.cursor_begin_of_row(cursor), None), - Key::E => (galley.cursor_end_of_row(cursor), None), - Key::P => galley.cursor_up_one_row(cursor, *h_pos), - Key::N => galley.cursor_down_one_row(cursor, *h_pos), - Key::B => (galley.cursor_left_one_character(cursor), None), - Key::F => (galley.cursor_right_one_character(cursor), None), - _ => return, - } - } else { - match key { - Key::ArrowLeft => { - if modifiers.alt || modifiers.ctrl { - // alt on mac, ctrl on windows - (ccursor_previous_word(galley, *cursor), None) - } else if modifiers.mac_cmd { - (galley.cursor_begin_of_row(cursor), None) - } else { - (galley.cursor_left_one_character(cursor), None) - } - } - Key::ArrowRight => { - if modifiers.alt || modifiers.ctrl { - // alt on mac, ctrl on windows - (ccursor_next_word(galley, *cursor), None) - } else if modifiers.mac_cmd { - (galley.cursor_end_of_row(cursor), None) - } else { - (galley.cursor_right_one_character(cursor), None) - } - } - Key::ArrowUp => { - if modifiers.command { - // mac and windows behavior - (galley.begin(), None) - } else { - galley.cursor_up_one_row(cursor, *h_pos) - } - } - Key::ArrowDown => { - if modifiers.command { - // mac and windows behavior - (galley.end(), None) - } else { - galley.cursor_down_one_row(cursor, *h_pos) - } - } - - Key::Home => { - if modifiers.ctrl { - // windows behavior - (galley.begin(), None) - } else { - (galley.cursor_begin_of_row(cursor), None) - } - } - Key::End => { - if modifiers.ctrl { - // windows behavior - (galley.end(), None) - } else { - (galley.cursor_end_of_row(cursor), None) - } - } - - _ => unreachable!(), - } - }; - - *cursor = new_cursor; - *h_pos = new_h_pos; -} diff --git a/crates/egui/src/text_selection/handle_event.rs b/crates/egui/src/text_selection/handle_event.rs new file mode 100644 index 00000000000..24beecddb92 --- /dev/null +++ b/crates/egui/src/text_selection/handle_event.rs @@ -0,0 +1,210 @@ +use epaint::{ + text::cursor::{ByteCursor, Selection}, + Galley, +}; + +use crate::{os::OperatingSystem, Event, Id, Key, Modifiers}; + +pub trait SelectionExt: Sized { + /// Check for events that modify the cursor range. + /// + /// Returns `true` if such an event was found and handled. + fn on_event( + &self, + os: OperatingSystem, + event: &Event, + galley: &Galley, + _widget_id: Id, + ) -> Option; + + /// Check for key presses that are moving the cursor. + /// + /// Returns `true` if we did mutate `self`. + fn on_key_press( + &self, + os: OperatingSystem, + galley: &Galley, + modifiers: &Modifiers, + key: Key, + ) -> Option; +} + +impl SelectionExt for Selection { + fn on_event( + &self, + os: OperatingSystem, + event: &Event, + galley: &Galley, + _widget_id: Id, + ) -> Option { + match event { + Event::Key { + modifiers, + key, + pressed: true, + .. + } => self.on_key_press(os, galley, modifiers, *key), + + #[cfg(feature = "accesskit")] + Event::AccessKitActionRequest(accesskit::ActionRequest { + action: accesskit::Action::SetTextSelection, + target, + data: Some(accesskit::ActionData::SetTextSelection(selection)), + }) if _widget_id.accesskit_id() == *target => { + galley.selection(|s| s.from_accesskit_selection(selection)) + } + + _ => None, + } + } + + fn on_key_press( + &self, + os: OperatingSystem, + galley: &Galley, + modifiers: &Modifiers, + key: Key, + ) -> Option { + match key { + Key::A if modifiers.command => Some(galley.selection(|s| s.select_all())), + + Key::ArrowLeft | Key::ArrowRight if modifiers.is_none() && !self.is_empty() => { + if key == Key::ArrowLeft { + Some(galley.selection(|s| s.select_prev_character(self, false))) + } else { + Some(galley.selection(|s| s.select_next_character(self, false))) + } + } + + Key::ArrowLeft + | Key::ArrowRight + | Key::ArrowUp + | Key::ArrowDown + | Key::Home + | Key::End => move_single_cursor(os, self, galley, key, modifiers), + + Key::P | Key::N | Key::B | Key::F | Key::A | Key::E + if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift => + { + move_single_cursor(os, self, galley, key, modifiers) + } + + _ => None, + } + } +} + +/// Move a text cursor based on keyboard +fn move_single_cursor( + os: OperatingSystem, + selection: &Selection, + galley: &Galley, + key: Key, + modifiers: &Modifiers, +) -> Option { + if os == OperatingSystem::Mac && modifiers.ctrl && !modifiers.shift { + match key { + Key::A => Some(galley.selection(|s| s.select_row_start(selection, modifiers.shift))), + Key::E => Some(galley.selection(|s| s.select_row_end(selection, modifiers.shift))), + Key::P => Some(galley.selection(|s| s.select_prev_row(selection, modifiers.shift))), + Key::N => Some(galley.selection(|s| s.select_next_row(selection, modifiers.shift))), + Key::B => { + Some(galley.selection(|s| s.select_prev_character(selection, modifiers.shift))) + } + Key::F => { + Some(galley.selection(|s| s.select_next_character(selection, modifiers.shift))) + } + _ => None, + } + } else { + match key { + Key::ArrowLeft => { + if modifiers.alt || modifiers.ctrl { + // alt on mac, ctrl on windows + Some(galley.selection(|s| s.select_prev_word(selection, modifiers.shift))) + } else if modifiers.mac_cmd { + Some(galley.selection(|s| s.select_row_start(selection, modifiers.shift))) + } else { + Some(galley.selection(|s| s.select_prev_character(selection, modifiers.shift))) + } + } + Key::ArrowRight => { + if modifiers.alt || modifiers.ctrl { + // alt on mac, ctrl on windows + Some(galley.selection(|s| s.select_next_word(selection, modifiers.shift))) + } else if modifiers.mac_cmd { + Some(galley.selection(|s| s.select_row_end(selection, modifiers.shift))) + } else { + Some(galley.selection(|s| s.select_next_character(selection, modifiers.shift))) + } + } + Key::ArrowUp => { + match (modifiers.command, modifiers.shift) { + (true, true) => { + // mac and windows behavior + Some(galley.selection(|s| { + s.extend_selection_to_cursor(selection, &ByteCursor::START) + })) + } + (true, false) => { + Some(galley.selection(|s| s.select_at_cursor(&ByteCursor::START))) + } + (false, extend) => { + Some(galley.selection(|s| s.select_prev_row(selection, extend))) + } + } + } + Key::ArrowDown => { + match (modifiers.command, modifiers.shift) { + (true, true) => { + // mac and windows behavior + Some(galley.selection(|s| { + s.extend_selection_to_cursor(selection, &ByteCursor::END) + })) + } + (true, false) => { + Some(galley.selection(|s| s.select_at_cursor(&ByteCursor::END))) + } + (false, extend) => { + Some(galley.selection(|s| s.select_next_row(selection, extend))) + } + } + } + + Key::Home => { + match (modifiers.command, modifiers.shift) { + (true, true) => { + // windows behavior + Some(galley.selection(|s| { + s.extend_selection_to_cursor(selection, &ByteCursor::START) + })) + } + (true, false) => { + Some(galley.selection(|s| s.select_at_cursor(&ByteCursor::START))) + } + (false, extend) => { + Some(galley.selection(|s| s.select_row_start(selection, extend))) + } + } + } + Key::End => { + match (modifiers.command, modifiers.shift) { + (true, true) => { + // windows behavior + Some(galley.selection(|s| { + s.extend_selection_to_cursor(selection, &ByteCursor::END) + })) + } + (true, false) => { + Some(galley.selection(|s| s.select_at_cursor(&ByteCursor::START))) + } + (false, extend) => { + Some(galley.selection(|s| s.select_row_end(selection, extend))) + } + } + } + + _ => unreachable!(), + } + } +} diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index 9ce8fbd5b85..314b5a2822a 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -1,15 +1,15 @@ use std::sync::Arc; use emath::TSTransform; +use epaint::text::cursor::Selection; use crate::{ - layers::ShapeIdx, text::CCursor, text_selection::CCursorRange, Context, CursorIcon, Event, - Galley, Id, LayerId, Pos2, Rect, Response, Ui, + layers::ShapeIdx, text::ByteCursor, Context, CursorIcon, Event, Galley, Id, LayerId, Pos2, + Rect, Response, Ui, }; use super::{ - text_cursor_state::cursor_rect, - visuals::{paint_text_selection, RowVertexIndices}, + handle_event::SelectionExt as _, text_cursor_state::cursor_rect, visuals::paint_text_selection, TextCursorState, }; @@ -20,7 +20,7 @@ const DEBUG: bool = false; // Don't merge `true`! #[derive(Clone, Copy)] struct WidgetTextCursor { widget_id: Id, - ccursor: CCursor, + cursor: ByteCursor, /// Last known screen position pos: Pos2, @@ -29,29 +29,29 @@ struct WidgetTextCursor { impl WidgetTextCursor { fn new( widget_id: Id, - cursor: impl Into, + cursor: impl Into, global_from_galley: TSTransform, galley: &Galley, ) -> Self { - let ccursor = cursor.into(); - let pos = global_from_galley * pos_in_galley(galley, ccursor); + let cursor = cursor.into(); + let pos = global_from_galley * pos_in_galley(galley, cursor); Self { widget_id, - ccursor, + cursor, pos, } } } -fn pos_in_galley(galley: &Galley, ccursor: CCursor) -> Pos2 { - galley.pos_from_cursor(ccursor).center() +fn pos_in_galley(galley: &Galley, cursor: ByteCursor) -> Pos2 { + galley.pos_from_cursor(cursor).center() } impl std::fmt::Debug for WidgetTextCursor { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WidgetTextCursor") .field("widget_id", &self.widget_id.short_debug_format()) - .field("ccursor", &self.ccursor.index) + .field("cursor", &self.cursor) .finish() } } @@ -66,11 +66,11 @@ struct CurrentSelection { /// When selecting with a mouse, this is where the mouse was released. /// When moving with e.g. shift+arrows, this is what moves. /// Note that the two ends can come in any order, and also be equal (no selection). - pub primary: WidgetTextCursor, + pub focus: WidgetTextCursor, /// When selecting with a mouse, this is where the mouse was first pressed. /// This part of the cursor does not move when shift is down. - pub secondary: WidgetTextCursor, + pub anchor: WidgetTextCursor, } /// Handles text selection in labels (NOT in [`crate::TextEdit`])s. @@ -91,10 +91,10 @@ pub struct LabelSelectionState { is_dragging: bool, /// Have we reached the widget containing the primary selection? - has_reached_primary: bool, + has_reached_focus: bool, /// Have we reached the widget containing the secondary selection? - has_reached_secondary: bool, + has_reached_anchor: bool, /// Accumulated text to copy. text_to_copy: String, @@ -103,7 +103,7 @@ pub struct LabelSelectionState { /// Painted selections this frame. /// /// Kept so we can undo a bad selection visualization if we don't see both ends of the selection this frame. - painted_selections: Vec<(ShapeIdx, Vec)>, + painted_selections: Vec, } impl Default for LabelSelectionState { @@ -114,8 +114,8 @@ impl Default for LabelSelectionState { selection_bbox_this_frame: Rect::NOTHING, any_hovered: Default::default(), is_dragging: Default::default(), - has_reached_primary: Default::default(), - has_reached_secondary: Default::default(), + has_reached_focus: Default::default(), + has_reached_anchor: Default::default(), text_to_copy: Default::default(), last_copied_galley_rect: Default::default(), painted_selections: Default::default(), @@ -154,8 +154,8 @@ impl LabelSelectionState { state.selection_bbox_this_frame = Rect::NOTHING; state.any_hovered = false; - state.has_reached_primary = false; - state.has_reached_secondary = false; + state.has_reached_focus = false; + state.has_reached_anchor = false; state.text_to_copy.clear(); state.last_copied_galley_rect = None; state.painted_selections.clear(); @@ -170,7 +170,7 @@ impl LabelSelectionState { ctx.set_cursor_icon(CursorIcon::Text); } - if !state.has_reached_primary || !state.has_reached_secondary { + if !state.has_reached_focus || !state.has_reached_anchor { // We didn't see both cursors this frame, // maybe because they are outside the visible area (scrolling), // or one disappeared. In either case we will have horrible glitches, so let's just deselect. @@ -181,26 +181,12 @@ impl LabelSelectionState { // glitching by removing all painted selections: ctx.graphics_mut(|layers| { if let Some(list) = layers.get_mut(selection.layer_id) { - for (shape_idx, row_selections) in state.painted_selections.drain(..) { + for shape_idx in state.painted_selections.drain(..) { list.mutate_shape(shape_idx, |shape| { if let epaint::Shape::Text(text_shape) = &mut shape.shape { let galley = Arc::make_mut(&mut text_shape.galley); - for row_selection in row_selections { - if let Some(placed_row) = - galley.rows.get_mut(row_selection.row) - { - let row = Arc::make_mut(&mut placed_row.row); - for vertex_index in row_selection.vertex_indices { - if let Some(vertex) = row - .visuals - .mesh - .vertices - .get_mut(vertex_index as usize) - { - vertex.color = epaint::Color32::TRANSPARENT; - } - } - } + for row in &mut galley.rows { + row.visuals.selection_rects = None; } } }); @@ -238,8 +224,8 @@ impl LabelSelectionState { self.selection = None; } - fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, cursor_range: &CCursorRange) { - let new_text = selected_text(galley, cursor_range); + fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, selection: &Selection) { + let new_text = selected_text(galley, selection); if new_text.is_empty() { return; } @@ -298,16 +284,14 @@ impl LabelSelectionState { underline: epaint::Stroke, ) { let mut state = Self::load(ui.ctx()); - let new_vertex_indices = state.on_label(ui, response, galley_pos, &mut galley); + let did_draw_selection = state.on_label(ui, response, galley_pos, &mut galley); let shape_idx = ui.painter().add( epaint::TextShape::new(galley_pos, galley, fallback_color).with_underline(underline), ); - if !new_vertex_indices.is_empty() { - state - .painted_selections - .push((shape_idx, new_vertex_indices)); + if did_draw_selection { + state.painted_selections.push(shape_idx); } state.store(ui.ctx()); @@ -335,31 +319,34 @@ impl LabelSelectionState { let multi_widget_text_select = ui.style().interaction.multi_widget_text_select; let may_select_widget = - multi_widget_text_select || selection.primary.widget_id == response.id; + multi_widget_text_select || selection.focus.widget_id == response.id; if self.is_dragging && may_select_widget { if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { let galley_rect = global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size()); - let galley_rect = galley_rect.intersect(ui.clip_rect()); + let galley_rect = galley_rect + // The response rectangle is set by hit testing, which includes interact_radius. galley_rect doesn't. + .expand(ui.style().interaction.interact_radius) + .intersect(ui.clip_rect()); let is_in_same_column = galley_rect .x_range() .intersects(self.selection_bbox_last_frame.x_range()); - let has_reached_primary = - self.has_reached_primary || response.id == selection.primary.widget_id; - let has_reached_secondary = - self.has_reached_secondary || response.id == selection.secondary.widget_id; + let has_reached_focus = + self.has_reached_focus || response.id == selection.focus.widget_id; + let has_reached_anchor = + self.has_reached_anchor || response.id == selection.anchor.widget_id; - let new_primary = if response.contains_pointer() { + let new_focus = if response.contains_pointer() { // Dragging into this widget - easy case: Some(galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2())) } else if is_in_same_column - && !self.has_reached_primary - && selection.primary.pos.y <= selection.secondary.pos.y + && !self.has_reached_focus + && selection.focus.pos.y <= selection.anchor.pos.y && pointer_pos.y <= galley_rect.top() - && galley_rect.top() <= selection.secondary.pos.y + && galley_rect.top() <= selection.anchor.pos.y { // The user is dragging the text selection upwards, above the first selected widget (this one): if DEBUG { @@ -368,10 +355,10 @@ impl LabelSelectionState { } Some(galley.begin()) } else if is_in_same_column - && has_reached_secondary - && has_reached_primary - && selection.secondary.pos.y <= selection.primary.pos.y - && selection.secondary.pos.y <= galley_rect.bottom() + && has_reached_anchor + && has_reached_focus + && selection.anchor.pos.y <= selection.focus.pos.y + && selection.anchor.pos.y <= galley_rect.bottom() && galley_rect.bottom() <= pointer_pos.y { // The user is dragging the text selection downwards, below this widget. @@ -386,9 +373,9 @@ impl LabelSelectionState { None }; - if let Some(new_primary) = new_primary { - selection.primary = - WidgetTextCursor::new(response.id, new_primary, global_from_galley, galley); + if let Some(new_focus) = new_focus { + selection.focus = + WidgetTextCursor::new(response.id, new_focus, global_from_galley, galley); // We don't want the latency of `drag_started`. let drag_started = ui.input(|i| i.pointer.any_pressed()); @@ -398,54 +385,50 @@ impl LabelSelectionState { // A continuation of a previous selection. } else { // A new selection in the same layer. - selection.secondary = selection.primary; + selection.anchor = selection.focus; } } else { // A new selection in a new layer. selection.layer_id = response.layer_id; - selection.secondary = selection.primary; + selection.anchor = selection.focus; } } } } } - let has_primary = response.id == selection.primary.widget_id; - let has_secondary = response.id == selection.secondary.widget_id; + let has_focus = response.id == selection.focus.widget_id; + let has_anchor = response.id == selection.anchor.widget_id; - if has_primary { - selection.primary.pos = - global_from_galley * pos_in_galley(galley, selection.primary.ccursor); + if has_focus { + selection.focus.pos = + global_from_galley * pos_in_galley(galley, selection.focus.cursor); } - if has_secondary { - selection.secondary.pos = - global_from_galley * pos_in_galley(galley, selection.secondary.ccursor); + if has_anchor { + selection.anchor.pos = + global_from_galley * pos_in_galley(galley, selection.anchor.cursor); } - self.has_reached_primary |= has_primary; - self.has_reached_secondary |= has_secondary; + self.has_reached_focus |= has_focus; + self.has_reached_anchor |= has_anchor; - let primary = has_primary.then_some(selection.primary.ccursor); - let secondary = has_secondary.then_some(selection.secondary.ccursor); + let focus = has_focus.then_some(selection.focus.cursor); + let anchor = has_anchor.then_some(selection.anchor.cursor); // The following code assumes we will encounter both ends of the cursor // at some point (but in any order). // If we don't (e.g. because one endpoint is outside the visible scroll areas), // we will have annoying failure cases. - match (primary, secondary) { - (Some(primary), Some(secondary)) => { + match (focus, anchor) { + (Some(focus), Some(anchor)) => { // This is the only selected label. - TextCursorState::from(CCursorRange { - primary, - secondary, - h_pos: None, - }) + TextCursorState::from(galley.selection(|s| s.select_cursor_range(&anchor, &focus))) } - (Some(primary), None) => { + (Some(focus), None) => { // This labels contains only the primary cursor. - let secondary = if self.has_reached_secondary { + let anchor = if self.has_reached_anchor { // Secondary was before primary. // Select everything up to the cursor. // We assume normal left-to-right and top-down layout order here. @@ -454,16 +437,12 @@ impl LabelSelectionState { // Select everything from the cursor onward: galley.end() }; - TextCursorState::from(CCursorRange { - primary, - secondary, - h_pos: None, - }) + TextCursorState::from(galley.selection(|s| s.select_cursor_range(&anchor, &focus))) } - (None, Some(secondary)) => { + (None, Some(anchor)) => { // This labels contains only the secondary cursor - let primary = if self.has_reached_primary { + let focus = if self.has_reached_focus { // Primary was before secondary. // Select everything up to the cursor. // We assume normal left-to-right and top-down layout order here. @@ -472,25 +451,21 @@ impl LabelSelectionState { // Select everything from the cursor onward: galley.end() }; - TextCursorState::from(CCursorRange { - primary, - secondary, - h_pos: None, - }) + TextCursorState::from(galley.selection(|s| s.select_cursor_range(&anchor, &focus))) } (None, None) => { // This widget has neither the primary or secondary cursor. - let is_in_middle = self.has_reached_primary != self.has_reached_secondary; + let is_in_middle = self.has_reached_focus != self.has_reached_anchor; if is_in_middle { if DEBUG { response.ctx.debug_text(format!( "widget in middle: {:?}, between {:?} and {:?}", - response.id, selection.primary.widget_id, selection.secondary.widget_id, + response.id, selection.focus.widget_id, selection.anchor.widget_id, )); } // …but it is between the two selection endpoints, and so is fully selected. - TextCursorState::from(CCursorRange::two(galley.begin(), galley.end())) + TextCursorState::from(galley.selection(|s| s.select_all())) } else { // Outside the selected range TextCursorState::default() @@ -499,14 +474,14 @@ impl LabelSelectionState { } } - /// Returns the painted selections, if any. + /// Returns true if any selections were painted. fn on_label( &mut self, ui: &Ui, response: &Response, galley_pos_in_layer: Pos2, galley: &mut Arc, - ) -> Vec { + ) -> bool { let widget_id = response.id; let global_from_layer = ui @@ -530,94 +505,99 @@ impl LabelSelectionState { let mut cursor_state = self.cursor_for(ui, response, global_from_galley, galley); - let old_range = cursor_state.char_range(); + let old_layout_selection = cursor_state.selection(); if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { if response.contains_pointer() { - let cursor_at_pointer = - galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2()); + let galley_space_pos = (galley_from_global * pointer_pos).to_vec2(); // This is where we handle start-of-drag and double-click-to-select. // Actual drag-to-select happens elsewhere. let dragged = false; - cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley, dragged); + cursor_state.pointer_interaction(ui, response, galley_space_pos, galley, dragged); } } - if let Some(mut cursor_range) = cursor_state.char_range() { + if let Some(mut layout_selection) = cursor_state.selection() { let galley_rect = global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size()); self.selection_bbox_this_frame = self.selection_bbox_this_frame.union(galley_rect); if let Some(selection) = &self.selection { - if selection.primary.widget_id == response.id { - process_selection_key_events(ui.ctx(), galley, response.id, &mut cursor_range); + if selection.focus.widget_id == response.id { + process_selection_key_events( + ui.ctx(), + galley, + response.id, + &mut layout_selection, + ); } } if got_copy_event(ui.ctx()) { - self.copy_text(galley_rect, galley, &cursor_range); + self.copy_text(galley_rect, galley, &layout_selection); } - cursor_state.set_char_range(Some(cursor_range)); + cursor_state.set_selection(Some(layout_selection)); } // Look for changes due to keyboard and/or mouse interaction: - let new_range = cursor_state.char_range(); - let selection_changed = old_range != new_range; + let new_layout_selection = cursor_state.selection(); + let selection_changed = old_layout_selection != new_layout_selection; - if let (true, Some(range)) = (selection_changed, new_range) { + if let (true, Some(range)) = (selection_changed, new_layout_selection) { // -------------- // Store results: if let Some(selection) = &mut self.selection { - let primary_changed = Some(range.primary) != old_range.map(|r| r.primary); - let secondary_changed = Some(range.secondary) != old_range.map(|r| r.secondary); + let focus_changed = Some(range.focus()) != old_layout_selection.map(|r| r.focus()); + let anchor_changed = + Some(range.anchor()) != old_layout_selection.map(|r| r.anchor()); selection.layer_id = response.layer_id; - if primary_changed || !ui.style().interaction.multi_widget_text_select { - selection.primary = - WidgetTextCursor::new(widget_id, range.primary, global_from_galley, galley); - self.has_reached_primary = true; + if focus_changed || !ui.style().interaction.multi_widget_text_select { + selection.focus = + WidgetTextCursor::new(widget_id, range.focus(), global_from_galley, galley); + self.has_reached_focus = true; } - if secondary_changed || !ui.style().interaction.multi_widget_text_select { - selection.secondary = WidgetTextCursor::new( + if anchor_changed || !ui.style().interaction.multi_widget_text_select { + selection.anchor = WidgetTextCursor::new( widget_id, - range.secondary, + range.anchor(), global_from_galley, galley, ); - self.has_reached_secondary = true; + self.has_reached_anchor = true; } } else { // Start of a new selection self.selection = Some(CurrentSelection { layer_id: response.layer_id, - primary: WidgetTextCursor::new( + focus: WidgetTextCursor::new( widget_id, - range.primary, + range.focus(), global_from_galley, galley, ), - secondary: WidgetTextCursor::new( + anchor: WidgetTextCursor::new( widget_id, - range.secondary, + range.anchor(), global_from_galley, galley, ), }); - self.has_reached_primary = true; - self.has_reached_secondary = true; + self.has_reached_focus = true; + self.has_reached_anchor = true; } } // Scroll containing ScrollArea on cursor change: - if let Some(range) = new_range { - let old_primary = old_selection.map(|s| s.primary); - let new_primary = self.selection.as_ref().map(|s| s.primary); + if let Some(range) = new_layout_selection { + let old_primary = old_selection.map(|s| s.focus); + let new_primary = self.selection.as_ref().map(|s| s.focus); if let Some(new_primary) = new_primary { let primary_changed = old_primary.is_none_or(|old| { - old.widget_id != new_primary.widget_id || old.ccursor != new_primary.ccursor + old.widget_id != new_primary.widget_id || old.cursor != new_primary.cursor }); if primary_changed && new_primary.widget_id == widget_id { let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO(emilk): remove this HACK workaround for https://github.com/emilk/egui/issues/1531 @@ -625,37 +605,29 @@ impl LabelSelectionState { // Scroll to keep primary cursor in view: let row_height = estimate_row_height(galley); let primary_cursor_rect = - global_from_galley * cursor_rect(galley, &range.primary, row_height); + global_from_galley * cursor_rect(galley, &range.focus(), row_height); ui.scroll_to_rect(primary_cursor_rect, None); } } } } - let cursor_range = cursor_state.char_range(); + let selection = cursor_state.selection(); - let mut new_vertex_indices = vec![]; - - if let Some(cursor_range) = cursor_range { - paint_text_selection( - galley, - ui.visuals(), - &cursor_range, - Some(&mut new_vertex_indices), - ); - } + let did_draw_selection = selection + .is_some_and(|selection| paint_text_selection(galley, ui.visuals(), &selection)); #[cfg(feature = "accesskit")] super::accesskit_text::update_accesskit_for_text_widget( ui.ctx(), response.id, - cursor_range, + selection, accesskit::Role::Label, global_from_galley, galley, ); - new_vertex_indices + did_draw_selection } } @@ -672,7 +644,7 @@ fn process_selection_key_events( ctx: &Context, galley: &Galley, widget_id: Id, - cursor_range: &mut CCursorRange, + selection: &mut Selection, ) -> bool { let os = ctx.os(); @@ -682,30 +654,33 @@ fn process_selection_key_events( // NOTE: we have a lock on ui/ctx here, // so be careful to not call into `ui` or `ctx` again. for event in &i.events { - changed |= cursor_range.on_event(os, event, galley, widget_id); + if let Some(new_selection) = selection.on_event(os, event, galley, widget_id) { + changed = true; + *selection = new_selection; + } } }); changed } -fn selected_text(galley: &Galley, cursor_range: &CCursorRange) -> String { +fn selected_text(galley: &Galley, selection: &Selection) -> String { // This logic means we can select everything in an elided label (including the `…`) // and still copy the entire un-elided text! - let everything_is_selected = cursor_range.contains(CCursorRange::select_all(galley)); + let everything_is_selected = selection.contains(&galley.selection(|s| s.select_all())); - let copy_everything = cursor_range.is_empty() || everything_is_selected; + let copy_everything = selection.is_empty() || everything_is_selected; if copy_everything { galley.text().to_owned() } else { - cursor_range.slice_str(galley).to_owned() + selection.slice_str(galley).to_owned() } } fn estimate_row_height(galley: &Galley) -> f32 { - if let Some(placed_row) = galley.rows.first() { - placed_row.height() + if let Some(row) = galley.rows.first() { + row.rect.height() } else { galley.size().y } diff --git a/crates/egui/src/text_selection/mod.rs b/crates/egui/src/text_selection/mod.rs index 8d0943d6097..6d726a4c63a 100644 --- a/crates/egui/src/text_selection/mod.rs +++ b/crates/egui/src/text_selection/mod.rs @@ -3,11 +3,10 @@ #[cfg(feature = "accesskit")] pub mod accesskit_text; -mod cursor_range; +pub mod handle_event; mod label_text_selection; pub mod text_cursor_state; pub mod visuals; -pub use cursor_range::CCursorRange; pub use label_text_selection::LabelSelectionState; pub use text_cursor_state::TextCursorState; diff --git a/crates/egui/src/text_selection/text_cursor_state.rs b/crates/egui/src/text_selection/text_cursor_state.rs index 298d8abfbff..8ba32e304a9 100644 --- a/crates/egui/src/text_selection/text_cursor_state.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -1,12 +1,13 @@ //! Text cursor changes/interaction, without modifying the text. -use epaint::text::{cursor::CCursor, Galley}; -use unicode_segmentation::UnicodeSegmentation as _; +use emath::Vec2; +use epaint::text::{ + cursor::{ByteCursor, Selection}, + Galley, +}; use crate::{epaint, NumExt as _, Rect, Response, Ui}; -use super::CCursorRange; - /// The state of a text cursor selection. /// /// Used for [`crate::TextEdit`] and [`crate::Label`]. @@ -14,30 +15,28 @@ use super::CCursorRange; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct TextCursorState { - ccursor_range: Option, + selection: Option, } -impl From for TextCursorState { - fn from(ccursor_range: CCursorRange) -> Self { +impl From for TextCursorState { + fn from(selection: Selection) -> Self { Self { - ccursor_range: Some(ccursor_range), + selection: Some(selection), } } } impl TextCursorState { pub fn is_empty(&self) -> bool { - self.ccursor_range.is_none() + self.selection.is_none() } - /// The currently selected range of characters. - pub fn char_range(&self) -> Option { - self.ccursor_range + pub fn selection(&self) -> Option { + self.selection } - /// Sets the currently selected range of characters. - pub fn set_char_range(&mut self, ccursor_range: Option) { - self.ccursor_range = ccursor_range; + pub fn set_selection(&mut self, selection: Option) { + self.selection = selection; } } @@ -49,41 +48,47 @@ impl TextCursorState { &mut self, ui: &Ui, response: &Response, - cursor_at_pointer: CCursor, + pointer_pos: Vec2, galley: &Galley, is_being_dragged: bool, ) -> bool { - let text = galley.text(); - if response.double_clicked() { // Select word: - let ccursor_range = select_word_at(text, cursor_at_pointer); - self.set_char_range(Some(ccursor_range)); + let selection = galley.selection(|s| s.select_word_at(pointer_pos)); + self.set_selection(Some(selection)); true } else if response.triple_clicked() { // Select line: - let ccursor_range = select_line_at(text, cursor_at_pointer); - self.set_char_range(Some(ccursor_range)); + let selection = galley.selection(|s| s.select_line_at(pointer_pos)); + self.set_selection(Some(selection)); true } else if response.sense.senses_drag() { if response.hovered() && ui.input(|i| i.pointer.any_pressed()) { // The start of a drag (or a click). if ui.input(|i| i.modifiers.shift) { - if let Some(mut cursor_range) = self.char_range() { - cursor_range.primary = cursor_at_pointer; - self.set_char_range(Some(cursor_range)); + if let Some(selection) = self.selection() { + self.set_selection(Some( + galley.selection(|s| { + s.extend_selection_to_point(&selection, pointer_pos) + }), + )); } else { - self.set_char_range(Some(CCursorRange::one(cursor_at_pointer))); + self.set_selection(Some( + galley.selection(|s| s.select_single_point_at(pointer_pos)), + )); } } else { - self.set_char_range(Some(CCursorRange::one(cursor_at_pointer))); + self.set_selection(Some( + galley.selection(|s| s.select_single_point_at(pointer_pos)), + )); } true } else if is_being_dragged { // Drag to select text: - if let Some(mut cursor_range) = self.char_range() { - cursor_range.primary = cursor_at_pointer; - self.set_char_range(Some(cursor_range)); + if let Some(selection) = self.selection() { + self.set_selection(Some( + galley.selection(|s| s.extend_selection_to_point(&selection, pointer_pos)), + )); } true } else { @@ -95,251 +100,16 @@ impl TextCursorState { } } -fn select_word_at(text: &str, ccursor: CCursor) -> CCursorRange { - if ccursor.index == 0 { - CCursorRange::two(ccursor, ccursor_next_word(text, ccursor)) - } else { - let it = text.chars(); - let mut it = it.skip(ccursor.index - 1); - if let Some(char_before_cursor) = it.next() { - if let Some(char_after_cursor) = it.next() { - if is_word_char(char_before_cursor) && is_word_char(char_after_cursor) { - let min = ccursor_previous_word(text, ccursor + 1); - let max = ccursor_next_word(text, min); - CCursorRange::two(min, max) - } else if is_word_char(char_before_cursor) { - let min = ccursor_previous_word(text, ccursor); - let max = ccursor_next_word(text, min); - CCursorRange::two(min, max) - } else if is_word_char(char_after_cursor) { - let max = ccursor_next_word(text, ccursor); - CCursorRange::two(ccursor, max) - } else { - let min = ccursor_previous_word(text, ccursor); - let max = ccursor_next_word(text, ccursor); - CCursorRange::two(min, max) - } - } else { - let min = ccursor_previous_word(text, ccursor); - CCursorRange::two(min, ccursor) - } - } else { - let max = ccursor_next_word(text, ccursor); - CCursorRange::two(ccursor, max) - } - } -} - -fn select_line_at(text: &str, ccursor: CCursor) -> CCursorRange { - if ccursor.index == 0 { - CCursorRange::two(ccursor, ccursor_next_line(text, ccursor)) - } else { - let it = text.chars(); - let mut it = it.skip(ccursor.index - 1); - if let Some(char_before_cursor) = it.next() { - if let Some(char_after_cursor) = it.next() { - if (!is_linebreak(char_before_cursor)) && (!is_linebreak(char_after_cursor)) { - let min = ccursor_previous_line(text, ccursor + 1); - let max = ccursor_next_line(text, min); - CCursorRange::two(min, max) - } else if !is_linebreak(char_before_cursor) { - let min = ccursor_previous_line(text, ccursor); - let max = ccursor_next_line(text, min); - CCursorRange::two(min, max) - } else if !is_linebreak(char_after_cursor) { - let max = ccursor_next_line(text, ccursor); - CCursorRange::two(ccursor, max) - } else { - let min = ccursor_previous_line(text, ccursor); - let max = ccursor_next_line(text, ccursor); - CCursorRange::two(min, max) - } - } else { - let min = ccursor_previous_line(text, ccursor); - CCursorRange::two(min, ccursor) - } - } else { - let max = ccursor_next_line(text, ccursor); - CCursorRange::two(ccursor, max) - } - } -} - -pub fn ccursor_next_word(text: &str, ccursor: CCursor) -> CCursor { - CCursor { - index: next_word_boundary_char_index(text, ccursor.index), - prefer_next_row: false, - } -} - -fn ccursor_next_line(text: &str, ccursor: CCursor) -> CCursor { - CCursor { - index: next_line_boundary_char_index(text.chars(), ccursor.index), - prefer_next_row: false, - } -} - -pub fn ccursor_previous_word(text: &str, ccursor: CCursor) -> CCursor { - let num_chars = text.chars().count(); - let reversed: String = text.graphemes(true).rev().collect(); - CCursor { - index: num_chars - - next_word_boundary_char_index(&reversed, num_chars - ccursor.index).min(num_chars), - prefer_next_row: true, - } -} - -fn ccursor_previous_line(text: &str, ccursor: CCursor) -> CCursor { - let num_chars = text.chars().count(); - CCursor { - index: num_chars - - next_line_boundary_char_index(text.chars().rev(), num_chars - ccursor.index), - prefer_next_row: true, - } -} - -fn next_word_boundary_char_index(text: &str, index: usize) -> usize { - for word in text.split_word_bound_indices() { - // Splitting considers contiguous whitespace as one word, such words must be skipped, - // this handles cases for example ' abc' (a space and a word), the cursor is at the beginning - // (before space) - this jumps at the end of 'abc' (this is consistent with text editors - // or browsers) - let ci = char_index_from_byte_index(text, word.0); - if ci > index && !skip_word(word.1) { - return ci; - } - } - - char_index_from_byte_index(text, text.len()) -} - -fn skip_word(text: &str) -> bool { - // skip words that contain anything other than alphanumeric characters and underscore - // (i.e. whitespace, dashes, etc.) - !text.chars().any(|c| !is_word_char(c)) -} - -fn next_line_boundary_char_index(it: impl Iterator, mut index: usize) -> usize { - let mut it = it.skip(index); - if let Some(_first) = it.next() { - index += 1; - - if let Some(second) = it.next() { - index += 1; - for next in it { - if is_linebreak(next) != is_linebreak(second) { - break; - } - index += 1; - } - } - } - index -} - -pub fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || c == '_' -} - -fn is_linebreak(c: char) -> bool { - c == '\r' || c == '\n' -} - -/// Accepts and returns character offset (NOT byte offset!). -pub fn find_line_start(text: &str, current_index: CCursor) -> CCursor { - // We know that new lines, '\n', are a single byte char, but we have to - // work with char offsets because before the new line there may be any - // number of multi byte chars. - // We need to know the char index to be able to correctly set the cursor - // later. - let chars_count = text.chars().count(); - - let position = text - .chars() - .rev() - .skip(chars_count - current_index.index) - .position(|x| x == '\n'); - - match position { - Some(pos) => CCursor::new(current_index.index - pos), - None => CCursor::new(0), - } -} - -pub fn byte_index_from_char_index(s: &str, char_index: usize) -> usize { - for (ci, (bi, _)) in s.char_indices().enumerate() { - if ci == char_index { - return bi; - } - } - s.len() -} - -pub fn char_index_from_byte_index(input: &str, byte_index: usize) -> usize { - for (ci, (bi, _)) in input.char_indices().enumerate() { - if bi == byte_index { - return ci; - } - } - - input.char_indices().last().map_or(0, |(i, _)| i + 1) -} - -pub fn slice_char_range(s: &str, char_range: std::ops::Range) -> &str { - assert!( - char_range.start <= char_range.end, - "Invalid range, start must be less than end, but start = {}, end = {}", - char_range.start, - char_range.end - ); - let start_byte = byte_index_from_char_index(s, char_range.start); - let end_byte = byte_index_from_char_index(s, char_range.end); - &s[start_byte..end_byte] -} - /// The thin rectangle of one end of the selection, e.g. the primary cursor, in local galley coordinates. -pub fn cursor_rect(galley: &Galley, cursor: &CCursor, row_height: f32) -> Rect { +pub fn cursor_rect(galley: &Galley, cursor: &ByteCursor, row_height: f32) -> Rect { let mut cursor_pos = galley.pos_from_cursor(*cursor); // Handle completely empty galleys - cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height); + if cursor_pos.height() < 1.0 { + cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height); + } cursor_pos = cursor_pos.expand(1.5); // slightly above/below row cursor_pos } - -#[cfg(test)] -mod test { - use crate::text_selection::text_cursor_state::next_word_boundary_char_index; - - #[test] - fn test_next_word_boundary_char_index() { - // ASCII only - let text = "abc d3f g_h i-j"; - assert_eq!(next_word_boundary_char_index(text, 1), 3); - assert_eq!(next_word_boundary_char_index(text, 3), 7); - assert_eq!(next_word_boundary_char_index(text, 9), 11); - assert_eq!(next_word_boundary_char_index(text, 12), 13); - assert_eq!(next_word_boundary_char_index(text, 13), 15); - assert_eq!(next_word_boundary_char_index(text, 15), 15); - - assert_eq!(next_word_boundary_char_index("", 0), 0); - assert_eq!(next_word_boundary_char_index("", 1), 0); - - // Unicode graphemes, some of which consist of multiple Unicode characters, - // !!! Unicode character is not always what is tranditionally considered a character, - // the values below are correct despite not seeming that way on the first look, - // handling of and around emojis is kind of weird and is not consistent across - // text editors and browsers - let text = "❤️👍 skvělá knihovna 👍❤️"; - assert_eq!(next_word_boundary_char_index(text, 0), 2); - assert_eq!(next_word_boundary_char_index(text, 2), 3); // this does not skip the space between thumbs-up and 'skvělá' - assert_eq!(next_word_boundary_char_index(text, 6), 10); - assert_eq!(next_word_boundary_char_index(text, 9), 10); - assert_eq!(next_word_boundary_char_index(text, 12), 19); - assert_eq!(next_word_boundary_char_index(text, 15), 19); - assert_eq!(next_word_boundary_char_index(text, 19), 20); - assert_eq!(next_word_boundary_char_index(text, 20), 21); - } -} diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index deee5690bce..aa064c582cd 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -1,98 +1,26 @@ use std::sync::Arc; -use crate::{pos2, vec2, Galley, Painter, Rect, Ui, Visuals}; +use epaint::text::cursor::Selection; -use super::CCursorRange; - -#[derive(Clone, Debug)] -pub struct RowVertexIndices { - pub row: usize, - pub vertex_indices: [u32; 6], -} +use crate::{vec2, Galley, Painter, Rect, Ui, Visuals}; /// Adds text selection rectangles to the galley. +/// Returns true if any selection rectangles were drawn. pub fn paint_text_selection( galley: &mut Arc, visuals: &Visuals, - cursor_range: &CCursorRange, - mut new_vertex_indices: Option<&mut Vec>, -) { - if cursor_range.is_empty() { - return; + selection: &Selection, +) -> bool { + if selection.is_empty() { + return false; } // We need to modify the galley (add text selection painting to it), // and so we need to clone it if it is shared: let galley: &mut Galley = Arc::make_mut(galley); - let color = visuals.selection.bg_fill; - let [min, max] = cursor_range.sorted_cursors(); - let min = galley.layout_from_cursor(min); - let max = galley.layout_from_cursor(max); - - for ri in min.row..=max.row { - let row = Arc::make_mut(&mut galley.rows[ri].row); - - let left = if ri == min.row { - row.x_offset(min.column) - } else { - 0.0 - }; - let right = if ri == max.row { - row.x_offset(max.column) - } else { - let newline_size = if row.ends_with_newline { - row.height() / 2.0 // visualize that we select the newline - } else { - 0.0 - }; - row.size.x + newline_size - }; - - let rect = Rect::from_min_max(pos2(left, 0.0), pos2(right, row.size.y)); - let mesh = &mut row.visuals.mesh; - - // Time to insert the selection rectangle into the row mesh. - // It should be on top (after) of any background in the galley, - // but behind (before) any glyphs. The row visuals has this information: - let glyph_index_start = row.visuals.glyph_index_start; - - // Start by appending the selection rectangle to end of the mesh, as two triangles (= 6 indices): - let num_indices_before = mesh.indices.len(); - mesh.add_colored_rect(rect, color); - assert_eq!( - num_indices_before + 6, - mesh.indices.len(), - "We expect exactly 6 new indices" - ); - - // Copy out the new triangles: - let selection_triangles = [ - mesh.indices[num_indices_before], - mesh.indices[num_indices_before + 1], - mesh.indices[num_indices_before + 2], - mesh.indices[num_indices_before + 3], - mesh.indices[num_indices_before + 4], - mesh.indices[num_indices_before + 5], - ]; - - // Move every old triangle forwards by 6 indices to make room for the new triangle: - for i in (glyph_index_start..num_indices_before).rev() { - mesh.indices.swap(i, i + 6); - } - // Put the new triangle in place: - mesh.indices[glyph_index_start..glyph_index_start + 6] - .clone_from_slice(&selection_triangles); - - row.visuals.mesh_bounds = mesh.calc_bounds(); - - if let Some(new_vertex_indices) = &mut new_vertex_indices { - new_vertex_indices.push(RowVertexIndices { - row: ri, - vertex_indices: selection_triangles, - }); - } - } + // TODO(valadaptive): implement selection stroke? the old code never did + galley.paint_selection(visuals.selection.bg_fill, selection) } /// Paint one end of the selection, e.g. the primary cursor. diff --git a/crates/egui/src/ui.rs b/crates/egui/src/ui.rs index 4174ca96111..5eda9a8dd89 100644 --- a/crates/egui/src/ui.rs +++ b/crates/egui/src/ui.rs @@ -2,7 +2,7 @@ #![allow(clippy::use_self)] use emath::GuiRounding as _; -use epaint::mutex::RwLock; +use epaint::{mutex::RwLock, text::Fonts}; use std::{any::Any, hash::Hash, sync::Arc}; use crate::close_tag::ClosableTag; @@ -12,9 +12,7 @@ use crate::Stroke; use crate::{ containers::{CollapsingHeader, CollapsingResponse, Frame}, ecolor::Hsva, - emath, epaint, - epaint::text::Fonts, - grid, + emath, epaint, grid, layout::{Direction, Layout}, pass_state, placer::Placer, @@ -839,9 +837,9 @@ impl Ui { self.ctx().output_mut(writer) } - /// Read-only access to [`Fonts`]. + /// Read-write access to [`Fonts`]. #[inline] - pub fn fonts(&self, reader: impl FnOnce(&Fonts) -> R) -> R { + pub fn fonts(&self, reader: impl FnOnce(&mut Fonts<'_>) -> R) -> R { self.ctx().fonts(reader) } } diff --git a/crates/egui/src/widget_text.rs b/crates/egui/src/widget_text.rs index d9f98859b5c..877c665b742 100644 --- a/crates/egui/src/widget_text.rs +++ b/crates/egui/src/widget_text.rs @@ -1,11 +1,14 @@ use std::{borrow::Cow, sync::Arc}; use emath::GuiRounding as _; -use epaint::text::TextFormat; +use epaint::text::style::{FontFeatures, FontVariations, FontWeight, FontWidth, TextFormat}; use crate::{ - text::{LayoutJob, TextWrapping}, - Align, Color32, FontFamily, FontSelection, Galley, Style, TextStyle, TextWrapMode, Ui, Visuals, + text::{ + style::{FontId, FontStack}, + LayoutJob, TextWrapping, + }, + Align, Color32, FontSelection, Galley, Style, TextStyle, TextWrapMode, Ui, Visuals, }; /// Text and optional style choices for it. @@ -29,7 +32,7 @@ pub struct RichText { size: Option, extra_letter_spacing: f32, line_height: Option, - family: Option, + family: Option, text_style: Option, background_color: Color32, expand_bg: f32, @@ -37,6 +40,10 @@ pub struct RichText { code: bool, strong: bool, weak: bool, + weight: Option, + width: Option, + variations: Option>, + features: Option>, strikethrough: bool, underline: bool, italics: bool, @@ -58,6 +65,10 @@ impl Default for RichText { code: Default::default(), strong: Default::default(), weak: Default::default(), + weight: Default::default(), + width: Default::default(), + variations: Default::default(), + features: Default::default(), strikethrough: Default::default(), underline: Default::default(), italics: Default::default(), @@ -181,18 +192,30 @@ impl RichText { /// /// Only the families available in [`crate::FontDefinitions::families`] may be used. #[inline] - pub fn family(mut self, family: FontFamily) -> Self { - self.family = Some(family); + pub fn family(mut self, family: impl Into) -> Self { + self.family = Some(family.into()); self } /// Select the font and size. /// This overrides the value from [`Self::text_style`]. #[inline] - pub fn font(mut self, font_id: crate::FontId) -> Self { - let crate::FontId { size, family } = font_id; + pub fn font(mut self, font_id: FontId) -> Self { + let FontId { + size, + family, + weight, + width, + variations, + features, + } = font_id; self.size = Some(size); self.family = Some(family); + self.weight = Some(weight); + self.width = Some(width); + self.variations = variations; + self.features = features; + self } @@ -243,6 +266,30 @@ impl RichText { self } + /// Override the font weight. + pub fn weight(mut self, weight: FontWeight) -> Self { + self.weight = Some(weight); + self + } + + /// Override the font width/stretch (not wrap width). + pub fn width(mut self, width: FontWidth) -> Self { + self.width = Some(width); + self + } + + /// Override the font's OpenType variations. + pub fn variations(mut self, variations: Arc) -> Self { + self.variations = Some(variations); + self + } + + /// Override the font's OpenType features. + pub fn features(mut self, features: Arc) -> Self { + self.features = Some(features); + self + } + /// Draw a line under the text. /// /// If you want to control the line color, use [`LayoutJob`] instead. @@ -307,7 +354,7 @@ impl RichText { /// Read the font height of the selected text style. /// /// Returns a value rounded to [`emath::GUI_ROUNDING`]. - pub fn font_height(&self, fonts: &epaint::Fonts, style: &Style) -> f32 { + pub fn font_height(&self, fonts: &mut epaint::Fonts<'_>, style: &Style) -> f32 { let mut font_id = self.text_style.as_ref().map_or_else( || FontSelection::Default.resolve(style), |text_style| text_style.resolve(style), @@ -394,6 +441,10 @@ impl RichText { code, strong: _, // already used by `get_text_color` weak: _, // already used by `get_text_color` + weight, + width, + variations, + features, strikethrough, underline, italics, @@ -404,21 +455,34 @@ impl RichText { let text_color = text_color.unwrap_or(crate::Color32::PLACEHOLDER); let font_id = { - let mut font_id = text_style - .or_else(|| style.override_text_style.clone()) - .map_or_else( - || fallback_font.resolve(style), - |text_style| text_style.resolve(style), - ); - if let Some(fid) = style.override_font_id.clone() { - font_id = fid; - } + let mut font_id = if let Some(fid) = style.override_font_id.clone() { + fid + } else if let Some(text_style) = text_style { + text_style.resolve(style) + } else if let Some(text_style) = style.override_text_style.clone() { + text_style.resolve(style) + } else { + fallback_font.resolve(style) + }; + if let Some(size) = size { font_id.size = size; } if let Some(family) = family { font_id.family = family; } + if let Some(weight) = weight { + font_id.weight = weight; + } + if let Some(width) = width { + font_id.width = width; + } + if let Some(variations) = variations { + font_id.variations = Some(variations); + } + if let Some(features) = features { + font_id.features = Some(features); + } font_id }; @@ -662,14 +726,14 @@ impl WidgetText { } /// Returns a value rounded to [`emath::GUI_ROUNDING`]. - pub(crate) fn font_height(&self, fonts: &epaint::Fonts, style: &Style) -> f32 { + pub(crate) fn font_height(&self, fonts: &mut epaint::Fonts<'_>, style: &Style) -> f32 { match self { Self::Text(_) => fonts.row_height(&FontSelection::Default.resolve(style)), Self::RichText(text) => text.font_height(fonts, style), Self::LayoutJob(job) => job.font_height(fonts), Self::Galley(galley) => { - if let Some(placed_row) = galley.rows.first() { - placed_row.height().round_ui() + if let Some(row) = galley.rows.first() { + row.rect.height().round_ui() } else { galley.size().y.round_ui() } diff --git a/crates/egui/src/widgets/drag_value.rs b/crates/egui/src/widgets/drag_value.rs index 864222ae1a1..47eeceb933d 100644 --- a/crates/egui/src/widgets/drag_value.rs +++ b/crates/egui/src/widgets/drag_value.rs @@ -3,8 +3,8 @@ use std::{cmp::Ordering, ops::RangeInclusive}; use crate::{ - emath, text, Button, CursorIcon, Key, Modifiers, NumExt as _, Response, RichText, Sense, - TextEdit, TextWrapMode, Ui, Widget, WidgetInfo, MINUS_CHAR_STR, + emath, Button, CursorIcon, Key, Modifiers, NumExt as _, Response, RichText, Sense, TextEdit, + TextWrapMode, Ui, Widget, WidgetInfo, MINUS_CHAR_STR, }; // ---------------------------------------------------------------------------- @@ -624,10 +624,7 @@ impl Widget for DragValue<'_> { ui.data_mut(|data| data.remove::(id)); ui.memory_mut(|mem| mem.request_focus(id)); let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - state.cursor.set_char_range(Some(text::CCursorRange::two( - text::CCursor::default(), - text::CCursor::new(value_text.chars().count()), - ))); + state.select_byte_range(0..value_text.len()); state.store(ui.ctx(), response.id); } else if response.dragged() { ui.ctx().set_cursor_icon(cursor_icon); diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index 07b08e53cb3..ee9bee49af2 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, slice::Iter, sync::Arc, time::Duration}; use emath::{Align, Float as _, GuiRounding as _, NumExt as _, Rot2}; use epaint::{ - text::{LayoutJob, TextFormat, TextWrapping}, + text::{style::TextFormat, LayoutJob, TextWrapping}, RectShape, }; diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 9f3606d12cc..4d30492b2de 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -1,8 +1,9 @@ use std::sync::Arc; use crate::{ - epaint, pos2, text_selection::LabelSelectionState, Align, Direction, FontSelection, Galley, - Pos2, Response, Sense, Stroke, TextWrapMode, Ui, Widget, WidgetInfo, WidgetText, WidgetType, + epaint, pos2, text_selection::LabelSelectionState, vec2, Align, Direction, FontSelection, + Galley, Pos2, Response, Sense, Stroke, TextWrapMode, Ui, Widget, WidgetInfo, WidgetText, + WidgetType, }; /// Static text. @@ -216,12 +217,10 @@ impl Label { let pos = pos2(ui.max_rect().left(), ui.cursor().top()); assert!(!galley.rows.is_empty(), "Galleys are never empty"); // collect a response from many rows: - let rect = galley.rows[0] - .rect_without_leading_space() - .translate(pos.to_vec2()); + let rect = galley.rows[0].rect.translate(vec2(pos.x, pos.y)); let mut response = ui.allocate_rect(rect, sense); - for placed_row in galley.rows.iter().skip(1) { - let rect = placed_row.rect().translate(pos.to_vec2()); + for row in galley.rows.iter().skip(1) { + let rect = row.rect.translate(vec2(pos.x, pos.y)); response |= ui.allocate_rect(rect, sense); } (pos, galley, response) diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 29f4b2cbbc8..0ce657f844f 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -2,7 +2,10 @@ use std::sync::Arc; use emath::{Rect, TSTransform}; use epaint::{ - text::{cursor::CCursor, Galley, LayoutJob}, + text::{ + cursor::{ByteCursor, Selection}, + Galley, LayoutJob, + }, StrokeKind, }; @@ -10,8 +13,11 @@ use crate::{ epaint, os::OperatingSystem, output::OutputEvent, - response, text_selection, - text_selection::{text_cursor_state::cursor_rect, visuals::paint_text_selection, CCursorRange}, + response, + text_selection::{ + self, handle_event::SelectionExt as _, text_cursor_state::cursor_rect, + visuals::paint_text_selection, + }, vec2, Align, Align2, Color32, Context, CursorIcon, Event, EventFilter, FontSelection, Id, ImeEvent, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, Shape, TextBuffer, TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetWithState, @@ -568,7 +574,7 @@ impl TextEdit<'_> { let mut response = ui.interact(outer_rect, id, sense); response.intrinsic_size = Some(Vec2::new(desired_width, desired_outer_size.y)); - // Don't sent `OutputEvent::Clicked` when a user presses the space bar + // Don't send `OutputEvent::Clicked` when a user presses the space bar response.flags -= response::Flags::FAKE_PRIMARY_CLICKED; let text_clip_rect = rect; let painter = ui.painter_at(text_clip_rect.expand(1.0)); // expand to avoid clipping cursor @@ -582,8 +588,8 @@ impl TextEdit<'_> { // TODO(emilk): drag selected text to either move or clone (ctrl on windows, alt on mac) let singleline_offset = vec2(state.singleline_offset, 0.0); - let cursor_at_pointer = - galley.cursor_from_pos(pointer_pos - rect.min + singleline_offset); + let galley_space_pos = pointer_pos - rect.min + singleline_offset; + let cursor_at_pointer = galley.cursor_from_pos(galley_space_pos); if ui.visuals().text_cursor.preview && response.hovered() @@ -599,7 +605,7 @@ impl TextEdit<'_> { let did_interact = state.cursor.pointer_interaction( ui, &response, - cursor_at_pointer, + galley_space_pos, &galley, is_being_dragged, ); @@ -616,18 +622,19 @@ impl TextEdit<'_> { ui.ctx().set_cursor_icon(CursorIcon::Text); } - let mut cursor_range = None; - let prev_cursor_range = state.cursor.char_range(); + let mut selection = None; + let prev_selection = state.cursor.selection(); if interactive && ui.memory(|mem| mem.has_focus(id)) { ui.memory_mut(|mem| mem.set_focus_lock_filter(id, event_filter)); - let default_cursor_range = if cursor_at_end { - CCursorRange::one(galley.end()) + let default_selection = if cursor_at_end { + let end = galley.end(); + galley.selection(|s| s.select_at_cursor(&end)) } else { - CCursorRange::default() + Selection::default() }; - let (changed, new_cursor_range) = events( + let (changed, new_selection) = events( ui, &mut state, text, @@ -637,7 +644,7 @@ impl TextEdit<'_> { wrap_width, multiline, password, - default_cursor_range, + default_selection, char_limit, event_filter, return_key, @@ -646,7 +653,7 @@ impl TextEdit<'_> { if changed { response.mark_changed(); } - cursor_range = Some(new_cursor_range); + selection = Some(new_selection); } let mut galley_pos = align @@ -657,8 +664,8 @@ impl TextEdit<'_> { // Visual clipping for singleline text editor with text larger than width if clip_text && align_offset == 0.0 { - let cursor_pos = match (cursor_range, ui.memory(|mem| mem.has_focus(id))) { - (Some(cursor_range), true) => galley.pos_from_cursor(cursor_range.primary).min.x, + let cursor_pos = match (selection, ui.memory(|mem| mem.has_focus(id))) { + (Some(selection), true) => galley.pos_from_cursor(selection.focus()).min.x, _ => 0.0, }; @@ -683,13 +690,12 @@ impl TextEdit<'_> { state.singleline_offset = align_offset; } - let selection_changed = if let (Some(cursor_range), Some(prev_cursor_range)) = - (cursor_range, prev_cursor_range) - { - prev_cursor_range != cursor_range - } else { - false - }; + let selection_changed = + if let (Some(selection), Some(prev_selection)) = (selection, prev_selection) { + prev_selection != selection + } else { + false + }; if ui.is_rect_visible(rect) { if text.as_str().is_empty() && !hint_text.is_empty() { @@ -720,9 +726,9 @@ impl TextEdit<'_> { let has_focus = ui.memory(|mem| mem.has_focus(id)); if has_focus { - if let Some(cursor_range) = state.cursor.char_range() { + if let Some(selection) = state.cursor.selection() { // Add text selection rectangles to the galley: - paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None); + paint_text_selection(&mut galley, ui.visuals(), &selection); } } @@ -742,10 +748,9 @@ impl TextEdit<'_> { painter.galley(galley_pos, galley.clone(), text_color); if has_focus { - if let Some(cursor_range) = state.cursor.char_range() { - let primary_cursor_rect = - cursor_rect(&galley, &cursor_range.primary, row_height) - .translate(galley_pos.to_vec2()); + if let Some(selection) = state.cursor.selection() { + let primary_cursor_rect = cursor_rect(&galley, &selection.focus(), row_height) + .translate(galley_pos.to_vec2()); if response.changed() || selection_changed { // Scroll to keep primary cursor in view: @@ -792,9 +797,8 @@ impl TextEdit<'_> { // Ensures correct IME behavior when the text input area gains or loses focus. if state.ime_enabled && (response.gained_focus() || response.lost_focus()) { state.ime_enabled = false; - if let Some(mut ccursor_range) = state.cursor.char_range() { - ccursor_range.secondary.index = ccursor_range.primary.index; - state.cursor.set_char_range(Some(ccursor_range)); + if let Some(selection) = state.cursor.selection() { + state.cursor.set_selection(Some(selection.collapse())); } ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_)))); } @@ -811,8 +815,9 @@ impl TextEdit<'_> { ) }); } else if selection_changed { - let cursor_range = cursor_range.unwrap(); - let char_range = cursor_range.primary.index..=cursor_range.secondary.index; + let selection = selection.unwrap(); + // TODO(valadaptive): this range can be backwards. Is that okay? + let char_range = selection.focus().index..=selection.anchor().index; let info = WidgetInfo::text_selection_changed( ui.is_enabled(), char_range, @@ -843,7 +848,7 @@ impl TextEdit<'_> { crate::text_selection::accesskit_text::update_accesskit_for_text_widget( ui.ctx(), id, - cursor_range, + selection, role, TSTransform::from_translation(galley_pos.to_vec2()), &galley, @@ -856,7 +861,7 @@ impl TextEdit<'_> { galley_pos, text_clip_rect, state, - cursor_range, + selection, } } } @@ -877,6 +882,11 @@ fn mask_if_password(is_password: bool, text: &str) -> String { } } +enum SelectionOrCursor { + Cursor(ByteCursor), + Selection(Selection), +} + // ---------------------------------------------------------------------------- /// Check for (keyboard) events to edit the cursor and/or text. @@ -891,21 +901,21 @@ fn events( wrap_width: f32, multiline: bool, password: bool, - default_cursor_range: CCursorRange, + default_selection: Selection, char_limit: usize, event_filter: EventFilter, return_key: Option, -) -> (bool, CCursorRange) { +) -> (bool, Selection) { let os = ui.ctx().os(); - let mut cursor_range = state.cursor.char_range().unwrap_or(default_cursor_range); + let mut selection = state.cursor.selection().unwrap_or(default_selection); // We feed state to the undoer both before and after handling input // so that the undoer creates automatic saves even when there are no events for a while. - state.undoer.lock().feed_state( - ui.input(|i| i.time), - &(cursor_range, text.as_str().to_owned()), - ); + state + .undoer + .lock() + .feed_state(ui.input(|i| i.time), &(selection, text.as_str().to_owned())); let copy_if_not_password = |ui: &Ui, text: String| { if !password { @@ -915,7 +925,23 @@ fn events( let mut any_change = false; - let mut events = ui.input(|i| i.filtered_events(&event_filter)); + // Whoever's controlling this widget manually set a selection. Process it first. + if let Some(pending_selection) = state.pending_selection.take() { + selection = galley.selection(|s| { + s.select_cursor_range( + &ByteCursor { + index: pending_selection.start, + affinity: Default::default(), + }, + &ByteCursor { + index: pending_selection.end, + affinity: Default::default(), + }, + ) + }); + } + + let mut events = ui.input(|i: &crate::InputState| i.filtered_events(&event_filter)); if state.ime_enabled { remove_ime_incompatible_events(&mut events); @@ -923,34 +949,48 @@ fn events( events.sort_by_key(|e| !matches!(e, Event::Ime(_))); } + use SelectionOrCursor::Cursor; + for event in &events { + // First handle events that only changes the selection cursor, not the text: + let selection_handled_event = + if let Some(new_selection) = selection.on_event(os, event, galley, id) { + selection = new_selection; + true + } else { + false + }; + let did_mutate_text = match event { - // First handle events that only changes the selection cursor, not the text: - event if cursor_range.on_event(os, event, galley, id) => None, + _ if selection_handled_event => None, Event::Copy => { - if cursor_range.is_empty() { + if selection.is_empty() { None } else { - copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned()); + copy_if_not_password(ui, selection.slice_str(text.as_str()).to_owned()); None } } Event::Cut => { - if cursor_range.is_empty() { + if selection.is_empty() { None } else { - copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned()); - Some(CCursorRange::one(text.delete_selected(&cursor_range))) + copy_if_not_password(ui, selection.slice_str(text.as_str()).to_owned()); + Some(Cursor(text.replace_selection( + selection.byte_range(), + "", + char_limit, + ))) } } Event::Paste(text_to_insert) => { if !text_to_insert.is_empty() { - let mut ccursor = text.delete_selected(&cursor_range); - - text.insert_text_at(&mut ccursor, text_to_insert, char_limit); - - Some(CCursorRange::one(ccursor)) + Some(Cursor(text.replace_selection( + selection.byte_range(), + text_to_insert, + char_limit, + ))) } else { None } @@ -958,11 +998,11 @@ fn events( Event::Text(text_to_insert) => { // Newlines are handled by `Key::Enter`. if !text_to_insert.is_empty() && text_to_insert != "\n" && text_to_insert != "\r" { - let mut ccursor = text.delete_selected(&cursor_range); - - text.insert_text_at(&mut ccursor, text_to_insert, char_limit); - - Some(CCursorRange::one(ccursor)) + Some(Cursor(text.replace_selection( + selection.byte_range(), + text_to_insert, + char_limit, + ))) } else { None } @@ -973,14 +1013,14 @@ fn events( modifiers, .. } if multiline => { - let mut ccursor = text.delete_selected(&cursor_range); + let mut new_cursor = text.replace_selection(selection.byte_range(), "", char_limit); if modifiers.shift { // TODO(emilk): support removing indentation over a selection? - text.decrease_indentation(&mut ccursor); + text.decrease_indentation(&mut new_cursor.index); } else { - text.insert_text_at(&mut ccursor, "\t", char_limit); + text.replace_selection(new_cursor.index..new_cursor.index, "\t", char_limit); } - Some(CCursorRange::one(ccursor)) + Some(Cursor(new_cursor)) } Event::Key { key, @@ -992,10 +1032,12 @@ fn events( }) => { if multiline { - let mut ccursor = text.delete_selected(&cursor_range); - text.insert_text_at(&mut ccursor, "\n", char_limit); // TODO(emilk): if code editor, auto-indent by same leading tabs, + one if the lines end on an opening bracket - Some(CCursorRange::one(ccursor)) + Some(Cursor(text.replace_selection( + selection.byte_range(), + "\n", + char_limit, + ))) } else { ui.memory_mut(|mem| mem.surrender_focus(id)); // End input with enter break; @@ -1011,13 +1053,13 @@ fn events( || (modifiers.matches_logically(Modifiers::SHIFT | Modifiers::COMMAND) && *key == Key::Z) => { - if let Some((redo_ccursor_range, redo_txt)) = state + if let Some((redo_selection, redo_txt)) = state .undoer .lock() - .redo(&(cursor_range, text.as_str().to_owned())) + .redo(&(selection, text.as_str().to_owned())) { text.replace_with(redo_txt); - Some(*redo_ccursor_range) + Some(SelectionOrCursor::Selection(*redo_selection)) } else { None } @@ -1029,13 +1071,13 @@ fn events( modifiers, .. } if modifiers.matches_logically(Modifiers::COMMAND) => { - if let Some((undo_ccursor_range, undo_txt)) = state + if let Some((undo_selection, undo_txt)) = state .undoer .lock() - .undo(&(cursor_range, text.as_str().to_owned())) + .undo(&(selection, text.as_str().to_owned())) { text.replace_with(undo_txt); - Some(*undo_ccursor_range) + Some(SelectionOrCursor::Selection(*undo_selection)) } else { None } @@ -1046,12 +1088,15 @@ fn events( key, pressed: true, .. - } => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key), + } => check_for_mutating_key_press( + os, &selection, text, galley, char_limit, modifiers, *key, + ), Event::Ime(ime_event) => match ime_event { + // TODO(valadaptive): look at what Parley's PlainEditorDriver does in `set_compose` ImeEvent::Enabled => { state.ime_enabled = true; - state.ime_cursor_range = cursor_range; + state.ime_selection = selection; None } ImeEvent::Preedit(text_mark) => { @@ -1060,13 +1105,10 @@ fn events( } else { // Empty prediction can be produced when user press backspace // or escape during IME, so we clear current text. - let mut ccursor = text.delete_selected(&cursor_range); - let start_cursor = ccursor; - if !text_mark.is_empty() { - text.insert_text_at(&mut ccursor, text_mark, char_limit); - } - state.ime_cursor_range = cursor_range; - Some(CCursorRange::two(start_cursor, ccursor)) + let new_selection = + text.replace_selection(selection.byte_range(), text_mark, char_limit); + state.ime_selection = selection; + Some(Cursor(new_selection)) } } ImeEvent::Commit(prediction) => { @@ -1076,15 +1118,15 @@ fn events( state.ime_enabled = false; if !prediction.is_empty() - && cursor_range.secondary.index - == state.ime_cursor_range.secondary.index + && selection.anchor().index == state.ime_selection.anchor().index { - let mut ccursor = text.delete_selected(&cursor_range); - text.insert_text_at(&mut ccursor, prediction, char_limit); - Some(CCursorRange::one(ccursor)) + Some(Cursor(text.replace_selection( + selection.byte_range(), + prediction, + char_limit, + ))) } else { - let ccursor = cursor_range.primary; - Some(CCursorRange::one(ccursor)) + Some(Cursor(selection.focus())) } } } @@ -1097,25 +1139,27 @@ fn events( _ => None, }; - if let Some(new_ccursor_range) = did_mutate_text { + if let Some(new_selection) = did_mutate_text { any_change = true; // Layout again to avoid frame delay, and to keep `text` and `galley` in sync. *galley = layouter(ui, text, wrap_width); - - // Set cursor_range using new galley: - cursor_range = new_ccursor_range; + selection = match new_selection { + // Set selection using new galley: + Cursor(byte_cursor) => galley.selection(|s| s.select_at_cursor(&byte_cursor)), + SelectionOrCursor::Selection(sel) => sel, + } } } - state.cursor.set_char_range(Some(cursor_range)); + state.cursor.set_selection(Some(selection)); - state.undoer.lock().feed_state( - ui.input(|i| i.time), - &(cursor_range, text.as_str().to_owned()), - ); + state + .undoer + .lock() + .feed_state(ui.input(|i| i.time), &(selection, text.as_str().to_owned())); - (any_change, cursor_range) + (any_change, selection) } // ---------------------------------------------------------------------------- @@ -1144,73 +1188,70 @@ fn remove_ime_incompatible_events(events: &mut Vec) { /// Returns `Some(new_cursor)` if we did mutate `text`. fn check_for_mutating_key_press( os: OperatingSystem, - cursor_range: &CCursorRange, + selection: &Selection, text: &mut dyn TextBuffer, galley: &Galley, + char_limit: usize, modifiers: &Modifiers, key: Key, -) -> Option { - match key { +) -> Option { + let selection_to_delete = match key { Key::Backspace => { - let ccursor = if modifiers.mac_cmd { - text.delete_paragraph_before_cursor(galley, cursor_range) - } else if let Some(cursor) = cursor_range.single() { + if modifiers.mac_cmd { + galley.selection(|s| s.paragraph_before_cursor(selection))? + } else if selection.is_empty() { if modifiers.alt || modifiers.ctrl { // alt on mac, ctrl on windows - text.delete_previous_word(cursor) + galley + .selection(|s| s.select_prev_word(selection, true)) + .byte_range() } else { - text.delete_previous_char(cursor) + galley.selection(|s| s.prev_cluster(selection))? } } else { - text.delete_selected(cursor_range) - }; - Some(CCursorRange::one(ccursor)) + selection.byte_range() + } } Key::Delete if !modifiers.shift || os != OperatingSystem::Windows => { - let ccursor = if modifiers.mac_cmd { - text.delete_paragraph_after_cursor(galley, cursor_range) - } else if let Some(cursor) = cursor_range.single() { + if modifiers.mac_cmd { + galley.selection(|s| s.paragraph_after_cursor(selection))? + } else if selection.is_empty() { if modifiers.alt || modifiers.ctrl { // alt on mac, ctrl on windows - text.delete_next_word(cursor) + galley + .selection(|s| s.select_next_word(selection, true)) + .byte_range() } else { - text.delete_next_char(cursor) + galley.selection(|s| s.next_cluster(selection))? } } else { - text.delete_selected(cursor_range) - }; - let ccursor = CCursor { - prefer_next_row: true, - ..ccursor - }; - Some(CCursorRange::one(ccursor)) + selection.byte_range() + } } - Key::H if modifiers.ctrl => { - let ccursor = text.delete_previous_char(cursor_range.primary); - Some(CCursorRange::one(ccursor)) - } + Key::H if modifiers.ctrl => galley.selection(|s| s.prev_cluster(&selection.collapse()))?, - Key::K if modifiers.ctrl => { - let ccursor = text.delete_paragraph_after_cursor(galley, cursor_range); - Some(CCursorRange::one(ccursor)) - } + Key::K if modifiers.ctrl => galley.selection(|s| s.paragraph_before_cursor(selection))?, - Key::U if modifiers.ctrl => { - let ccursor = text.delete_paragraph_before_cursor(galley, cursor_range); - Some(CCursorRange::one(ccursor)) - } + Key::U if modifiers.ctrl => galley.selection(|s| s.paragraph_after_cursor(selection))?, Key::W if modifiers.ctrl => { - let ccursor = if let Some(cursor) = cursor_range.single() { - text.delete_previous_word(cursor) + if selection.is_empty() { + galley + .selection(|s| s.select_prev_word(selection, true)) + .byte_range() } else { - text.delete_selected(cursor_range) - }; - Some(CCursorRange::one(ccursor)) + selection.byte_range() + } } - _ => None, - } + _ => return None, + }; + + Some(SelectionOrCursor::Cursor(text.replace_selection( + selection_to_delete, + "", + char_limit, + ))) } diff --git a/crates/egui/src/widgets/text_edit/output.rs b/crates/egui/src/widgets/text_edit/output.rs index 8149bbe5871..852551febd7 100644 --- a/crates/egui/src/widgets/text_edit/output.rs +++ b/crates/egui/src/widgets/text_edit/output.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::text::CCursorRange; +use crate::text::Selection; /// The output from a [`TextEdit`](crate::TextEdit). pub struct TextEditOutput { @@ -20,7 +20,7 @@ pub struct TextEditOutput { pub state: super::TextEditState, /// Where the text cursor is. - pub cursor_range: Option, + pub selection: Option, } // TODO(emilk): add `output.paint` and `output.store` and split out that code from `TextEdit::show`. diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index 0051ea8e7ef..169bb727bb7 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -1,13 +1,11 @@ +use std::ops::Range; use std::sync::Arc; use crate::mutex::Mutex; -use crate::{ - text_selection::{CCursorRange, TextCursorState}, - Context, Id, -}; +use crate::{text::Selection, text_selection::TextCursorState, Context, Id}; -pub type TextEditUndoer = crate::util::undoer::Undoer<(CCursorRange, String)>; +pub type TextEditUndoer = crate::util::undoer::Undoer<(Selection, String)>; /// The text edit state stored between frames. /// @@ -15,17 +13,10 @@ pub type TextEditUndoer = crate::util::undoer::Undoer<(CCursorRange, String)>; /// ``` /// # egui::__run_test_ui(|ui| { /// # let mut text = String::new(); -/// use egui::text::{CCursor, CCursorRange}; -/// /// let mut output = egui::TextEdit::singleline(&mut text).show(ui); /// -/// // Create a new selection range -/// let min = CCursor::new(0); -/// let max = CCursor::new(0); -/// let new_range = CCursorRange::two(min, max); -/// /// // Update the state -/// output.state.cursor.set_char_range(Some(new_range)); +/// output.state.cursor.select_byte_range(0..0); /// // Store the updated state /// output.state.store(ui.ctx(), output.response.id); /// # }); @@ -47,7 +38,7 @@ pub struct TextEditState { // cursor range for IME candidate. #[cfg_attr(feature = "serde", serde(skip))] - pub(crate) ime_cursor_range: CCursorRange, + pub(crate) ime_selection: Selection, // Visual offset when editing singleline text bigger than the width. #[cfg_attr(feature = "serde", serde(skip))] @@ -57,6 +48,12 @@ pub struct TextEditState { /// Used to pause the cursor animation when typing. #[cfg_attr(feature = "serde", serde(skip))] pub(crate) last_interaction_time: f64, + + /// Byte selection set by whatever's controlling this `TextEdit`, to be + /// resolved into a `Selection` the next time the `TextEdit` widget is + /// shown. + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) pending_selection: Option>, } impl TextEditState { @@ -80,4 +77,10 @@ impl TextEditState { pub fn clear_undoer(&mut self) { self.set_undoer(TextEditUndoer::default()); } + + /// Select a specific byte range next time the `TextEdit` is shown. This is + /// processed before any events on the same frame. + pub fn select_byte_range(&mut self, range: Range) { + self.pending_selection = Some(range); + } } diff --git a/crates/egui/src/widgets/text_edit/text_buffer.rs b/crates/egui/src/widgets/text_edit/text_buffer.rs index ebf33b097dc..5eb8f95731c 100644 --- a/crates/egui/src/widgets/text_edit/text_buffer.rs +++ b/crates/egui/src/widgets/text_edit/text_buffer.rs @@ -1,16 +1,8 @@ use std::{borrow::Cow, ops::Range}; -use epaint::{ - text::{cursor::CCursor, TAB_SIZE}, - Galley, -}; - -use crate::{ - text::CCursorRange, - text_selection::text_cursor_state::{ - byte_index_from_char_index, ccursor_next_word, ccursor_previous_word, - char_index_from_byte_index, find_line_start, slice_char_range, - }, +use epaint::text::{ + cursor::{Affinity, ByteCursor}, + TAB_SIZE, }; /// Trait constraining what types [`crate::TextEdit`] may use as @@ -24,37 +16,68 @@ pub trait TextBuffer { /// Returns this buffer as a `str`. fn as_str(&self) -> &str; - /// Inserts text `text` into this buffer at character index `char_index`. - /// - /// # Notes - /// `char_index` is a *character index*, not a byte index. + /// Inserts text `text` into this buffer at byte index `byte_index`. /// /// # Return - /// Returns how many *characters* were successfully inserted - fn insert_text(&mut self, text: &str, char_index: usize) -> usize; + /// Returns how many bytes were successfully inserted + fn insert_text(&mut self, text: &str, byte_index: usize) -> usize { + let end_idx = self.replace_range(byte_index..byte_index, text); + end_idx - byte_index + } - /// Deletes a range of text `char_range` from this buffer. + /// Reads the given byte range. + fn byte_range(&self, byte_range: Range) -> &str { + &self.as_str()[byte_range] + } + + /// Replaces a given byte range with `replacement_text`. /// - /// # Notes - /// `char_range` is a *character range*, not a byte range. - fn delete_char_range(&mut self, char_range: Range); + /// # Return + /// Returns the end index of the replacement text in the buffer + fn replace_range(&mut self, range: Range, replacement_text: &str) -> usize; - /// Reads the given character range. - fn char_range(&self, char_range: Range) -> &str { - slice_char_range(self.as_str(), char_range) + fn delete_byte_range(&mut self, range: Range) { + self.replace_range(range, ""); } - fn byte_index_from_char_index(&self, char_index: usize) -> usize { - byte_index_from_char_index(self.as_str(), char_index) - } + /// Replaces the range of text specified in the given [`Selection`] with the + /// string passed in. Returns the new cursor position. + fn replace_selection( + &mut self, + selection: Range, + replacement_text: &str, + char_limit: usize, + ) -> ByteCursor { + let mut new_string = replacement_text; + if char_limit < usize::MAX && { + // Optimization: one Unicode character can take up to 4 bytes. This + // gives us an upper bound for the new length of the string. If + // char_limit exceeds that upper bound, we don't need to count + // characters (potentially expensive). + let byte_count_after_removal = self.as_str().len() - (selection.end - selection.start); + char_limit < (byte_count_after_removal + replacement_text.len()) * 4 + } { + let current_char_count = self.as_str().chars().count(); + let removed_char_count = self.as_str()[selection.clone()].chars().count(); + // Avoid subtract with overflow panic + let cutoff = char_limit.saturating_sub(current_char_count - removed_char_count); + + new_string = match new_string.char_indices().nth(cutoff) { + None => new_string, + Some((idx, _)) => &new_string[..idx], + }; + } - fn char_index_from_byte_index(&self, char_index: usize) -> usize { - char_index_from_byte_index(self.as_str(), char_index) + let new_index = self.replace_range(selection, new_string); + ByteCursor { + index: new_index, + affinity: Affinity::Downstream, + } } /// Clears all characters in this buffer fn clear(&mut self) { - self.delete_char_range(0..self.as_str().len()); + self.delete_byte_range(0..self.as_str().len()); } /// Replaces all contents of this string with `text` @@ -70,34 +93,14 @@ pub trait TextBuffer { s } - fn insert_text_at(&mut self, ccursor: &mut CCursor, text_to_insert: &str, char_limit: usize) { - if char_limit < usize::MAX { - let mut new_string = text_to_insert; - // Avoid subtract with overflow panic - let cutoff = char_limit.saturating_sub(self.as_str().chars().count()); - - new_string = match new_string.char_indices().nth(cutoff) { - None => new_string, - Some((idx, _)) => &new_string[..idx], - }; - - ccursor.index += self.insert_text(new_string, ccursor.index); - } else { - ccursor.index += self.insert_text(text_to_insert, ccursor.index); - } - } + fn decrease_indentation(&mut self, index: &mut usize) { + let line_start = find_line_start(self.as_str(), *index); - fn decrease_indentation(&mut self, ccursor: &mut CCursor) { - let line_start = find_line_start(self.as_str(), *ccursor); - - let remove_len = if self.as_str().chars().nth(line_start.index) == Some('\t') { + let remove_len = if self.as_str().as_bytes().get(line_start) == Some(&b'\t') { Some(1) - } else if self - .as_str() - .chars() - .skip(line_start.index) - .take(TAB_SIZE) - .all(|c| c == ' ') + } else if self.as_str().as_bytes()[line_start..line_start + TAB_SIZE] + .iter() + .all(|c| c == &b' ') { Some(TAB_SIZE) } else { @@ -105,78 +108,13 @@ pub trait TextBuffer { }; if let Some(len) = remove_len { - self.delete_char_range(line_start.index..(line_start.index + len)); - if *ccursor != line_start { - *ccursor -= len; + self.delete_byte_range(line_start..(line_start + len)); + if *index != line_start { + *index -= len; } } } - fn delete_selected(&mut self, cursor_range: &CCursorRange) -> CCursor { - let [min, max] = cursor_range.sorted_cursors(); - self.delete_selected_ccursor_range([min, max]) - } - - fn delete_selected_ccursor_range(&mut self, [min, max]: [CCursor; 2]) -> CCursor { - self.delete_char_range(min.index..max.index); - CCursor { - index: min.index, - prefer_next_row: true, - } - } - - fn delete_previous_char(&mut self, ccursor: CCursor) -> CCursor { - if ccursor.index > 0 { - let max_ccursor = ccursor; - let min_ccursor = max_ccursor - 1; - self.delete_selected_ccursor_range([min_ccursor, max_ccursor]) - } else { - ccursor - } - } - - fn delete_next_char(&mut self, ccursor: CCursor) -> CCursor { - self.delete_selected_ccursor_range([ccursor, ccursor + 1]) - } - - fn delete_previous_word(&mut self, max_ccursor: CCursor) -> CCursor { - let min_ccursor = ccursor_previous_word(self.as_str(), max_ccursor); - self.delete_selected_ccursor_range([min_ccursor, max_ccursor]) - } - - fn delete_next_word(&mut self, min_ccursor: CCursor) -> CCursor { - let max_ccursor = ccursor_next_word(self.as_str(), min_ccursor); - self.delete_selected_ccursor_range([min_ccursor, max_ccursor]) - } - - fn delete_paragraph_before_cursor( - &mut self, - galley: &Galley, - cursor_range: &CCursorRange, - ) -> CCursor { - let [min, max] = cursor_range.sorted_cursors(); - let min = galley.cursor_begin_of_paragraph(&min); - if min == max { - self.delete_previous_char(min) - } else { - self.delete_selected(&CCursorRange::two(min, max)) - } - } - - fn delete_paragraph_after_cursor( - &mut self, - galley: &Galley, - cursor_range: &CCursorRange, - ) -> CCursor { - let [min, max] = cursor_range.sorted_cursors(); - let max = galley.cursor_end_of_paragraph(&max); - if min == max { - self.delete_next_char(min) - } else { - self.delete_selected(&CCursorRange::two(min, max)) - } - } - /// Returns a unique identifier for the implementing type. /// /// This is useful for downcasting from this trait to the implementing type. @@ -220,28 +158,13 @@ impl TextBuffer for String { self.as_ref() } - fn insert_text(&mut self, text: &str, char_index: usize) -> usize { - // Get the byte index from the character index - let byte_idx = byte_index_from_char_index(self.as_str(), char_index); - - // Then insert the string - self.insert_str(byte_idx, text); - - text.chars().count() - } - - fn delete_char_range(&mut self, char_range: Range) { - assert!( - char_range.start <= char_range.end, - "start must be <= end, but got {char_range:?}" - ); - - // Get both byte indices - let byte_start = byte_index_from_char_index(self.as_str(), char_range.start); - let byte_end = byte_index_from_char_index(self.as_str(), char_range.end); - - // Then drain all characters within this range - self.drain(byte_start..byte_end); + fn replace_range(&mut self, range: Range, replacement_text: &str) -> usize { + if range.end - range.start == 0 { + self.insert_str(range.start, replacement_text); + } else { + self.replace_range(range.clone(), replacement_text); + } + range.start + replacement_text.len() } fn clear(&mut self) { @@ -270,12 +193,8 @@ impl TextBuffer for Cow<'_, str> { self.as_ref() } - fn insert_text(&mut self, text: &str, char_index: usize) -> usize { - ::insert_text(self.to_mut(), text, char_index) - } - - fn delete_char_range(&mut self, char_range: Range) { - ::delete_char_range(self.to_mut(), char_range); + fn replace_range(&mut self, range: Range, replacement_text: &str) -> usize { + ::replace_range(self.to_mut(), range, replacement_text) } fn clear(&mut self) { @@ -305,13 +224,19 @@ impl TextBuffer for &str { self } - fn insert_text(&mut self, _text: &str, _ch_idx: usize) -> usize { - 0 + fn replace_range(&mut self, range: Range, _replacement_text: &str) -> usize { + // Since we don't modify anything, return the start of the range + range.start } - fn delete_char_range(&mut self, _ch_range: Range) {} - fn type_id(&self) -> std::any::TypeId { std::any::TypeId::of::<&str>() } } + +/// Accepts and returns byte offset +fn find_line_start(text: &str, current_index: usize) -> usize { + text[..current_index] + .rfind('\n') + .map_or(0, |line_ending| line_ending + 1) +} diff --git a/crates/egui_demo_lib/Cargo.toml b/crates/egui_demo_lib/Cargo.toml index f83258e5261..623842dc728 100644 --- a/crates/egui_demo_lib/Cargo.toml +++ b/crates/egui_demo_lib/Cargo.toml @@ -61,7 +61,6 @@ egui_extras = { workspace = true, features = ["image", "svg"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } image = { workspace = true, features = ["png"] } mimalloc.workspace = true # for benchmarks -rand = "0.9" [[bench]] name = "benchmark" diff --git a/crates/egui_demo_lib/benches/benchmark.rs b/crates/egui_demo_lib/benches/benchmark.rs index b511f0de8dc..4f365a5633d 100644 --- a/crates/egui_demo_lib/benches/benchmark.rs +++ b/crates/egui_demo_lib/benches/benchmark.rs @@ -1,12 +1,9 @@ -use std::fmt::Write as _; - use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; use egui::epaint::TextShape; use egui::load::SizedTexture; use egui::{Button, Id, RichText, TextureId, Ui, UiBuilder, Vec2}; use egui_demo_lib::LOREM_IPSUM_LONG; -use rand::Rng as _; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; // Much faster allocator @@ -163,18 +160,15 @@ pub fn criterion_benchmark(c: &mut Criterion) { let pixels_per_point = 1.0; let max_texture_side = 8 * 1024; let wrap_width = 512.0; - let font_id = egui::FontId::default(); + let font_id = egui::text::style::FontId::default(); let text_color = egui::Color32::WHITE; - let fonts = egui::epaint::text::Fonts::new( - pixels_per_point, - max_texture_side, - egui::FontDefinitions::default(), - ); + let mut fonts = + egui::epaint::text::FontStore::new(max_texture_side, egui::FontDefinitions::default()); + let mut fonts = fonts.with_pixels_per_point(pixels_per_point); { - let mut locked_fonts = fonts.lock(); c.bench_function("text_layout_uncached", |b| { b.iter(|| { - use egui::epaint::text::{layout, LayoutJob}; + use egui::epaint::text::LayoutJob; let job = LayoutJob::simple( LOREM_IPSUM_LONG.to_owned(), @@ -182,7 +176,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { text_color, wrap_width, ); - layout(&mut locked_fonts.fonts, job.into()) + fonts.layout_job_uncached(job); }); }); } @@ -197,33 +191,9 @@ pub fn criterion_benchmark(c: &mut Criterion) { }); }); - c.bench_function("text_layout_cached_many_lines_modified", |b| { - const NUM_LINES: usize = 2_000; - - let mut string = String::new(); - for _ in 0..NUM_LINES { - for i in 0..30_u8 { - write!(string, "{i:02X} ").unwrap(); - } - string.push('\n'); - } - - let mut rng = rand::rng(); - b.iter(|| { - fonts.begin_pass(pixels_per_point, max_texture_side); - - // Delete a random character, simulating a user making an edit in a long file: - let mut new_string = string.clone(); - let idx = rng.random_range(0..string.len()); - new_string.remove(idx); - - fonts.layout(new_string, font_id.clone(), text_color, wrap_width); - }); - }); - let galley = fonts.layout(LOREM_IPSUM_LONG.to_owned(), font_id, text_color, wrap_width); let font_image_size = fonts.font_image_size(); - let prepared_discs = fonts.texture_atlas().lock().prepared_discs(); + let prepared_discs = fonts.texture_atlas().prepared_discs(); let mut tessellator = egui::epaint::Tessellator::new( 1.0, Default::default(), diff --git a/crates/egui_demo_lib/src/demo/font_book.rs b/crates/egui_demo_lib/src/demo/font_book.rs index 1ef9867f98e..7e2489310a4 100644 --- a/crates/egui_demo_lib/src/demo/font_book.rs +++ b/crates/egui_demo_lib/src/demo/font_book.rs @@ -1,5 +1,7 @@ use std::collections::BTreeMap; +use egui::text::style::{FontFamily, FontId}; + struct GlyphInfo { name: String, @@ -9,15 +11,15 @@ struct GlyphInfo { pub struct FontBook { filter: String, - font_id: egui::FontId, - available_glyphs: BTreeMap>, + font_id: FontId, + available_glyphs: BTreeMap>, } impl Default for FontBook { fn default() -> Self { Self { filter: Default::default(), - font_id: egui::FontId::proportional(18.0), + font_id: FontId::system_ui(18.0), available_glyphs: Default::default(), } } @@ -45,7 +47,7 @@ impl crate::View for FontBook { ui.label(format!( "The selected font supports {} characters.", self.available_glyphs - .get(&self.font_id.family) + .get(self.font_id.family.first_family()) .map(|map| map.len()) .unwrap_or_default() )); @@ -76,8 +78,8 @@ impl crate::View for FontBook { let filter = &self.filter; let available_glyphs = self .available_glyphs - .entry(self.font_id.family.clone()) - .or_insert_with(|| available_characters(ui, self.font_id.family.clone())); + .entry(self.font_id.family.first_family().clone()) + .or_insert_with(|| available_characters(ui, self.font_id.family.first_family())); ui.separator(); @@ -111,7 +113,7 @@ impl crate::View for FontBook { } } -fn char_info_ui(ui: &mut egui::Ui, chr: char, glyph_info: &GlyphInfo, font_id: egui::FontId) { +fn char_info_ui(ui: &mut egui::Ui, chr: char, glyph_info: &GlyphInfo, font_id: FontId) { let resp = ui.label(egui::RichText::new(chr.to_string()).font(font_id)); egui::Grid::new("char_info") @@ -140,25 +142,28 @@ fn char_info_ui(ui: &mut egui::Ui, chr: char, glyph_info: &GlyphInfo, font_id: e }); } -fn available_characters(ui: &egui::Ui, family: egui::FontFamily) -> BTreeMap { +fn available_characters(ui: &egui::Ui, family: &FontFamily) -> BTreeMap { + let mut chars = BTreeMap::new(); ui.fonts(|f| { - f.lock() - .fonts - .font(&egui::FontId::new(10.0, family)) // size is arbitrary for getting the characters - .characters() - .iter() - .filter(|(chr, _fonts)| !chr.is_whitespace() && !chr.is_ascii_control()) - .map(|(chr, fonts)| { - ( - *chr, - GlyphInfo { - name: char_name(*chr), - fonts: fonts.clone(), - }, - ) - }) - .collect() - }) + f.with_characters(family, |chr, _glyph| { + let Some(chr) = char::from_u32(chr) else { + return; + }; + + if chr.is_whitespace() || chr.is_ascii_control() { + return; + } + + chars.insert( + chr, + GlyphInfo { + name: char_name(chr), + fonts: vec![], + }, + ); + }); + }); + chars } fn char_name(chr: char) -> String { diff --git a/crates/egui_demo_lib/src/demo/misc_demo_window.rs b/crates/egui_demo_lib/src/demo/misc_demo_window.rs index edb19c3eaa7..907a9ce49de 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -1,8 +1,8 @@ use super::{Demo, View}; use egui::{ - vec2, Align, Checkbox, CollapsingHeader, Color32, Context, FontId, Resize, RichText, Sense, - Slider, Stroke, TextFormat, TextStyle, Ui, Vec2, Window, + text::style::FontId, vec2, Align, Checkbox, CollapsingHeader, Color32, Context, Resize, + RichText, Sense, Slider, Stroke, TextFormat, TextStyle, Ui, Vec2, Window, }; /// Showcase some ui code @@ -583,7 +583,7 @@ fn text_layout_demo(ui: &mut Ui) { first_row_indentation, TextFormat { color: default_color, - font_id: FontId::proportional(20.0), + font_id: FontId::system_ui(20.0), ..Default::default() }, ); @@ -640,7 +640,7 @@ fn text_layout_demo(ui: &mut Ui) { "mixing ", 0.0, TextFormat { - font_id: FontId::proportional(20.0), + font_id: FontId::system_ui(20.0), color: default_color, ..Default::default() }, @@ -658,7 +658,7 @@ fn text_layout_demo(ui: &mut Ui) { "raised text, ", 0.0, TextFormat { - font_id: FontId::proportional(7.0), + font_id: FontId::system_ui(7.0), color: default_color, valign: Align::TOP, ..Default::default() @@ -719,7 +719,7 @@ fn text_layout_demo(ui: &mut Ui) { " mix these!", 0.0, TextFormat { - font_id: FontId::proportional(7.0), + font_id: FontId::system_ui(7.0), color: Color32::LIGHT_BLUE, background: Color32::from_rgb(128, 0, 0), underline: Stroke::new(1.0, strong_color), diff --git a/crates/egui_demo_lib/src/demo/text_edit.rs b/crates/egui_demo_lib/src/demo/text_edit.rs index 685a9c38fba..bb140c18c27 100644 --- a/crates/egui_demo_lib/src/demo/text_edit.rs +++ b/crates/egui_demo_lib/src/demo/text_edit.rs @@ -52,13 +52,13 @@ impl crate::View for TextEditDemo { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label("Selected text: "); - if let Some(text_cursor_range) = output.cursor_range { - let selected_text = text_cursor_range.slice_str(text); + if let Some(selection) = output.selection { + let selected_text = selection.slice_str(text); ui.code(selected_text); } }); - let anything_selected = output.cursor_range.is_some_and(|cursor| !cursor.is_empty()); + let anything_selected = output.selection.is_some_and(|cursor| !cursor.is_empty()); ui.add_enabled( anything_selected, @@ -66,18 +66,18 @@ impl crate::View for TextEditDemo { ); if ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Y)) { - if let Some(text_cursor_range) = output.cursor_range { - use egui::TextBuffer as _; - let selected_chars = text_cursor_range.as_sorted_char_range(); - let selected_text = text.char_range(selected_chars.clone()); + // TODO(valadaptive): in both the old and new selection code, this doesn't account for uppercase and + // lowercase character counts being different. In the new code, it may panic if we end up between bytes. + if let Some(selection) = output.selection { + let selected_range = selection.byte_range(); + let selected_text = &text[selected_range.clone()]; let upper_case = selected_text.to_uppercase(); let new_text = if selected_text == upper_case { selected_text.to_lowercase() } else { upper_case }; - text.delete_char_range(selected_chars.clone()); - text.insert_text(&new_text, selected_chars.start); + ::replace_range(text, selected_range, &new_text); } } @@ -87,10 +87,7 @@ impl crate::View for TextEditDemo { if ui.button("start").clicked() { let text_edit_id = output.response.id; if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { - let ccursor = egui::text::CCursor::new(0); - state - .cursor - .set_char_range(Some(egui::text::CCursorRange::one(ccursor))); + state.select_byte_range(0..0); state.store(ui.ctx(), text_edit_id); ui.ctx().memory_mut(|mem| mem.request_focus(text_edit_id)); // give focus back to the [`TextEdit`]. } @@ -99,10 +96,7 @@ impl crate::View for TextEditDemo { if ui.button("end").clicked() { let text_edit_id = output.response.id; if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) { - let ccursor = egui::text::CCursor::new(text.chars().count()); - state - .cursor - .set_char_range(Some(egui::text::CCursorRange::one(ccursor))); + state.select_byte_range(text.len()..text.len()); state.store(ui.ctx(), text_edit_id); ui.ctx().memory_mut(|mem| mem.request_focus(text_edit_id)); // give focus back to the [`TextEdit`]. } diff --git a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs index 292e5f0aa62..32a12ded807 100644 --- a/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs +++ b/crates/egui_demo_lib/src/easy_mark/easy_mark_editor.rs @@ -1,5 +1,7 @@ +use std::ops::Range; + use egui::{ - text::CCursorRange, Key, KeyboardShortcut, Modifiers, ScrollArea, TextBuffer, TextEdit, Ui, + text::Selection, Key, KeyboardShortcut, Modifiers, ScrollArea, TextBuffer, TextEdit, Ui, }; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -97,10 +99,9 @@ impl EasyMarkEditor { }; if let Some(mut state) = TextEdit::load_state(ui.ctx(), response.id) { - if let Some(mut ccursor_range) = state.cursor.char_range() { - let any_change = shortcuts(ui, code, &mut ccursor_range); - if any_change { - state.cursor.set_char_range(Some(ccursor_range)); + if let Some(selection) = state.cursor.selection() { + if let Some(new_selection_range) = shortcuts(ui, code, &selection) { + state.select_byte_range(new_selection_range); state.store(ui.ctx(), response.id); } } @@ -140,17 +141,17 @@ fn nested_hotkeys_ui(ui: &mut egui::Ui) { }); } -fn shortcuts(ui: &Ui, code: &mut dyn TextBuffer, ccursor_range: &mut CCursorRange) -> bool { +fn shortcuts(ui: &Ui, code: &mut dyn TextBuffer, selection: &Selection) -> Option> { let mut any_change = false; + let mut selection_range = selection.byte_range(); if ui.input_mut(|i| i.consume_shortcut(&SHORTCUT_INDENT)) { // This is a placeholder till we can indent the active line any_change = true; - let [primary, _secondary] = ccursor_range.sorted_cursors(); - let advance = code.insert_text(" ", primary.index); - ccursor_range.primary.index += advance; - ccursor_range.secondary.index += advance; + let advance = code.insert_text(" ", selection_range.start); + selection_range.start += advance; + selection_range.end += advance; } for (shortcut, surrounding) in [ @@ -164,39 +165,33 @@ fn shortcuts(ui: &Ui, code: &mut dyn TextBuffer, ccursor_range: &mut CCursorRang ] { if ui.input_mut(|i| i.consume_shortcut(&shortcut)) { any_change = true; - toggle_surrounding(code, ccursor_range, surrounding); + toggle_surrounding(code, &mut selection_range, surrounding); }; } - any_change + any_change.then_some(selection_range) } /// E.g. toggle *strong* with `toggle_surrounding(&mut text, &mut cursor, "*")` -fn toggle_surrounding( - code: &mut dyn TextBuffer, - ccursor_range: &mut CCursorRange, - surrounding: &str, -) { - let [primary, secondary] = ccursor_range.sorted_cursors(); - - let surrounding_ccount = surrounding.chars().count(); +fn toggle_surrounding(code: &mut dyn TextBuffer, byte_range: &mut Range, surrounding: &str) { + let surrounding_count = surrounding.len(); - let prefix_crange = primary.index.saturating_sub(surrounding_ccount)..primary.index; - let suffix_crange = secondary.index..secondary.index.saturating_add(surrounding_ccount); - let already_surrounded = code.char_range(prefix_crange.clone()) == surrounding - && code.char_range(suffix_crange.clone()) == surrounding; + let prefix_range = byte_range.start.saturating_sub(surrounding_count)..byte_range.start; + let suffix_range = byte_range.end..byte_range.end.saturating_add(surrounding_count); + let already_surrounded = code.byte_range(prefix_range.clone()) == surrounding + && code.byte_range(suffix_range.clone()) == surrounding; if already_surrounded { - code.delete_char_range(suffix_crange); - code.delete_char_range(prefix_crange); - ccursor_range.primary.index -= surrounding_ccount; - ccursor_range.secondary.index -= surrounding_ccount; + code.replace_range(suffix_range, ""); + code.replace_range(prefix_range, ""); + byte_range.start -= surrounding_count; + byte_range.end -= surrounding_count; } else { - code.insert_text(surrounding, secondary.index); - let advance = code.insert_text(surrounding, primary.index); + code.insert_text(surrounding, byte_range.end); + let advance = code.insert_text(surrounding, byte_range.start); - ccursor_range.primary.index += advance; - ccursor_range.secondary.index += advance; + byte_range.start += advance; + byte_range.end += advance; } } diff --git a/crates/egui_demo_lib/src/rendering_test.rs b/crates/egui_demo_lib/src/rendering_test.rs index 5f0e91bc5a4..8f3e5292e5c 100644 --- a/crates/egui_demo_lib/src/rendering_test.rs +++ b/crates/egui_demo_lib/src/rendering_test.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; use egui::{ - emath::GuiRounding as _, epaint, lerp, pos2, vec2, widgets::color_picker::show_color, Align2, - Color32, FontId, Image, Mesh, Pos2, Rect, Response, Rgba, RichText, Sense, Shape, Stroke, - TextureHandle, TextureOptions, Ui, Vec2, + emath::GuiRounding as _, epaint, lerp, pos2, text::style::FontId, vec2, + widgets::color_picker::show_color, Align2, Color32, Image, Mesh, Pos2, Rect, Response, Rgba, + RichText, Sense, Shape, Stroke, TextureHandle, TextureOptions, Ui, Vec2, }; const GRADIENT_SIZE: Vec2 = vec2(256.0, 18.0); @@ -629,21 +629,21 @@ fn paint_fine_lines_and_text(painter: &egui::Painter, mut rect: Rect, color: Col rect.center_top() + vec2(0.0, y), Align2::LEFT_TOP, format!("{:.0}% white", 100.0 * opacity), - FontId::proportional(14.0), + FontId::system_ui(14.0), Color32::WHITE.gamma_multiply(opacity), ); painter.text( rect.center_top() + vec2(80.0, y), Align2::LEFT_TOP, format!("{:.0}% gray", 100.0 * opacity), - FontId::proportional(14.0), + FontId::system_ui(14.0), Color32::GRAY.gamma_multiply(opacity), ); painter.text( rect.center_top() + vec2(160.0, y), Align2::LEFT_TOP, format!("{:.0}% black", 100.0 * opacity), - FontId::proportional(14.0), + FontId::system_ui(14.0), Color32::BLACK.gamma_multiply(opacity), ); y += 20.0; @@ -656,7 +656,7 @@ fn paint_fine_lines_and_text(painter: &egui::Painter, mut rect: Rect, color: Col format!( "{font_size}px - The quick brown fox jumps over the lazy dog and runs away." ), - FontId::proportional(font_size), + FontId::system_ui(font_size), color, ); y += font_size + 1.0; diff --git a/crates/egui_extras/src/syntax_highlighting.rs b/crates/egui_extras/src/syntax_highlighting.rs index 6bb51184f12..13603da8e18 100644 --- a/crates/egui_extras/src/syntax_highlighting.rs +++ b/crates/egui_extras/src/syntax_highlighting.rs @@ -5,7 +5,7 @@ #![allow(clippy::mem_forget)] // False positive from enum_map macro -use egui::text::LayoutJob; +use egui::text::{style::FontId, LayoutJob}; use egui::TextStyle; /// View some code with syntax highlighting and selection. @@ -34,10 +34,10 @@ pub fn highlight( // (ui.ctx(), ui.style()) can be used #[expect(non_local_definitions)] - impl egui::cache::ComputerMut<(&egui::FontId, &CodeTheme, &str, &str), LayoutJob> for Highlighter { + impl egui::cache::ComputerMut<(&FontId, &CodeTheme, &str, &str), LayoutJob> for Highlighter { fn compute( &mut self, - (font_id, theme, code, lang): (&egui::FontId, &CodeTheme, &str, &str), + (font_id, theme, code, lang): (&FontId, &CodeTheme, &str, &str), ) -> LayoutJob { self.highlight(font_id.clone(), theme, code, lang) } @@ -153,7 +153,7 @@ pub struct CodeTheme { #[cfg(feature = "syntect")] syntect_theme: SyntectTheme, #[cfg(feature = "syntect")] - font_id: egui::FontId, + font_id: FontId, #[cfg(not(feature = "syntect"))] formats: enum_map::EnumMap, @@ -189,7 +189,7 @@ impl CodeTheme { /// # }); /// ``` pub fn dark(font_size: f32) -> Self { - Self::dark_with_font_id(egui::FontId::monospace(font_size)) + Self::dark_with_font_id(FontId::monospace(font_size)) } /// ### Example @@ -201,7 +201,7 @@ impl CodeTheme { /// # }); /// ``` pub fn light(font_size: f32) -> Self { - Self::light_with_font_id(egui::FontId::monospace(font_size)) + Self::light_with_font_id(FontId::monospace(font_size)) } /// Load code theme from egui memory. @@ -253,7 +253,7 @@ impl CodeTheme { #[cfg(feature = "syntect")] impl CodeTheme { - fn dark_with_font_id(font_id: egui::FontId) -> Self { + fn dark_with_font_id(font_id: FontId) -> Self { Self { dark_mode: true, syntect_theme: SyntectTheme::Base16MochaDark, @@ -261,7 +261,7 @@ impl CodeTheme { } } - fn light_with_font_id(font_id: egui::FontId) -> Self { + fn light_with_font_id(font_id: FontId) -> Self { Self { dark_mode: false, syntect_theme: SyntectTheme::SolarizedLight, @@ -286,7 +286,7 @@ impl CodeTheme { // The syntect version takes it by value. This could be avoided by specializing the from_style // function, but at the cost of more code duplication. #[expect(clippy::needless_pass_by_value)] - fn dark_with_font_id(font_id: egui::FontId) -> Self { + fn dark_with_font_id(font_id: FontId) -> Self { use egui::{Color32, TextFormat}; Self { dark_mode: true, @@ -303,7 +303,7 @@ impl CodeTheme { // The syntect version takes it by value #[expect(clippy::needless_pass_by_value)] - fn light_with_font_id(font_id: egui::FontId) -> Self { + fn light_with_font_id(font_id: FontId) -> Self { use egui::{Color32, TextFormat}; Self { dark_mode: false, @@ -413,13 +413,7 @@ impl Default for Highlighter { } impl Highlighter { - fn highlight( - &self, - font_id: egui::FontId, - theme: &CodeTheme, - code: &str, - lang: &str, - ) -> LayoutJob { + fn highlight(&self, font_id: FontId, theme: &CodeTheme, code: &str, lang: &str) -> LayoutJob { self.highlight_impl(theme, code, lang).unwrap_or_else(|| { // Fallback: LayoutJob::simple( @@ -511,7 +505,7 @@ struct Highlighter {} #[cfg(not(feature = "syntect"))] impl Highlighter { - #[expect(clippy::unused_self, clippy::unnecessary_wraps)] + #[expect(clippy::unused_self)] fn highlight_impl( &self, theme: &CodeTheme, diff --git a/crates/egui_glow/src/painter.rs b/crates/egui_glow/src/painter.rs index 0646b560daa..b3cee268dde 100644 --- a/crates/egui_glow/src/painter.rs +++ b/crates/egui_glow/src/painter.rs @@ -532,23 +532,6 @@ impl Painter { self.upload_texture_srgb(delta.pos, image.size, delta.options, data); } - egui::ImageData::Font(image) => { - assert_eq!( - image.width() * image.height(), - image.pixels.len(), - "Mismatch between texture size and texel count" - ); - - let data: Vec = { - profiling::scope!("font -> sRGBA"); - image - .srgba_pixels(None) - .flat_map(|a| a.to_array()) - .collect() - }; - - self.upload_texture_srgb(delta.pos, image.size, delta.options, &data); - } }; } diff --git a/crates/emath/src/pos2.rs b/crates/emath/src/pos2.rs index 62590b10f45..1f4bd86427f 100644 --- a/crates/emath/src/pos2.rs +++ b/crates/emath/src/pos2.rs @@ -1,7 +1,5 @@ -use std::{ - fmt, - ops::{Add, AddAssign, MulAssign, Sub, SubAssign}, -}; +use std::fmt; +use std::ops::{Add, AddAssign, Sub, SubAssign}; use crate::{lerp, Div, Mul, Vec2}; @@ -307,14 +305,6 @@ impl Mul for f32 { } } -impl MulAssign for Pos2 { - #[inline(always)] - fn mul_assign(&mut self, rhs: f32) { - self.x *= rhs; - self.y *= rhs; - } -} - impl Div for Pos2 { type Output = Self; diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index 00bed04f0f8..521b6f33f43 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -710,11 +710,7 @@ impl Rect { impl fmt::Debug for Rect { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(precision) = f.precision() { - write!(f, "[{1:.0$?} - {2:.0$?}]", precision, self.min, self.max) - } else { - write!(f, "[{:?} - {:?}]", self.min, self.max) - } + write!(f, "[{:?} - {:?}]", self.min, self.max) } } diff --git a/crates/epaint/Cargo.toml b/crates/epaint/Cargo.toml index 5a4016980ef..8798aad485d 100644 --- a/crates/epaint/Cargo.toml +++ b/crates/epaint/Cargo.toml @@ -29,6 +29,8 @@ rustdoc-args = ["--generate-link-to-definition"] [features] +accesskit = ["dep:accesskit", "parley/accesskit"] + default = ["default_fonts"] ## [`bytemuck`](https://docs.rs/bytemuck) enables you to cast [`Vertex`] to `&[u8]`. @@ -61,7 +63,7 @@ mint = ["emath/mint"] rayon = ["dep:rayon"] ## Allow serialization using [`serde`](https://docs.rs/serde). -serde = ["dep:serde", "ahash/serde", "emath/serde", "ecolor/serde"] +serde = ["dep:serde", "ahash/serde", "emath/serde", "ecolor/serde", "parley/serde"] ## Change Vertex layout to be compatible with unity unity = [] @@ -74,13 +76,19 @@ _override_unity = [] emath.workspace = true ecolor.workspace = true -ab_glyph = "0.2.11" +#parley = { version = "0.3", features = ["system"] } +parley = { git = "https://github.com/valadaptive/parley", branch = "egui", features = ["system"] } +#swash = { version = "0.2" } +swash = { git = "https://github.com/valadaptive/swash", branch = "tight-bounds" } ahash.workspace = true nohash-hasher.workspace = true parking_lot.workspace = true # Using parking_lot over std::sync::Mutex gives 50% speedups in some real-world scenarios. profiling = { workspace = true} #! ### Optional dependencies + +accesskit = { workspace = true, optional = true } + bytemuck = { workspace = true, optional = true, features = ["derive"] } ## Enable this when generating docs. @@ -102,7 +110,6 @@ backtrace = { workspace = true, optional = true } [dev-dependencies] criterion.workspace = true mimalloc.workspace = true -similar-asserts.workspace = true [[bench]] diff --git a/crates/epaint/src/image.rs b/crates/epaint/src/image.rs index b6183e7a1cc..484ef1ed58f 100644 --- a/crates/epaint/src/image.rs +++ b/crates/epaint/src/image.rs @@ -7,24 +7,20 @@ use std::sync::Arc; /// /// To load an image file, see [`ColorImage::from_rgba_unmultiplied`]. /// -/// In order to paint the image on screen, you first need to convert it to +/// This is currently an enum with only one variant, but more image types may be added in the future. /// /// See also: [`ColorImage`], [`FontImage`]. -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub enum ImageData { /// RGBA image. Color(Arc), - - /// Used for the font texture. - Font(FontImage), } impl ImageData { pub fn size(&self) -> [usize; 2] { match self { Self::Color(image) => image.size, - Self::Font(image) => image.size, } } @@ -38,7 +34,7 @@ impl ImageData { pub fn bytes_per_pixel(&self) -> usize { match self { - Self::Color(_) | Self::Font(_) => 4, + Self::Color(_) => 4, } } } @@ -271,117 +267,9 @@ impl ColorImage { } Self::new([width, height], output) } -} - -impl std::ops::Index<(usize, usize)> for ColorImage { - type Output = Color32; - - #[inline] - fn index(&self, (x, y): (usize, usize)) -> &Color32 { - let [w, h] = self.size; - assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); - &self.pixels[y * w + x] - } -} - -impl std::ops::IndexMut<(usize, usize)> for ColorImage { - #[inline] - fn index_mut(&mut self, (x, y): (usize, usize)) -> &mut Color32 { - let [w, h] = self.size; - assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); - &mut self.pixels[y * w + x] - } -} - -impl From for ImageData { - #[inline(always)] - fn from(image: ColorImage) -> Self { - Self::Color(Arc::new(image)) - } -} - -impl From> for ImageData { - #[inline] - fn from(image: Arc) -> Self { - Self::Color(image) - } -} - -impl std::fmt::Debug for ColorImage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ColorImage") - .field("size", &self.size) - .field("pixel-count", &self.pixels.len()) - .finish_non_exhaustive() - } -} - -// ---------------------------------------------------------------------------- - -/// A single-channel image designed for the font texture. -/// -/// Each value represents "coverage", i.e. how much a texel is covered by a character. -/// -/// This is roughly interpreted as the opacity of a white image. -#[derive(Clone, Default, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct FontImage { - /// width, height - pub size: [usize; 2], - - /// The coverage value. - /// - /// Often you want to use [`Self::srgba_pixels`] instead. - pub pixels: Vec, -} - -impl FontImage { - pub fn new(size: [usize; 2]) -> Self { - Self { - size, - pixels: vec![0.0; size[0] * size[1]], - } - } - - #[inline] - pub fn width(&self) -> usize { - self.size[0] - } - - #[inline] - pub fn height(&self) -> usize { - self.size[1] - } - - /// Returns the textures as `sRGBA` premultiplied pixels, row by row, top to bottom. - /// - /// `gamma` should normally be set to `None`. - /// - /// If you are having problems with text looking skinny and pixelated, try using a low gamma, e.g. `0.4`. - #[inline] - pub fn srgba_pixels(&self, gamma: Option) -> impl ExactSizeIterator + '_ { - // This whole function is less than rigorous. - // Ideally we should do this in a shader instead, and use different computations - // for different text colors. - // See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis. - self.pixels.iter().map(move |coverage| { - let alpha = if let Some(gamma) = gamma { - coverage.powf(gamma) - } else { - // alpha = coverage * coverage; // recommended by the article for WHITE text (using linear blending) - - // The following is recommended by the article for BLACK text (using linear blending). - // Very similar to a gamma of 0.5, but produces sharper text. - // In practice it works well for all text colors (better than a gamma of 0.5, for instance). - // See https://www.desmos.com/calculator/w0ndf5blmn for a visual comparison. - 2.0 * coverage - coverage * coverage - }; - Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) - }) - } /// Clone a sub-region as a new image. - pub fn region(&self, [x, y]: [usize; 2], [w, h]: [usize; 2]) -> Self { + pub fn region_by_pixels(&self, [x, y]: [usize; 2], [w, h]: [usize; 2]) -> Self { assert!( x + w <= self.width(), "x + w should be <= self.width(), but x: {}, w: {}, width: {}", @@ -408,37 +296,50 @@ impl FontImage { "pixels.len should be w * h, but got {}", pixels.len() ); - Self { - size: [w, h], - pixels, - } + Self::new([w, h], pixels) } } -impl std::ops::Index<(usize, usize)> for FontImage { - type Output = f32; +impl std::ops::Index<(usize, usize)> for ColorImage { + type Output = Color32; #[inline] - fn index(&self, (x, y): (usize, usize)) -> &f32 { + fn index(&self, (x, y): (usize, usize)) -> &Color32 { let [w, h] = self.size; assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); &self.pixels[y * w + x] } } -impl std::ops::IndexMut<(usize, usize)> for FontImage { +impl std::ops::IndexMut<(usize, usize)> for ColorImage { #[inline] - fn index_mut(&mut self, (x, y): (usize, usize)) -> &mut f32 { + fn index_mut(&mut self, (x, y): (usize, usize)) -> &mut Color32 { let [w, h] = self.size; assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); &mut self.pixels[y * w + x] } } -impl From for ImageData { +impl From for ImageData { #[inline(always)] - fn from(image: FontImage) -> Self { - Self::Font(image) + fn from(image: ColorImage) -> Self { + Self::Color(Arc::new(image)) + } +} + +impl From> for ImageData { + #[inline] + fn from(image: Arc) -> Self { + Self::Color(image) + } +} + +impl std::fmt::Debug for ColorImage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ColorImage") + .field("size", &self.size) + .field("pixel-count", &self.pixels.len()) + .finish_non_exhaustive() } } @@ -447,7 +348,7 @@ impl From for ImageData { /// A change to an image. /// /// Either a whole new image, or an update to a rectangular region of it. -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[must_use = "The painter must take care of this"] pub struct ImageDelta { diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 633aa668977..538614a40bd 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -50,7 +50,7 @@ pub use self::{ color::ColorMode, corner_radius::CornerRadius, corner_radius_f32::CornerRadiusF32, - image::{ColorImage, FontImage, ImageData, ImageDelta}, + image::{ColorImage, ImageData, ImageDelta}, margin::Margin, margin_f32::*, mesh::{Mesh, Mesh16, Vertex}, @@ -62,7 +62,7 @@ pub use self::{ stats::PaintStats, stroke::{PathStroke, Stroke, StrokeKind}, tessellator::{TessellationOptions, Tessellator}, - text::{FontFamily, FontId, Fonts, Galley}, + text::{Fonts, Galley}, texture_atlas::TextureAtlas, texture_handle::TextureHandle, textures::TextureManager, @@ -117,7 +117,7 @@ impl Default for TextureId { /// A [`Shape`] within a clip rectangle. /// /// Everything is using logical points. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct ClippedShape { /// Clip / scissor rectangle. /// Only show the part of the [`Shape`] that falls within this. diff --git a/crates/epaint/src/mesh.rs b/crates/epaint/src/mesh.rs index 60be2935c52..0387ba4e02e 100644 --- a/crates/epaint/src/mesh.rs +++ b/crates/epaint/src/mesh.rs @@ -73,7 +73,6 @@ impl Mesh { pub fn clear(&mut self) { self.indices.clear(); self.vertices.clear(); - self.vertices = Default::default(); } /// Returns the amount of memory used by the vertices and indices. diff --git a/crates/epaint/src/mutex.rs b/crates/epaint/src/mutex.rs index 465722c1776..050ebea8628 100644 --- a/crates/epaint/src/mutex.rs +++ b/crates/epaint/src/mutex.rs @@ -9,7 +9,7 @@ mod mutex_impl { /// This is a thin wrapper around [`parking_lot::Mutex`], except if /// the feature `deadlock_detection` is turned enabled, in which case /// extra checks are added to detect deadlocks. - #[derive(Default)] + #[derive(Default, Debug)] pub struct Mutex(parking_lot::Mutex); /// The lock you get from [`Mutex`]. @@ -35,7 +35,7 @@ mod mutex_impl { /// This is a thin wrapper around [`parking_lot::Mutex`], except if /// the feature `deadlock_detection` is turned enabled, in which case /// extra checks are added to detect deadlocks. - #[derive(Default)] + #[derive(Default, Debug)] pub struct Mutex(parking_lot::Mutex); /// The lock you get from [`Mutex`]. diff --git a/crates/epaint/src/shape_transform.rs b/crates/epaint/src/shape_transform.rs index 57de149692d..469f2e5218f 100644 --- a/crates/epaint/src/shape_transform.rs +++ b/crates/epaint/src/shape_transform.rs @@ -89,8 +89,7 @@ pub fn adjust_colors( if !galley.is_empty() { let galley = Arc::make_mut(galley); - for placed_row in &mut galley.rows { - let row = Arc::make_mut(&mut placed_row.row); + for row in &mut galley.rows { for vertex in &mut row.visuals.mesh.vertices { adjust_color(&mut vertex.color); } diff --git a/crates/epaint/src/shapes/shape.rs b/crates/epaint/src/shapes/shape.rs index bc16e43d552..c52f4837fe0 100644 --- a/crates/epaint/src/shapes/shape.rs +++ b/crates/epaint/src/shapes/shape.rs @@ -6,8 +6,8 @@ use emath::{pos2, Align2, Pos2, Rangef, Rect, TSTransform, Vec2}; use crate::{ stroke::PathStroke, - text::{FontId, Fonts, Galley}, - Color32, CornerRadius, Mesh, Stroke, StrokeKind, TextureId, + text::{style::FontId, Galley}, + Color32, CornerRadius, Fonts, Mesh, Stroke, StrokeKind, TextureId, }; use super::{ @@ -23,7 +23,7 @@ use super::{ /// [`Shape::Text`] depends on the current `pixels_per_point` (dpi scale) /// and so must be recreated every time `pixels_per_point` changes. #[must_use = "Add a Shape to a Painter"] -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub enum Shape { /// Paint nothing. This can be useful as a placeholder. Noop, @@ -298,7 +298,7 @@ impl Shape { #[expect(clippy::needless_pass_by_value)] pub fn text( - fonts: &Fonts, + fonts: &mut Fonts<'_>, pos: Pos2, anchor: Align2, text: impl ToString, @@ -456,7 +456,46 @@ impl Shape { rect_shape.blur_width *= transform.scaling; } Self::Text(text_shape) => { - text_shape.transform(transform); + let TextShape { + pos, + galley, + underline, + fallback_color: _, + override_text_color: _, + opacity_factor: _, + angle: _, + } = text_shape; + + *pos = transform * *pos; + underline.width *= transform.scaling; + + let Galley { + job: _, + rows, + elided: _, + rect, + mesh_bounds, + num_vertices: _, + num_indices: _, + pixels_per_point: _, + selection_color: _, + .. + } = Arc::make_mut(galley); + + for row in rows { + row.visuals.mesh_bounds = transform.scaling * row.visuals.mesh_bounds; + for v in &mut row.visuals.mesh.vertices { + v.pos = Pos2::new(transform.scaling * v.pos.x, transform.scaling * v.pos.y); + } + if let Some(rects) = &mut row.visuals.selection_rects { + for rect in rects { + *rect = transform.scaling * *rect; + } + } + } + + *mesh_bounds = transform.scaling * *mesh_bounds; + *rect = transform.scaling * *rect; } Self::Mesh(mesh) => { Arc::make_mut(mesh).transform(transform); diff --git a/crates/epaint/src/shapes/text_shape.rs b/crates/epaint/src/shapes/text_shape.rs index 4ea0ac352c2..f43d9c63f3d 100644 --- a/crates/epaint/src/shapes/text_shape.rs +++ b/crates/epaint/src/shapes/text_shape.rs @@ -5,7 +5,7 @@ use crate::*; /// How to paint some text on screen. /// /// This needs to be recreated if `pixels_per_point` (dpi scale) changes. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct TextShape { /// Where the origin of [`Self::galley`] is. @@ -92,63 +92,6 @@ impl TextShape { self.opacity_factor = opacity_factor; self } - - /// Move the shape by this many points, in-place. - pub fn transform(&mut self, transform: emath::TSTransform) { - let Self { - pos, - galley, - underline, - fallback_color: _, - override_text_color: _, - opacity_factor: _, - angle: _, - } = self; - - *pos = transform * *pos; - underline.width *= transform.scaling; - - let Galley { - job: _, - rows, - elided: _, - rect, - mesh_bounds, - num_vertices: _, - num_indices: _, - pixels_per_point: _, - } = Arc::make_mut(galley); - - *rect = transform.scaling * *rect; - *mesh_bounds = transform.scaling * *mesh_bounds; - - for text::PlacedRow { pos, row } in rows { - *pos *= transform.scaling; - - let text::Row { - section_index_at_start: _, - glyphs: _, // TODO(emilk): would it make sense to transform these? - size, - visuals, - ends_with_newline: _, - } = Arc::make_mut(row); - - *size *= transform.scaling; - - let text::RowVisuals { - mesh, - mesh_bounds, - glyph_index_start: _, - glyph_vertex_range: _, - } = visuals; - - *mesh_bounds = transform.scaling * *mesh_bounds; - - for v in &mut mesh.vertices { - v.pos *= transform.scaling; - } - } - } } impl From for Shape { @@ -161,16 +104,16 @@ impl From for Shape { #[cfg(test)] mod tests { use super::{super::*, *}; - use crate::text::FontDefinitions; + use crate::text::{style::FontId, FontDefinitions, FontStore}; use emath::almost_equal; #[test] fn text_bounding_box_under_rotation() { - let fonts = Fonts::new(1.0, 1024, FontDefinitions::default()); + let mut fonts = FontStore::new(1024, FontDefinitions::default()); let font = FontId::monospace(12.0); let mut t = crate::Shape::text( - &fonts, + &mut fonts.with_pixels_per_point(1.0), Pos2::ZERO, emath::Align2::CENTER_CENTER, "testing123", diff --git a/crates/epaint/src/stats.rs b/crates/epaint/src/stats.rs index 2acf1e93cb3..4ae66d37ba4 100644 --- a/crates/epaint/src/stats.rs +++ b/crates/epaint/src/stats.rs @@ -86,13 +86,14 @@ impl AllocInfo { // } pub fn from_galley(galley: &Galley) -> Self { + // TODO(valadaptive): this doesn't count the Parley layout's memory usage Self::from_slice(galley.text().as_bytes()) + Self::from_slice(&galley.rows) + galley.rows.iter().map(Self::from_galley_row).sum() } - fn from_galley_row(row: &crate::text::PlacedRow) -> Self { - Self::from_mesh(&row.visuals.mesh) + Self::from_slice(&row.glyphs) + fn from_galley_row(row: &crate::text::Row) -> Self { + Self::from_mesh(&row.visuals.mesh) } pub fn from_mesh(mesh: &Mesh) -> Self { diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index b083ea452c1..aa5f1c1e262 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -2008,8 +2008,11 @@ impl Tessellator { println!("{warn}"); } - out.vertices.reserve(galley.num_vertices); - out.indices.reserve(galley.num_indices); + let num_vertices = galley.num_vertices; + let num_indices = galley.num_indices; + + out.vertices.reserve(num_vertices); + out.indices.reserve(num_indices); // The contents of the galley are already snapped to pixel coordinates, // but we need to make sure the galley ends up on the start of a physical pixel: @@ -2031,13 +2034,11 @@ impl Tessellator { continue; } - let final_row_pos = galley_pos + row.pos.to_vec2(); - let mut row_rect = row.visuals.mesh_bounds; if *angle != 0.0 { row_rect = row_rect.rotate_bb(rotator); } - row_rect = row_rect.translate(final_row_pos.to_vec2()); + row_rect = row_rect.translate(galley_pos.to_vec2()); if self.options.coarse_tessellation_culling && !self.clip_rect.intersects(row_rect) { // culling individual lines of text is important, since a single `Shape::Text` @@ -2045,13 +2046,71 @@ impl Tessellator { continue; } - let index_offset = out.vertices.len() as u32; + let mut post_selection_index_start = 0; + let mut index_offset = out.vertices.len() as u32; + + if let Some(selection_rects) = &row.visuals.selection_rects { + // Paint the selection above the background but below the text. + index_offset += (selection_rects.len() * 4) as u32; + // We're drawing the background here, so only draw the foreground later. + post_selection_index_start = row.visuals.glyph_index_start; + + out.indices.extend( + row.visuals + .mesh + .indices + .iter() + .take(post_selection_index_start) + // We know how many vertices the selection rectangles will take up, and since we're adding the + // selection vertices first and the row vertices all at once, we should add that to the index + // offset here too. + .map(|index| index + index_offset), + ); + + if *angle == 0.0 { + for rect in selection_rects { + out.add_colored_rect( + rect.translate(galley_pos.to_vec2()), + galley.selection_color, + ); + } + } else { + for rect in selection_rects { + // We don't feather the background so let's not feather the selection either. + fill_closed_path( + 0.0, + &mut [ + PathPoint { + pos: galley_pos + (rotator * rect.left_top().to_vec2()), + normal: Vec2::ZERO, + }, + PathPoint { + pos: galley_pos + (rotator * rect.right_top().to_vec2()), + normal: Vec2::ZERO, + }, + PathPoint { + pos: galley_pos + (rotator * rect.right_bottom().to_vec2()), + normal: Vec2::ZERO, + }, + PathPoint { + pos: galley_pos + (rotator * rect.left_bottom().to_vec2()), + normal: Vec2::ZERO, + }, + ], + galley.selection_color, + out, + ); + } + } + } out.indices.extend( row.visuals .mesh .indices .iter() + // If there was a selection, we start from the foreground vertices. Otherwise, we start from zero. + .skip(post_selection_index_start) .map(|index| index + index_offset), ); @@ -2086,7 +2145,7 @@ impl Tessellator { }; Vertex { - pos: final_row_pos + offset, + pos: galley_pos + offset, uv: (uv.to_vec2() * uv_normalizer).to_pos2(), color, } diff --git a/crates/epaint/src/text/cursor.rs b/crates/epaint/src/text/cursor.rs index a436ca1b1eb..f4b0cabc055 100644 --- a/crates/epaint/src/text/cursor.rs +++ b/crates/epaint/src/text/cursor.rs @@ -1,87 +1,175 @@ //! Different types of text cursors, i.e. ways to point into a [`super::Galley`]. -/// Character cursor. -/// -/// The default cursor is zero. -#[derive(Clone, Copy, Debug, Default)] +use std::ops::Range; + +use ecolor::Color32; + +/// Determines whether a cursor is attached to the preceding or following character. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct CCursor { - /// Character offset (NOT byte offset!). - pub index: usize, +pub enum Affinity { + /// The cursor is attached to the following character in the text (e.g. if it's positioned in the middle of a line + /// wrap, it'll be on the bottom line). + #[default] + Downstream, + /// The cursor is attached to the preceding character in the text. + Upstream, +} + +impl PartialOrd for Affinity { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} - /// If this cursors sits right at the border of a wrapped row break (NOT paragraph break) - /// do we prefer the next row? - /// This is *almost* always what you want, *except* for when - /// explicitly clicking the end of a row or pressing the end key. - pub prefer_next_row: bool, +impl Ord for Affinity { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match (self, other) { + (Self::Downstream, Self::Downstream) | (Self::Upstream, Self::Upstream) => { + std::cmp::Ordering::Equal + } + (Self::Downstream, Self::Upstream) => std::cmp::Ordering::Greater, + (Self::Upstream, Self::Downstream) => std::cmp::Ordering::Less, + } + } } -impl CCursor { +impl From for Affinity { #[inline] - pub fn new(index: usize) -> Self { - Self { - index, - prefer_next_row: false, + fn from(value: parley::Affinity) -> Self { + match value { + parley::Affinity::Downstream => Self::Downstream, + parley::Affinity::Upstream => Self::Upstream, } } } -/// Two `CCursor`s are considered equal if they refer to the same character boundary, -/// even if one prefers the start of the next row. -impl PartialEq for CCursor { +impl From for parley::Affinity { #[inline] - fn eq(&self, other: &Self) -> bool { - self.index == other.index + fn from(value: Affinity) -> Self { + match value { + Affinity::Downstream => Self::Downstream, + Affinity::Upstream => Self::Upstream, + } } } -impl std::ops::Add for CCursor { - type Output = Self; +/// Byte-index-based text cursor. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ByteCursor { + pub index: usize, + pub affinity: Affinity, +} + +impl ByteCursor { + pub const START: Self = Self { + index: 0, + affinity: Affinity::Downstream, + }; + + pub const END: Self = Self { + index: usize::MAX, + affinity: Affinity::Downstream, + }; + + #[inline] + pub(crate) fn as_parley(&self, layout: &parley::Layout) -> parley::Cursor { + parley::Cursor::from_byte_index(layout, self.index, self.affinity.into()) + } +} - fn add(self, rhs: usize) -> Self::Output { +impl From for ByteCursor { + #[inline] + fn from(value: parley::Cursor) -> Self { Self { - index: self.index.saturating_add(rhs), - prefer_next_row: self.prefer_next_row, + index: value.index(), + affinity: value.affinity().into(), } } } -impl std::ops::Sub for CCursor { - type Output = Self; +/// Range between two cursors, with some extra text-edit state. Requires text layout to be done before it can be +/// constructed from two [`ByteCursor`]s. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Selection(pub(super) parley::Selection); - fn sub(self, rhs: usize) -> Self::Output { - Self { - index: self.index.saturating_sub(rhs), - prefer_next_row: self.prefer_next_row, +impl Selection { + /// When selecting with a mouse, this is where the mouse was first pressed. + /// This part of the cursor does not move when shift is down. + #[inline] + pub fn anchor(&self) -> ByteCursor { + self.0.anchor().into() + } + + /// When selecting with a mouse, this is where the mouse was released. + /// When moving with e.g. shift+arrows, this is what moves. + /// Note that the two ends can come in any order, and also be equal (no selection). + #[inline] + pub fn focus(&self) -> ByteCursor { + self.0.focus().into() + } + + #[deprecated = "use `focus` instead"] + pub fn primary(&self) -> ByteCursor { + self.focus() + } + + #[deprecated = "use `anchor` instead"] + pub fn secondary(&self) -> ByteCursor { + self.anchor() + } + + /// Does this selection contain any characters, or is it empty (both ends are the same)? + #[inline] + pub fn is_empty(&self) -> bool { + self.0.is_collapsed() + } + + #[inline] + pub fn sorted_cursors(&self) -> [ByteCursor; 2] { + if self.anchor() < self.focus() { + [self.anchor(), self.focus()] + } else { + [self.focus(), self.anchor()] } } -} -impl std::ops::AddAssign for CCursor { - fn add_assign(&mut self, rhs: usize) { - self.index = self.index.saturating_add(rhs); + #[inline] + pub fn byte_range(&self) -> Range { + let [min, max] = self.sorted_cursors(); + min.index..max.index + } + + pub fn contains(&self, other: &Self) -> bool { + let [my_min, my_max] = self.sorted_cursors(); + let [other_min, other_max] = other.sorted_cursors(); + other_min >= my_min && other_max <= my_max + } + + #[inline] + pub fn slice_str<'s>(&self, text: &'s str) -> &'s str { + &text[self.byte_range()] + } + + /// Collapses this selection into an empty one around its [`Self::focus()`]. + #[inline] + pub fn collapse(&self) -> Self { + self.0.collapse().into() } } -impl std::ops::SubAssign for CCursor { - fn sub_assign(&mut self, rhs: usize) { - self.index = self.index.saturating_sub(rhs); +impl From for Selection { + #[inline] + fn from(value: parley::Selection) -> Self { + Self(value) } } -/// Row/column cursor. -/// -/// This refers to rows and columns in layout terms--text wrapping creates multiple rows. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct LayoutCursor { - /// 0 is first row, and so on. - /// Note that a single paragraph can span multiple rows. - /// (a paragraph is text separated by `\n`). - pub row: usize, - - /// Character based (NOT bytes). - /// It is fine if this points to something beyond the end of the current row. - /// When moving up/down it may again be within the next row. - pub column: usize, +impl From for parley::Selection { + #[inline] + fn from(value: Selection) -> Self { + value.0 + } } diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs deleted file mode 100644 index b79bea2f249..00000000000 --- a/crates/epaint/src/text/font.rs +++ /dev/null @@ -1,533 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::Arc; - -use emath::{vec2, GuiRounding as _, Vec2}; - -use crate::{ - mutex::{Mutex, RwLock}, - text::FontTweak, - TextureAtlas, -}; - -// ---------------------------------------------------------------------------- - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct UvRect { - /// X/Y offset for nice rendering (unit: points). - pub offset: Vec2, - - /// Screen size (in points) of this glyph. - /// Note that the height is different from the font height. - pub size: Vec2, - - /// Top left corner UV in texture. - pub min: [u16; 2], - - /// Bottom right corner (exclusive). - pub max: [u16; 2], -} - -impl UvRect { - pub fn is_nothing(&self) -> bool { - self.min == self.max - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct GlyphInfo { - /// Used for pair-kerning. - /// - /// Doesn't need to be unique. - /// Use `ab_glyph::GlyphId(0)` if you just want to have an id, and don't care. - pub(crate) id: ab_glyph::GlyphId, - - /// Unit: points. - pub advance_width: f32, - - /// Texture coordinates. - pub uv_rect: UvRect, -} - -impl Default for GlyphInfo { - /// Basically a zero-width space. - fn default() -> Self { - Self { - id: ab_glyph::GlyphId(0), - advance_width: 0.0, - uv_rect: Default::default(), - } - } -} - -// ---------------------------------------------------------------------------- - -/// A specific font with a size. -/// The interface uses points as the unit for everything. -pub struct FontImpl { - name: String, - ab_glyph_font: ab_glyph::FontArc, - - /// Maximum character height - scale_in_pixels: u32, - - height_in_points: f32, - - // move each character by this much (hack) - y_offset_in_points: f32, - - ascent: f32, - pixels_per_point: f32, - glyph_info_cache: RwLock>, // TODO(emilk): standard Mutex - atlas: Arc>, -} - -impl FontImpl { - pub fn new( - atlas: Arc>, - pixels_per_point: f32, - name: String, - ab_glyph_font: ab_glyph::FontArc, - scale_in_pixels: f32, - tweak: FontTweak, - ) -> Self { - assert!( - scale_in_pixels > 0.0, - "scale_in_pixels is smaller than 0, got: {scale_in_pixels:?}" - ); - assert!( - pixels_per_point > 0.0, - "pixels_per_point must be greater than 0, got: {pixels_per_point:?}" - ); - - use ab_glyph::{Font as _, ScaleFont as _}; - let scaled = ab_glyph_font.as_scaled(scale_in_pixels); - let ascent = (scaled.ascent() / pixels_per_point).round_ui(); - let descent = (scaled.descent() / pixels_per_point).round_ui(); - let line_gap = (scaled.line_gap() / pixels_per_point).round_ui(); - - // Tweak the scale as the user desired - let scale_in_pixels = scale_in_pixels * tweak.scale; - let scale_in_points = scale_in_pixels / pixels_per_point; - - let baseline_offset = (scale_in_points * tweak.baseline_offset_factor).round_ui(); - - let y_offset_points = - ((scale_in_points * tweak.y_offset_factor) + tweak.y_offset).round_ui(); - - // Center scaled glyphs properly: - let height = ascent + descent; - let y_offset_points = y_offset_points - (1.0 - tweak.scale) * 0.5 * height; - - // Round to an even number of physical pixels to get even kerning. - // See https://github.com/emilk/egui/issues/382 - let scale_in_pixels = scale_in_pixels.round() as u32; - - // Round to closest pixel: - let y_offset_in_points = (y_offset_points * pixels_per_point).round() / pixels_per_point; - - Self { - name, - ab_glyph_font, - scale_in_pixels, - height_in_points: ascent - descent + line_gap, - y_offset_in_points, - ascent: ascent + baseline_offset, - pixels_per_point, - glyph_info_cache: Default::default(), - atlas, - } - } - - /// Code points that will always be replaced by the replacement character. - /// - /// See also [`invisible_char`]. - fn ignore_character(&self, chr: char) -> bool { - use crate::text::FontDefinitions; - - if !FontDefinitions::builtin_font_names().contains(&self.name.as_str()) { - return false; - } - - matches!( - chr, - // Strip out a religious symbol with secondary nefarious interpretation: - '\u{534d}' | '\u{5350}' | - - // Ignore ubuntu-specific stuff in `Ubuntu-Light.ttf`: - '\u{E0FF}' | '\u{EFFD}' | '\u{F0FF}' | '\u{F200}' - ) - } - - /// An un-ordered iterator over all supported characters. - fn characters(&self) -> impl Iterator + '_ { - use ab_glyph::Font as _; - self.ab_glyph_font - .codepoint_ids() - .map(|(_, chr)| chr) - .filter(|&chr| !self.ignore_character(chr)) - } - - /// `\n` will result in `None` - fn glyph_info(&self, c: char) -> Option { - { - if let Some(glyph_info) = self.glyph_info_cache.read().get(&c) { - return Some(*glyph_info); - } - } - - if self.ignore_character(c) { - return None; // these will result in the replacement character when rendering - } - - if c == '\t' { - if let Some(space) = self.glyph_info(' ') { - let glyph_info = GlyphInfo { - advance_width: crate::text::TAB_SIZE as f32 * space.advance_width, - ..space - }; - self.glyph_info_cache.write().insert(c, glyph_info); - return Some(glyph_info); - } - } - - if c == '\u{2009}' { - // Thin space, often used as thousands deliminator: 1 234 567 890 - // https://www.compart.com/en/unicode/U+2009 - // https://en.wikipedia.org/wiki/Thin_space - - if let Some(space) = self.glyph_info(' ') { - let em = self.height_in_points; // TODO(emilk): is this right? - let advance_width = f32::min(em / 6.0, space.advance_width * 0.5); - let glyph_info = GlyphInfo { - advance_width, - ..space - }; - self.glyph_info_cache.write().insert(c, glyph_info); - return Some(glyph_info); - } - } - - if invisible_char(c) { - let glyph_info = GlyphInfo::default(); - self.glyph_info_cache.write().insert(c, glyph_info); - return Some(glyph_info); - } - - // Add new character: - use ab_glyph::Font as _; - let glyph_id = self.ab_glyph_font.glyph_id(c); - - if glyph_id.0 == 0 { - None // unsupported character - } else { - let glyph_info = self.allocate_glyph(glyph_id); - self.glyph_info_cache.write().insert(c, glyph_info); - Some(glyph_info) - } - } - - #[inline] - pub fn pair_kerning( - &self, - last_glyph_id: ab_glyph::GlyphId, - glyph_id: ab_glyph::GlyphId, - ) -> f32 { - use ab_glyph::{Font as _, ScaleFont as _}; - self.ab_glyph_font - .as_scaled(self.scale_in_pixels as f32) - .kern(last_glyph_id, glyph_id) - / self.pixels_per_point - } - - /// Height of one row of text in points. - /// - /// Returns a value rounded to [`emath::GUI_ROUNDING`]. - #[inline(always)] - pub fn row_height(&self) -> f32 { - self.height_in_points - } - - #[inline(always)] - pub fn pixels_per_point(&self) -> f32 { - self.pixels_per_point - } - - /// This is the distance from the top to the baseline. - /// - /// Unit: points. - #[inline(always)] - pub fn ascent(&self) -> f32 { - self.ascent - } - - fn allocate_glyph(&self, glyph_id: ab_glyph::GlyphId) -> GlyphInfo { - assert!(glyph_id.0 != 0, "Can't allocate glyph for id 0"); - use ab_glyph::{Font as _, ScaleFont as _}; - - let glyph = glyph_id.with_scale_and_position( - self.scale_in_pixels as f32, - ab_glyph::Point { x: 0.0, y: 0.0 }, - ); - - let uv_rect = self.ab_glyph_font.outline_glyph(glyph).map(|glyph| { - let bb = glyph.px_bounds(); - let glyph_width = bb.width() as usize; - let glyph_height = bb.height() as usize; - if glyph_width == 0 || glyph_height == 0 { - UvRect::default() - } else { - let glyph_pos = { - let atlas = &mut self.atlas.lock(); - let (glyph_pos, image) = atlas.allocate((glyph_width, glyph_height)); - glyph.draw(|x, y, v| { - if 0.0 < v { - let px = glyph_pos.0 + x as usize; - let py = glyph_pos.1 + y as usize; - image[(px, py)] = v; - } - }); - glyph_pos - }; - - let offset_in_pixels = vec2(bb.min.x, bb.min.y); - let offset = - offset_in_pixels / self.pixels_per_point + self.y_offset_in_points * Vec2::Y; - UvRect { - offset, - size: vec2(glyph_width as f32, glyph_height as f32) / self.pixels_per_point, - min: [glyph_pos.0 as u16, glyph_pos.1 as u16], - max: [ - (glyph_pos.0 + glyph_width) as u16, - (glyph_pos.1 + glyph_height) as u16, - ], - } - } - }); - let uv_rect = uv_rect.unwrap_or_default(); - - let advance_width_in_points = self - .ab_glyph_font - .as_scaled(self.scale_in_pixels as f32) - .h_advance(glyph_id) - / self.pixels_per_point; - - GlyphInfo { - id: glyph_id, - advance_width: advance_width_in_points, - uv_rect, - } - } -} - -type FontIndex = usize; - -// TODO(emilk): rename? -/// Wrapper over multiple [`FontImpl`] (e.g. a primary + fallbacks for emojis) -pub struct Font { - fonts: Vec>, - - /// Lazily calculated. - characters: Option>>, - - replacement_glyph: (FontIndex, GlyphInfo), - pixels_per_point: f32, - row_height: f32, - glyph_info_cache: ahash::HashMap, -} - -impl Font { - pub fn new(fonts: Vec>) -> Self { - if fonts.is_empty() { - return Self { - fonts, - characters: None, - replacement_glyph: Default::default(), - pixels_per_point: 1.0, - row_height: 0.0, - glyph_info_cache: Default::default(), - }; - } - - let pixels_per_point = fonts[0].pixels_per_point(); - let row_height = fonts[0].row_height(); - - let mut slf = Self { - fonts, - characters: None, - replacement_glyph: Default::default(), - pixels_per_point, - row_height, - glyph_info_cache: Default::default(), - }; - - const PRIMARY_REPLACEMENT_CHAR: char = '◻'; // white medium square - const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback - - let replacement_glyph = slf - .glyph_info_no_cache_or_fallback(PRIMARY_REPLACEMENT_CHAR) - .or_else(|| slf.glyph_info_no_cache_or_fallback(FALLBACK_REPLACEMENT_CHAR)) - .unwrap_or_else(|| { - #[cfg(feature = "log")] - log::warn!( - "Failed to find replacement characters {PRIMARY_REPLACEMENT_CHAR:?} or {FALLBACK_REPLACEMENT_CHAR:?}. Will use empty glyph." - ); - (0, GlyphInfo::default()) - }); - slf.replacement_glyph = replacement_glyph; - - slf - } - - pub fn preload_characters(&mut self, s: &str) { - for c in s.chars() { - self.glyph_info(c); - } - } - - pub fn preload_common_characters(&mut self) { - // Preload the printable ASCII characters [32, 126] (which excludes control codes): - const FIRST_ASCII: usize = 32; // 32 == space - const LAST_ASCII: usize = 126; - for c in (FIRST_ASCII..=LAST_ASCII).map(|c| c as u8 as char) { - self.glyph_info(c); - } - self.glyph_info('°'); - self.glyph_info(crate::text::PASSWORD_REPLACEMENT_CHAR); - } - - /// All supported characters, and in which font they are available in. - pub fn characters(&mut self) -> &BTreeMap> { - self.characters.get_or_insert_with(|| { - let mut characters: BTreeMap> = Default::default(); - for font in &self.fonts { - for chr in font.characters() { - characters.entry(chr).or_default().push(font.name.clone()); - } - } - characters - }) - } - - #[inline(always)] - pub fn round_to_pixel(&self, point: f32) -> f32 { - (point * self.pixels_per_point).round() / self.pixels_per_point - } - - /// Height of one row of text. In points. - /// - /// Returns a value rounded to [`emath::GUI_ROUNDING`]. - #[inline(always)] - pub fn row_height(&self) -> f32 { - self.row_height - } - - pub fn uv_rect(&self, c: char) -> UvRect { - self.glyph_info_cache - .get(&c) - .map(|gi| gi.1.uv_rect) - .unwrap_or_default() - } - - /// Width of this character in points. - pub fn glyph_width(&mut self, c: char) -> f32 { - self.glyph_info(c).1.advance_width - } - - /// Can we display this glyph? - pub fn has_glyph(&mut self, c: char) -> bool { - self.glyph_info(c) != self.replacement_glyph // TODO(emilk): this is a false negative if the user asks about the replacement character itself 🤦‍♂️ - } - - /// Can we display all the glyphs in this text? - pub fn has_glyphs(&mut self, s: &str) -> bool { - s.chars().all(|c| self.has_glyph(c)) - } - - /// `\n` will (intentionally) show up as the replacement character. - fn glyph_info(&mut self, c: char) -> (FontIndex, GlyphInfo) { - if let Some(font_index_glyph_info) = self.glyph_info_cache.get(&c) { - return *font_index_glyph_info; - } - - let font_index_glyph_info = self.glyph_info_no_cache_or_fallback(c); - let font_index_glyph_info = font_index_glyph_info.unwrap_or(self.replacement_glyph); - self.glyph_info_cache.insert(c, font_index_glyph_info); - font_index_glyph_info - } - - #[inline] - pub(crate) fn font_impl_and_glyph_info(&mut self, c: char) -> (Option<&FontImpl>, GlyphInfo) { - if self.fonts.is_empty() { - return (None, self.replacement_glyph.1); - } - let (font_index, glyph_info) = self.glyph_info(c); - let font_impl = &self.fonts[font_index]; - (Some(font_impl), glyph_info) - } - - pub(crate) fn ascent(&self) -> f32 { - if let Some(first) = self.fonts.first() { - first.ascent() - } else { - self.row_height - } - } - - fn glyph_info_no_cache_or_fallback(&mut self, c: char) -> Option<(FontIndex, GlyphInfo)> { - for (font_index, font_impl) in self.fonts.iter().enumerate() { - if let Some(glyph_info) = font_impl.glyph_info(c) { - self.glyph_info_cache.insert(c, (font_index, glyph_info)); - return Some((font_index, glyph_info)); - } - } - None - } -} - -/// Code points that will always be invisible (zero width). -/// -/// See also [`FontImpl::ignore_character`]. -#[inline] -fn invisible_char(c: char) -> bool { - if c == '\r' { - // A character most vile and pernicious. Don't display it. - return true; - } - - // See https://github.com/emilk/egui/issues/336 - - // From https://www.fileformat.info/info/unicode/category/Cf/list.htm - - // TODO(emilk): heed bidi characters - - matches!( - c, - '\u{200B}' // ZERO WIDTH SPACE - | '\u{200C}' // ZERO WIDTH NON-JOINER - | '\u{200D}' // ZERO WIDTH JOINER - | '\u{200E}' // LEFT-TO-RIGHT MARK - | '\u{200F}' // RIGHT-TO-LEFT MARK - | '\u{202A}' // LEFT-TO-RIGHT EMBEDDING - | '\u{202B}' // RIGHT-TO-LEFT EMBEDDING - | '\u{202C}' // POP DIRECTIONAL FORMATTING - | '\u{202D}' // LEFT-TO-RIGHT OVERRIDE - | '\u{202E}' // RIGHT-TO-LEFT OVERRIDE - | '\u{2060}' // WORD JOINER - | '\u{2061}' // FUNCTION APPLICATION - | '\u{2062}' // INVISIBLE TIMES - | '\u{2063}' // INVISIBLE SEPARATOR - | '\u{2064}' // INVISIBLE PLUS - | '\u{2066}' // LEFT-TO-RIGHT ISOLATE - | '\u{2067}' // RIGHT-TO-LEFT ISOLATE - | '\u{2068}' // FIRST STRONG ISOLATE - | '\u{2069}' // POP DIRECTIONAL ISOLATE - | '\u{206A}' // INHIBIT SYMMETRIC SWAPPING - | '\u{206B}' // ACTIVATE SYMMETRIC SWAPPING - | '\u{206C}' // INHIBIT ARABIC FORM SHAPING - | '\u{206D}' // ACTIVATE ARABIC FORM SHAPING - | '\u{206E}' // NATIONAL DIGIT SHAPES - | '\u{206F}' // NOMINAL DIGIT SHAPES - | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE - ) -} diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index e655142151f..237884a33ae 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -1,108 +1,24 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{borrow::Cow, collections::BTreeMap, sync::Arc}; use crate::{ - mutex::{Mutex, MutexGuard}, - text::{ - font::{Font, FontImpl}, - Galley, LayoutJob, LayoutSection, - }, + text::{glyph_atlas::GlyphAtlas, Galley, LayoutJob}, TextureAtlas, }; -use emath::{NumExt as _, OrderedFloat}; +use ecolor::Color32; +use emath::{vec2, GuiRounding as _, NumExt as _, OrderedFloat}; + +use parley::{ + fontique::{self, Blob, FontInfoOverride, QueryFamily}, + PositionedLayoutItem, +}; #[cfg(feature = "default_fonts")] use epaint_default_fonts::{EMOJI_ICON, HACK_REGULAR, NOTO_EMOJI_REGULAR, UBUNTU_LIGHT}; -// ---------------------------------------------------------------------------- - -/// How to select a sized font. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct FontId { - /// Height in points. - pub size: f32, - - /// What font family to use. - pub family: FontFamily, - // TODO(emilk): weight (bold), italics, … -} - -impl Default for FontId { - #[inline] - fn default() -> Self { - Self { - size: 14.0, - family: FontFamily::Proportional, - } - } -} - -impl FontId { - #[inline] - pub const fn new(size: f32, family: FontFamily) -> Self { - Self { size, family } - } - - #[inline] - pub const fn proportional(size: f32) -> Self { - Self::new(size, FontFamily::Proportional) - } - - #[inline] - pub const fn monospace(size: f32) -> Self { - Self::new(size, FontFamily::Monospace) - } -} - -impl std::hash::Hash for FontId { - #[inline(always)] - fn hash(&self, state: &mut H) { - let Self { size, family } = self; - emath::OrderedFloat(*size).hash(state); - family.hash(state); - } -} - -// ---------------------------------------------------------------------------- - -/// Font of unknown size. -/// -/// Which style of font: [`Monospace`][`FontFamily::Monospace`], [`Proportional`][`FontFamily::Proportional`], -/// or by user-chosen name. -#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub enum FontFamily { - /// A font where some characters are wider than other (e.g. 'w' is wider than 'i'). - /// - /// Proportional fonts are easier to read and should be the preferred choice in most situations. - #[default] - Proportional, - - /// A font where each character is the same width (`w` is the same width as `i`). - /// - /// Useful for code snippets, or when you need to align numbers or text. - Monospace, - - /// One of the names in [`FontDefinitions::families`]. - /// - /// ``` - /// # use epaint::FontFamily; - /// // User-chosen names: - /// FontFamily::Name("arial".into()); - /// FontFamily::Name("serif".into()); - /// ``` - Name(Arc), -} - -impl std::fmt::Display for FontFamily { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Monospace => "Monospace".fmt(f), - Self::Proportional => "Proportional".fmt(f), - Self::Name(name) => (*name).fmt(f), - } - } -} +use super::{ + glyph_atlas::SubpixelBin, + style::{FontFamily, FontId, GenericFamily, TextFormat}, +}; // ---------------------------------------------------------------------------- @@ -110,6 +26,7 @@ impl std::fmt::Display for FontFamily { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct FontData { + // TODO(valadaptive): the font definitions API takes an Arc but Parley wants the data *itself* to be an Arc /// The content of a `.ttf` or `.otf` file. pub font: std::borrow::Cow<'static, [u8]>, @@ -186,6 +103,11 @@ pub struct FontTweak { /// A positive value shifts the text downwards. /// A negative value shifts it upwards. pub baseline_offset_factor: f32, + + /// Override the global font hinting setting for this specific font. + /// + /// `None` means use the global setting. + pub hinting_override: Option, } impl Default for FontTweak { @@ -195,26 +117,13 @@ impl Default for FontTweak { y_offset_factor: 0.0, y_offset: 0.0, baseline_offset_factor: 0.0, + hinting_override: None, } } } // ---------------------------------------------------------------------------- -fn ab_glyph_font_from_font_data(name: &str, data: &FontData) -> ab_glyph::FontArc { - match &data.font { - std::borrow::Cow::Borrowed(bytes) => { - ab_glyph::FontRef::try_from_slice_and_index(bytes, data.index) - .map(ab_glyph::FontArc::from) - } - std::borrow::Cow::Owned(bytes) => { - ab_glyph::FontVec::try_from_vec_and_index(bytes.clone(), data.index) - .map(ab_glyph::FontArc::from) - } - } - .unwrap_or_else(|err| panic!("Error parsing {name:?} TTF/OTF font file: {err}")) -} - /// Describes the font data and the sizes to use. /// /// Often you would start with [`FontDefinitions::default()`] and then add/change the contents. @@ -254,13 +163,17 @@ pub struct FontDefinitions { /// `epaint` has built-in-default for these, but you can override them if you like. pub font_data: BTreeMap>, - /// Which fonts (names) to use for each [`FontFamily`]. + /// Which named font families to use for each [`GenericFamily`]. /// /// The list should be a list of keys into [`Self::font_data`]. /// When looking for a character glyph `epaint` will start with /// the first font and then move to the second, and so on. /// So the first font is the primary, and then comes a list of fallbacks in order of priority. - pub families: BTreeMap>, + pub families: BTreeMap>, + + /// Whether system fonts should also be loaded. Useful for supporting broad character sets without shipping large + /// fonts, at the expense of load time. + pub include_system_fonts: bool, } #[derive(Debug, Clone)] @@ -275,10 +188,11 @@ pub struct FontInsert { pub families: Vec, } +/// Optionally, you can add a font you insert to be used as a generic font family. #[derive(Debug, Clone)] pub struct InsertFontFamily { /// Font family - pub family: FontFamily, + pub family: GenericFamily, /// Fallback or Primary font pub priority: FontPriority, @@ -295,6 +209,16 @@ pub enum FontPriority { /// /// This font will only be used if the glyph is not found in any of the previously installed fonts. Lowest, + + /// Insert this font above the given family in the priority stack. + /// + /// If the given family cannot be found, this font will be placed at the lowest priority. + Above(Cow<'static, str>), + + /// Insert this font below the given family in the priority stack. + /// + /// If the given family cannot be found, this font will be placed at the lowest priority. + Below(Cow<'static, str>), } impl FontInsert { @@ -352,7 +276,7 @@ impl Default for FontDefinitions { ); families.insert( - FontFamily::Monospace, + GenericFamily::Monospace, vec![ "Hack".to_owned(), "Ubuntu-Light".to_owned(), // fallback for √ etc @@ -361,17 +285,30 @@ impl Default for FontDefinitions { ], ); families.insert( - FontFamily::Proportional, + GenericFamily::SystemUi, + vec![ + "Ubuntu-Light".to_owned(), + "NotoEmoji-Regular".to_owned(), + "emoji-icon-font".to_owned(), + ], + ); + families.insert( + GenericFamily::SansSerif, vec![ "Ubuntu-Light".to_owned(), "NotoEmoji-Regular".to_owned(), "emoji-icon-font".to_owned(), ], ); + families.insert( + GenericFamily::Emoji, + vec!["NotoEmoji-Regular".to_owned(), "emoji-icon-font".to_owned()], + ); Self { font_data, families, + include_system_fonts: false, } } } @@ -380,15 +317,22 @@ impl FontDefinitions { /// No fonts. pub fn empty() -> Self { let mut families = BTreeMap::new(); - families.insert(FontFamily::Monospace, vec![]); - families.insert(FontFamily::Proportional, vec![]); + families.insert(GenericFamily::Monospace, vec![]); + families.insert(GenericFamily::SystemUi, vec![]); Self { font_data: Default::default(), families, + include_system_fonts: false, } } + /// Set whether [`Self::include_system_fonts`] is enabled. + pub fn with_system_fonts(mut self, include_system_fonts: bool) -> Self { + self.include_system_fonts = include_system_fonts; + self + } + /// List of all the builtin font names used by `epaint`. #[cfg(feature = "default_fonts")] pub fn builtin_font_names() -> &'static [&'static str] { @@ -409,6 +353,20 @@ impl FontDefinitions { // ---------------------------------------------------------------------------- +/// "View struct" of the fields of [`Fonts`] necessary to perform a layout job. +#[doc(hidden)] +pub(super) struct FontsLayoutView<'a> { + pub font_context: &'a mut parley::FontContext, + pub layout_context: &'a mut parley::LayoutContext, + pub texture_atlas: &'a mut TextureAtlas, + pub glyph_atlas: &'a mut GlyphAtlas, + pub font_tweaks: &'a mut ahash::HashMap, + pub hinting_enabled: bool, + pub pixels_per_point: f32, +} + +// ---------------------------------------------------------------------------- + /// The collection of fonts used by `epaint`. /// /// Required in order to paint text. Create one and reuse. Cheap to clone. @@ -418,160 +376,514 @@ impl FontDefinitions { /// If you are using `egui`, use `egui::Context::set_fonts` and `egui::Context::fonts`. /// /// You need to call [`Self::begin_pass`] and [`Self::font_image_delta`] once every frame. -#[derive(Clone)] -pub struct Fonts(Arc>); +pub struct FontStore { + max_texture_side: usize, + definitions: FontDefinitions, + font_tweaks: ahash::HashMap, + hinting_enabled: bool, + atlas: TextureAtlas, + galley_cache: GalleyCache, + + // TODO(valadaptive): glyph_width_cache and has_glyphs_cache should be frame-to-frame caches, but FrameCache is in + // the egui crate + glyph_width_cache: ahash::HashMap, + has_glyphs_cache: ahash::HashMap<(Cow<'static, str>, Cow<'static, FontId>), bool>, + all_families: Option>, -impl Fonts { + pub(super) font_context: parley::FontContext, + pub(super) layout_context: parley::LayoutContext, + pub(super) glyph_atlas: GlyphAtlas, +} + +impl FontStore { /// Create a new [`Fonts`] for text layout. /// This call is expensive, so only create one [`Fonts`] and then reuse it. /// - /// * `pixels_per_point`: how many physical pixels per logical "point". /// * `max_texture_side`: largest supported texture size (one side). - pub fn new( - pixels_per_point: f32, - max_texture_side: usize, - definitions: FontDefinitions, - ) -> Self { - let fonts_and_cache = FontsAndCache { - fonts: FontsImpl::new(pixels_per_point, max_texture_side, definitions), + pub fn new(max_texture_side: usize, definitions: FontDefinitions) -> Self { + let texture_width = max_texture_side.at_most(16 * 1024).at_most( + 1024, /* limit atlas size to test that multiple atlases work */ + ); + // Keep initial font atlas small, so it is fast to upload to GPU. This will expand as needed anyways. + let initial_height = 32; + let atlas = TextureAtlas::new([texture_width, initial_height]); + + let collection = fontique::Collection::new(fontique::CollectionOptions { + shared: false, + system_fonts: definitions.include_system_fonts, + }); + + let mut font_store = Self { + max_texture_side, + definitions, + font_tweaks: Default::default(), + hinting_enabled: true, + glyph_atlas: GlyphAtlas::new(), + atlas, galley_cache: Default::default(), + + glyph_width_cache: Default::default(), + has_glyphs_cache: Default::default(), + all_families: Default::default(), + + font_context: parley::FontContext { + collection, + source_cache: fontique::SourceCache::new_shared(), + }, + layout_context: parley::LayoutContext::new(), }; - Self(Arc::new(Mutex::new(fonts_and_cache))) + + font_store.load_fonts_from_definitions(); + + font_store } - /// Call at the start of each frame with the latest known - /// `pixels_per_point` and `max_texture_side`. + /// Call at the start of each frame with the latest known `max_texture_side`. /// /// Call after painting the previous frame, but before using [`Fonts`] for the new frame. /// - /// This function will react to changes in `pixels_per_point` and `max_texture_side`, - /// as well as notice when the font atlas is getting full, and handle that. - pub fn begin_pass(&self, pixels_per_point: f32, max_texture_side: usize) { - let mut fonts_and_cache = self.0.lock(); - - let pixels_per_point_changed = fonts_and_cache.fonts.pixels_per_point != pixels_per_point; - let max_texture_side_changed = fonts_and_cache.fonts.max_texture_side != max_texture_side; - let font_atlas_almost_full = fonts_and_cache.fonts.atlas.lock().fill_ratio() > 0.8; - let needs_recreate = - pixels_per_point_changed || max_texture_side_changed || font_atlas_almost_full; + /// This function will react to changes in `max_texture_side`, as well as notice when the font atlas is getting + /// full, and handle that. + pub fn begin_pass(&mut self, max_texture_side: usize) { + let max_texture_side_changed = self.max_texture_side != max_texture_side; + // TODO(valadaptive): this seems suspicious. Does this mean the atlas can never use more than 80% of its actual + // capacity? + let font_atlas_almost_full = self.atlas.fill_ratio() > 0.8; + let needs_recreate = max_texture_side_changed || font_atlas_almost_full; if needs_recreate { - let definitions = fonts_and_cache.fonts.definitions.clone(); + self.clear_atlas(max_texture_side); + } - *fonts_and_cache = FontsAndCache { - fonts: FontsImpl::new(pixels_per_point, max_texture_side, definitions), - galley_cache: Default::default(), - }; + self.galley_cache.flush_cache(); + // TODO(valadaptive): make this configurable? + self.font_context.source_cache.prune(250, false); + } + + fn load_fonts_from_definitions(&mut self) { + for (name, data) in &self.definitions.font_data { + // TODO(valadaptive): in the case where we're just adding new fonts, we can probably reuse the blobs + let blob = Blob::new(Arc::new(data.font.clone())); + self.font_tweaks.insert(blob.id(), data.tweak); + // TODO(valadaptive): we completely ignore the font index because fontique only lets us load all the fonts + self.font_context.collection.register_fonts( + blob, + Some(FontInfoOverride { + family_name: Some(name), + ..Default::default() + }), + ); } - fonts_and_cache.galley_cache.flush_cache(); + for (generic_family, family_fonts) in &self.definitions.families { + let family_ids: Vec<_> = family_fonts + .iter() + .filter_map(|family_name| self.font_context.collection.family_id(family_name)) + .collect(); + + self.font_context + .collection + .set_generic_families(generic_family.as_parley(), family_ids.into_iter()); + } + + self.clear_cache(self.max_texture_side); } - /// Call at the end of each frame (before painting) to get the change to the font texture since last call. - pub fn font_image_delta(&self) -> Option { - self.lock().fonts.atlas.lock().take_delta() + pub fn with_pixels_per_point(&mut self, pixels_per_point: f32) -> Fonts<'_> { + Fonts { + fonts: self, + pixels_per_point, + } } - /// Access the underlying [`FontsAndCache`]. - #[doc(hidden)] #[inline] - pub fn lock(&self) -> MutexGuard<'_, FontsAndCache> { - self.0.lock() + pub fn definitions(&self) -> &FontDefinitions { + &self.definitions } - #[inline] - pub fn pixels_per_point(&self) -> f32 { - self.lock().fonts.pixels_per_point + pub fn set_definitions(&mut self, definitions: FontDefinitions) { + // We need to recreate the font collection if we start or stop loading system fonts + if definitions.include_system_fonts != self.definitions.include_system_fonts { + self.font_context.collection = fontique::Collection::new(fontique::CollectionOptions { + shared: false, + system_fonts: self.definitions.include_system_fonts, + }); + } else { + self.font_context.collection.clear(); + } + + self.definitions = definitions; + self.font_tweaks.clear(); + self.font_context.source_cache.prune(0, true); + self.clear_cache(self.max_texture_side); + self.load_fonts_from_definitions(); + } + + pub fn hinting_enabled(&self) -> bool { + self.hinting_enabled + } + + pub fn set_hinting_enabled(&mut self, enabled: bool) { + self.hinting_enabled = enabled; + self.clear_cache(self.max_texture_side); + } + + /// Width of this character in points. + pub fn glyph_width(&mut self, font_id: &FontId, c: char) -> f32 { + *self.glyph_width_cache.entry(c).or_insert_with(|| { + let text = c.to_string(); + let text_style = TextFormat::simple(font_id.clone(), Default::default()).as_parley(); + let mut builder = + self.layout_context + .tree_builder(&mut self.font_context, 1.0, false, &text_style); + builder.push_text(&text); + let (mut layout, _) = builder.build(); + layout.break_lines().break_next(f32::MAX); + let Some(first_line) = layout.lines().next() else { + return 0.0; + }; + first_line.metrics().advance + }) + } + + pub fn preload_common_characters(&mut self, font_id: &FontId) { + // LAST_ASCII - FIRST_ASCII + 1 ASCII characters (since the range is inclusive) + the degrees symbol and + // password replacement character + let mut common_chars = String::with_capacity(LAST_ASCII - FIRST_ASCII + 3); + + // Preload the printable ASCII characters [32, 126] (which excludes control codes): + const FIRST_ASCII: usize = 32; // 32 == space + const LAST_ASCII: usize = 126; + for c in (FIRST_ASCII..=LAST_ASCII).map(|c| c as u8 as char) { + common_chars.push(c); + } + common_chars.push('°'); + common_chars.push(crate::text::PASSWORD_REPLACEMENT_CHAR); + + self.preload_text(1.0, font_id, &common_chars); + } + + fn preload_text(&mut self, pixels_per_point: f32, font_id: &FontId, text: &str) { + let style = TextFormat { + font_id: font_id.clone(), + ..Default::default() + }; + let style = style.as_parley(); + let mut builder = + self.layout_context + .tree_builder(&mut self.font_context, 1.0, false, &style); + builder.push_text(text); + let (mut layout, _) = builder.build(); + layout.break_all_lines(None); + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(run) = item else { + continue; + }; + + for x_offset in SubpixelBin::SUBPIXEL_OFFSETS { + self.glyph_atlas + .render_glyph_run( + &mut self.atlas, + &run, + vec2(x_offset, 0.0), + self.hinting_enabled, + pixels_per_point, + &self.font_tweaks, + ) + .for_each(|_| {}); + } + } + } + } + + pub fn has_glyphs_for(&mut self, font_id: &FontId, text: &str) -> bool { + if let Some(has_glyphs) = self + .has_glyphs_cache + .get(&(text.into(), Cow::Borrowed(font_id))) + { + return *has_glyphs; + } + + *self + .has_glyphs_cache + .entry((Cow::Owned(text.to_owned()), Cow::Owned(font_id.clone()))) + .or_insert_with(|| { + let style = TextFormat { + font_id: font_id.clone(), + ..Default::default() + }; + let style = style.as_parley(); + let mut builder = + self.layout_context + .tree_builder(&mut self.font_context, 1.0, false, &style); + builder.push_text(text); + let (mut layout, _) = builder.build(); + layout.break_all_lines(None); + + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(run) = item else { + continue; + }; + + for glyph in run.glyphs() { + if glyph.id == 0 { + return false; + } + } + } + } + + true + }) + } + + pub fn with_characters(&mut self, font_family: &FontFamily, cb: impl FnMut(u32, u16)) { + let mut query = self + .font_context + .collection + .query(&mut self.font_context.source_cache); + query.set_families(std::iter::once(match font_family { + FontFamily::Named(cow) => QueryFamily::Named(cow), + FontFamily::Generic(generic_family) => QueryFamily::Generic(generic_family.as_parley()), + })); + + let mut font_data = None; + + query.matches_with(|font| { + font_data = Some((font.blob.clone(), font.index)); + fontique::QueryStatus::Stop + }); + + let Some((font_data, font_index)) = font_data else { + return; + }; + + let Some(swash_font) = + parley::swash::FontRef::from_index(font_data.as_ref(), font_index as usize) + else { + return; + }; + + swash_font.charmap().enumerate(cb); + } + + /// Height of one row of text in points. + #[expect(clippy::unused_self, clippy::needless_pass_by_ref_mut)] + pub fn row_height(&mut self, font_id: &FontId) -> f32 { + // TODO(valadaptive): if styling is changed so line height is more overridable, this function won't make very + // much sense + font_id.size + } + + fn clear_atlas(&mut self, new_max_texture_side: usize) { + self.atlas.clear(); + self.glyph_atlas.clear(); + self.galley_cache.clear(); + self.max_texture_side = new_max_texture_side; + } + + fn clear_cache(&mut self, new_max_texture_side: usize) { + self.clear_atlas(new_max_texture_side); + self.glyph_width_cache.clear(); + self.has_glyphs_cache.clear(); + self.all_families = None; + } + + /// Call at the end of each frame (before painting) to get the change to the font texture since last call. + pub fn font_image_delta(&mut self) -> Option { + self.atlas.take_delta() } #[inline] pub fn max_texture_side(&self) -> usize { - self.lock().fonts.max_texture_side + self.max_texture_side } /// The font atlas. /// Pass this to [`crate::Tessellator`]. - pub fn texture_atlas(&self) -> Arc> { - self.lock().fonts.atlas.clone() - } - - /// The full font atlas image. - #[inline] - pub fn image(&self) -> crate::FontImage { - self.lock().fonts.atlas.lock().image().clone() + pub fn texture_atlas(&self) -> &TextureAtlas { + &self.atlas } /// Current size of the font image. /// Pass this to [`crate::Tessellator`]. pub fn font_image_size(&self) -> [usize; 2] { - self.lock().fonts.atlas.lock().size() + self.atlas.size() + } + + /// List of all loaded font families. + pub fn families(&mut self) -> &[FontFamily] { + self.all_families.get_or_insert_with(|| { + let mut all_families = self + .font_context + .collection + .family_names() + .map(|name| FontFamily::Named(Cow::Owned(name.to_owned()))) + .collect::>(); + + all_families.sort_by_cached_key(|f| match f { + FontFamily::Named(name) => name.to_lowercase(), + FontFamily::Generic(_) => unreachable!(), + }); + + all_families + }) + } + + pub fn num_galleys_in_cache(&self) -> usize { + self.galley_cache.num_galleys_in_cache() + } + + /// How full is the font atlas? + /// + /// This increases as new fonts and/or glyphs are used, + /// but can also decrease in a call to [`Self::begin_pass`]. + pub fn font_atlas_fill_ratio(&self) -> f32 { + self.atlas.fill_ratio() + } +} + +// ---------------------------------------------------------------------------- + +/// View into a [`FontStore`] that lets you perform text layout at a given DPI. +pub struct Fonts<'a> { + fonts: &'a mut FontStore, + pixels_per_point: f32, +} + +impl Fonts<'_> { + #[inline] + pub fn definitions(&self) -> &FontDefinitions { + self.fonts.definitions() } - /// Width of this character in points. #[inline] - pub fn glyph_width(&self, font_id: &FontId, c: char) -> f32 { - self.lock().fonts.glyph_width(font_id, c) + pub fn hinting_enabled(&self) -> bool { + self.fonts.hinting_enabled() } - /// Can we display this glyph? + /// Width of this character in points. #[inline] - pub fn has_glyph(&self, font_id: &FontId, c: char) -> bool { - self.lock().fonts.has_glyph(font_id, c) + pub fn glyph_width(&mut self, font_id: &FontId, c: char) -> f32 { + self.fonts.glyph_width(font_id, c) } /// Can we display all the glyphs in this text? - pub fn has_glyphs(&self, font_id: &FontId, s: &str) -> bool { - self.lock().fonts.has_glyphs(font_id, s) + #[inline] + pub fn has_glyphs_for(&mut self, font_id: &FontId, s: &str) -> bool { + self.fonts.has_glyphs_for(font_id, s) + } + pub fn with_characters(&mut self, font_family: &FontFamily, cb: impl FnMut(u32, u16)) { + self.fonts.with_characters(font_family, cb); } /// Height of one row of text in points. /// /// Returns a value rounded to [`emath::GUI_ROUNDING`]. #[inline] - pub fn row_height(&self, font_id: &FontId) -> f32 { - self.lock().fonts.row_height(font_id) + pub fn row_height(&mut self, font_id: &FontId) -> f32 { + self.fonts + .row_height(font_id) + .round_to_pixels(self.pixels_per_point) } - /// List of all known font families. - pub fn families(&self) -> Vec { - self.lock() - .fonts - .definitions - .families - .keys() - .cloned() - .collect() + /// Call at the end of each frame (before painting) to get the change to the font texture since last call. + #[inline] + pub fn font_image_delta(&mut self) -> Option { + self.fonts.font_image_delta() } - /// Layout some text. - /// - /// This is the most advanced layout function. - /// See also [`Self::layout`], [`Self::layout_no_wrap`] and - /// [`Self::layout_delayed_color`]. - /// - /// The implementation uses memoization so repeated calls are cheap. #[inline] - pub fn layout_job(&self, job: LayoutJob) -> Arc { - self.lock().layout_job(job) + pub fn max_texture_side(&self) -> usize { + self.fonts.max_texture_side() + } + + /// The font atlas. + /// Pass this to [`crate::Tessellator`]. + #[inline] + pub fn texture_atlas(&self) -> &TextureAtlas { + self.fonts.texture_atlas() + } + + /// Current size of the font image. + /// Pass this to [`crate::Tessellator`]. + #[inline] + pub fn font_image_size(&self) -> [usize; 2] { + self.fonts.font_image_size() + } + + /// List of all loaded font families. + #[inline] + pub fn families(&mut self) -> &[FontFamily] { + self.fonts.families() } + #[inline] pub fn num_galleys_in_cache(&self) -> usize { - self.lock().galley_cache.num_galleys_in_cache() + self.fonts.num_galleys_in_cache() } /// How full is the font atlas? /// /// This increases as new fonts and/or glyphs are used, /// but can also decrease in a call to [`Self::begin_pass`]. + #[inline] pub fn font_atlas_fill_ratio(&self) -> f32 { - self.lock().fonts.atlas.lock().fill_ratio() + self.fonts.font_atlas_fill_ratio() + } + + /// Layout some text. + /// + /// This is the most advanced layout function. + /// See also [`Self::layout`], [`Self::layout_no_wrap`] and + /// [`Self::layout_delayed_color`]. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout_job(&mut self, job: LayoutJob) -> Arc { + self.fonts.galley_cache.layout( + &mut FontsLayoutView { + font_context: &mut self.fonts.font_context, + layout_context: &mut self.fonts.layout_context, + texture_atlas: &mut self.fonts.atlas, + glyph_atlas: &mut self.fonts.glyph_atlas, + font_tweaks: &mut self.fonts.font_tweaks, + hinting_enabled: self.fonts.hinting_enabled, + pixels_per_point: self.pixels_per_point, + }, + job, + self.pixels_per_point, + ) + } + + /// Layout some text, without memoization. + /// + /// Mostly useful for benchmarking. + #[inline] + #[doc(hidden)] + pub fn layout_job_uncached(&mut self, job: LayoutJob) -> Arc { + Arc::new(super::parley_layout::layout( + &mut FontsLayoutView { + font_context: &mut self.fonts.font_context, + layout_context: &mut self.fonts.layout_context, + texture_atlas: &mut self.fonts.atlas, + glyph_atlas: &mut self.fonts.glyph_atlas, + font_tweaks: &mut self.fonts.font_tweaks, + hinting_enabled: self.fonts.hinting_enabled, + pixels_per_point: self.pixels_per_point, + }, + job, + )) } /// Will wrap text at the given width and line break at `\n`. /// /// The implementation uses memoization so repeated calls are cheap. + #[inline] pub fn layout( - &self, + &mut self, text: String, font_id: FontId, color: crate::Color32, @@ -584,8 +896,9 @@ impl Fonts { /// Will line break at `\n`. /// /// The implementation uses memoization so repeated calls are cheap. + #[inline] pub fn layout_no_wrap( - &self, + &mut self, text: String, font_id: FontId, color: crate::Color32, @@ -597,8 +910,9 @@ impl Fonts { /// Like [`Self::layout`], made for when you want to pick a color for the text later. /// /// The implementation uses memoization so repeated calls are cheap. + #[inline] pub fn layout_delayed_color( - &self, + &mut self, text: String, font_id: FontId, wrap_width: f32, @@ -609,130 +923,9 @@ impl Fonts { // ---------------------------------------------------------------------------- -pub struct FontsAndCache { - pub fonts: FontsImpl, - galley_cache: GalleyCache, -} - -impl FontsAndCache { - fn layout_job(&mut self, job: LayoutJob) -> Arc { - let allow_split_paragraphs = true; // Optimization for editing text with many paragraphs. - self.galley_cache - .layout(&mut self.fonts, job, allow_split_paragraphs) - } -} - -// ---------------------------------------------------------------------------- - -/// The collection of fonts used by `epaint`. -/// -/// Required in order to paint text. -pub struct FontsImpl { - pixels_per_point: f32, - max_texture_side: usize, - definitions: FontDefinitions, - atlas: Arc>, - font_impl_cache: FontImplCache, - sized_family: ahash::HashMap<(OrderedFloat, FontFamily), Font>, -} - -impl FontsImpl { - /// Create a new [`FontsImpl`] for text layout. - /// This call is expensive, so only create one [`FontsImpl`] and then reuse it. - pub fn new( - pixels_per_point: f32, - max_texture_side: usize, - definitions: FontDefinitions, - ) -> Self { - assert!( - 0.0 < pixels_per_point && pixels_per_point < 100.0, - "pixels_per_point out of range: {pixels_per_point}" - ); - - let texture_width = max_texture_side.at_most(16 * 1024); - let initial_height = 32; // Keep initial font atlas small, so it is fast to upload to GPU. This will expand as needed anyways. - let atlas = TextureAtlas::new([texture_width, initial_height]); - - let atlas = Arc::new(Mutex::new(atlas)); - - let font_impl_cache = - FontImplCache::new(atlas.clone(), pixels_per_point, &definitions.font_data); - - Self { - pixels_per_point, - max_texture_side, - definitions, - atlas, - font_impl_cache, - sized_family: Default::default(), - } - } - - #[inline(always)] - pub fn pixels_per_point(&self) -> f32 { - self.pixels_per_point - } - - #[inline] - pub fn definitions(&self) -> &FontDefinitions { - &self.definitions - } - - /// Get the right font implementation from size and [`FontFamily`]. - pub fn font(&mut self, font_id: &FontId) -> &mut Font { - let FontId { mut size, family } = font_id; - size = size.at_least(0.1).at_most(2048.0); - - self.sized_family - .entry((OrderedFloat(size), family.clone())) - .or_insert_with(|| { - let fonts = &self.definitions.families.get(family); - let fonts = fonts - .unwrap_or_else(|| panic!("FontFamily::{family:?} is not bound to any fonts")); - - let fonts: Vec> = fonts - .iter() - .map(|font_name| self.font_impl_cache.font_impl(size, font_name)) - .collect(); - - Font::new(fonts) - }) - } - - /// Width of this character in points. - fn glyph_width(&mut self, font_id: &FontId, c: char) -> f32 { - self.font(font_id).glyph_width(c) - } - - /// Can we display this glyph? - pub fn has_glyph(&mut self, font_id: &FontId, c: char) -> bool { - self.font(font_id).has_glyph(c) - } - - /// Can we display all the glyphs in this text? - pub fn has_glyphs(&mut self, font_id: &FontId, s: &str) -> bool { - self.font(font_id).has_glyphs(s) - } - - /// Height of one row of text in points. - /// - /// Returns a value rounded to [`emath::GUI_ROUNDING`]. - fn row_height(&mut self, font_id: &FontId) -> f32 { - self.font(font_id).row_height() - } -} - -// ---------------------------------------------------------------------------- - struct CachedGalley { /// When it was last used last_used: u32, - - /// Hashes of all other entries this one depends on for quick re-layout. - /// Their `last_used`s should be updated alongside this one to make sure they're - /// not evicted. - children: Option>, - galley: Arc, } @@ -744,18 +937,18 @@ struct GalleyCache { } impl GalleyCache { - fn layout_internal( + fn layout( &mut self, - fonts: &mut FontsImpl, + fonts: &mut FontsLayoutView<'_>, mut job: LayoutJob, - allow_split_paragraphs: bool, - ) -> (u64, Arc) { + pixels_per_point: f32, + ) -> Arc { if job.wrap.max_width.is_finite() { // Protect against rounding errors in egui layout code. // Say the user asks to wrap at width 200.0. // The text layout wraps, and reports that the final width was 196.0 points. - // This then trickles up the `Ui` chain and gets stored as the width for a tooltip (say). + // This than trickles up the `Ui` chain and gets stored as the width for a tooltip (say). // On the next frame, this is then set as the max width for the tooltip, // and we end up calling the text layout code again, this time with a wrap width of 196.0. // Except, somewhere in the `Ui` chain with added margins etc, a rounding error was introduced, @@ -775,178 +968,25 @@ impl GalleyCache { job.wrap.max_width = job.wrap.max_width.round(); } - let hash = crate::util::hash(&job); // TODO(emilk): even faster hasher? + let hash = crate::util::hash((&job, OrderedFloat(pixels_per_point))); // TODO(emilk): even faster hasher? - let galley = match self.cache.entry(hash) { + match self.cache.entry(hash) { std::collections::hash_map::Entry::Occupied(entry) => { - // The job was found in cache - no need to re-layout. let cached = entry.into_mut(); cached.last_used = self.generation; - - let galley = cached.galley.clone(); - if let Some(children) = &cached.children { - // The point of `allow_split_paragraphs` is to split large jobs into paragraph, - // and then cache each paragraph individually. - // That way, if we edit a single paragraph, only that paragraph will be re-layouted. - // For that to work we need to keep all the child/paragraph - // galleys alive while the parent galley is alive: - for child_hash in children.clone().iter() { - if let Some(cached_child) = self.cache.get_mut(child_hash) { - cached_child.last_used = self.generation; - } - } - } - - galley + cached.galley.clone() } std::collections::hash_map::Entry::Vacant(entry) => { - let job = Arc::new(job); - if allow_split_paragraphs && should_cache_each_paragraph_individually(&job) { - let (child_galleys, child_hashes) = - self.layout_each_paragraph_individuallly(fonts, &job); - debug_assert_eq!( - child_hashes.len(), - child_galleys.len(), - "Bug in `layout_each_paragraph_individuallly`" - ); - let galley = - Arc::new(Galley::concat(job, &child_galleys, fonts.pixels_per_point)); - - self.cache.insert( - hash, - CachedGalley { - last_used: self.generation, - children: Some(child_hashes.into()), - galley: galley.clone(), - }, - ); - galley - } else { - let galley = super::layout(fonts, job); - let galley = Arc::new(galley); - entry.insert(CachedGalley { - last_used: self.generation, - children: None, - galley: galley.clone(), - }); - galley - } - } - }; - - (hash, galley) - } - - fn layout( - &mut self, - fonts: &mut FontsImpl, - job: LayoutJob, - allow_split_paragraphs: bool, - ) -> Arc { - self.layout_internal(fonts, job, allow_split_paragraphs).1 - } - - /// Split on `\n` and lay out (and cache) each paragraph individually. - fn layout_each_paragraph_individuallly( - &mut self, - fonts: &mut FontsImpl, - job: &LayoutJob, - ) -> (Vec>, Vec) { - profiling::function_scope!(); - - let mut current_section = 0; - let mut start = 0; - let mut max_rows_remaining = job.wrap.max_rows; - let mut child_galleys = Vec::new(); - let mut child_hashes = Vec::new(); - - while start < job.text.len() { - let is_first_paragraph = start == 0; - let end = job.text[start..] - .find('\n') - .map_or(job.text.len(), |i| start + i + 1); - - let mut paragraph_job = LayoutJob { - text: job.text[start..end].to_owned(), - wrap: crate::text::TextWrapping { - max_rows: max_rows_remaining, - ..job.wrap - }, - sections: Vec::new(), - break_on_newline: job.break_on_newline, - halign: job.halign, - justify: job.justify, - first_row_min_height: if is_first_paragraph { - job.first_row_min_height - } else { - 0.0 - }, - round_output_to_gui: job.round_output_to_gui, - }; - - // Add overlapping sections: - for section in &job.sections[current_section..job.sections.len()] { - let LayoutSection { - leading_space, - byte_range: section_range, - format, - } = section; - - // `start` and `end` are the byte range of the current paragraph. - // How does the current section overlap with the paragraph range? - - if section_range.end <= start { - // The section is behind us - current_section += 1; - } else if end <= section_range.start { - break; // Haven't reached this one yet. - } else { - // Section range overlaps with paragraph range - debug_assert!( - section_range.start < section_range.end, - "Bad byte_range: {section_range:?}" - ); - let new_range = section_range.start.saturating_sub(start) - ..(section_range.end.at_most(end)).saturating_sub(start); - debug_assert!( - new_range.start <= new_range.end, - "Bad new section range: {new_range:?}" - ); - paragraph_job.sections.push(LayoutSection { - leading_space: if start <= section_range.start { - *leading_space - } else { - 0.0 - }, - byte_range: new_range, - format: format.clone(), - }); - } - } - - // TODO(emilk): we could lay out each paragraph in parallel to get a nice speedup on multicore machines. - let (hash, galley) = self.layout_internal(fonts, paragraph_job, false); - child_hashes.push(hash); - - // This will prevent us from invalidating cache entries unnecessarily: - if max_rows_remaining != usize::MAX { - max_rows_remaining -= galley.rows.len(); - // Ignore extra trailing row, see merging `Galley::concat` for more details. - if end < job.text.len() && !galley.elided { - max_rows_remaining += 1; - } - } - - let elided = galley.elided; - child_galleys.push(galley); - if elided { - break; + //let galley = super::layout(fonts, job.into()); + let galley = super::parley_layout::layout(fonts, job); + let galley = Arc::new(galley); + entry.insert(CachedGalley { + last_used: self.generation, + galley: galley.clone(), + }); + galley } - - start = end; } - - (child_galleys, child_hashes) } pub fn num_galleys_in_cache(&self) -> usize { @@ -961,203 +1001,8 @@ impl GalleyCache { }); self.generation = self.generation.wrapping_add(1); } -} - -/// If true, lay out and cache each paragraph (sections separated by newlines) individually. -/// -/// This makes it much faster to re-layout the full text when only a portion of it has changed since last frame, i.e. when editing somewhere in a file with thousands of lines/paragraphs. -fn should_cache_each_paragraph_individually(job: &LayoutJob) -> bool { - // We currently don't support this elided text, i.e. when `max_rows` is set. - // Most often, elided text is elided to one row, - // and so will always be fast to lay out. - job.break_on_newline && job.wrap.max_rows == usize::MAX && job.text.contains('\n') -} - -// ---------------------------------------------------------------------------- - -struct FontImplCache { - atlas: Arc>, - pixels_per_point: f32, - ab_glyph_fonts: BTreeMap, - - /// Map font pixel sizes and names to the cached [`FontImpl`]. - cache: ahash::HashMap<(u32, String), Arc>, -} - -impl FontImplCache { - pub fn new( - atlas: Arc>, - pixels_per_point: f32, - font_data: &BTreeMap>, - ) -> Self { - let ab_glyph_fonts = font_data - .iter() - .map(|(name, font_data)| { - let tweak = font_data.tweak; - let ab_glyph = ab_glyph_font_from_font_data(name, font_data); - (name.clone(), (tweak, ab_glyph)) - }) - .collect(); - - Self { - atlas, - pixels_per_point, - ab_glyph_fonts, - cache: Default::default(), - } - } - - pub fn font_impl(&mut self, scale_in_points: f32, font_name: &str) -> Arc { - use ab_glyph::Font as _; - - let (tweak, ab_glyph_font) = self - .ab_glyph_fonts - .get(font_name) - .unwrap_or_else(|| panic!("No font data found for {font_name:?}")) - .clone(); - let scale_in_pixels = self.pixels_per_point * scale_in_points; - - // Scale the font properly (see https://github.com/emilk/egui/issues/2068). - let units_per_em = ab_glyph_font.units_per_em().unwrap_or_else(|| { - panic!("The font unit size of {font_name:?} exceeds the expected range (16..=16384)") - }); - let font_scaling = ab_glyph_font.height_unscaled() / units_per_em; - let scale_in_pixels = scale_in_pixels * font_scaling; - - self.cache - .entry(( - (scale_in_pixels * tweak.scale).round() as u32, - font_name.to_owned(), - )) - .or_insert_with(|| { - Arc::new(FontImpl::new( - self.atlas.clone(), - self.pixels_per_point, - font_name.to_owned(), - ab_glyph_font, - scale_in_pixels, - tweak, - )) - }) - .clone() - } -} - -#[cfg(feature = "default_fonts")] -#[cfg(test)] -mod tests { - use core::f32; - - use super::*; - use crate::{text::TextFormat, Stroke}; - use ecolor::Color32; - use emath::Align; - - fn jobs() -> Vec { - vec![ - LayoutJob::simple( - String::default(), - FontId::new(14.0, FontFamily::Monospace), - Color32::WHITE, - f32::INFINITY, - ), - LayoutJob::simple( - "Simple test.".to_owned(), - FontId::new(14.0, FontFamily::Monospace), - Color32::WHITE, - f32::INFINITY, - ), - LayoutJob::simple( - "This some text that may be long.\nDet kanske också finns lite ÅÄÖ här.".to_owned(), - FontId::new(14.0, FontFamily::Proportional), - Color32::WHITE, - 50.0, - ), - { - let mut job = LayoutJob { - first_row_min_height: 20.0, - ..Default::default() - }; - job.append( - "1st paragraph has underline and strikethrough, and has some non-ASCII characters:\n ÅÄÖ.", - 0.0, - TextFormat { - font_id: FontId::new(15.0, FontFamily::Monospace), - underline: Stroke::new(1.0, Color32::RED), - strikethrough: Stroke::new(1.0, Color32::GREEN), - ..Default::default() - }, - ); - job.append( - "2nd paragraph has some leading space.\n", - 16.0, - TextFormat { - font_id: FontId::new(14.0, FontFamily::Proportional), - ..Default::default() - }, - ); - job.append( - "3rd paragraph is kind of boring, but has italics.\nAnd a newline", - 0.0, - TextFormat { - font_id: FontId::new(10.0, FontFamily::Proportional), - italics: true, - ..Default::default() - }, - ); - - job - }, - ] - } - - #[test] - fn test_split_paragraphs() { - for pixels_per_point in [1.0, 2.0_f32.sqrt(), 2.0] { - let max_texture_side = 4096; - let mut fonts = FontsImpl::new( - pixels_per_point, - max_texture_side, - FontDefinitions::default(), - ); - - for halign in [Align::Min, Align::Center, Align::Max] { - for justify in [false, true] { - for mut job in jobs() { - job.halign = halign; - job.justify = justify; - - let whole = GalleyCache::default().layout(&mut fonts, job.clone(), false); - - let split = GalleyCache::default().layout(&mut fonts, job.clone(), true); - - for (i, row) in whole.rows.iter().enumerate() { - println!( - "Whole row {i}: section_index_at_start={}, first glyph section_index: {:?}", - row.row.section_index_at_start, - row.row.glyphs.first().map(|g| g.section_index) - ); - } - for (i, row) in split.rows.iter().enumerate() { - println!( - "Split row {i}: section_index_at_start={}, first glyph section_index: {:?}", - row.row.section_index_at_start, - row.row.glyphs.first().map(|g| g.section_index) - ); - } - - // Don't compare for equaliity; but format with a specific precision and make sure we hit that. - // NOTE: we use a rather low precision, because as long as we're within a pixel I think it's good enough. - similar_asserts::assert_eq!( - format!("{:#.1?}", split), - format!("{:#.1?}", whole), - "pixels_per_point: {pixels_per_point:.2}, input text: '{}'", - job.text - ); - } - } - } - } + pub fn clear(&mut self) { + self.cache.clear(); } } diff --git a/crates/epaint/src/text/glyph_atlas.rs b/crates/epaint/src/text/glyph_atlas.rs new file mode 100644 index 00000000000..786aaf82ce9 --- /dev/null +++ b/crates/epaint/src/text/glyph_atlas.rs @@ -0,0 +1,386 @@ +use std::borrow::Cow; + +use ecolor::Color32; +use emath::{vec2, OrderedFloat, Vec2}; +use parley::{Glyph, GlyphRun}; +use swash::zeno; + +use crate::TextureAtlas; + +use super::FontTweak; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct UvRect { + /// X/Y offset for nice rendering (unit: points). + pub offset: Vec2, + + /// Screen size (in points) of this glyph. + /// Note that the height is different from the font height. + pub size: Vec2, + + /// Top left corner UV in texture. + pub min: [u16; 2], + + /// Bottom right corner (exclusive). + pub max: [u16; 2], +} + +impl UvRect { + pub fn is_nothing(&self) -> bool { + self.min == self.max + } +} + +// Subpixel binning, taken from cosmic-text: +// https://github.com/pop-os/cosmic-text/blob/974ddaed96b334f560b606ebe5d2ca2d2f9f23ef/src/glyph_cache.rs +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(super) enum SubpixelBin { + Zero, + One, + Two, + Three, +} + +impl SubpixelBin { + fn new(pos: f32) -> (i32, Self) { + let trunc = pos as i32; + let fract = pos - trunc as f32; + + #[expect(clippy::collapsible_else_if)] + if pos.is_sign_negative() { + if fract > -0.125 { + (trunc, Self::Zero) + } else if fract > -0.375 { + (trunc - 1, Self::Three) + } else if fract > -0.625 { + (trunc - 1, Self::Two) + } else if fract > -0.875 { + (trunc - 1, Self::One) + } else { + (trunc - 1, Self::Zero) + } + } else { + if fract < 0.125 { + (trunc, Self::Zero) + } else if fract < 0.375 { + (trunc, Self::One) + } else if fract < 0.625 { + (trunc, Self::Two) + } else if fract < 0.875 { + (trunc, Self::Three) + } else { + (trunc + 1, Self::Zero) + } + } + } + + fn as_float(&self) -> f32 { + match self { + Self::Zero => 0.0, + Self::One => 0.25, + Self::Two => 0.5, + Self::Three => 0.75, + } + } + + pub const SUBPIXEL_OFFSETS: [f32; 4] = [0.0, 0.25, 0.5, 0.75]; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct GlyphKey { + glyph_id: swash::GlyphId, + // We don't store the y-position because it's always rounded to an integer coordinate + x: SubpixelBin, + style_id: u32, +} + +impl GlyphKey { + fn from_glyph(glyph: &Glyph, scale: f32, style_id: u32) -> (Self, i32) { + let (x, x_bin) = SubpixelBin::new(glyph.x * scale); + ( + Self { + glyph_id: glyph.id, + x: x_bin, + style_id, + }, + x, + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct StyleKey<'a> { + font_id: u64, + font_size: OrderedFloat, + skew: i8, + hinting_enabled: bool, + /// We want to avoid doing a bunch of allocations. When looking up this key in a map, this can be a borrowed slice. + /// We only need to convert it to an owned [`Vec`] the first time we insert it into the map. + normalized_coords: Cow<'a, [i16]>, +} + +impl<'a> StyleKey<'a> { + fn new( + font_id: u64, + font_size: f32, + skew: i8, + hinting_enabled: bool, + normalized_coords: &'a [i16], + ) -> Self { + Self { + font_id, + font_size: font_size.into(), + skew, + hinting_enabled, + normalized_coords: Cow::Borrowed(normalized_coords), + } + } + + fn to_static(&self) -> StyleKey<'static> { + StyleKey { + normalized_coords: self.normalized_coords.clone().into_owned().into(), + ..*self + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RenderedGlyph { + rect: UvRect, + is_color_glyph: bool, +} + +pub(super) struct GlyphAtlas { + scale_context: swash::scale::ScaleContext, + /// Style-related properties (font, size, variation coordinates) are the same for each glyph run and don't need to + /// be part of each glyph's cache key. Instead, we associate each style with its own compact ID, included in each + /// glyph's cache key. Compared to having a nested hash map of one cache per style, this keeps things flat and + /// avoids a bunch of gnarly lifetime issues. + style_ids: ahash::HashMap, u32>, + next_style_id: u32, + /// Cache of rendered glyph bitmaps. Also stores `None` for certain glyphs if they're blank. + rendered_glyphs: ahash::HashMap>, + /// Map of [`parley::fontique::Blob`] ID + [`parley::Font`] indexes to [`swash::FontRef`] offsets and cache keys. + swash_keys: ahash::HashMap<(u64, u32), (swash::CacheKey, u32)>, + /// Scratch image buffer, used to write bitmap data into. + scratch: swash::scale::image::Image, +} + +impl GlyphAtlas { + pub(super) fn new() -> Self { + Self { + scale_context: Default::default(), + style_ids: Default::default(), + next_style_id: 0, + rendered_glyphs: Default::default(), + swash_keys: Default::default(), + scratch: Default::default(), + } + } + + /// Clears this glyph atlas, allowing it to be reused. + /// This will not clear the associated texture atlas--you should do that yourself before calling this. + pub fn clear(&mut self) { + self.style_ids.clear(); + self.next_style_id = 0; + self.rendered_glyphs.clear(); + self.swash_keys.clear(); + } + + pub fn render_glyph_run<'a: 'b, 'b, 'c>( + &'a mut self, + atlas: &'c mut TextureAtlas, + glyph_run: &'b GlyphRun<'b, Color32>, + offset: Vec2, + hinting_enabled: bool, + pixels_per_point: f32, + font_tweaks: &ahash::HashMap, + ) -> impl Iterator, (i32, i32), Color32)> + use<'a, 'b, 'c> { + let run = glyph_run.run(); + let font = run.font(); + let font_size = run.font_size(); + let font_id = font.data.id(); + + let (swash_key, swash_offset) = *self + .swash_keys + .entry((font_id, font.index)) + .or_insert_with(|| { + let font_ref = + swash::FontRef::from_index(font.data.data(), font.index as usize).unwrap(); + (font_ref.key, font_ref.offset) + }); + let font_ref: swash::FontRef<'b> = swash::FontRef { + data: font.data.data(), + offset: swash_offset, + key: swash_key, + }; + + let size = font_size * pixels_per_point; + let normalized_coords = run.normalized_coords(); + + let font_tweak = font_tweaks.get(&font_id); + let tweak_offset = font_tweak.map_or(0.0, |tweak| { + (font_size * tweak.y_offset_factor) + tweak.y_offset + }); + let hinting_enabled = font_tweak + .and_then(|tweak| tweak.hinting_override) + .unwrap_or(hinting_enabled); + + let mut scaler: swash::scale::Scaler<'b> = self + .scale_context + .builder(font_ref) + .size(size) + .normalized_coords(normalized_coords) + .hint(hinting_enabled) + .build(); + let rendered_glyphs = &mut self.rendered_glyphs; + let image = &mut self.scratch; + let color = glyph_run.style().brush; + + // There's also a faux embolden property, but it's always true with the default font because it's "light" and we + // technically asked for "normal", so we can't use it + let skew = run.synthesis().skew(); + + let style_key = StyleKey::<'b>::new( + font_id, + size, + // Parley stores skew as an i8 internally, so it's fine to convert it back into one + skew.unwrap_or_default() as i8, + hinting_enabled, + normalized_coords, + ); + + let style_id = match self.style_ids.get(&style_key) { + Some(key) => *key, + None => *self + .style_ids + .entry(style_key.to_static()) + .or_insert_with(|| { + let id = self.next_style_id; + self.next_style_id += 1; + id + }), + }; + + glyph_run.positioned_glyphs().map(move |mut glyph| { + // The Y-position transform applies to the font *after* it's been hinted, making it blurry. (So does the + // X-position transform, but the hinter doesn't change the X coordinates anymore.) + glyph.x += offset.x; + let y = ((glyph.y + offset.y + tweak_offset) * pixels_per_point).round(); + glyph.y = y / pixels_per_point; + let y = y as i32; + let (cache_key, x) = GlyphKey::from_glyph(&glyph, pixels_per_point, style_id); + + if let Some(rendered_glyph) = rendered_glyphs.get(&cache_key) { + return ( + glyph, + rendered_glyph.map(|r| r.rect), + (x, y), + rendered_glyph.map_or(color, |r| { + if r.is_color_glyph { + Color32::WHITE + } else { + color + } + }), + ); + } + let offset = zeno::Vector::new(cache_key.x.as_float(), 0.0); + + image.clear(); + let did_render = swash::scale::Render::new(&[ + swash::scale::Source::ColorOutline(0), + swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit), + swash::scale::Source::Outline, + ]) + .transform(skew.map(|skew| { + zeno::Transform::skew(zeno::Angle::from_degrees(skew), zeno::Angle::ZERO) + })) + .format(swash::zeno::Format::Alpha) + .offset(offset) + .render_into(&mut scaler, glyph.id, image); + + if !did_render { + rendered_glyphs.insert(cache_key, None); + return (glyph, None, (x, y), color); + }; + + // Some glyphs may have zero size (e.g. whitespace). Don't bother rendering them. + if image.placement.width == 0 || image.placement.height == 0 { + rendered_glyphs.insert(cache_key, None); + return (glyph, None, (x, y), color); + } + + let gamma = atlas.gamma; + let (allocated_pos, font_image) = atlas.allocate(( + image.placement.width as usize, + image.placement.height as usize, + )); + + let is_color_glyph = match image.content { + swash::scale::image::Content::Mask => { + let mut i = 0; + for y in 0..image.placement.height as usize { + for x in 0..image.placement.width as usize { + font_image[(x + allocated_pos.0, y + allocated_pos.1)] = + TextureAtlas::coverage_to_color( + gamma, + image.data[i] as f32 / 255.0, + ); + i += 1; + } + } + + false + } + swash::scale::image::Content::SubpixelMask => { + panic!("Got a subpixel glyph we didn't ask for") + } + swash::scale::image::Content::Color => { + let mut i = 0; + for y in 0..image.placement.height as usize { + for x in 0..image.placement.width as usize { + let [r, g, b, a] = image.data[i * 4..(i * 4) + 4].try_into().unwrap(); + font_image[(x + allocated_pos.0, y + allocated_pos.1)] = + Color32::from_rgba_unmultiplied(r, g, b, a); + i += 1; + } + } + + true + } + }; + + let uv_rect = UvRect { + offset: vec2(image.placement.left as f32, -image.placement.top as f32) + / pixels_per_point, + size: vec2(image.placement.width as f32, image.placement.height as f32) + / pixels_per_point, + min: [allocated_pos.0 as u16, allocated_pos.1 as u16], + max: [ + allocated_pos.0 as u16 + image.placement.width as u16, + allocated_pos.1 as u16 + image.placement.height as u16, + ], + }; + + rendered_glyphs.insert( + cache_key, + Some(RenderedGlyph { + rect: uv_rect, + is_color_glyph, + }), + ); + ( + glyph, + Some(uv_rect), + (x, y), + if is_color_glyph { + Color32::WHITE + } else { + color + }, + ) + }) + } +} diff --git a/crates/epaint/src/text/mod.rs b/crates/epaint/src/text/mod.rs index cf5c8ebfc99..adbce3cde7a 100644 --- a/crates/epaint/src/text/mod.rs +++ b/crates/epaint/src/text/mod.rs @@ -1,9 +1,11 @@ //! Everything related to text, fonts, text layout, cursors etc. pub mod cursor; -mod font; mod fonts; -mod text_layout; +mod glyph_atlas; +#[doc(hidden)] +pub mod parley_layout; +pub mod style; mod text_layout_types; /// One `\t` character is this many spaces wide. @@ -11,10 +13,9 @@ pub const TAB_SIZE: usize = 4; pub use { fonts::{ - FontData, FontDefinitions, FontFamily, FontId, FontInsert, FontPriority, FontTweak, Fonts, - FontsImpl, InsertFontFamily, + FontData, FontDefinitions, FontInsert, FontPriority, FontStore, FontTweak, Fonts, + InsertFontFamily, }, - text_layout::*, text_layout_types::*, }; diff --git a/crates/epaint/src/text/parley_layout.rs b/crates/epaint/src/text/parley_layout.rs new file mode 100644 index 00000000000..7256f10fac6 --- /dev/null +++ b/crates/epaint/src/text/parley_layout.rs @@ -0,0 +1,528 @@ +use std::sync::Arc; + +use ecolor::Color32; +use emath::{pos2, vec2, GuiRounding as _, NumExt as _, Pos2, Rect, Vec2}; +use log::debug; +use parley::{AlignmentOptions, BreakReason, GlyphRun, InlineBox, PositionedLayoutItem}; + +use crate::{ + tessellator::Path, + text::{LayoutAndOffset, Row, RowVisuals}, + Mesh, Stroke, +}; + +use super::{fonts::FontsLayoutView, Galley, LayoutJob}; + +fn render_decoration( + pixels_per_point: f32, + run: &GlyphRun<'_, Color32>, + mesh: &mut Mesh, + offset: (f32, f32), + stroke: Stroke, +) { + let mut y = run.baseline() + offset.1; + stroke.round_center_to_pixel(pixels_per_point, &mut y); + let x_start = run.offset() + offset.0; + let x_end = x_start + run.advance(); + + let mut path = Path::default(); + path.reserve(2); + path.add_line_segment([pos2(x_start, y), pos2(x_end, y)]); + path.stroke_open(1.0 / pixels_per_point, &stroke.into(), mesh); +} + +pub(super) fn layout(fonts: &mut FontsLayoutView<'_>, job: LayoutJob) -> Galley { + let Some(first_section) = job.sections.first() else { + // Early-out: no text + return Galley { + job: Arc::new(job), + rows: Default::default(), + parley_layout: LayoutAndOffset::default(), + overflow_char_layout: None, + #[cfg(feature = "accesskit")] + accessibility: Default::default(), + selection_color: Color32::TRANSPARENT, + rect: Rect::from_min_max(Pos2::ZERO, Pos2::ZERO), + mesh_bounds: Rect::NOTHING, + num_vertices: 0, + num_indices: 0, + pixels_per_point: fonts.pixels_per_point, + elided: true, + }; + }; + + let justify = job.justify && job.wrap.max_width.is_finite(); + + let mut default_style = first_section.format.as_parley(); + job.wrap.apply_to_parley_style(&mut default_style); + let mut builder = + fonts + .layout_context + .tree_builder(fonts.font_context, 1.0, false, &default_style); + + let first_row_height = job.first_row_min_height; + + job.sections.iter().enumerate().for_each(|(i, section)| { + // TODO(valadaptive): this only works for the first section + if section.leading_space > 0.0 { + // Emulate the leading space with an inline box. + builder.push_inline_box(InlineBox { + id: 0, + index: section.byte_range.start, + width: section.leading_space, + // If we set the height to first_row_min_height or similar, it will progressively push text downwards + // because inline boxes are aligned to the baseline, not the descent, but first_row_min_height is set + // from the previous text's ascent + descent. + height: 0.0, + }); + } + let mut style = section.format.as_parley(); + job.wrap.apply_to_parley_style(&mut style); + if i == 0 { + // If the first section takes up more than one row, this will apply to the entire first section. There + // doesn't seem to be any way to prevent this because we don't know ahead of time what the "first row" will + // be due to line wrapping. + + //TODO(valadaptive): how to make this work with metrics-relative line height? + //first_row_height = first_row_height.max(section.format.line_height()); + //style.line_height = parley::LineHeight::Absolute(first_row_height); + } + + builder.push_style_span(style); + builder.push_text(&job.text[section.byte_range.clone()]); + builder.pop_style_span(); + }); + + // TODO(valadaptive): we don't need to assemble this string + // (but RangedBuilder requires one call per individual style attribute :( ) + let (mut layout, _text) = builder.build(); + + let mut overflow_char_layout = None; + + let mut break_lines = layout.break_lines(); + for i in 0..job.wrap.max_rows { + let wrap_width = job.effective_wrap_width(); + let wrap_width = if wrap_width.is_finite() { + wrap_width + } else { + f32::MAX + }; + let line = break_lines.break_next(wrap_width); + + // We're truncating the text with an overflow character. + if let (Some(overflow_character), true, true) = ( + job.wrap.overflow_character, + i == job.wrap.max_rows - 1, + !break_lines.is_done(), + ) { + let mut builder = + fonts + .layout_context + .tree_builder(fonts.font_context, 1.0, false, &default_style); + + builder.push_text(&overflow_character.to_string()); + let (mut layout, _text) = builder.build(); + layout.break_all_lines(None); + + break_lines.revert(); + break_lines.break_next(wrap_width - layout.full_width()); + overflow_char_layout = Some((layout, Vec2::ZERO)); + } + + if line.is_none() { + break; + } + } + let broke_all_lines: bool = break_lines.is_done(); + break_lines.finish(); + + // Parley will left-align the line if there's not enough space. In this + // case, that could occur due to floating-point error if we use + // `layout.width()` as the alignment width, but it's okay as left-alignment + // will look the same as the "correct" alignment. + // + // Note that we only use the "effective wrap width" for determining line + // breaks. Everywhere else, we want to use the actual specified width. + let alignment_width = if job.wrap.max_width.is_finite() { + job.wrap.max_width + } else { + layout.width() + }; + let horiz_offset = match (justify, job.halign) { + (false, emath::Align::Center) => -alignment_width * 0.5, + (false, emath::Align::RIGHT) => -alignment_width, + _ => 0.0, + }; + + layout.align( + Some(alignment_width), + match (justify, job.halign) { + (true, _) => parley::Alignment::Justified, + (false, emath::Align::Min) => parley::Alignment::Left, + (false, emath::Align::Center) => parley::Alignment::Middle, + (false, emath::Align::Max) => parley::Alignment::Right, + }, + AlignmentOptions::default(), + ); + + let mut rows = Vec::new(); + let mut acc_mesh_bounds = Rect::NOTHING; + let mut acc_num_indices = 0; + let mut acc_num_vertices = 0; + let mut acc_logical_bounds = Rect::NOTHING; + // Temporary mesh used to store each row's decorations before they're all appended after the glyphs. Reused to avoid + // allocations. + let mut decorations = Mesh::default(); + + let mut vertical_offset = 0f32; + + let mut prev_break_reason = None; + for (i, line) in layout.lines().enumerate() { + let mut mesh = Mesh::default(); + let mut background_mesh = Mesh::default(); + let mut row_logical_bounds = Rect::NOTHING; + let mut box_offset = 0.0; + + // Parley will wrap the last whitespace character(s) onto a whole new + // line. We don't want that to count for layout purposes. + let is_trailing_wrapped_whitespace = i == layout.len() - 1 + && matches!( + prev_break_reason, + Some(BreakReason::Regular | BreakReason::Emergency) + ) + && line + .runs() + .all(|run| run.clusters().all(|cluster| cluster.is_space_or_nbsp())); + + prev_break_reason = Some(line.break_reason()); + + // Nothing on this line except whitespace that should be collapsed. + if is_trailing_wrapped_whitespace { + continue; + } + + let mut draw_run = + |run: &GlyphRun<'_, Color32>, + section_idx: usize, + (horiz_offset, vertical_offset): (f32, f32)| { + let section_format = &job.sections[section_idx].format; + let background = section_format.background; + + let mut visual_vertical_offset = vertical_offset; + visual_vertical_offset += match section_format.valign { + emath::Align::TOP => run.run().metrics().ascent - line.metrics().ascent, + emath::Align::Center => 0.0, + emath::Align::BOTTOM => run.run().metrics().descent - line.metrics().descent, + }; + + if background != Color32::TRANSPARENT { + let min_y = run.baseline() - run.run().metrics().ascent; + let max_y = run.baseline() + run.run().metrics().descent; + let min_x = run.offset(); + let max_x = run.offset() + run.advance(); + + background_mesh.add_colored_rect( + Rect::from_min_max(pos2(min_x, min_y), pos2(max_x, max_y)) + .translate(vec2(horiz_offset, visual_vertical_offset)) + .expand(section_format.expand_bg), + background, + ); + } + + let run_metrics = run.run().metrics(); + + for (_glyph, uv_rect, (x, y), color) in fonts.glyph_atlas.render_glyph_run( + fonts.texture_atlas, + run, + vec2(horiz_offset, visual_vertical_offset), + fonts.hinting_enabled, + fonts.pixels_per_point, + fonts.font_tweaks, + ) { + let Some(uv_rect) = uv_rect else { + continue; + }; + + let left_top = + (pos2(x as f32, y as f32) / fonts.pixels_per_point) + uv_rect.offset; + + let rect = Rect::from_min_max(left_top, left_top + uv_rect.size); + let uv = Rect::from_min_max( + pos2(uv_rect.min[0] as f32, uv_rect.min[1] as f32), + pos2(uv_rect.max[0] as f32, uv_rect.max[1] as f32), + ); + + //mesh.add_colored_rect(rect, Color32::DEBUG_COLOR.gamma_multiply(0.3)); + mesh.add_rect_with_uv(rect, uv, color); + } + + if let Some(underline) = &run.style().underline { + let offset = underline.offset.unwrap_or(run_metrics.underline_offset); + let size = underline.size.unwrap_or(run_metrics.underline_size); + render_decoration( + fonts.pixels_per_point, + run, + &mut decorations, + (horiz_offset, visual_vertical_offset - offset), + Stroke { + width: size, + color: underline.brush, + }, + ); + } + + if let Some(strikethrough) = &run.style().strikethrough { + let offset = strikethrough + .offset + .unwrap_or(run_metrics.strikethrough_offset); + let size = strikethrough.size.unwrap_or(run_metrics.strikethrough_size); + render_decoration( + fonts.pixels_per_point, + run, + &mut decorations, + (horiz_offset, visual_vertical_offset - offset), + Stroke { + width: size, + color: strikethrough.brush, + }, + ); + } + }; + + let mut is_inline_box_only = i == 0; + for item in line.items() { + match item { + PositionedLayoutItem::GlyphRun(run) => { + // We saw something that isn't a box on the first line. (See below for why we need vertical_offset) + if i == 0 { + /*if vertical_offset != 0.0 { + println!( + "reset vertical offset because we saw {:?}", + &job.text[run.run().text_range()] + ); + }*/ + vertical_offset = 0.0; + is_inline_box_only = false; + } + + let run_range = run.run().text_range(); + + // Get the layout section corresponding to this run, for the background color. We have to do a + // binary search each time because in mixed-direction text, we may not traverse glyph runs in + // increasing byte order. + let section_idx = job + .sections + .binary_search_by(|section| section.byte_range.start.cmp(&run_range.start)) + .unwrap_or_else(|i| i.saturating_sub(1)); + + draw_run(&run, section_idx, (horiz_offset, vertical_offset)); + } + PositionedLayoutItem::InlineBox(inline_box) => { + /*mesh.add_colored_rect( + Rect::from_min_size( + pos2(inline_box.x, inline_box.y), + vec2(inline_box.width, inline_box.height.max(1.0)), + ), + Color32::RED.gamma_multiply(0.3), + );*/ + + // As described above, the InlineBox can't have any height, but that means that if the first line + // completely wraps, it'll end up at the same place instead of one line down. To avoid this, add a + // vertical offset to all text in this layout if it wraps. + vertical_offset += first_row_height; + box_offset += inline_box.width; + } + } + } + + let line_metrics = line.metrics(); + + // The text overflowed, and we have an overflow character to use when truncating the text. Add it to the mesh. + if i == layout.len() - 1 && !broke_all_lines { + if let Some((overflow_line, overflow_layout_offset)) = overflow_char_layout + .as_mut() + .and_then(|(layout, overflow_layout_offset)| { + Some((layout.lines().next()?, overflow_layout_offset)) + }) + { + let overflow_metrics = overflow_line.metrics(); + let overflow_horiz_offset = (line_metrics.offset + + horiz_offset + + line_metrics.advance + + overflow_metrics.advance) + .min(job.wrap.max_width) + - overflow_metrics.advance; + let overflow_vertical_offset = + vertical_offset + line_metrics.baseline - overflow_metrics.baseline; + + for item in overflow_line.items() { + let PositionedLayoutItem::GlyphRun(run) = item else { + continue; + }; + + draw_run(&run, 0, (overflow_horiz_offset, overflow_vertical_offset)); + } + + row_logical_bounds = row_logical_bounds.union(Rect::from_min_max( + pos2( + overflow_horiz_offset, + overflow_metrics.baseline + - overflow_metrics.ascent + - (overflow_metrics.leading * 0.5) + + overflow_vertical_offset, + ), + pos2( + overflow_horiz_offset + overflow_metrics.advance, + overflow_metrics.baseline + + overflow_metrics.descent + + (overflow_metrics.leading * 0.5) + + overflow_vertical_offset, + ), + )); + + overflow_layout_offset.x = overflow_horiz_offset; + overflow_layout_offset.y = overflow_vertical_offset; + } + } + + // Don't include the leading inline box when calculating text bounds. + // TODO(valadaptive): the old layout code includes this box. Is that good? + if is_inline_box_only { + continue; + } + + // Don't include the leading inline box in the row bounds + let line_start = line_metrics.offset + horiz_offset + box_offset; + + // Be flexible with trailing whitespace. + // - max_line_end is the widest the line can be if all trailing + // whitespace is included. + // - min_line_end is the narrowest the line can be if all trailing + // whitespace is excluded. + // + // This lets us count trailing whitespace for inline labels while + // ignoring it for the purpose of text wrapping. + let max_line_end = line_metrics.offset + horiz_offset + line_metrics.advance; + let min_line_end = max_line_end - line_metrics.trailing_whitespace; + // If this line's trailing whitespace is what would push the line over + // the max wrap width, clamp it. However, we must be at least as wide as + // min_line_end. + let line_end = max_line_end.min(job.wrap.max_width).max(min_line_end); + + row_logical_bounds = row_logical_bounds.union(Rect::from_min_max( + pos2( + line_start, + line_metrics.baseline - line_metrics.ascent - (line_metrics.leading * 0.5) + + vertical_offset, + ), + pos2( + line_end, + line_metrics.baseline + + line_metrics.descent + + (line_metrics.leading * 0.5) + + vertical_offset, + ), + )); + + if job.round_output_to_gui { + let did_exceed_wrap_width_by_a_lot = + row_logical_bounds.max.x > job.wrap.max_width + 1.0; + + row_logical_bounds = row_logical_bounds.round_ui(); + + if did_exceed_wrap_width_by_a_lot { + // If the user picked a too aggressive wrap width (e.g. more narrow than any individual glyph), + // we should let the user know by reporting that our width is wider than the wrap width. + } else { + // Make sure we don't report being wider than the wrap width the user picked: + row_logical_bounds.max.x = row_logical_bounds.max.x.at_most(job.wrap.max_width); + } + } + + acc_logical_bounds = acc_logical_bounds.union(row_logical_bounds); + + if acc_logical_bounds.width() > job.wrap.max_width { + debug!( + "actual wrapped text width {} exceeds max_width {}", + acc_logical_bounds.width(), + job.wrap.max_width + ); + } + + let num_glyph_vertices = mesh.vertices.len(); + + // TODO(valadaptive): it would be really nice to avoid this temporary allocation, which only exists to ensure + // that override_text_color doesn't change the decoration color by moving all decoration meshes past the end of + // `glyph_vertex_range`. + if !decorations.is_empty() { + mesh.append_ref(&decorations); + decorations.clear(); + } + + // Glyph vertices start after (above) the background vertices. + let glyph_index_start = background_mesh.indices.len(); + let glyph_vertex_range = glyph_index_start..glyph_index_start + num_glyph_vertices; + // Prepend the background to the text mesh. Actually, we append the *text* to the *background*, then set the + // text mesh to the newly-appended-to background mesh. + if !background_mesh.is_empty() { + background_mesh.append(mesh); + mesh = background_mesh; + } + + let mesh_bounds = mesh.calc_bounds(); + + acc_mesh_bounds = acc_mesh_bounds.union(mesh_bounds); + acc_num_indices += mesh.indices.len(); + acc_num_vertices += mesh.vertices.len(); + + if !row_logical_bounds.is_finite() { + row_logical_bounds = Rect::ZERO; + } + + let row = Row { + rect: row_logical_bounds, + visuals: RowVisuals { + mesh, + mesh_bounds, + selection_rects: None, + glyph_index_start, + glyph_vertex_range, + }, + }; + + rows.push(row); + } + + // In case no glyphs got drawn (e.g. all whitespace) + for bounds in [&mut acc_logical_bounds, &mut acc_mesh_bounds] { + if !bounds.is_finite() { + *bounds = Rect::from_min_size(pos2(horiz_offset, 0.0), Vec2::ZERO); + } + + debug_assert!( + !bounds.is_negative(), + "Invalid bounds for galley mesh: {bounds:?}" + ); + } + + if job.round_output_to_gui { + acc_logical_bounds = acc_logical_bounds.round_ui(); + } + + Galley { + job: Arc::new(job), + rows, + parley_layout: LayoutAndOffset::new(layout, vec2(horiz_offset, vertical_offset)), + overflow_char_layout: overflow_char_layout + .map(|(layout, offset)| Box::new(LayoutAndOffset::new(layout, offset))), + #[cfg(feature = "accesskit")] + accessibility: Default::default(), + selection_color: Color32::TRANSPARENT, + elided: !broke_all_lines, + rect: acc_logical_bounds, + mesh_bounds: acc_mesh_bounds, + num_vertices: acc_num_vertices, + num_indices: acc_num_indices, + pixels_per_point: fonts.pixels_per_point, + } +} diff --git a/crates/epaint/src/text/style.rs b/crates/epaint/src/text/style.rs new file mode 100644 index 00000000000..31446258ccb --- /dev/null +++ b/crates/epaint/src/text/style.rs @@ -0,0 +1,1088 @@ +//! Font style definitions. These mostly mirror the ones in Parley, but allow us to not expose Parley types publicly, as +//! well as tweak them to fit our needs. + +use std::{borrow::Cow, hash::Hash, str::FromStr, sync::Arc}; + +use ecolor::Color32; +use emath::{Align, OrderedFloat}; +use named_variants::NamedFontVariants; + +use crate::Stroke; + +// TODO(valadaptive): Cow<'static, str> or Arc? + +/// A generic font family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum GenericFamily { + // Parley exposes a lot more settings here, but not all of them behave well cross-platform. Only expose a subset here. + /// The default user interface font. + #[default] + SystemUi, + Serif, + SansSerif, + Monospace, + Cursive, + Emoji, +} + +impl GenericFamily { + pub(crate) fn as_parley(&self) -> parley::GenericFamily { + match self { + // TODO(valadaptive): SystemUi is not necessarily well-behaved (e.g. on my Linux system, it causes Arabic + // text to disappear whereas SansSerif does not). There should be a more complex mapping of these generic + // families to Parley's at some level. + Self::SystemUi => parley::GenericFamily::SystemUi, + Self::Serif => parley::GenericFamily::Serif, + Self::SansSerif => parley::GenericFamily::SansSerif, + Self::Monospace => parley::GenericFamily::Monospace, + Self::Cursive => parley::GenericFamily::Cursive, + Self::Emoji => parley::GenericFamily::Emoji, + } + } + + pub const ALL: [Self; 6] = [ + Self::SystemUi, + Self::Serif, + Self::SansSerif, + Self::Monospace, + Self::Cursive, + Self::Emoji, + ]; +} + +impl std::fmt::Display for GenericFamily { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(&self, f) + } +} + +/// A single font family, either a specific named font or a generic family. +/// +/// For styling purposes, or if you're exposing an API that lets its users choose a font, you should consider using the +/// more flexible [`FontStack`] API to specify a set of multiple fonts. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum FontFamily { + /// One of the names in [`super::FontDefinitions::families`]. + /// + /// ``` + /// # use epaint::text::style::FontFamily; + /// // User-chosen names: + /// FontFamily::Named("arial".into()); + /// FontFamily::Named("serif".into()); + /// ``` + Named(Cow<'static, str>), + Generic(GenericFamily), +} + +impl FontFamily { + pub(crate) fn as_parley(&self) -> parley::FontFamily<'static> { + match self { + Self::Named(cow) => parley::FontFamily::Named(cow.clone()), + Self::Generic(generic_family) => { + parley::FontFamily::Generic(generic_family.as_parley()) + } + } + } + + pub fn named(name: impl Into>) -> Self { + Self::Named(name.into()) + } + + pub fn generic(generic_family: GenericFamily) -> Self { + Self::Generic(generic_family) + } +} + +impl Default for FontFamily { + fn default() -> Self { + Self::Generic(Default::default()) + } +} + +impl From for FontFamily { + fn from(value: GenericFamily) -> Self { + Self::Generic(value) + } +} + +impl std::fmt::Display for FontFamily { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Named(name) => (*name).fmt(f), + Self::Generic(name) => std::fmt::Debug::fmt(name, f), + } + } +} + +/// A stack of font families, in order of preference. Fonts lower down the stack will be used if the fonts higher up the +/// stack do not contain the necessary glyphs. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum FontStack { + Single(FontFamily), + // TODO(valadaptive): make this an Arc> for better performance? + // Especially if we're taking away the ability to register custom font stacks under family names + List(Cow<'static, [FontFamily]>), +} + +impl FontStack { + pub(crate) fn as_parley(&self) -> parley::FontStack<'static> { + match self { + Self::Single(family) => parley::FontStack::Single(family.as_parley()), + Self::List(families) => { + parley::FontStack::List(families.iter().map(|f| f.as_parley()).collect()) + } + } + } + + pub fn first_family(&self) -> &FontFamily { + match self { + Self::Single(family) => family, + Self::List(families) => &families[0], + } + } +} + +impl Default for FontStack { + fn default() -> Self { + Self::Single(Default::default()) + } +} + +impl From for FontStack { + fn from(value: FontFamily) -> Self { + Self::Single(value) + } +} + +impl From for FontStack { + fn from(value: GenericFamily) -> Self { + Self::Single(value.into()) + } +} + +impl FromIterator for FontStack { + fn from_iter>(iter: T) -> Self { + Self::List(Cow::Owned(iter.into_iter().collect())) + } +} + +/// Weight of a font, typically on a scale of 1.0 to 1000.0. +/// +/// The default value is [`Self::NORMAL`] or 400.0. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontWeight(pub f32); + +impl FontWeight { + /// Weight value of 100. + pub const THIN: Self = Self(100.0); + /// Weight value of 200. + pub const EXTRA_LIGHT: Self = Self(200.0); + /// Weight value of 300. + pub const LIGHT: Self = Self(300.0); + /// Weight value of 350. + pub const SEMI_LIGHT: Self = Self(350.0); + /// Weight value of 400. This is the default value. + pub const NORMAL: Self = Self(400.0); + /// Weight value of 500. + pub const MEDIUM: Self = Self(500.0); + /// Weight value of 600. + pub const SEMI_BOLD: Self = Self(600.0); + /// Weight value of 700. + pub const BOLD: Self = Self(700.0); + /// Weight value of 800. + pub const EXTRA_BOLD: Self = Self(800.0); + /// Weight value of 900. + pub const BLACK: Self = Self(900.0); + /// Weight value of 950. + pub const EXTRA_BLACK: Self = Self(950.0); + + pub(crate) fn as_parley(&self) -> parley::FontWeight { + parley::FontWeight::new(self.0) + } +} + +impl Hash for FontWeight { + fn hash(&self, state: &mut H) { + OrderedFloat(self.0).hash(state); + } +} + +impl std::cmp::PartialEq for FontWeight { + fn eq(&self, other: &Self) -> bool { + OrderedFloat(self.0) == OrderedFloat(other.0) + } +} +impl std::cmp::Eq for FontWeight {} + +impl std::cmp::PartialOrd for FontWeight { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl std::cmp::Ord for FontWeight { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + OrderedFloat(self.0).cmp(&OrderedFloat(other.0)) + } +} + +impl Default for FontWeight { + fn default() -> Self { + Self::NORMAL + } +} + +/// Visual width / "condensedness" of a font, relative to its normal aspect ratio (1.0). +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontWidth(pub f32); +impl FontWidth { + /// Width that is 50% of normal. + pub const ULTRA_CONDENSED: Self = Self(0.5); + /// Width that is 62.5% of normal. + pub const EXTRA_CONDENSED: Self = Self(0.625); + /// Width that is 75% of normal. + pub const CONDENSED: Self = Self(0.75); + /// Width that is 87.5% of normal. + pub const SEMI_CONDENSED: Self = Self(0.875); + /// Width that is 100% of normal. This is the default value. + pub const NORMAL: Self = Self(1.0); + /// Width that is 112.5% of normal. + pub const SEMI_EXPANDED: Self = Self(1.125); + /// Width that is 125% of normal. + pub const EXPANDED: Self = Self(1.25); + /// Width that is 150% of normal. + pub const EXTRA_EXPANDED: Self = Self(1.5); + /// Width that is 200% of normal. + pub const ULTRA_EXPANDED: Self = Self(2.0); + + pub(crate) fn as_parley(&self) -> parley::FontWidth { + parley::FontWidth::from_ratio(self.0) + } +} + +impl Hash for FontWidth { + fn hash(&self, state: &mut H) { + OrderedFloat(self.0).hash(state); + } +} + +impl std::cmp::PartialEq for FontWidth { + fn eq(&self, other: &Self) -> bool { + OrderedFloat(self.0) == OrderedFloat(other.0) + } +} +impl std::cmp::Eq for FontWidth {} + +impl std::cmp::PartialOrd for FontWidth { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl std::cmp::Ord for FontWidth { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + OrderedFloat(self.0).cmp(&OrderedFloat(other.0)) + } +} + +impl Default for FontWidth { + fn default() -> Self { + Self::NORMAL + } +} + +// Clippy thinks it's too long because the links are long +// (https://github.com/rust-lang/rust-clippy/issues/13315) +#[expect(clippy::too_long_first_doc_paragraph)] +/// An OpenType tag, typically a [feature tag](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist) +/// or [variation axis tag](https://learn.microsoft.com/en-us/typography/opentype/spec/dvaraxisreg). +/// +/// This is a 4-byte identifier, typically represented as a 4-character string. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Tag([u8; 4]); + +impl Tag { + pub(crate) fn as_swash(&self) -> parley::swash::Tag { + parley::swash::tag_from_bytes(&self.0) + } +} + +impl FromStr for Tag { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(Self(s.as_bytes().try_into().map_err(|_e| ())?)) + } +} + +impl From<[u8; 4]> for Tag { + fn from(value: [u8; 4]) -> Self { + Self(value) + } +} + +impl From<&[u8; 4]> for Tag { + fn from(value: &[u8; 4]) -> Self { + Self(*value) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontSetting { + pub tag: Tag, + pub value: T, +} + +impl FontSetting { + pub fn new(tag: Tag, value: T) -> Self { + Self { tag, value } + } +} + +impl FontSetting { + pub(crate) fn as_parley(&self) -> parley::swash::Setting { + parley::swash::Setting { + tag: self.tag.as_swash(), + value: self.value.clone(), + } + } +} + +impl> From<(U, T)> for FontSetting { + fn from(val: (U, T)) -> Self { + Self { + tag: val.0.into(), + value: val.1, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontSettings( + #[cfg_attr( + feature = "serde", + serde(bound( + deserialize = "<[FontSetting] as ToOwned>::Owned: serde::Deserialize<'de>" + )) + )] + pub Cow<'static, [FontSetting]>, +) +where + [FontSetting]: ToOwned, + <[FontSetting] as ToOwned>::Owned: std::fmt::Debug + PartialEq + Clone; + +impl FontSettings +where + <[parley::swash::Setting] as ToOwned>::Owned: + std::fmt::Debug + PartialEq + Clone + FromIterator>, + T: std::fmt::Debug + PartialEq + Clone, +{ + pub(crate) fn as_parley(&self) -> parley::FontSettings<'static, parley::swash::Setting> { + let settings = self.0.iter().map(|setting| setting.as_parley()).collect(); + parley::FontSettings::List(Cow::Owned(settings)) + } +} + +impl Default for FontSettings +where + [FontSetting]: ToOwned, + <[FontSetting] as ToOwned>::Owned: std::fmt::Debug + PartialEq + Clone, +{ + fn default() -> Self { + Self(Cow::Borrowed(&[])) + } +} + +impl FontSettings +where + [FontSetting]: ToOwned, + <[FontSetting] as ToOwned>::Owned: std::fmt::Debug + PartialEq + Clone, +{ + pub fn new>, U: IntoIterator>(iter: U) -> Self + where + [FontSetting]: ToOwned>>, + { + Self(Cow::Owned( + iter.into_iter().map(|v| v.into()).collect::>(), + )) + } +} + +/// Set of OpenType [font variation axis](https://learn.microsoft.com/en-us/typography/opentype/spec/dvaraxisreg) values +/// (for variable fonts). +/// +/// The `wght` and `wdth` axes can be controlled via [`FontWeight`] and [`FontWidth`] respectively. If you need to +/// control other, possibly font-specific, axes, you can use this. +#[derive(Debug, Clone, PartialEq, PartialOrd, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontVariations(FontSettings); + +impl FontVariations { + pub(crate) fn as_parley(&self) -> parley::FontSettings<'static, parley::swash::Setting> { + self.0.as_parley() + } +} + +impl Hash for FontVariations { + fn hash(&self, state: &mut H) { + let settings = &self.0 .0; + state.write_usize(settings.len()); + for setting in settings.iter() { + setting.tag.hash(state); + OrderedFloat(setting.value).hash(state); + } + } +} + +/// Set of OpenType [font feature](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist) values. +/// These can be used to enable or disable specific typographic features. +/// +/// For more user-friendly access to common font features, see [`NamedFontVariants`]. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontFeatures(FontSettings); + +impl FontFeatures { + pub(crate) fn as_parley(&self) -> parley::FontSettings<'static, parley::swash::Setting> { + self.0.as_parley() + } +} + +pub mod named_variants { + use std::borrow::Cow; + + use super::{FontFeatures, FontSettings}; + + /// Controls the selection of glyphs used for e.g. small caps, or for titling. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] + pub enum CapsVariant { + #[default] + Normal, + /// Enables display of small capitals for lowercase letters. + SmallCaps, + /// Enables display of small capitals for uppercase and lowercase letters. + AllSmallCaps, + /// Enables display of petite capitals for lowercase letters. + PetiteCaps, + /// Enables display of petite capitals for uppercase and lowercase letters. + AllPetiteCaps, + /// Enables display of small capitals for uppercase letters and normal lowercase letters. + Unicase, + /// Enables display of titling capitals (glyphs specifically designed for all-caps titles). + TitlingCaps, + } + + /// Controls the selection of specialized typographic subscript and superscript glyphs. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] + pub enum PositionVariant { + #[default] + Normal, + /// Enables display of subscript variants (glyphs specifically designed for use in subscripts). + Sub, + /// Enables display of superscript variants (glyphs specifically designed for use in superscripts). + Super, + } + + /// Controls the selection of numeric figure variants. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] + pub enum NumericFigureVariant { + #[default] + Normal, + /// Enables display of lining numerals (numerals that share the height of uppercase letters). + LiningNumerals, + /// Enables display of old-style numerals (numerals that share the height of lowercase letters). + OldStyleNumerals, + } + + /// Controls the selection of numeric spacing variants. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] + pub enum NumericSpacingVariant { + #[default] + Normal, + /// Enables display of proportional numerals. + ProportionalNumerals, + /// Enables display of tabular numerals (all digits are the same width). + TabularNumerals, + } + + /// Controls the selection of numeric fraction variants. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] + pub enum NumericFractionVariant { + /// Display fractions as normal text. + #[default] + Normal, + /// Enables display of diagonal fractions (e.g. transforms "1/2" into a diagonal fraction). + Diagonal, + /// Enables display of stacked fractions (e.g. transforms "1/2" into a stacked fraction). + Stacked, + } + + /// Controls glyph substitution and sizing in East Asian text. The JIS variants reflect the forms defined in different + /// Japanese national standards. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] + pub enum EastAsianVariant { + #[default] + Normal, + /// Enables rendering of JIS78 forms. + Jis78, + /// Enables rendering of JIS83 forms. + Jis83, + /// Enables rendering of JIS90 forms. + Jis90, + /// Enables rendering of JIS2004 forms. + Jis04, + /// Enables rendering of simplified forms. + Simplified, + /// Enables rendering of traditional forms. + Traditional, + } + + /// Controls the width variant of East Asian characters. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] + pub enum EastAsianWidth { + /// Enables rendering of full-width variants. + FullWidth, + /// Enables rendering of proportionally-spaced variants. + Proportional, + } + + /// Somewhat-common OpenType font features, as named and categorized by CSS. A more user-friendly way to obtain + /// [`FontFeatures`] that carries more semantic meaning. + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] + pub struct NamedFontVariants<'a> { + /// Enables display of common ligatures. + pub common_ligatures: bool, + /// Enables display of discretionary ligatures. + pub discretionary_ligatures: bool, + /// Enables display of historical ligatures. + pub historical_ligatures: bool, + /// Enables display of contextual alternates (typically used to substitute glyphs based on their surrounding + /// context). + pub contextual_alternates: bool, + /// Selects typographic subscript or superscript glyphs. + pub position: PositionVariant, + /// Selects variants of glyphs for different types of capitalization (e.g. small caps). + pub caps: CapsVariant, + /// Selects the style of numeric figures. + pub numeric_figures: NumericFigureVariant, + /// Selects the spacing (proportional or tabular) of numeric features. + pub numeric_spacing: NumericSpacingVariant, + /// Selects the appearance of fractions. + pub numeric_fractions: NumericFractionVariant, + /// Enables letterforms used with ordinal numbers (e.g. the "st" and "nd" in "1st" and "2nd"). + pub ordinal: bool, + /// Enables display of slashed zeros. + pub slashed_zero: bool, + /// Enables display of historical letterforms. + pub historical_forms: bool, + /// Enables display of font-specific stylistic alternates. + pub stylistic_alternates: u16, + /// Enables display of font-specific stylistic sets. + pub stylesets: Cow<'a, [u16]>, + /// Enables display of font-specific character variants. + pub character_variants: Cow<'a, [u16]>, + /// Enables display of font-specific swash glyphs. + pub swash: u16, + /// Enables replacement of default glyphs with font-specific ornaments (typically as replacements for the bullet + /// character). + pub ornaments: u16, + /// Enables display of font-specific alternate annotation forms. + pub annotation: u16, + /// Selects the way East Asian glyphs are rendered. + pub east_asian_variant: EastAsianVariant, + /// Selects the width variants of East Asian glyphs. + pub east_asian_width: Option, + /// Enables display of ruby (superscript-like annotations) variant glyphs. + pub ruby: bool, + } + + impl Default for NamedFontVariants<'_> { + fn default() -> Self { + Self { + common_ligatures: false, + discretionary_ligatures: false, + historical_ligatures: false, + contextual_alternates: false, + position: Default::default(), + caps: Default::default(), + numeric_figures: Default::default(), + numeric_spacing: Default::default(), + numeric_fractions: Default::default(), + ordinal: false, + slashed_zero: false, + historical_forms: false, + stylistic_alternates: 0, + stylesets: Cow::Borrowed(&[]), + character_variants: Cow::Borrowed(&[]), + swash: 0, + ornaments: 0, + annotation: 0, + east_asian_variant: Default::default(), + east_asian_width: None, + ruby: false, + } + } + } + + impl From> for FontFeatures { + fn from(value: NamedFontVariants<'_>) -> Self { + let mut features = vec![]; + + if value.common_ligatures { + features.extend([(*b"clig", 1), (*b"liga", 1)]); + } + if value.discretionary_ligatures { + features.push((*b"dlig", 1)); + } + if value.historical_ligatures { + features.push((*b"hlig", 1)); + } + if value.contextual_alternates { + features.push((*b"calt", 1)); + } + match value.position { + PositionVariant::Normal => {} + PositionVariant::Sub => { + features.push((*b"subs", 1)); + } + PositionVariant::Super => { + features.push((*b"sups", 1)); + } + } + match value.caps { + CapsVariant::Normal => {} + CapsVariant::SmallCaps => { + features.push((*b"smcp", 1)); + } + CapsVariant::AllSmallCaps => features.extend([(*b"smcp", 1), (*b"c2pc", 1)]), + CapsVariant::PetiteCaps => { + features.push((*b"pcap", 1)); + } + CapsVariant::AllPetiteCaps => features.extend([(*b"pcap", 1), (*b"c2pc", 1)]), + CapsVariant::Unicase => { + features.push((*b"unic", 1)); + } + CapsVariant::TitlingCaps => { + features.push((*b"titl", 1)); + } + } + match value.numeric_figures { + NumericFigureVariant::Normal => {} + NumericFigureVariant::LiningNumerals => { + features.push((*b"lnum", 1)); + } + NumericFigureVariant::OldStyleNumerals => { + features.push((*b"onum", 1)); + } + } + match value.numeric_spacing { + NumericSpacingVariant::Normal => {} + NumericSpacingVariant::ProportionalNumerals => { + features.push((*b"pnum", 1)); + } + NumericSpacingVariant::TabularNumerals => { + features.push((*b"tnum", 1)); + } + } + match value.numeric_fractions { + NumericFractionVariant::Normal => {} + NumericFractionVariant::Diagonal => { + features.push((*b"frac", 1)); + } + NumericFractionVariant::Stacked => { + features.push((*b"afrc", 1)); + } + } + if value.ordinal { + features.push((*b"ordn", 1)); + } + if value.slashed_zero { + features.push((*b"zero", 1)); + } + if value.historical_forms { + features.push((*b"hist", 1)); + } + if value.stylistic_alternates > 0 { + features.push((*b"salt", value.stylistic_alternates)); + } + features.extend(value.stylesets.iter().filter_map(|styleset| { + if *styleset > 20 { + return None; + } + let styleset = format!("ss{styleset:02}"); + let styleset_tag: [u8; 4] = styleset.into_bytes().try_into().ok()?; + Some((styleset_tag, 1u16)) + })); + features.extend(value.character_variants.iter().filter_map(|cvar| { + if *cvar > 99 { + return None; + } + let styleset = format!("cv{cvar:02}"); + let styleset_tag: [u8; 4] = styleset.into_bytes().try_into().ok()?; + Some((styleset_tag, 1u16)) + })); + if value.swash > 0 { + features.push((*b"swsh", value.swash)); + } + if value.ornaments > 0 { + features.push((*b"ornm", value.ornaments)); + } + if value.annotation > 0 { + features.push((*b"nalt", value.annotation)); + } + match value.east_asian_variant { + EastAsianVariant::Normal => {} + EastAsianVariant::Jis78 => { + features.push((*b"jp78", 1)); + } + EastAsianVariant::Jis83 => { + features.push((*b"jp83", 1)); + } + EastAsianVariant::Jis90 => { + features.push((*b"jp90", 1)); + } + EastAsianVariant::Jis04 => { + features.push((*b"jp04", 1)); + } + EastAsianVariant::Simplified => { + features.push((*b"smpl", 1)); + } + EastAsianVariant::Traditional => { + features.push((*b"trad", 1)); + } + } + match value.east_asian_width { + Some(EastAsianWidth::FullWidth) => { + features.push((*b"fwid", 1)); + } + Some(EastAsianWidth::Proportional) => { + features.push((*b"pwid", 1)); + } + None => {} + } + if value.ruby { + features.push((*b"ruby", 1)); + } + + Self(FontSettings::new(features)) + } + } +} + +/// All the properties of a given piece of text that affect its layout. +#[derive(Debug, Clone, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontId { + /// The font family to use. + pub family: FontStack, + /// The font size, in points. + pub size: f32, + /// The font weight. + pub weight: FontWeight, + /// The font width / "condensedness". + pub width: FontWidth, + /// OpenType font variation axes (for variable fonts). + pub variations: Option>, + /// OpenType font features. These can be initialized more easily via [`NamedFontVariants`]. + pub features: Option>, +} + +impl FontId { + /// Create a new [`FontStyle`] from a given font size and font family or stack of font families. All other font + /// settings will be set to their default values. + pub fn simple(size: f32, family: impl Into) -> Self { + Self { + family: family.into(), + size, + ..Default::default() + } + } + + pub fn with_family(&self, family: impl Into) -> Self { + Self { + family: family.into(), + ..self.clone() + } + } + + pub fn with_size(&self, size: f32) -> Self { + Self { + size, + ..self.clone() + } + } + + pub fn with_weight(&self, weight: FontWeight) -> Self { + Self { + weight, + ..self.clone() + } + } + + pub fn with_width(&self, width: FontWidth) -> Self { + Self { + width, + ..self.clone() + } + } + + pub fn with_variations(&self, variations: Option>) -> Self { + Self { + variations, + ..self.clone() + } + } + + pub fn with_features(&self, features: Option>) -> Self { + Self { + features, + ..self.clone() + } + } + + pub fn with_named_variants(&self, named_variants: NamedFontVariants<'_>) -> Self { + Self { + features: Some(Arc::new(named_variants.into())), + ..self.clone() + } + } + + /// Create a new [`FontStyle`] of the given font size and the [`GenericFamily::SystemUi`] family. + pub fn system_ui(size: f32) -> Self { + Self::simple(size, GenericFamily::SystemUi) + } + + /// Create a new [`FontStyle`] of the given font size and the [`GenericFamily::Serif`] family. + pub fn serif(size: f32) -> Self { + Self::simple(size, GenericFamily::Serif) + } + + /// Create a new [`FontStyle`] of the given font size and the [`GenericFamily::SansSerif`] family. + pub fn sans_serif(size: f32) -> Self { + Self::simple(size, GenericFamily::SansSerif) + } + + /// Create a new [`FontStyle`] of the given font size and the [`GenericFamily::Monospace`] family. + pub fn monospace(size: f32) -> Self { + Self::simple(size, GenericFamily::Monospace) + } + + /// Create a new [`FontStyle`] of the given font size and the [`GenericFamily::Cursive`] family. + pub fn cursive(size: f32) -> Self { + Self::simple(size, GenericFamily::Cursive) + } + + /// Create a new [`FontStyle`] of the given font size and the [`GenericFamily::Emoji`] family. + pub fn emoji(size: f32) -> Self { + Self::simple(size, GenericFamily::Emoji) + } +} + +impl Default for FontId { + fn default() -> Self { + Self { + family: Default::default(), + size: 14.0, + weight: Default::default(), + width: Default::default(), + variations: Default::default(), + features: Default::default(), + } + } +} + +impl Hash for FontId { + fn hash(&self, state: &mut H) { + self.family.hash(state); + OrderedFloat(self.size).hash(state); + self.weight.hash(state); + self.width.hash(state); + self.variations.hash(state); + self.features.hash(state); + } +} + +impl std::cmp::PartialEq for FontId { + fn eq(&self, other: &Self) -> bool { + self.family == other.family + && OrderedFloat(self.size) == OrderedFloat(other.size) + && self.weight == other.weight + && self.width == other.width + && self.variations == other.variations + && self.features == other.features + } +} +impl std::cmp::Eq for FontId {} + +// ---------------------------------------------------------------------------- + +/// Style / formatting for a section of text. Includes not only the [`FontStyle`] but also things like color, +/// strikethrough, background color, etc. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextFormat { + pub font_id: FontId, + + /// Extra spacing between letters, in points. + /// + /// Default: 0.0. + /// + /// For even text it is recommended you round this to an even number of _pixels_. + pub extra_letter_spacing: f32, + + /// Explicit line height of the text in points. + /// + /// This is the distance between the bottom row of two subsequent lines of text. + /// + /// If `None` (the default), the line height is determined by the font. + /// + /// For even text it is recommended you round this to an even number of _pixels_. + pub line_height: Option, + + /// Text color + pub color: Color32, + + pub background: Color32, + + /// Amount to expand background fill by. + /// + /// Default: 1.0 + pub expand_bg: f32, + + pub italics: bool, + + pub underline: Stroke, + + pub strikethrough: Stroke, + + /// If you use a small font and [`Align::TOP`] you + /// can get the effect of raised text. + /// + /// If you use a small font and [`Align::BOTTOM`] + /// you get the effect of a subscript. + /// + /// If you use [`Align::Center`], you get text that is centered + /// around a common center-line, which is nice when mixining emojis + /// and normal text in e.g. a button. + pub valign: Align, +} + +impl TextFormat { + pub(crate) fn as_parley(&self) -> parley::TextStyle<'static, Color32> { + parley::TextStyle { + font_stack: self.font_id.family.as_parley(), + font_size: self.font_id.size, + font_width: self.font_id.width.as_parley(), + font_style: if self.italics { + parley::FontStyle::Italic + } else { + parley::FontStyle::Normal + }, + font_weight: self.font_id.weight.as_parley(), + font_variations: self.font_id.variations.as_ref().map_or_else( + || parley::FontSettings::List(Cow::Borrowed(&[])), + |s| s.as_parley(), + ), + font_features: self.font_id.features.as_ref().map_or_else( + || parley::FontSettings::List(Cow::Borrowed(&[])), + |s| s.as_parley(), + ), + locale: None, + brush: self.color, + has_underline: !self.underline.is_empty(), + underline_offset: None, + underline_size: (!self.underline.is_empty()).then_some(self.underline.width), + underline_brush: (!self.underline.is_empty()).then_some(self.underline.color), + has_strikethrough: !self.strikethrough.is_empty(), + strikethrough_offset: None, + strikethrough_size: (!self.strikethrough.is_empty()) + .then_some(self.strikethrough.width), + strikethrough_brush: (!self.strikethrough.is_empty()) + .then_some(self.strikethrough.color), + line_height: self.parley_line_height(), + word_spacing: 0.0, + letter_spacing: self.extra_letter_spacing, + // These will be filled in during layout + word_break: Default::default(), + overflow_wrap: Default::default(), + } + } +} + +impl Default for TextFormat { + #[inline] + fn default() -> Self { + Self { + font_id: FontId::default(), + extra_letter_spacing: 0.0, + line_height: None, + color: Color32::GRAY, + background: Color32::TRANSPARENT, + expand_bg: 1.0, + italics: false, + underline: Stroke::NONE, + strikethrough: Stroke::NONE, + valign: Align::BOTTOM, + } + } +} + +impl std::hash::Hash for TextFormat { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + font_id, + extra_letter_spacing, + line_height, + color, + background, + expand_bg, + italics, + underline, + strikethrough, + valign, + } = self; + font_id.hash(state); + OrderedFloat(*extra_letter_spacing).hash(state); + line_height.map(OrderedFloat).hash(state); + color.hash(state); + background.hash(state); + OrderedFloat(*expand_bg).hash(state); + italics.hash(state); + underline.hash(state); + strikethrough.hash(state); + valign.hash(state); + } +} + +impl TextFormat { + #[inline] + pub fn simple(font: FontId, color: Color32) -> Self { + Self { + font_id: font, + color, + ..Default::default() + } + } + + pub(crate) fn parley_line_height(&self) -> parley::style::LineHeight { + match self.line_height { + Some(height) => parley::style::LineHeight::Absolute(height), + None => parley::style::LineHeight::MetricsRelative(1.0), + } + } +} diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs deleted file mode 100644 index 63dfc389311..00000000000 --- a/crates/epaint/src/text/text_layout.rs +++ /dev/null @@ -1,1143 +0,0 @@ -use std::sync::Arc; - -use emath::{pos2, vec2, Align, GuiRounding as _, NumExt as _, Pos2, Rect, Vec2}; - -use crate::{stroke::PathStroke, text::font::Font, Color32, Mesh, Stroke, Vertex}; - -use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, RowVisuals}; - -// ---------------------------------------------------------------------------- - -/// Represents GUI scale and convenience methods for rounding to pixels. -#[derive(Clone, Copy)] -struct PointScale { - pub pixels_per_point: f32, -} - -impl PointScale { - #[inline(always)] - pub fn new(pixels_per_point: f32) -> Self { - Self { pixels_per_point } - } - - #[inline(always)] - pub fn pixels_per_point(&self) -> f32 { - self.pixels_per_point - } - - #[inline(always)] - pub fn round_to_pixel(&self, point: f32) -> f32 { - (point * self.pixels_per_point).round() / self.pixels_per_point - } - - #[inline(always)] - pub fn floor_to_pixel(&self, point: f32) -> f32 { - (point * self.pixels_per_point).floor() / self.pixels_per_point - } -} - -// ---------------------------------------------------------------------------- - -/// Temporary storage before line-wrapping. -#[derive(Clone)] -struct Paragraph { - /// Start of the next glyph to be added. - pub cursor_x: f32, - - /// This is included in case there are no glyphs - pub section_index_at_start: u32, - - pub glyphs: Vec, - - /// In case of an empty paragraph ("\n"), use this as height. - pub empty_paragraph_height: f32, -} - -impl Paragraph { - pub fn from_section_index(section_index_at_start: u32) -> Self { - Self { - cursor_x: 0.0, - section_index_at_start, - glyphs: vec![], - empty_paragraph_height: 0.0, - } - } -} - -/// Layout text into a [`Galley`]. -/// -/// In most cases you should use [`crate::Fonts::layout_job`] instead -/// since that memoizes the input, making subsequent layouting of the same text much faster. -pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { - profiling::function_scope!(); - - if job.wrap.max_rows == 0 { - // Early-out: no text - return Galley { - job, - rows: Default::default(), - rect: Rect::ZERO, - mesh_bounds: Rect::NOTHING, - num_vertices: 0, - num_indices: 0, - pixels_per_point: fonts.pixels_per_point(), - elided: true, - }; - } - - // For most of this we ignore the y coordinate: - - let mut paragraphs = vec![Paragraph::from_section_index(0)]; - for (section_index, section) in job.sections.iter().enumerate() { - layout_section(fonts, &job, section_index as u32, section, &mut paragraphs); - } - - let point_scale = PointScale::new(fonts.pixels_per_point()); - - let mut elided = false; - let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided); - if elided { - if let Some(last_placed) = rows.last_mut() { - let last_row = Arc::make_mut(&mut last_placed.row); - replace_last_glyph_with_overflow_character(fonts, &job, last_row); - if let Some(last) = last_row.glyphs.last() { - last_row.size.x = last.max_x(); - } - } - } - - let justify = job.justify && job.wrap.max_width.is_finite(); - - if justify || job.halign != Align::LEFT { - let num_rows = rows.len(); - for (i, placed_row) in rows.iter_mut().enumerate() { - let is_last_row = i + 1 == num_rows; - let justify_row = justify && !placed_row.ends_with_newline && !is_last_row; - halign_and_justify_row( - point_scale, - placed_row, - job.halign, - job.wrap.max_width, - justify_row, - ); - } - } - - // Calculate the Y positions and tessellate the text: - galley_from_rows(point_scale, job, rows, elided) -} - -// Ignores the Y coordinate. -fn layout_section( - fonts: &mut FontsImpl, - job: &LayoutJob, - section_index: u32, - section: &LayoutSection, - out_paragraphs: &mut Vec, -) { - let LayoutSection { - leading_space, - byte_range, - format, - } = section; - let font = fonts.font(&format.font_id); - let line_height = section - .format - .line_height - .unwrap_or_else(|| font.row_height()); - let extra_letter_spacing = section.format.extra_letter_spacing; - - let mut paragraph = out_paragraphs.last_mut().unwrap(); - if paragraph.glyphs.is_empty() { - paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? - } - - paragraph.cursor_x += leading_space; - - let mut last_glyph_id = None; - - for chr in job.text[byte_range.clone()].chars() { - if job.break_on_newline && chr == '\n' { - out_paragraphs.push(Paragraph::from_section_index(section_index)); - paragraph = out_paragraphs.last_mut().unwrap(); - paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? - } else { - let (font_impl, glyph_info) = font.font_impl_and_glyph_info(chr); - if let Some(font_impl) = font_impl { - if let Some(last_glyph_id) = last_glyph_id { - paragraph.cursor_x += font_impl.pair_kerning(last_glyph_id, glyph_info.id); - paragraph.cursor_x += extra_letter_spacing; - } - } - - paragraph.glyphs.push(Glyph { - chr, - pos: pos2(paragraph.cursor_x, f32::NAN), - advance_width: glyph_info.advance_width, - line_height, - font_impl_height: font_impl.map_or(0.0, |f| f.row_height()), - font_impl_ascent: font_impl.map_or(0.0, |f| f.ascent()), - font_height: font.row_height(), - font_ascent: font.ascent(), - uv_rect: glyph_info.uv_rect, - section_index, - }); - - paragraph.cursor_x += glyph_info.advance_width; - paragraph.cursor_x = font.round_to_pixel(paragraph.cursor_x); - last_glyph_id = Some(glyph_info.id); - } - } -} - -// Ignores the Y coordinate. -fn rows_from_paragraphs( - paragraphs: Vec, - job: &LayoutJob, - elided: &mut bool, -) -> Vec { - let num_paragraphs = paragraphs.len(); - - let mut rows = vec![]; - - for (i, paragraph) in paragraphs.into_iter().enumerate() { - if job.wrap.max_rows <= rows.len() { - *elided = true; - break; - } - - let is_last_paragraph = (i + 1) == num_paragraphs; - - if paragraph.glyphs.is_empty() { - rows.push(PlacedRow { - pos: Pos2::ZERO, - row: Arc::new(Row { - section_index_at_start: paragraph.section_index_at_start, - glyphs: vec![], - visuals: Default::default(), - size: vec2(0.0, paragraph.empty_paragraph_height), - ends_with_newline: !is_last_paragraph, - }), - }); - } else { - let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x(); - if paragraph_max_x <= job.effective_wrap_width() { - // Early-out optimization: the whole paragraph fits on one row. - rows.push(PlacedRow { - pos: pos2(0.0, f32::NAN), - row: Arc::new(Row { - section_index_at_start: paragraph.section_index_at_start, - glyphs: paragraph.glyphs, - visuals: Default::default(), - size: vec2(paragraph_max_x, 0.0), - ends_with_newline: !is_last_paragraph, - }), - }); - } else { - line_break(¶graph, job, &mut rows, elided); - let placed_row = rows.last_mut().unwrap(); - let row = Arc::make_mut(&mut placed_row.row); - row.ends_with_newline = !is_last_paragraph; - } - } - } - - rows -} - -fn line_break( - paragraph: &Paragraph, - job: &LayoutJob, - out_rows: &mut Vec, - elided: &mut bool, -) { - let wrap_width = job.effective_wrap_width(); - - // Keeps track of good places to insert row break if we exceed `wrap_width`. - let mut row_break_candidates = RowBreakCandidates::default(); - - let mut first_row_indentation = paragraph.glyphs[0].pos.x; - let mut row_start_x = 0.0; - let mut row_start_idx = 0; - - for i in 0..paragraph.glyphs.len() { - if job.wrap.max_rows <= out_rows.len() { - *elided = true; - break; - } - - let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x; - - if wrap_width < potential_row_width { - // Row break: - - if first_row_indentation > 0.0 - && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) - { - // Allow the first row to be completely empty, because we know there will be more space on the next row: - // TODO(emilk): this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height. - out_rows.push(PlacedRow { - pos: pos2(0.0, f32::NAN), - row: Arc::new(Row { - section_index_at_start: paragraph.section_index_at_start, - glyphs: vec![], - visuals: Default::default(), - size: Vec2::ZERO, - ends_with_newline: false, - }), - }); - row_start_x += first_row_indentation; - first_row_indentation = 0.0; - } else if let Some(last_kept_index) = row_break_candidates.get(job.wrap.break_anywhere) - { - let glyphs: Vec = paragraph.glyphs[row_start_idx..=last_kept_index] - .iter() - .copied() - .map(|mut glyph| { - glyph.pos.x -= row_start_x; - glyph - }) - .collect(); - - let section_index_at_start = glyphs[0].section_index; - let paragraph_max_x = glyphs.last().unwrap().max_x(); - - out_rows.push(PlacedRow { - pos: pos2(0.0, f32::NAN), - row: Arc::new(Row { - section_index_at_start, - glyphs, - visuals: Default::default(), - size: vec2(paragraph_max_x, 0.0), - ends_with_newline: false, - }), - }); - - // Start a new row: - row_start_idx = last_kept_index + 1; - row_start_x = paragraph.glyphs[row_start_idx].pos.x; - row_break_candidates.forget_before_idx(row_start_idx); - } else { - // Found no place to break, so we have to overrun wrap_width. - } - } - - row_break_candidates.add(i, ¶graph.glyphs[i..]); - } - - if row_start_idx < paragraph.glyphs.len() { - // Final row of text: - - if job.wrap.max_rows <= out_rows.len() { - *elided = true; // can't fit another row - } else { - let glyphs: Vec = paragraph.glyphs[row_start_idx..] - .iter() - .copied() - .map(|mut glyph| { - glyph.pos.x -= row_start_x; - glyph - }) - .collect(); - - let section_index_at_start = glyphs[0].section_index; - let paragraph_min_x = glyphs[0].pos.x; - let paragraph_max_x = glyphs.last().unwrap().max_x(); - - out_rows.push(PlacedRow { - pos: pos2(paragraph_min_x, 0.0), - row: Arc::new(Row { - section_index_at_start, - glyphs, - visuals: Default::default(), - size: vec2(paragraph_max_x - paragraph_min_x, 0.0), - ends_with_newline: false, - }), - }); - } - } -} - -/// Trims the last glyphs in the row and replaces it with an overflow character (e.g. `…`). -/// -/// Called before we have any Y coordinates. -fn replace_last_glyph_with_overflow_character( - fonts: &mut FontsImpl, - job: &LayoutJob, - row: &mut Row, -) { - fn row_width(row: &Row) -> f32 { - if let (Some(first), Some(last)) = (row.glyphs.first(), row.glyphs.last()) { - last.max_x() - first.pos.x - } else { - 0.0 - } - } - - fn row_height(section: &LayoutSection, font: &Font) -> f32 { - section - .format - .line_height - .unwrap_or_else(|| font.row_height()) - } - - let Some(overflow_character) = job.wrap.overflow_character else { - return; - }; - - // We always try to just append the character first: - if let Some(last_glyph) = row.glyphs.last() { - let section_index = last_glyph.section_index; - let section = &job.sections[section_index as usize]; - let font = fonts.font(§ion.format.font_id); - let line_height = row_height(section, font); - - let (_, last_glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); - - let mut x = last_glyph.pos.x + last_glyph.advance_width; - - let (font_impl, replacement_glyph_info) = font.font_impl_and_glyph_info(overflow_character); - - { - // Kerning: - x += section.format.extra_letter_spacing; - if let Some(font_impl) = font_impl { - x += font_impl.pair_kerning(last_glyph_info.id, replacement_glyph_info.id); - } - } - - row.glyphs.push(Glyph { - chr: overflow_character, - pos: pos2(x, f32::NAN), - advance_width: replacement_glyph_info.advance_width, - line_height, - font_impl_height: font_impl.map_or(0.0, |f| f.row_height()), - font_impl_ascent: font_impl.map_or(0.0, |f| f.ascent()), - font_height: font.row_height(), - font_ascent: font.ascent(), - uv_rect: replacement_glyph_info.uv_rect, - section_index, - }); - } else { - let section_index = row.section_index_at_start; - let section = &job.sections[section_index as usize]; - let font = fonts.font(§ion.format.font_id); - let line_height = row_height(section, font); - - let x = 0.0; // TODO(emilk): heed paragraph leading_space 😬 - - let (font_impl, replacement_glyph_info) = font.font_impl_and_glyph_info(overflow_character); - - row.glyphs.push(Glyph { - chr: overflow_character, - pos: pos2(x, f32::NAN), - advance_width: replacement_glyph_info.advance_width, - line_height, - font_impl_height: font_impl.map_or(0.0, |f| f.row_height()), - font_impl_ascent: font_impl.map_or(0.0, |f| f.ascent()), - font_height: font.row_height(), - font_ascent: font.ascent(), - uv_rect: replacement_glyph_info.uv_rect, - section_index, - }); - } - - if row_width(row) <= job.effective_wrap_width() || row.glyphs.len() == 1 { - return; // we are done - } - - // We didn't fit it. Remove it again… - row.glyphs.pop(); - - // …then go into a loop where we replace the last character with the overflow character - // until we fit within the max_width: - - loop { - let (prev_glyph, last_glyph) = match row.glyphs.as_mut_slice() { - [.., prev, last] => (Some(prev), last), - [.., last] => (None, last), - _ => { - unreachable!("We've already explicitly handled the empty row"); - } - }; - - let section = &job.sections[last_glyph.section_index as usize]; - let extra_letter_spacing = section.format.extra_letter_spacing; - let font = fonts.font(§ion.format.font_id); - - if let Some(prev_glyph) = prev_glyph { - let prev_glyph_id = font.font_impl_and_glyph_info(prev_glyph.chr).1.id; - - // Undo kerning with previous glyph: - let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); - last_glyph.pos.x -= extra_letter_spacing; - if let Some(font_impl) = font_impl { - last_glyph.pos.x -= font_impl.pair_kerning(prev_glyph_id, glyph_info.id); - } - - // Replace the glyph: - last_glyph.chr = overflow_character; - let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); - last_glyph.advance_width = glyph_info.advance_width; - last_glyph.font_impl_ascent = font_impl.map_or(0.0, |f| f.ascent()); - last_glyph.font_impl_height = font_impl.map_or(0.0, |f| f.row_height()); - last_glyph.uv_rect = glyph_info.uv_rect; - - // Reapply kerning: - last_glyph.pos.x += extra_letter_spacing; - if let Some(font_impl) = font_impl { - last_glyph.pos.x += font_impl.pair_kerning(prev_glyph_id, glyph_info.id); - } - - // Check if we're within width budget: - if row_width(row) <= job.effective_wrap_width() || row.glyphs.len() == 1 { - return; // We are done - } - - // We didn't fit - pop the last glyph and try again. - row.glyphs.pop(); - } else { - // Just replace and be done with it. - last_glyph.chr = overflow_character; - let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); - last_glyph.advance_width = glyph_info.advance_width; - last_glyph.font_impl_ascent = font_impl.map_or(0.0, |f| f.ascent()); - last_glyph.font_impl_height = font_impl.map_or(0.0, |f| f.row_height()); - last_glyph.uv_rect = glyph_info.uv_rect; - return; - } - } -} - -/// Horizontally aligned the text on a row. -/// -/// Ignores the Y coordinate. -fn halign_and_justify_row( - point_scale: PointScale, - placed_row: &mut PlacedRow, - halign: Align, - wrap_width: f32, - justify: bool, -) { - let row = Arc::make_mut(&mut placed_row.row); - - if row.glyphs.is_empty() { - return; - } - - let num_leading_spaces = row - .glyphs - .iter() - .take_while(|glyph| glyph.chr.is_whitespace()) - .count(); - - let glyph_range = if num_leading_spaces == row.glyphs.len() { - // There is only whitespace - (0, row.glyphs.len()) - } else { - let num_trailing_spaces = row - .glyphs - .iter() - .rev() - .take_while(|glyph| glyph.chr.is_whitespace()) - .count(); - - (num_leading_spaces, row.glyphs.len() - num_trailing_spaces) - }; - let num_glyphs_in_range = glyph_range.1 - glyph_range.0; - assert!(num_glyphs_in_range > 0, "Should have at least one glyph"); - - let original_min_x = row.glyphs[glyph_range.0].logical_rect().min.x; - let original_max_x = row.glyphs[glyph_range.1 - 1].logical_rect().max.x; - let original_width = original_max_x - original_min_x; - - let target_width = if justify && num_glyphs_in_range > 1 { - wrap_width - } else { - original_width - }; - - let (target_min_x, target_max_x) = match halign { - Align::LEFT => (0.0, target_width), - Align::Center => (-target_width / 2.0, target_width / 2.0), - Align::RIGHT => (-target_width, 0.0), - }; - - let num_spaces_in_range = row.glyphs[glyph_range.0..glyph_range.1] - .iter() - .filter(|glyph| glyph.chr.is_whitespace()) - .count(); - - let mut extra_x_per_glyph = if num_glyphs_in_range == 1 { - 0.0 - } else { - (target_width - original_width) / (num_glyphs_in_range as f32 - 1.0) - }; - extra_x_per_glyph = extra_x_per_glyph.at_least(0.0); // Don't contract - - let mut extra_x_per_space = 0.0; - if 0 < num_spaces_in_range && num_spaces_in_range < num_glyphs_in_range { - // Add an integral number of pixels between each glyph, - // and add the balance to the spaces: - - extra_x_per_glyph = point_scale.floor_to_pixel(extra_x_per_glyph); - - extra_x_per_space = (target_width - - original_width - - extra_x_per_glyph * (num_glyphs_in_range as f32 - 1.0)) - / (num_spaces_in_range as f32); - } - - placed_row.pos.x = point_scale.round_to_pixel(target_min_x); - let mut translate_x = -original_min_x - extra_x_per_glyph * glyph_range.0 as f32; - - for glyph in &mut row.glyphs { - glyph.pos.x += translate_x; - glyph.pos.x = point_scale.round_to_pixel(glyph.pos.x); - translate_x += extra_x_per_glyph; - if glyph.chr.is_whitespace() { - translate_x += extra_x_per_space; - } - } - - // Note we ignore the leading/trailing whitespace here! - row.size.x = target_max_x - target_min_x; -} - -/// Calculate the Y positions and tessellate the text. -fn galley_from_rows( - point_scale: PointScale, - job: Arc, - mut rows: Vec, - elided: bool, -) -> Galley { - let mut first_row_min_height = job.first_row_min_height; - let mut cursor_y = 0.0; - - for placed_row in &mut rows { - let mut max_row_height = first_row_min_height.max(placed_row.rect().height()); - let row = Arc::make_mut(&mut placed_row.row); - - first_row_min_height = 0.0; - for glyph in &row.glyphs { - max_row_height = max_row_height.max(glyph.line_height); - } - max_row_height = point_scale.round_to_pixel(max_row_height); - - // Now position each glyph vertically: - for glyph in &mut row.glyphs { - let format = &job.sections[glyph.section_index as usize].format; - - glyph.pos.y = glyph.font_impl_ascent - - // Apply valign to the different in height of the entire row, and the height of this `Font`: - + format.valign.to_factor() * (max_row_height - glyph.line_height) - - // When mixing different `FontImpl` (e.g. latin and emojis), - // we always center the difference: - + 0.5 * (glyph.font_height - glyph.font_impl_height); - - glyph.pos.y = point_scale.round_to_pixel(glyph.pos.y); - } - - placed_row.pos.y = cursor_y; - row.size.y = max_row_height; - - cursor_y += max_row_height; - cursor_y = point_scale.round_to_pixel(cursor_y); // TODO(emilk): it would be better to do the calculations in pixels instead. - } - - let format_summary = format_summary(&job); - - let mut rect = Rect::ZERO; - let mut mesh_bounds = Rect::NOTHING; - let mut num_vertices = 0; - let mut num_indices = 0; - - for placed_row in &mut rows { - rect = rect.union(placed_row.rect()); - - let row = Arc::make_mut(&mut placed_row.row); - row.visuals = tessellate_row(point_scale, &job, &format_summary, row); - - mesh_bounds = - mesh_bounds.union(row.visuals.mesh_bounds.translate(placed_row.pos.to_vec2())); - num_vertices += row.visuals.mesh.vertices.len(); - num_indices += row.visuals.mesh.indices.len(); - - row.section_index_at_start = u32::MAX; // No longer in use. - for glyph in &mut row.glyphs { - glyph.section_index = u32::MAX; // No longer in use. - } - } - - let mut galley = Galley { - job, - rows, - elided, - rect, - mesh_bounds, - num_vertices, - num_indices, - pixels_per_point: point_scale.pixels_per_point, - }; - - if galley.job.round_output_to_gui { - galley.round_output_to_gui(); - } - - galley -} - -#[derive(Default)] -struct FormatSummary { - any_background: bool, - any_underline: bool, - any_strikethrough: bool, -} - -fn format_summary(job: &LayoutJob) -> FormatSummary { - let mut format_summary = FormatSummary::default(); - for section in &job.sections { - format_summary.any_background |= section.format.background != Color32::TRANSPARENT; - format_summary.any_underline |= section.format.underline != Stroke::NONE; - format_summary.any_strikethrough |= section.format.strikethrough != Stroke::NONE; - } - format_summary -} - -fn tessellate_row( - point_scale: PointScale, - job: &LayoutJob, - format_summary: &FormatSummary, - row: &Row, -) -> RowVisuals { - if row.glyphs.is_empty() { - return Default::default(); - } - - let mut mesh = Mesh::default(); - - mesh.reserve_triangles(row.glyphs.len() * 2); - mesh.reserve_vertices(row.glyphs.len() * 4); - - if format_summary.any_background { - add_row_backgrounds(point_scale, job, row, &mut mesh); - } - - let glyph_index_start = mesh.indices.len(); - let glyph_vertex_start = mesh.vertices.len(); - tessellate_glyphs(point_scale, job, row, &mut mesh); - let glyph_vertex_end = mesh.vertices.len(); - - if format_summary.any_underline { - add_row_hline(point_scale, row, &mut mesh, |glyph| { - let format = &job.sections[glyph.section_index as usize].format; - let stroke = format.underline; - let y = glyph.logical_rect().bottom(); - (stroke, y) - }); - } - - if format_summary.any_strikethrough { - add_row_hline(point_scale, row, &mut mesh, |glyph| { - let format = &job.sections[glyph.section_index as usize].format; - let stroke = format.strikethrough; - let y = glyph.logical_rect().center().y; - (stroke, y) - }); - } - - let mesh_bounds = mesh.calc_bounds(); - - RowVisuals { - mesh, - mesh_bounds, - glyph_index_start, - glyph_vertex_range: glyph_vertex_start..glyph_vertex_end, - } -} - -/// Create background for glyphs that have them. -/// Creates as few rectangular regions as possible. -fn add_row_backgrounds(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh: &mut Mesh) { - if row.glyphs.is_empty() { - return; - } - - let mut end_run = |start: Option<(Color32, Rect, f32)>, stop_x: f32| { - if let Some((color, start_rect, expand)) = start { - let rect = Rect::from_min_max(start_rect.left_top(), pos2(stop_x, start_rect.bottom())); - let rect = rect.expand(expand); - let rect = rect.round_to_pixels(point_scale.pixels_per_point()); - mesh.add_colored_rect(rect, color); - } - }; - - let mut run_start = None; - let mut last_rect = Rect::NAN; - - for glyph in &row.glyphs { - let format = &job.sections[glyph.section_index as usize].format; - let color = format.background; - let rect = glyph.logical_rect(); - - if color == Color32::TRANSPARENT { - end_run(run_start.take(), last_rect.right()); - } else if let Some((existing_color, start, expand)) = run_start { - if existing_color == color - && start.top() == rect.top() - && start.bottom() == rect.bottom() - && format.expand_bg == expand - { - // continue the same background rectangle - } else { - end_run(run_start.take(), last_rect.right()); - run_start = Some((color, rect, format.expand_bg)); - } - } else { - run_start = Some((color, rect, format.expand_bg)); - } - - last_rect = rect; - } - - end_run(run_start.take(), last_rect.right()); -} - -fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh: &mut Mesh) { - for glyph in &row.glyphs { - let uv_rect = glyph.uv_rect; - if !uv_rect.is_nothing() { - let mut left_top = glyph.pos + uv_rect.offset; - left_top.x = point_scale.round_to_pixel(left_top.x); - left_top.y = point_scale.round_to_pixel(left_top.y); - - let rect = Rect::from_min_max(left_top, left_top + uv_rect.size); - let uv = Rect::from_min_max( - pos2(uv_rect.min[0] as f32, uv_rect.min[1] as f32), - pos2(uv_rect.max[0] as f32, uv_rect.max[1] as f32), - ); - - let format = &job.sections[glyph.section_index as usize].format; - - let color = format.color; - - if format.italics { - let idx = mesh.vertices.len() as u32; - mesh.add_triangle(idx, idx + 1, idx + 2); - mesh.add_triangle(idx + 2, idx + 1, idx + 3); - - let top_offset = rect.height() * 0.25 * Vec2::X; - - mesh.vertices.push(Vertex { - pos: rect.left_top() + top_offset, - uv: uv.left_top(), - color, - }); - mesh.vertices.push(Vertex { - pos: rect.right_top() + top_offset, - uv: uv.right_top(), - color, - }); - mesh.vertices.push(Vertex { - pos: rect.left_bottom(), - uv: uv.left_bottom(), - color, - }); - mesh.vertices.push(Vertex { - pos: rect.right_bottom(), - uv: uv.right_bottom(), - color, - }); - } else { - mesh.add_rect_with_uv(rect, uv, color); - } - } - } -} - -/// Add a horizontal line over a row of glyphs with a stroke and y decided by a callback. -fn add_row_hline( - point_scale: PointScale, - row: &Row, - mesh: &mut Mesh, - stroke_and_y: impl Fn(&Glyph) -> (Stroke, f32), -) { - let mut path = crate::tessellator::Path::default(); // reusing path to avoid re-allocations. - - let mut end_line = |start: Option<(Stroke, Pos2)>, stop_x: f32| { - if let Some((stroke, start)) = start { - let stop = pos2(stop_x, start.y); - path.clear(); - path.add_line_segment([start, stop]); - let feathering = 1.0 / point_scale.pixels_per_point(); - path.stroke_open(feathering, &PathStroke::from(stroke), mesh); - } - }; - - let mut line_start = None; - let mut last_right_x = f32::NAN; - - for glyph in &row.glyphs { - let (stroke, mut y) = stroke_and_y(glyph); - stroke.round_center_to_pixel(point_scale.pixels_per_point, &mut y); - - if stroke.is_empty() { - end_line(line_start.take(), last_right_x); - } else if let Some((existing_stroke, start)) = line_start { - if existing_stroke == stroke && start.y == y { - // continue the same line - } else { - end_line(line_start.take(), last_right_x); - line_start = Some((stroke, pos2(glyph.pos.x, y))); - } - } else { - line_start = Some((stroke, pos2(glyph.pos.x, y))); - } - - last_right_x = glyph.max_x(); - } - - end_line(line_start.take(), last_right_x); -} - -// ---------------------------------------------------------------------------- - -/// Keeps track of good places to break a long row of text. -/// Will focus primarily on spaces, secondarily on things like `-` -#[derive(Clone, Copy, Default)] -struct RowBreakCandidates { - /// Breaking at ` ` or other whitespace - /// is always the primary candidate. - space: Option, - - /// Logograms (single character representing a whole word) or kana (Japanese hiragana and katakana) are good candidates for line break. - cjk: Option, - - /// Breaking anywhere before a CJK character is acceptable too. - pre_cjk: Option, - - /// Breaking at a dash is a super- - /// good idea. - dash: Option, - - /// This is nicer for things like URLs, e.g. www. - /// example.com. - punctuation: Option, - - /// Breaking after just random character is some - /// times necessary. - any: Option, -} - -impl RowBreakCandidates { - fn add(&mut self, index: usize, glyphs: &[Glyph]) { - let chr = glyphs[0].chr; - const NON_BREAKING_SPACE: char = '\u{A0}'; - if chr.is_whitespace() && chr != NON_BREAKING_SPACE { - self.space = Some(index); - } else if is_cjk(chr) && (glyphs.len() == 1 || is_cjk_break_allowed(glyphs[1].chr)) { - self.cjk = Some(index); - } else if chr == '-' { - self.dash = Some(index); - } else if chr.is_ascii_punctuation() { - self.punctuation = Some(index); - } else if glyphs.len() > 1 && is_cjk(glyphs[1].chr) { - self.pre_cjk = Some(index); - } - self.any = Some(index); - } - - fn word_boundary(&self) -> Option { - [self.space, self.cjk, self.pre_cjk] - .into_iter() - .max() - .flatten() - } - - fn has_good_candidate(&self, break_anywhere: bool) -> bool { - if break_anywhere { - self.any.is_some() - } else { - self.word_boundary().is_some() - } - } - - fn get(&self, break_anywhere: bool) -> Option { - if break_anywhere { - self.any - } else { - self.word_boundary() - .or(self.dash) - .or(self.punctuation) - .or(self.any) - } - } - - fn forget_before_idx(&mut self, index: usize) { - let Self { - space, - cjk, - pre_cjk, - dash, - punctuation, - any, - } = self; - if space.is_some_and(|s| s < index) { - *space = None; - } - if cjk.is_some_and(|s| s < index) { - *cjk = None; - } - if pre_cjk.is_some_and(|s| s < index) { - *pre_cjk = None; - } - if dash.is_some_and(|s| s < index) { - *dash = None; - } - if punctuation.is_some_and(|s| s < index) { - *punctuation = None; - } - if any.is_some_and(|s| s < index) { - *any = None; - } - } -} - -#[inline] -fn is_cjk_ideograph(c: char) -> bool { - ('\u{4E00}' <= c && c <= '\u{9FFF}') - || ('\u{3400}' <= c && c <= '\u{4DBF}') - || ('\u{2B740}' <= c && c <= '\u{2B81F}') -} - -#[inline] -fn is_kana(c: char) -> bool { - ('\u{3040}' <= c && c <= '\u{309F}') // Hiragana block - || ('\u{30A0}' <= c && c <= '\u{30FF}') // Katakana block -} - -#[inline] -fn is_cjk(c: char) -> bool { - // TODO(bigfarts): Add support for Korean Hangul. - is_cjk_ideograph(c) || is_kana(c) -} - -#[inline] -fn is_cjk_break_allowed(c: char) -> bool { - // See: https://en.wikipedia.org/wiki/Line_breaking_rules_in_East_Asian_languages#Characters_not_permitted_on_the_start_of_a_line. - !")]}〕〉》」』】〙〗〟'\"⦆»ヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻‐゠–〜?!‼⁇⁈⁉・、:;,。.".contains(c) -} - -// ---------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::{super::*, *}; - - #[test] - fn test_zero_max_width() { - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); - let mut layout_job = LayoutJob::single_section("W".into(), TextFormat::default()); - layout_job.wrap.max_width = 0.0; - let galley = layout(&mut fonts, layout_job.into()); - assert_eq!(galley.rows.len(), 1); - } - - #[test] - fn test_truncate_with_newline() { - // No matter where we wrap, we should be appending the newline character. - - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); - let text_format = TextFormat { - font_id: FontId::monospace(12.0), - ..Default::default() - }; - - for text in ["Hello\nworld", "\nfoo"] { - for break_anywhere in [false, true] { - for max_width in [0.0, 5.0, 10.0, 20.0, f32::INFINITY] { - let mut layout_job = - LayoutJob::single_section(text.into(), text_format.clone()); - layout_job.wrap.max_width = max_width; - layout_job.wrap.max_rows = 1; - layout_job.wrap.break_anywhere = break_anywhere; - - let galley = layout(&mut fonts, layout_job.into()); - - assert!(galley.elided); - assert_eq!(galley.rows.len(), 1); - let row_text = galley.rows[0].text(); - assert!( - row_text.ends_with('…'), - "Expected row to end with `…`, got {row_text:?} when line-breaking the text {text:?} with max_width {max_width} and break_anywhere {break_anywhere}.", - ); - } - } - } - - { - let mut layout_job = LayoutJob::single_section("Hello\nworld".into(), text_format); - layout_job.wrap.max_width = 50.0; - layout_job.wrap.max_rows = 1; - layout_job.wrap.break_anywhere = false; - - let galley = layout(&mut fonts, layout_job.into()); - - assert!(galley.elided); - assert_eq!(galley.rows.len(), 1); - let row_text = galley.rows[0].text(); - assert_eq!(row_text, "Hello…"); - } - } - - #[test] - fn test_cjk() { - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); - let mut layout_job = LayoutJob::single_section( - "日本語とEnglishの混在した文章".into(), - TextFormat::default(), - ); - layout_job.wrap.max_width = 90.0; - let galley = layout(&mut fonts, layout_job.into()); - assert_eq!( - galley.rows.iter().map(|row| row.text()).collect::>(), - vec!["日本語と", "Englishの混在", "した文章"] - ); - } - - #[test] - fn test_pre_cjk() { - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); - let mut layout_job = LayoutJob::single_section( - "日本語とEnglishの混在した文章".into(), - TextFormat::default(), - ); - layout_job.wrap.max_width = 110.0; - let galley = layout(&mut fonts, layout_job.into()); - assert_eq!( - galley.rows.iter().map(|row| row.text()).collect::>(), - vec!["日本語とEnglish", "の混在した文章"] - ); - } - - #[test] - fn test_truncate_width() { - let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default()); - let mut layout_job = - LayoutJob::single_section("# DNA\nMore text".into(), TextFormat::default()); - layout_job.wrap.max_width = f32::INFINITY; - layout_job.wrap.max_rows = 1; - layout_job.round_output_to_gui = false; - let galley = layout(&mut fonts, layout_job.into()); - assert!(galley.elided); - assert_eq!( - galley.rows.iter().map(|row| row.text()).collect::>(), - vec!["# DNA…"] - ); - let row = &galley.rows[0]; - assert_eq!(row.pos, Pos2::ZERO); - assert_eq!(row.rect().max.x, row.glyphs.last().unwrap().max_x()); - } -} diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 6b786342675..24f50939f13 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -4,12 +4,12 @@ use std::ops::Range; use std::sync::Arc; -use super::{ - cursor::{CCursor, LayoutCursor}, - font::UvRect, -}; -use crate::{Color32, FontId, Mesh, Stroke}; -use emath::{pos2, vec2, Align, GuiRounding as _, NumExt as _, OrderedFloat, Pos2, Rect, Vec2}; +use super::cursor::{ByteCursor, Selection}; +use super::glyph_atlas::UvRect; +use super::style::{FontId, TextFormat}; +use crate::{Color32, Mesh}; +use emath::{Align, OrderedFloat, Pos2, Rect, Vec2}; +use parley::OverflowWrap; /// Describes the task of laying out text. /// @@ -19,13 +19,13 @@ use emath::{pos2, vec2, Align, GuiRounding as _, NumExt as _, OrderedFloat, Pos2 /// /// ## Example: /// ``` -/// use epaint::{Color32, text::{LayoutJob, TextFormat}, FontFamily, FontId}; +/// use epaint::{Color32, text::{LayoutJob, TextStyle}, FontFamily, FontId}; /// /// let mut job = LayoutJob::default(); /// job.append( /// "Hello ", /// 0.0, -/// TextFormat { +/// TextStyle { /// font_id: FontId::new(14.0, FontFamily::Proportional), /// color: Color32::WHITE, /// ..Default::default() @@ -34,7 +34,7 @@ use emath::{pos2, vec2, Align, GuiRounding as _, NumExt as _, OrderedFloat, Pos2 /// job.append( /// "World!", /// 0.0, -/// TextFormat { +/// TextStyle { /// font_id: FontId::new(14.0, FontFamily::Monospace), /// color: Color32::BLACK, /// ..Default::default() @@ -70,6 +70,7 @@ pub struct LayoutJob { /// and show up as the replacement character. /// /// Default: `true`. + /// TODO(valadaptive): implement this pub break_on_newline: bool, /// How to horizontally align the text (`Align::LEFT`, `Align::Center`, `Align::RIGHT`). @@ -101,12 +102,12 @@ impl Default for LayoutJob { impl LayoutJob { /// Break on `\n` and at the given wrap width. #[inline] - pub fn simple(text: String, font_id: FontId, color: Color32, wrap_width: f32) -> Self { + pub fn simple(text: String, font: FontId, color: Color32, wrap_width: f32) -> Self { Self { sections: vec![LayoutSection { leading_space: 0.0, byte_range: 0..text.len(), - format: TextFormat::simple(font_id, color), + format: TextFormat::simple(font, color), }], text, wrap: TextWrapping { @@ -135,12 +136,12 @@ impl LayoutJob { /// Does not break on `\n`, but shows the replacement character instead. #[inline] - pub fn simple_singleline(text: String, font_id: FontId, color: Color32) -> Self { + pub fn simple_singleline(text: String, font: FontId, color: Color32) -> Self { Self { sections: vec![LayoutSection { leading_space: 0.0, byte_range: 0..text.len(), - format: TextFormat::simple(font_id, color), + format: TextFormat::simple(font, color), }], text, wrap: Default::default(), @@ -184,7 +185,7 @@ impl LayoutJob { /// The height of the tallest font used in the job. /// /// Returns a value rounded to [`emath::GUI_ROUNDING`]. - pub fn font_height(&self, fonts: &crate::Fonts) -> f32 { + pub fn font_height(&self, fonts: &mut crate::Fonts<'_>) -> f32 { let mut max_height = 0.0_f32; for section in &self.sections { max_height = max_height.max(fonts.row_height(§ion.format.font_id)); @@ -250,122 +251,11 @@ impl std::hash::Hash for LayoutSection { let Self { leading_space, byte_range, - format, + format: style, } = self; OrderedFloat(*leading_space).hash(state); byte_range.hash(state); - format.hash(state); - } -} - -// ---------------------------------------------------------------------------- - -/// Formatting option for a section of text. -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct TextFormat { - pub font_id: FontId, - - /// Extra spacing between letters, in points. - /// - /// Default: 0.0. - /// - /// For even text it is recommended you round this to an even number of _pixels_. - pub extra_letter_spacing: f32, - - /// Explicit line height of the text in points. - /// - /// This is the distance between the bottom row of two subsequent lines of text. - /// - /// If `None` (the default), the line height is determined by the font. - /// - /// For even text it is recommended you round this to an even number of _pixels_. - pub line_height: Option, - - /// Text color - pub color: Color32, - - pub background: Color32, - - /// Amount to expand background fill by. - /// - /// Default: 1.0 - pub expand_bg: f32, - - pub italics: bool, - - pub underline: Stroke, - - pub strikethrough: Stroke, - - /// If you use a small font and [`Align::TOP`] you - /// can get the effect of raised text. - /// - /// If you use a small font and [`Align::BOTTOM`] - /// you get the effect of a subscript. - /// - /// If you use [`Align::Center`], you get text that is centered - /// around a common center-line, which is nice when mixining emojis - /// and normal text in e.g. a button. - pub valign: Align, -} - -impl Default for TextFormat { - #[inline] - fn default() -> Self { - Self { - font_id: FontId::default(), - extra_letter_spacing: 0.0, - line_height: None, - color: Color32::GRAY, - background: Color32::TRANSPARENT, - expand_bg: 1.0, - italics: false, - underline: Stroke::NONE, - strikethrough: Stroke::NONE, - valign: Align::BOTTOM, - } - } -} - -impl std::hash::Hash for TextFormat { - #[inline] - fn hash(&self, state: &mut H) { - let Self { - font_id, - extra_letter_spacing, - line_height, - color, - background, - expand_bg, - italics, - underline, - strikethrough, - valign, - } = self; - font_id.hash(state); - emath::OrderedFloat(*extra_letter_spacing).hash(state); - if let Some(line_height) = *line_height { - emath::OrderedFloat(line_height).hash(state); - } - color.hash(state); - background.hash(state); - emath::OrderedFloat(*expand_bg).hash(state); - italics.hash(state); - underline.hash(state); - strikethrough.hash(state); - valign.hash(state); - } -} - -impl TextFormat { - #[inline] - pub fn simple(font_id: FontId, color: Color32) -> Self { - Self { - font_id, - color, - ..Default::default() - } + style.hash(state); } } @@ -498,6 +388,29 @@ impl TextWrapping { ..Default::default() } } + + pub(crate) fn apply_to_parley_style(&self, style: &mut parley::TextStyle<'_, Color32>) { + style.overflow_wrap = OverflowWrap::Anywhere; + style.word_break = if self.break_anywhere { + parley::WordBreakStrength::BreakAll + } else { + parley::WordBreakStrength::Normal + }; + } +} + +#[derive(Clone, Default)] +pub(super) struct LayoutAndOffset { + pub(super) layout: parley::Layout, + /// Position offset added to the Parley layout. Must be subtracted again to + /// translate coords into Parley-space. + pub(super) offset: Vec2, +} + +impl LayoutAndOffset { + pub(super) fn new(layout: parley::Layout, offset: Vec2) -> Self { + Self { layout, offset } + } } // ---------------------------------------------------------------------------- @@ -516,21 +429,43 @@ impl TextWrapping { /// /// The name comes from typography, where a "galley" is a metal tray /// containing a column of set type, usually the size of a page of text. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Galley { /// The job that this galley is the result of. /// Contains the original string and style sections. pub job: Arc, - /// Rows of text, from top to bottom, and their offsets. + /// Rows of text, from top to bottom. /// /// The number of characters in all rows sum up to `job.text.chars().count()` /// unless [`Self::elided`] is `true`. /// /// Note that a paragraph (a piece of text separated with `\n`) /// can be split up into multiple rows. - pub rows: Vec, + pub rows: Vec, + + /// Parley text layout, primarily used for text editing. + /// TODO(valadaptive): serde support? + #[cfg_attr(feature = "serde", serde(skip))] + pub(super) parley_layout: LayoutAndOffset, + + /// Optional extra layout for the character used to truncate this text. + /// Boxed because it's pretty large and most text is not truncated. + /// + /// TODO(valadaptive): use this to test whether the truncation character is + /// selected. + #[cfg_attr(feature = "serde", serde(skip))] + #[expect(dead_code)] + pub(super) overflow_char_layout: Option>, + + /// Lazy-initialized AccessKit adapter for this galley's layout. + #[cfg(feature = "accesskit")] + #[cfg_attr(feature = "serde", serde(skip))] + pub(super) accessibility: LazyAccessibility, + + /// Color of the selection, if it exists. Otherwise, [`Color32::TRANSPARENT`]. + pub selection_color: Color32, /// Set to true the text was truncated due to [`TextWrapping::max_rows`]. pub elided: bool, @@ -562,68 +497,85 @@ pub struct Galley { pub pixels_per_point: f32, } -#[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct PlacedRow { - /// The position of this [`Row`] relative to the galley. - /// - /// This is rounded to the closest _pixel_ in order to produce crisp, pixel-perfect text. - pub pos: Pos2, - - /// The underlying unpositioned [`Row`]. - pub row: Arc, -} - -impl PlacedRow { - /// Logical bounding rectangle on font heights etc. - /// - /// This ignores / includes the `LayoutSection::leading_space`. - pub fn rect(&self) -> Rect { - Rect::from_min_size(self.pos, self.row.size) +// parley::Layout does not implement Debug +impl std::fmt::Debug for Galley { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Galley") + .field("job", &self.job) + .field("elided", &self.elided) + .field("rect", &self.rect) + .field("mesh_bounds", &self.mesh_bounds) + .field("num_vertices", &self.num_vertices) + .field("num_indices", &self.num_indices) + .field("pixels_per_point", &self.pixels_per_point) + .finish() } +} - /// Same as [`Self::rect`] but excluding the `LayoutSection::leading_space`. - pub fn rect_without_leading_space(&self) -> Rect { - let x = self.glyphs.first().map_or(self.pos.x, |g| g.pos.x); - let size_x = self.size.x - x; - Rect::from_min_size(Pos2::new(x, self.pos.y), Vec2::new(size_x, self.size.y)) - } +#[cfg(feature = "accesskit")] +#[derive(Clone)] +pub struct GalleyAccessibility { + layout_access: parley::LayoutAccessibility, + pub nodes: Vec<(accesskit::NodeId, accesskit::Node)>, } -impl std::ops::Deref for PlacedRow { - type Target = Row; +#[cfg(feature = "accesskit")] +#[derive(Default, Clone)] +pub(super) struct LazyAccessibility(std::sync::OnceLock); - fn deref(&self) -> &Self::Target { - &self.row +#[cfg(feature = "accesskit")] +impl LazyAccessibility { + fn get_or_init( + &self, + text: &str, + layout: &parley::Layout, + layout_offset: Vec2, + ) -> &GalleyAccessibility { + self.0.get_or_init(|| { + // TODO(valadaptive): this is quite janky since parley expects to be + // able to directly write to a TreeUpdate. Ask if there's a better + // way to do this. + let nodes = Vec::new(); + let mut tree_update = accesskit::TreeUpdate { + nodes, + tree: None, + focus: accesskit::NodeId(0), // TODO(valadaptive): does this need to be a "real" value? + }; + let mut parent_node = accesskit::Node::new(accesskit::Role::Unknown); + let mut id_counter = 0; + let mut next_node_id = || { + id_counter += 1; + accesskit::NodeId(id_counter) + }; + + let mut layout_access = parley::LayoutAccessibility::default(); + layout_access.build_nodes( + text, + layout, + &mut tree_update, + &mut parent_node, + &mut next_node_id, + layout_offset.x as f64, + layout_offset.y as f64, + ); + + GalleyAccessibility { + layout_access, + nodes: tree_update.nodes, + } + }) } } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Row { - /// This is included in case there are no glyphs. - /// - /// Only used during layout, then set to an invalid value in order to - /// enable the paragraph-concat optimization path without having to - /// adjust `section_index` when concatting. - pub(crate) section_index_at_start: u32, - - /// One for each `char`. - pub glyphs: Vec, - - /// Logical size based on font heights etc. + /// Logical bounding rectangle based on font heights etc. + /// Use this when drawing a selection or similar! /// Includes leading and trailing whitespace. - pub size: Vec2, - + pub rect: Rect, /// The mesh, ready to be rendered. pub visuals: RowVisuals, - - /// If true, this [`Row`] came from a paragraph ending with a `\n`. - /// The `\n` itself is omitted from [`Self::glyphs`]. - /// A `\n` in the input text always creates a new [`Row`] below it, - /// so that text that ends with `\n` has an empty [`Row`] last. - /// This also implies that the last [`Row`] in a [`Galley`] always has `ends_with_newline == false`. - pub ends_with_newline: bool, } /// The tessellated output of a row. @@ -638,6 +590,9 @@ pub struct RowVisuals { /// Does NOT include leading or trailing whitespace glyphs!! pub mesh_bounds: Rect, + /// Geometry for any selection to be painted. + pub selection_rects: Option>, + /// The number of triangle indices added before the first glyph triangle. /// /// This can be used to insert more triangles after the background but before the glyphs, @@ -655,6 +610,7 @@ impl Default for RowVisuals { Self { mesh: Default::default(), mesh_bounds: Rect::NOTHING, + selection_rects: None, glyph_index_start: 0, glyph_vertex_range: 0..0, } @@ -667,8 +623,8 @@ pub struct Glyph { /// The character this glyph represents. pub chr: char, - /// Baseline position, relative to the row. - /// Logical position: pos.y is the same for all chars of the same [`TextFormat`]. + /// Baseline position, relative to the galley. + /// Logical position: pos.y is the same for all chars of the same [`TextStyle`]. pub pos: Pos2, /// Logical width of the glyph. @@ -677,30 +633,11 @@ pub struct Glyph { /// Height of this row of text. /// /// Usually same as [`Self::font_height`], - /// unless explicitly overridden by [`TextFormat::line_height`]. + /// unless explicitly overridden by [`TextStyle::line_height`]. pub line_height: f32, - /// The ascent of this font. - pub font_ascent: f32, - - /// The row/line height of this font. - pub font_height: f32, - - /// The ascent of the sub-font within the font (`FontImpl`). - pub font_impl_ascent: f32, - - /// The row/line height of the sub-font within the font (`FontImpl`). - pub font_impl_height: f32, - /// Position and size of the glyph in the font texture, in texels. pub uv_rect: UvRect, - - /// Index into [`LayoutJob::sections`]. Decides color etc. - /// - /// Only used during layout, then set to an invalid value in order to - /// enable the paragraph-concat optimization path without having to - /// adjust `section_index` when concatting. - pub(crate) section_index: u32, } impl Glyph { @@ -709,75 +646,250 @@ impl Glyph { Vec2::new(self.advance_width, self.line_height) } - #[inline] - pub fn max_x(&self) -> f32 { - self.pos.x + self.advance_width - } - - /// Same y range for all characters with the same [`TextFormat`]. + /// Same y range for all characters with the same [`TextStyle`]. #[inline] pub fn logical_rect(&self) -> Rect { - Rect::from_min_size(self.pos - vec2(0.0, self.font_ascent), self.size()) + Rect::from_min_size(self.pos, self.size()) } } -// ---------------------------------------------------------------------------- +/// Helper for creating and transforming text [`Selection`]s given a layout +/// computed in a [`Galley`]. +pub struct SelectionDriver<'a> { + layout_offset: Vec2, + layout: &'a parley::Layout, + text: &'a str, + #[cfg(feature = "accesskit")] + accessibility: &'a LazyAccessibility, +} -impl Row { - /// The text on this row, excluding the implicit `\n` if any. - pub fn text(&self) -> String { - self.glyphs.iter().map(|g| g.chr).collect() +impl SelectionDriver<'_> { + fn pos_to_parley(&self, pos: Vec2) -> Vec2 { + pos - self.layout_offset } - /// Excludes the implicit `\n` after the [`Row`], if any. - #[inline] - pub fn char_count_excluding_newline(&self) -> usize { - self.glyphs.len() + /// Returns a [`Selection`] of the entire contents of the associated [`Galley`]. + pub fn select_all(&self) -> Selection { + parley::Selection::from_byte_index(self.layout, 0usize, Default::default()) + .move_lines(self.layout, isize::MAX, true) + .into() } - /// Includes the implicit `\n` after the [`Row`], if any. - #[inline] - pub fn char_count_including_newline(&self) -> usize { - self.glyphs.len() + (self.ends_with_newline as usize) + /// Returns an empty [`Selection`] at the given byte location. + pub fn select_at_cursor(&self, cursor: &ByteCursor) -> Selection { + parley::Selection::from_byte_index(self.layout, cursor.index, cursor.affinity.into()).into() } - /// Closest char at the desired x coordinate in row-relative coordinates. - /// Returns something in the range `[0, char_count_excluding_newline()]`. - pub fn char_at(&self, desired_x: f32) -> usize { - for (i, glyph) in self.glyphs.iter().enumerate() { - if desired_x < glyph.logical_rect().center().x { - return i; - } + /// Returns a [`Selection`] at the given [`ByteCursor`] range. See [`Selection::anchor`] and [`Selection::focus`] + /// for more info. + pub fn select_cursor_range(&self, anchor: &ByteCursor, focus: &ByteCursor) -> Selection { + parley::Selection::from_byte_index(self.layout, anchor.index, Default::default()) + .extend(focus.as_parley(self.layout)) + .into() + } + + /// Returns an empty [`Selection`] at the given galley-space location. + pub fn select_single_point_at(&self, pos: Vec2) -> Selection { + let Vec2 { x, y } = self.pos_to_parley(pos); + parley::Selection::from_point(self.layout, x, y).into() + } + + /// Returns a [`Selection`] of the word at the given galley-space location. + pub fn select_word_at(&self, pos: Vec2) -> Selection { + let Vec2 { x, y } = self.pos_to_parley(pos); + parley::Selection::word_from_point(self.layout, x, y).into() + } + + /// Returns a [`Selection`] of the layout line (wrapping text creates distinct lines) at the given galley-space + /// location. + pub fn select_line_at(&self, pos: Vec2) -> Selection { + let Vec2 { x, y } = self.pos_to_parley(pos); + parley::Selection::line_from_point(self.layout, x, y).into() + } + + /// Returns a [`Selection`] with the [`Selection::focus`] moved to the given galley-space location. If this is a + /// word-based or line-based selection, the [`Selection::anchor`] may also be extended to a word or line boundary + /// respectively. + pub fn extend_selection_to_point(&self, selection: &Selection, pos: Vec2) -> Selection { + let Vec2 { x, y } = self.pos_to_parley(pos); + selection.0.extend_to_point(self.layout, x, y).into() + } + + /// Returns a [`Selection`] with the [`Selection::focus`] moved to the given [`ByteCursor`]. If this is a word-based + /// or line-based selection, the [`Selection::anchor`] may also be extended to a word or line boundary respectively. + pub fn extend_selection_to_cursor( + &self, + selection: &Selection, + focus: &ByteCursor, + ) -> Selection { + selection.0.extend(focus.as_parley(self.layout)).into() + } + + /// Returns a [`Selection`] at the previous visual character (this will differ from the previous "logical" character + /// in right-to-left text). If the `extend` parameter is true, the original selection's [`Selection::anchor`] will + /// stay where it is; if it is false, the selection at the new location will be empty. + pub fn select_prev_character(&self, selection: &Selection, extend: bool) -> Selection { + selection.0.previous_visual(self.layout, extend).into() + } + + /// Returns a [`Selection`] at the next visual character (this will differ from the previous "logical" character + /// in right-to-left text). If the `extend` parameter is true, the original selection's [`Selection::anchor`] will + /// stay where it is; if it is false, the selection at the new location will be empty. + pub fn select_next_character(&self, selection: &Selection, extend: bool) -> Selection { + selection.0.next_visual(self.layout, extend).into() + } + + /// Returns the byte range of the beginning of the paragraph to the left of the cursor (returning 0 if we're at the + /// first paragraph). Used for deleting text, so we don't need to return a [`Selection`]. + pub fn paragraph_before_cursor(&self, selection: &Selection) -> Option> { + let range = selection.byte_range(); + let newline_index = self.text[0..range.start].rfind('\n').unwrap_or(0); + if newline_index == range.end { + self.prev_cluster(selection) + } else { + Some(newline_index..range.end) } - self.char_count_excluding_newline() } - pub fn x_offset(&self, column: usize) -> f32 { - if let Some(glyph) = self.glyphs.get(column) { - glyph.pos.x + /// Returns the byte range of the beginning of the paragraph to the right of the cursor (returning 0 if we're at the + /// first paragraph). Used for deleting text, so we don't need to return a [`Selection`]. + pub fn paragraph_after_cursor(&self, selection: &Selection) -> Option> { + let range = selection.byte_range(); + let newline_index = self.text[range.start..] + .find('\n') + .map_or(self.text.len(), |idx| idx + range.start); + if newline_index == range.end { + self.next_cluster(selection) } else { - self.size.x + Some(range.start..newline_index) } } - #[inline] - pub fn height(&self) -> f32 { - self.size.y + /// Returns the byte range of the previous logical character. Used for deleting text, so we don't need to return a + /// [`Selection`]. + pub fn prev_cluster(&self, selection: &Selection) -> Option> { + // Adapted from Parley: + // https://github.com/linebender/parley/blob/4307d3f/parley/src/layout/editor.rs#L236-L275 + let cluster = selection.0.focus().logical_clusters(self.layout)[0]?; + let range = cluster.text_range(); + let end = range.end; + let start = if cluster.is_hard_line_break() || cluster.is_emoji() { + // For newline sequences and emoji, delete the previous cluster + range.start + } else { + // Otherwise, delete the previous character + let (start, _) = self + .text + .get(..end) + .and_then(|s| s.char_indices().next_back())?; + start + }; + Some(start..end) } -} -impl PlacedRow { - #[inline] - pub fn min_y(&self) -> f32 { - self.rect().top() + /// Returns the byte range of the previous logical character. Used for deleting text, so we don't need to return a + /// [`Selection`]. + pub fn next_cluster(&self, selection: &Selection) -> Option> { + // Adapted from Parley: + // https://github.com/linebender/parley/blob/4307d3f/parley/src/layout/editor.rs#L215-L233 + let cluster = selection.0.focus().logical_clusters(self.layout)[1]?; + let range = cluster.text_range(); + if range.is_empty() { + return None; + } + Some(range) } - #[inline] - pub fn max_y(&self) -> f32 { - self.rect().bottom() + /// Returns a [`Selection`] at the previous visual word (this will differ from the previous "logical" word in + /// right-to-left text). If the `extend` parameter is true, the original selection's [`Selection::anchor`] will stay + /// where it is; if it is false, the selection at the new location will be empty. + pub fn select_prev_word(&self, selection: &Selection, extend: bool) -> Selection { + selection.0.previous_visual_word(self.layout, extend).into() + } + + /// Returns a [`Selection`] at the next visual word (this will differ from the next "logical" word in + /// right-to-left text). If the `extend` parameter is true, the original selection's [`Selection::anchor`] will stay + /// where it is; if it is false, the selection at the new location will be empty. + pub fn select_next_word(&self, selection: &Selection, extend: bool) -> Selection { + selection.0.next_visual_word(self.layout, extend).into() + } + + /// Returns a [`Selection`] at the same approximate x-position in the previous line. Successive calls to + /// [`Self::select_prev_row`] and [`Self::select_next_row`] maintain the [`Selection`]'s internal state, and will + /// remember the selection cursor's horizontal position, even if the currently-selected line is not that long. + pub fn select_prev_row(&self, selection: &Selection, extend: bool) -> Selection { + selection.0.previous_line(self.layout, extend).into() + } + + /// Returns a [`Selection`] at the same approximate x-position in the next line. Successive calls to + /// [`Self::select_prev_row`] and [`Self::select_next_row`] maintain the [`Selection`]'s internal state, and will + /// remember the selection cursor's horizontal position, even if the currently-selected line is not that long. + pub fn select_next_row(&self, selection: &Selection, extend: bool) -> Selection { + selection.0.next_line(self.layout, extend).into() + } + + /// Returns a [`Selection`] at the start of the current line. If the `extend` parameter is true, the original + /// selection's [`Selection::anchor`] will stay where it is; if it is false, the selection at the new location will + /// be empty. + pub fn select_row_start(&self, selection: &Selection, extend: bool) -> Selection { + selection.0.line_start(self.layout, extend).into() + } + + /// Returns a [`Selection`] at the end of the current line. If the `extend` parameter is true, the original + /// selection's [`Selection::anchor`] will stay where it is; if it is false, the selection at the new location will + /// be empty. + pub fn select_row_end(&self, selection: &Selection, extend: bool) -> Selection { + selection.0.line_end(self.layout, extend).into() + } + + /// Call the given function with a sequence of rectangles (in galley-space) that represents the visual geometry of + /// this selection. + pub fn with_selection_rects(&self, selection: &Selection, mut f: impl FnMut(Rect, usize)) { + selection + .0 + .geometry_with(self.layout, |parley_rect, line_idx| { + let rect = Rect { + min: Pos2::new(parley_rect.x0 as f32, parley_rect.y0 as f32), + max: Pos2::new(parley_rect.x1 as f32, parley_rect.y1 as f32), + }; + f(rect, line_idx); + }); + } + + #[cfg(feature = "accesskit")] + /// Create a new selection from an [`accesskit::TextSelection`]. + pub fn from_accesskit_selection( + &self, + selection: &accesskit::TextSelection, + ) -> Option { + let accessibility = + self.accessibility + .get_or_init(self.text, self.layout, self.layout_offset); + parley::Selection::from_access_selection( + selection, + self.layout, + &accessibility.layout_access, + ) + .map(Into::into) + } + + #[cfg(feature = "accesskit")] + /// Convert the given selection to an [`accesskit::TextSelection`]. + pub fn to_accesskit_selection( + &self, + selection: &Selection, + ) -> Option { + let accessibility = + self.accessibility + .get_or_init(self.text, self.layout, self.layout_offset); + selection + .0 + .to_access_selection(self.layout, &accessibility.layout_access) } } +// ---------------------------------------------------------------------------- + impl Galley { #[inline] pub fn is_empty(&self) -> bool { @@ -795,90 +907,40 @@ impl Galley { self.rect.size() } - pub(crate) fn round_output_to_gui(&mut self) { - for placed_row in &mut self.rows { - // Optimization: only call `make_mut` if necessary (can cause a deep clone) - let rounded_size = placed_row.row.size.round_ui(); - if placed_row.row.size != rounded_size { - Arc::make_mut(&mut placed_row.row).size = rounded_size; - } - } - - let rect = &mut self.rect; - - let did_exceed_wrap_width_by_a_lot = rect.width() > self.job.wrap.max_width + 1.0; - - *rect = rect.round_ui(); - - if did_exceed_wrap_width_by_a_lot { - // If the user picked a too aggressive wrap width (e.g. more narrow than any individual glyph), - // we should let the user know by reporting that our width is wider than the wrap width. - } else { - // Make sure we don't report being wider than the wrap width the user picked: - rect.max.x = rect - .max - .x - .at_most(rect.min.x + self.job.wrap.max_width) - .floor_ui(); - } + #[cfg(feature = "accesskit")] + pub fn accessibility(&self) -> &GalleyAccessibility { + self.accessibility.get_or_init( + &self.job.text, + &self.parley_layout.layout, + self.parley_layout.offset, + ) } - /// Append each galley under the previous one. - pub fn concat(job: Arc, galleys: &[Arc], pixels_per_point: f32) -> Self { - profiling::function_scope!(); - - let mut merged_galley = Self { - job, - rows: Vec::new(), - elided: false, - rect: Rect::ZERO, - mesh_bounds: Rect::NOTHING, - num_vertices: 0, - num_indices: 0, - pixels_per_point, - }; - - for (i, galley) in galleys.iter().enumerate() { - let current_y_offset = merged_galley.rect.height(); - - let mut rows = galley.rows.iter(); - // As documented in `Row::ends_with_newline`, a '\n' will always create a - // new `Row` immediately below the current one. Here it doesn't make sense - // for us to append this new row so we just ignore it. - let is_last_row = i + 1 == galleys.len(); - if !is_last_row && !galley.elided { - let popped = rows.next_back(); - debug_assert_eq!(popped.unwrap().row.glyphs.len(), 0, "Bug in Galley::concat"); - } + pub fn paint_selection(&mut self, color: Color32, selection: &Selection) -> bool { + self.selection_color = color; - merged_galley.rows.extend(rows.map(|placed_row| { - let new_pos = placed_row.pos + current_y_offset * Vec2::Y; - let new_pos = new_pos.round_to_pixels(pixels_per_point); - merged_galley.mesh_bounds = merged_galley - .mesh_bounds - .union(placed_row.visuals.mesh_bounds.translate(new_pos.to_vec2())); - merged_galley.rect = merged_galley - .rect - .union(Rect::from_min_size(new_pos, placed_row.size)); - - super::PlacedRow { - pos: new_pos, - row: placed_row.row.clone(), + let mut did_draw_any = false; + selection + .0 + .geometry_with(&self.parley_layout.layout, |parley_rect, line_idx| { + let rect = Rect { + min: Pos2::new(parley_rect.x0 as f32, parley_rect.y0 as f32), + max: Pos2::new(parley_rect.x1 as f32, parley_rect.y1 as f32), } - })); + .translate(self.parley_layout.offset); - merged_galley.num_vertices += galley.num_vertices; - merged_galley.num_indices += galley.num_indices; - // Note that if `galley.elided` is true this will be the last `Galley` in - // the vector and the loop will end. - merged_galley.elided |= galley.elided; - } + let Some(row) = self.rows.get_mut(line_idx) else { + return; + }; - if merged_galley.job.round_output_to_gui { - merged_galley.round_output_to_gui(); - } + let selection_rects = row.visuals.selection_rects.get_or_insert_default(); + selection_rects.push(rect); + // Count selection rectangle bounds when culling + row.visuals.mesh_bounds = row.visuals.mesh_bounds.union(rect); + did_draw_any = true; + }); - merged_galley + did_draw_any } } @@ -908,83 +970,30 @@ impl std::ops::Deref for Galley { /// ## Physical positions impl Galley { - /// Zero-width rect past the last character. - fn end_pos(&self) -> Rect { - if let Some(row) = self.rows.last() { - let x = row.rect().right(); - Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())) - } else { - // Empty galley - Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 0.0)) - } + fn pos_to_parley(&self, pos: Vec2) -> Vec2 { + pos - self.parley_layout.offset } /// Returns a 0-width Rect. - fn pos_from_layout_cursor(&self, layout_cursor: &LayoutCursor) -> Rect { - let Some(row) = self.rows.get(layout_cursor.row) else { - return self.end_pos(); - }; - - let x = row.x_offset(layout_cursor.column); - Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())) - } - - /// Returns a 0-width Rect. - pub fn pos_from_cursor(&self, cursor: CCursor) -> Rect { - self.pos_from_layout_cursor(&self.layout_from_cursor(cursor)) + pub fn pos_from_cursor(&self, cursor: ByteCursor) -> Rect { + let layout = &self.parley_layout.layout; + let cursor = cursor.as_parley(layout); + let parley_rect = cursor.geometry(layout, 0.0); + Rect { + min: Pos2::new(parley_rect.x0 as f32, parley_rect.y0 as f32), + max: Pos2::new(parley_rect.x1 as f32, parley_rect.y1 as f32), + } + .translate(self.parley_layout.offset) } /// Cursor at the given position within the galley. /// - /// A cursor above the galley is considered - /// same as a cursor at the start, - /// and a cursor below the galley is considered - /// same as a cursor at the end. - /// This allows implementing text-selection by dragging above/below the galley. - pub fn cursor_from_pos(&self, pos: Vec2) -> CCursor { - // Vertical margin around galley improves text selection UX - const VMARGIN: f32 = 5.0; - - if let Some(first_row) = self.rows.first() { - if pos.y < first_row.min_y() - VMARGIN { - return self.begin(); - } - } - if let Some(last_row) = self.rows.last() { - if last_row.max_y() + VMARGIN < pos.y { - return self.end(); - } - } - - let mut best_y_dist = f32::INFINITY; - let mut cursor = CCursor::default(); - - let mut ccursor_index = 0; - - for row in &self.rows { - let min_y = row.min_y(); - let max_y = row.max_y(); - - let is_pos_within_row = min_y <= pos.y && pos.y <= max_y; - let y_dist = (min_y - pos.y).abs().min((max_y - pos.y).abs()); - if is_pos_within_row || y_dist < best_y_dist { - best_y_dist = y_dist; - // char_at is `Row` not `PlacedRow` relative which means we have to subtract the pos. - let column = row.char_at(pos.x - row.pos.x); - let prefer_next_row = column < row.char_count_excluding_newline(); - cursor = CCursor { - index: ccursor_index + column, - prefer_next_row, - }; - - if is_pos_within_row { - return cursor; - } - } - ccursor_index += row.char_count_including_newline(); - } - - cursor + /// A cursor above the galley is considered same as a cursor at the start, and a cursor below the galley is + /// considered same as a cursor at the end. This allows implementing text-selection by dragging above/below the + /// galley. + pub fn cursor_from_pos(&self, pos: Vec2) -> ByteCursor { + let Vec2 { x, y } = self.pos_to_parley(pos); + parley::Cursor::from_point(&self.parley_layout.layout, x, y).into() } } @@ -992,220 +1001,33 @@ impl Galley { impl Galley { /// Cursor to the first character. /// - /// This is the same as [`CCursor::default`]. + /// This is the same as [`ByteCursor::default`]. #[inline] #[expect(clippy::unused_self)] - pub fn begin(&self) -> CCursor { - CCursor::default() + pub fn begin(&self) -> ByteCursor { + ByteCursor::default() } /// Cursor to one-past last character. - pub fn end(&self) -> CCursor { - if self.rows.is_empty() { - return Default::default(); - } - let mut ccursor = CCursor { - index: 0, - prefer_next_row: true, - }; - for row in &self.rows { - let row_char_count = row.char_count_including_newline(); - ccursor.index += row_char_count; - } - ccursor + pub fn end(&self) -> ByteCursor { + parley::Cursor::from_byte_index(&self.parley_layout.layout, usize::MAX, Default::default()) + .into() } } -/// ## Cursor conversions +/// ## Selections impl Galley { - // The returned cursor is clamped. - pub fn layout_from_cursor(&self, cursor: CCursor) -> LayoutCursor { - let prefer_next_row = cursor.prefer_next_row; - let mut ccursor_it = CCursor { - index: 0, - prefer_next_row, - }; - - for (row_nr, row) in self.rows.iter().enumerate() { - let row_char_count = row.char_count_excluding_newline(); - - if ccursor_it.index <= cursor.index && cursor.index <= ccursor_it.index + row_char_count - { - let column = cursor.index - ccursor_it.index; - - let select_next_row_instead = prefer_next_row - && !row.ends_with_newline - && column >= row.char_count_excluding_newline(); - if !select_next_row_instead { - return LayoutCursor { - row: row_nr, - column, - }; - } - } - ccursor_it.index += row.char_count_including_newline(); - } - debug_assert!(ccursor_it == self.end(), "Cursor out of bounds"); - - if let Some(last_row) = self.rows.last() { - LayoutCursor { - row: self.rows.len() - 1, - column: last_row.char_count_including_newline(), - } - } else { - Default::default() - } - } - - fn cursor_from_layout(&self, layout_cursor: LayoutCursor) -> CCursor { - if layout_cursor.row >= self.rows.len() { - return self.end(); - } - - let prefer_next_row = - layout_cursor.column < self.rows[layout_cursor.row].char_count_excluding_newline(); - let mut cursor_it = CCursor { - index: 0, - prefer_next_row, + /// Scoped access to a [`SelectionDriver`] for creating and transforming + /// text [`Selection`]s. + pub fn selection(&self, f: impl FnOnce(&mut SelectionDriver<'_>) -> T) -> T { + let mut driver = SelectionDriver { + layout_offset: self.parley_layout.offset, + layout: &self.parley_layout.layout, + text: &self.job.text, + #[cfg(feature = "accesskit")] + accessibility: &self.accessibility, }; - for (row_nr, row) in self.rows.iter().enumerate() { - if row_nr == layout_cursor.row { - cursor_it.index += layout_cursor - .column - .at_most(row.char_count_excluding_newline()); - - return cursor_it; - } - cursor_it.index += row.char_count_including_newline(); - } - cursor_it - } -} - -/// ## Cursor positions -impl Galley { - #[expect(clippy::unused_self)] - pub fn cursor_left_one_character(&self, cursor: &CCursor) -> CCursor { - if cursor.index == 0 { - Default::default() - } else { - CCursor { - index: cursor.index - 1, - prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the beginning of a row than at the end. - } - } - } - - pub fn cursor_right_one_character(&self, cursor: &CCursor) -> CCursor { - CCursor { - index: (cursor.index + 1).min(self.end().index), - prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the beginning of a row than at the end. - } - } - - pub fn cursor_up_one_row( - &self, - cursor: &CCursor, - h_pos: Option, - ) -> (CCursor, Option) { - let layout_cursor = self.layout_from_cursor(*cursor); - let h_pos = h_pos.unwrap_or_else(|| self.pos_from_layout_cursor(&layout_cursor).center().x); - if layout_cursor.row == 0 { - (CCursor::default(), None) - } else { - let new_row = layout_cursor.row - 1; - - let new_layout_cursor = { - // keep same X coord - let column = self.rows[new_row].char_at(h_pos); - LayoutCursor { - row: new_row, - column, - } - }; - (self.cursor_from_layout(new_layout_cursor), Some(h_pos)) - } - } - - pub fn cursor_down_one_row( - &self, - cursor: &CCursor, - h_pos: Option, - ) -> (CCursor, Option) { - let layout_cursor = self.layout_from_cursor(*cursor); - let h_pos = h_pos.unwrap_or_else(|| self.pos_from_layout_cursor(&layout_cursor).center().x); - if layout_cursor.row + 1 < self.rows.len() { - let new_row = layout_cursor.row + 1; - - let new_layout_cursor = { - // keep same X coord - let column = self.rows[new_row].char_at(h_pos); - LayoutCursor { - row: new_row, - column, - } - }; - - (self.cursor_from_layout(new_layout_cursor), Some(h_pos)) - } else { - (self.end(), None) - } - } - - pub fn cursor_begin_of_row(&self, cursor: &CCursor) -> CCursor { - let layout_cursor = self.layout_from_cursor(*cursor); - self.cursor_from_layout(LayoutCursor { - row: layout_cursor.row, - column: 0, - }) - } - - pub fn cursor_end_of_row(&self, cursor: &CCursor) -> CCursor { - let layout_cursor = self.layout_from_cursor(*cursor); - self.cursor_from_layout(LayoutCursor { - row: layout_cursor.row, - column: self.rows[layout_cursor.row].char_count_excluding_newline(), - }) - } - - pub fn cursor_begin_of_paragraph(&self, cursor: &CCursor) -> CCursor { - let mut layout_cursor = self.layout_from_cursor(*cursor); - layout_cursor.column = 0; - - loop { - let prev_row = layout_cursor - .row - .checked_sub(1) - .and_then(|row| self.rows.get(row)); - - let Some(prev_row) = prev_row else { - // This is the first row - break; - }; - - if prev_row.ends_with_newline { - break; - } - - layout_cursor.row -= 1; - } - - self.cursor_from_layout(layout_cursor) - } - - pub fn cursor_end_of_paragraph(&self, cursor: &CCursor) -> CCursor { - let mut layout_cursor = self.layout_from_cursor(*cursor); - loop { - let row = &self.rows[layout_cursor.row]; - if row.ends_with_newline || layout_cursor.row == self.rows.len() - 1 { - layout_cursor.column = row.char_count_excluding_newline(); - break; - } - - layout_cursor.row += 1; - } - - self.cursor_from_layout(layout_cursor) + f(&mut driver) } } diff --git a/crates/epaint/src/texture_atlas.rs b/crates/epaint/src/texture_atlas.rs index 79054022439..74929560977 100644 --- a/crates/epaint/src/texture_atlas.rs +++ b/crates/epaint/src/texture_atlas.rs @@ -1,6 +1,7 @@ +use ecolor::Color32; use emath::{remap_clamp, Rect}; -use crate::{FontImage, ImageDelta}; +use crate::{ColorImage, ImageDelta}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct Rectu { @@ -57,7 +58,7 @@ pub struct PreparedDisc { /// More characters can be added, possibly expanding the texture. #[derive(Clone)] pub struct TextureAtlas { - image: FontImage, + image: ColorImage, /// What part of the image that is dirty dirty: Rectu, @@ -72,28 +73,38 @@ pub struct TextureAtlas { /// pre-rasterized discs of radii `2^i`, where `i` is the index. discs: Vec, + + /// Gamma to use when converting coverage to alpha for glyphs and other shapes. + pub gamma: Option, } impl TextureAtlas { pub fn new(size: [usize; 2]) -> Self { assert!(size[0] >= 1024, "Tiny texture atlas"); let mut atlas = Self { - image: FontImage::new(size), + image: ColorImage::filled(size, Color32::TRANSPARENT), dirty: Rectu::EVERYTHING, cursor: (0, 0), row_height: 0, overflowed: false, discs: vec![], // will be filled in below + gamma: None, }; + atlas.initialize(); + + atlas + } + + fn initialize(&mut self) { // Make the top left pixel fully white for `WHITE_UV`, i.e. painting something with solid color: - let (pos, image) = atlas.allocate((1, 1)); + let (pos, image) = self.allocate((1, 1)); assert_eq!( pos, (0, 0), "Expected the first allocation to be at (0, 0), but was at {pos:?}" ); - image[pos] = 1.0; + image[pos] = Color32::WHITE; // Allocate a series of anti-aliased discs used to render small filled circles: // TODO(emilk): these circles can be packed A LOT better. @@ -109,17 +120,18 @@ impl TextureAtlas { } let hw = (r + 0.5).ceil() as i32; let w = (2 * hw + 1) as usize; - let ((x, y), image) = atlas.allocate((w, w)); + let gamma = self.gamma; + let ((x, y), image) = self.allocate((w, w)); for dx in -hw..=hw { for dy in -hw..=hw { let distance_to_center = ((dx * dx + dy * dy) as f32).sqrt(); let coverage = remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0); image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = - coverage; + Self::coverage_to_color(gamma, coverage); } } - atlas.discs.push(PrerasterizedDisc { + self.discs.push(PrerasterizedDisc { r, uv: Rectu { min_x: x, @@ -129,8 +141,22 @@ impl TextureAtlas { }, }); } + } - atlas + #[inline] + pub fn coverage_to_color(gamma: Option, coverage: f32) -> Color32 { + let alpha = if let Some(gamma) = gamma { + coverage.powf(gamma) + } else { + // alpha = coverage * coverage; // recommended by the article for WHITE text (using linear blending) + + // The following is recommended by the article for BLACK text (using linear blending). + // Very similar to a gamma of 0.5, but produces sharper text. + // In practice it works well for all text colors (better than a gamma of 0.5, for instance). + // See https://www.desmos.com/calculator/w0ndf5blmn for a visual comparison. + 2.0 * coverage - coverage * coverage + }; + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) } pub fn size(&self) -> [usize; 2] { @@ -184,7 +210,7 @@ impl TextureAtlas { /// The full font atlas image. #[inline] - pub fn image(&self) -> &FontImage { + pub fn image(&self) -> &ColorImage { &self.image } @@ -200,14 +226,14 @@ impl TextureAtlas { } else { let pos = [dirty.min_x, dirty.min_y]; let size = [dirty.max_x - dirty.min_x, dirty.max_y - dirty.min_y]; - let region = self.image.region(pos, size); + let region = self.image.region_by_pixels(pos, size); Some(ImageDelta::partial(pos, region, texture_options)) } } /// Returns the coordinates of where the rect ended up, /// and invalidates the region. - pub fn allocate(&mut self, (w, h): (usize, usize)) -> ((usize, usize), &mut FontImage) { + pub fn allocate(&mut self, (w, h): (usize, usize)) -> ((usize, usize), &mut ColorImage) { /// On some low-precision GPUs (my old iPad) characters get muddled up /// if we don't add some empty pixels between the characters. /// On modern high-precision GPUs this is not needed. @@ -252,15 +278,30 @@ impl TextureAtlas { (pos, &mut self.image) } + + /// Clear this atlas, allowing it to be reused. + pub fn clear(&mut self) { + // We can't just let new glyphs overwrite old ones because they won't overwrite the 1-pixel padding. + self.image.pixels.fill(Color32::TRANSPARENT); + + self.dirty = Rectu::EVERYTHING; + self.cursor = (0, 0); + self.row_height = 0; + self.overflowed = false; + self.discs.clear(); + self.initialize(); + } } -fn resize_to_min_height(image: &mut FontImage, required_height: usize) -> bool { +fn resize_to_min_height(image: &mut ColorImage, required_height: usize) -> bool { while required_height >= image.height() { image.size[1] *= 2; // double the height } if image.width() * image.height() > image.pixels.len() { - image.pixels.resize(image.width() * image.height(), 0.0); + image + .pixels + .resize(image.width() * image.height(), Color32::TRANSPARENT); true } else { false diff --git a/crates/epaint/src/textures.rs b/crates/epaint/src/textures.rs index cc191a75fb0..0944a9052ea 100644 --- a/crates/epaint/src/textures.rs +++ b/crates/epaint/src/textures.rs @@ -271,7 +271,7 @@ pub enum TextureWrapMode { /// What has been allocated and freed during the last period. /// /// These are commands given to the integration painter. -#[derive(Clone, Default, PartialEq)] +#[derive(Clone, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[must_use = "The painter must take care of this"] pub struct TexturesDelta { diff --git a/examples/custom_font/src/main.rs b/examples/custom_font/src/main.rs index c487b6714c0..0497790c94c 100644 --- a/examples/custom_font/src/main.rs +++ b/examples/custom_font/src/main.rs @@ -3,7 +3,7 @@ use eframe::{ egui, - epaint::text::{FontInsert, InsertFontFamily}, + epaint::text::{style::GenericFamily, FontInsert, InsertFontFamily}, }; fn main() -> eframe::Result { @@ -28,11 +28,11 @@ fn add_font(ctx: &egui::Context) { )), vec![ InsertFontFamily { - family: egui::FontFamily::Proportional, + family: GenericFamily::SystemUi, priority: egui::epaint::text::FontPriority::Highest, }, InsertFontFamily { - family: egui::FontFamily::Monospace, + family: GenericFamily::Monospace, priority: egui::epaint::text::FontPriority::Lowest, }, ], @@ -56,14 +56,14 @@ fn replace_fonts(ctx: &egui::Context) { // Put my font first (highest priority) for proportional text: fonts .families - .entry(egui::FontFamily::Proportional) + .entry(GenericFamily::SystemUi) .or_default() .insert(0, "my_font".to_owned()); // Put my font as last fallback for monospace: fonts .families - .entry(egui::FontFamily::Monospace) + .entry(GenericFamily::Monospace) .or_default() .push("my_font".to_owned()); diff --git a/examples/custom_font_style/src/main.rs b/examples/custom_font_style/src/main.rs index 4600fbff331..3015c4f1d10 100644 --- a/examples/custom_font_style/src/main.rs +++ b/examples/custom_font_style/src/main.rs @@ -1,8 +1,11 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![allow(rustdoc::missing_crate_level_docs)] // it's an example -use eframe::egui; -use egui::{FontFamily, FontId, RichText, TextStyle}; +use eframe::egui::{ + self, + text::style::{FontId, GenericFamily}, +}; +use egui::{RichText, TextStyle}; use std::collections::BTreeMap; fn main() -> eframe::Result { @@ -27,16 +30,16 @@ fn heading3() -> TextStyle { } fn configure_text_styles(ctx: &egui::Context) { - use FontFamily::{Monospace, Proportional}; + use GenericFamily::{Monospace, SystemUi}; let text_styles: BTreeMap = [ - (TextStyle::Heading, FontId::new(25.0, Proportional)), - (heading2(), FontId::new(22.0, Proportional)), - (heading3(), FontId::new(19.0, Proportional)), - (TextStyle::Body, FontId::new(16.0, Proportional)), - (TextStyle::Monospace, FontId::new(12.0, Monospace)), - (TextStyle::Button, FontId::new(12.0, Proportional)), - (TextStyle::Small, FontId::new(8.0, Proportional)), + (TextStyle::Heading, FontId::simple(25.0, SystemUi)), + (heading2(), FontId::simple(22.0, SystemUi)), + (heading3(), FontId::simple(19.0, SystemUi)), + (TextStyle::Body, FontId::simple(16.0, SystemUi)), + (TextStyle::Monospace, FontId::simple(12.0, Monospace)), + (TextStyle::Button, FontId::simple(12.0, SystemUi)), + (TextStyle::Small, FontId::simple(8.0, SystemUi)), ] .into(); ctx.all_styles_mut(move |style| style.text_styles = text_styles.clone()); diff --git a/examples/custom_window_frame/src/main.rs b/examples/custom_window_frame/src/main.rs index bf347848f23..80058ca527e 100644 --- a/examples/custom_window_frame/src/main.rs +++ b/examples/custom_window_frame/src/main.rs @@ -3,7 +3,11 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release #![allow(rustdoc::missing_crate_level_docs)] // it's an example -use eframe::egui::{self, ViewportCommand}; +use eframe::egui::{ + self, + text::style::{FontId, GenericFamily}, + ViewportCommand, +}; fn main() -> eframe::Result { env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). @@ -75,7 +79,7 @@ fn custom_window_frame(ctx: &egui::Context, title: &str, add_contents: impl FnOn } fn title_bar_ui(ui: &mut egui::Ui, title_bar_rect: eframe::epaint::Rect, title: &str) { - use egui::{vec2, Align2, FontId, Id, PointerButton, Sense, UiBuilder}; + use egui::{vec2, Align2, Id, PointerButton, Sense, UiBuilder}; let painter = ui.painter(); @@ -90,7 +94,7 @@ fn title_bar_ui(ui: &mut egui::Ui, title_bar_rect: eframe::epaint::Rect, title: title_bar_rect.center(), Align2::CENTER_CENTER, title, - FontId::proportional(20.0), + FontId::simple(20.0, GenericFamily::SystemUi), ui.style().visuals.text_color(), ); diff --git a/parley-todo.md b/parley-todo.md new file mode 100644 index 00000000000..be45ee27506 --- /dev/null +++ b/parley-todo.md @@ -0,0 +1,160 @@ +## Actually actionable list +- [ ] Implement vertical alignment in Parley. Then we can just use a regular InlineBox to implement first_row_min_height and leading_space +- [ ] Figure out what to do for the tab character. My swash changes alone don't fix it, and it may be best to just wait for parley to switch to harfruzz +- [ ] Land a "font size multiplier" in Parley to implement FontTweak::scale +- [ ] Find a better way to expose AccessKit from Parley that doesn't require so many hacks +- [ ] Add a Parley API for using *full* style objects in RangedBuilder instead of having to set one property at a time +- [ ] Figure out the best way to implement "newlines don't actually create a new line" + - If Parley exposes a lower-level line-by-line API, we can just lay out the lines horizontally instead of vertically + - If Parley can shape text based on a character iterator instead of a string, we can map newlines to spaces without having to allocate an entire string + +## Parley: +- [x] Text wrap styling (https://github.com/linebender/parley/pull/315) +- [x] Text truncation with ellipsis +- [ ] serde support for some types (the selection ones at least) +- [ ] Vertical alignment options, especially for InlineBox (https://github.com/linebender/parley/issues/291) +- [ ] Ability to set line.offset (necessary for LayoutSection::leading_space) + - [ ] This can't just be a visual thing because of hit testing; Layout needs to agree on where everything is +- [x] Absolute line height +- [x] Custom family names (https://github.com/linebender/parley/issues/117) +- [x] Inline box fix (https://github.com/linebender/parley/pull/299) +- [x] Don't round vertical metrics (https://github.com/linebender/parley/pull/297) +- [x] RTL jank (https://github.com/linebender/parley/issues/298) +- [ ] Support the tab character (https://github.com/linebender/parley/issues/302) +- [ ] AccessKit improvements (https://github.com/linebender/parley/issues/310) +- [ ] Not Parley, but Swash: tighter glyph bounds (https://github.com/dfrg/zeno/pull/15) +- [ ] SystemUi doesn't properly fallback in some cases (e.g. Arabic text, macOS shortcut symbols) on my machine; SansSerif does (https://github.com/linebender/parley/issues/323) + +## Here: +- [ ] Text layout + - [x] Sometimes when resizing the Font Book window, the text doesn't wrap properly (fixed) + - ~~Only happens when the mouse is held down~~ + - ~~Could it be trailing whitespace?~~ + - [ ] In the EasyMark example, with the text "There is no alternative way to specify the strong style", at certain wrap widths, the text from "strong" onwards will be shifted down 1px + - [ ] With the fancy variable autohinted Ubuntu font, *sometimes* the "Interactive Container" label on the right demos bar appears improperly wrapped? + - [x] With "Text Wrap Mode" set to "Some(Wrap)" or "Some(Truncate)" in the Settings window, labels are not as wide as they should be compared to master branch (see Text Layout window) + - [x] Text wrapping + - [x] max_rows + - [x] break_anywhere + - [x] overflow_character + - [x] `LayoutJob::round_output_to_gui` + - [ ] `LayoutJob::break_on_newline` + - [ ] RTL considerations + - [ ] Label wrapping only occurs in LTR layouts, but make sure it doesn't do anything weird with RTL labels + - [ ] Do label wrapping in RTL too? + - [x] Text is right-justified if a label is split in the middle of RTL text, or if a line contains only an RTL label + - This may be desirable behavior, but is weird + - Whoops; we asked Parley for RTL-aware behavior and got it + - [ ] overflow_character should appear at the "start" of the line for RTL text + - [ ] RTL support for `egui::Align` + - [ ] Once Parley has vertical alignment, remove the hack for leading_space/first_row_min_height + - [ ] Align multiple consecutive labels to the same baseline, even with different fonts + - [x] Line height discrepancy between old and new layout (Parley line height is just the font size) +- [ ] Text rendering + - [x] Investigate whether swash is being too conservative with its shape bounds and cutting off rendered glyphs + - The reverse is true https://github.com/dfrg/zeno/pull/15 + - [x] We don't need to do all the weird DPI stuff now, probably + - [x] (see https://github.com/emilk/egui/issues/3664 for an example of the hacks we can get rid of) + - [ ] A bunch of font atlas stuff + - [ ] Use etagere instead of rolling our own atlas? + - guillotiere did well in synthetic testing but seemed to fall over when I tried it here; revisit? + - This requires multiple texture sampler support in the backends. This can be done another time + - [ ] If using etagere or guillotiere, allow reclaiming unused glyphs + - [ ] Properly handle the atlas overflowing during layout instead of trying to predict it ahead of time + - [x] Colored emoji + - [x] Alpha is a bit messed up, probably due to sRGB not un-multiplying and re-multiplying the alpha + - ~~Probably~~ fixed in https://github.com/emilk/egui/pull/5824 + - [x] When zooming in and out, there are these one-frame glitches where the wrong texture coordinates are used for the glyphs + - Once again, confirm that this is a regression and not an existing bug + - Forgot to clear the glyph atlas +- [ ] Text selection and editing + - [x] Unify the three different cursor types and move to a Parley-like API before moving to the actual Parley API (done) + - [x] Rewrite selection code to use parley's API + - [x] Basic API mapping (done) + - [x] Selection painting (done but kinda janky) + - [x] Probably rework label_text_selection (does it take bidirectional text into account?) + - [x] Still some jank when the cursor is kinda below the first label and it selects the "rest of the line" (fixed) + - [x] Make it support bidirectional text (fun!) + - I think this... just works already? + - [x] finish the gnarly parts that i've been putting off + - [x] indentation (done, but untested because parley's tab character support is broken) + - [x] selecting a range without having a Galley rendered already (done) + - [x] Fully remove CCursor and CCursorRange + - [x] Support `char_limit` (done but untested) + - [x] AccessKit integration(?) (done; kinda janky and cannot currently test whether the bounding boxes are correct) + - [x] Remove RowVertexIndices from selection painting + - [ ] Do another pass over TextBuffer's API + - [ ] Test IME support + - [ ] Smoothe out AccessKit API integration (and reduce temp allocations) + - [ ] Test AccessKit text bounding boxes (horiz_offset for alignment and vertical_offset for wrapped labels working) + - [ ] Character-based cursors could never be in an invalid state; byte-based cursors can. Ensure we don't try to slice a string using any non-validated selections +- [ ] Text styling + - [x] Fix FontDefinitions and adding fonts + - [x] Get fallback/ordering working properly + - I think this is done? + - [x] Auto fallback to faux italics (and perhaps faux bold) + - [x] run.synthesis() + - Faux bold doesn't work because Parley always tries to embolden Ubuntu Light because we ask for Ubuntu Regular + - [ ] Actually render text decorations (underline, strikethrough, etc) and backgrounds + - [x] Strikethrough and underline + - [x] Feathered + - [x] Backgrounds + - [x] valign ~~(this will take a lot of implementing in Parley)~~ + - [x] Families and not just files + - [x] Option to load system fonts in FontDefinitions + - [ ] FontTweak is very not-implemented + - [ ] scale + - This actually *does* affect layout, but only horizontally. Not sure if this is implementable. + - [x] y_offset_factor + - [x] y_offset + - [ ] baseline_offset_factor + - What does this even do? + - [x] Can we do bold now? + - [x] Letter spacing + - Why was this here? It's been implemented for a while + - [x] Hinting enable/disable + - [x] Maybe a global setting and also an override in FontTweak? + - [ ] If file size isn't an issue, ship variable fonts in epaint-default-fonts + - [x] Make sure to test with syntect disabled (there's some old style-heavy code that is cfg'd out with syntect enabled) + - [ ] Smoothe out the janky parts of the new API + - [ ] For mixed-DPI purposes, and because we don't need to store the FontStore as a mutex, ctx.fonts() now returns a "fonts view" that's technically read/write. But there are no operations that *semantically* modify the fonts from it + - [ ] FontStore and Fonts are different and we should just expose them separately instead of passing through all the FontStore methods onto Fonts + - [x] Work around https://github.com/jslegers/emoji-icon-font/issues/18 / https://github.com/emilk/egui/issues/1284 + - [ ] Ship a newer revision of the Ubuntu font? The hinting on the current one is kinda distracting + - [ ] https://github.com/emilk/egui/pull/5979/files great, now we have two different implementations of system font scanning + - https://github.com/linebender/resvg/issues/862 +- [x] Cross-cutting concerns + - [x] Actually remove all the ab_glyph stuff + - Sayonara, ab_glyph 🫡 +- [ ] Perf optimizations! + - [ ] Stop using TreeBuilder so we don't have to allocate a bunch of strings + - [ ] https://github.com/emilk/egui/issues/1098 + - [ ] Line-level layout memoization (https://github.com/emilk/egui/pull/5411) +- [ ] The other 90% + - [ ] Comment the new code better + - [ ] update All Of The Doctests... + - [ ] Go over APIs and clean them up + - [ ] New documentation for the new APIs + - [ ] Make sure everything's landed in Parley + - [ ] Migration guide for the release notes +- [ ] Deferred to the future + - [ ] Multiple (and smaller) text atlases + - [ ] Use monochrome texture for font atlas (R8 format) + - [ ] Does the current one only use RGBA so we can use the same shader for everything? + - [ ] Probably needs support in the backends for mask-only textures (gl.ALPHA and whatever the wgpu equivalent is) + - [ ] Have separate atlases for color emoji and glyphs/discs + - [ ] Multiple texture sampler support in all the backends + - [ ] Automatically switch between multiple atlases if one isn't enough + - [ ] Better (more CSSish) font API + - [ ] Revamp TextFormat in general + - [ ] Can it cascade? + - [ ] Drawing really really big glyphs causes a panic because the atlas can't allocate enough space + - [ ] Allow modifying text without doing a relayout afterwards (or at least not a full one) + - [ ] The original TextEdit code had a relayout too + - [ ] Per-viewport pixels_per_point seems to not be taken into account when calculating cursor positions for selections + - Not a new issue + - [ ] If you really scrunch up the "Code Example" window, it'll wrap while the cursor is held + - This is an issue in the non-Parley branch too, but only at >1x zoom + - [ ] If you select the text overflow character, the "logical selection" should extend to the end of the text. + - [ ] Text overflow character should use the style at the end of the line it replaces, but we have no way to get that without redoing the *entire* layout + - [ ] Unify the behavior of `Context::set_fonts` and `Context::add_font` wrt equality checking (or just don't do it, and unconditionally set fonts)