From 8c84bbfde4cbe3e88216c804c92a4d89b861decc Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 4 Sep 2023 21:46:57 +0200 Subject: [PATCH 1/5] Prune old egui memory data when reaching some limit (#3299) * Add generations to serialized state * Make IdTypeMap into a field struct * Less code duplication * Implement garbage collection during serialization * Add unit-test * Add docstring * Build fix * another fix --- crates/egui/src/util/id_type_map.rs | 378 +++++++++++++++++++++++----- 1 file changed, 312 insertions(+), 66 deletions(-) diff --git a/crates/egui/src/util/id_type_map.rs b/crates/egui/src/util/id_type_map.rs index 1f2960e64..a9867eb1b 100644 --- a/crates/egui/src/util/id_type_map.rs +++ b/crates/egui/src/util/id_type_map.rs @@ -3,8 +3,7 @@ // For non-serializable types, these simply return `None`. // This will also allow users to pick their own serialization format per type. -use std::any::Any; -use std::sync::Arc; +use std::{any::Any, sync::Arc}; // ----------------------------------------------------------------------------------------------- @@ -32,6 +31,8 @@ impl From for TypeId { } } +impl nohash_hasher::IsEnabled for TypeId {} + // ----------------------------------------------------------------------------------------------- #[cfg(feature = "persistence")] @@ -54,11 +55,21 @@ impl SerializableAny for T where T: 'static + Any + Clone + for<'a> Send + Sy // ----------------------------------------------------------------------------------------------- -#[cfg(feature = "persistence")] #[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +#[derive(Clone, Debug)] struct SerializedElement { + /// The type of value we are storing. type_id: TypeId, + + /// The ron data we can deserialize. ron: Arc, + + /// Increased by one each time we re-serialize an element that was never deserialized. + /// + /// Large value = old value that hasn't been read in a while. + /// + /// Used to garbage collect old values that hasn't been read in a while. + generation: usize, } #[cfg(feature = "persistence")] @@ -80,13 +91,7 @@ enum Element { }, /// A serialized value - Serialized { - /// The type of value we are storing. - type_id: TypeId, - - /// The ron data we can deserialize. - ron: Arc, - }, + Serialized(SerializedElement), } impl Clone for Element { @@ -104,10 +109,7 @@ impl Clone for Element { serialize_fn: *serialize_fn, }, - Self::Serialized { type_id, ron } => Self::Serialized { - type_id: *type_id, - ron: ron.clone(), - }, + Self::Serialized(element) => Self::Serialized(element.clone()), } } } @@ -116,13 +118,18 @@ impl std::fmt::Debug for Element { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self { Self::Value { value, .. } => f - .debug_struct("MaybeSerializable::Value") + .debug_struct("Element::Value") .field("type_id", &value.type_id()) .finish_non_exhaustive(), - Self::Serialized { type_id, ron } => f - .debug_struct("MaybeSerializable::Serialized") - .field("type_id", &type_id) - .field("ron", &ron) + Self::Serialized(SerializedElement { + type_id, + ron, + generation, + }) => f + .debug_struct("Element::Serialized") + .field("type_id", type_id) + .field("ron", ron) + .field("generation", generation) .finish(), } } @@ -165,7 +172,7 @@ impl Element { pub(crate) fn type_id(&self) -> TypeId { match self { Self::Value { value, .. } => (**value).type_id().into(), - Self::Serialized { type_id, .. } => *type_id, + Self::Serialized(SerializedElement { type_id, .. }) => *type_id, } } @@ -173,7 +180,7 @@ impl Element { pub(crate) fn get_temp(&self) -> Option<&T> { match self { Self::Value { value, .. } => value.downcast_ref(), - Self::Serialized { .. } => None, + Self::Serialized(_) => None, } } @@ -181,7 +188,7 @@ impl Element { pub(crate) fn get_mut_temp(&mut self) -> Option<&mut T> { match self { Self::Value { value, .. } => value.downcast_mut(), - Self::Serialized { .. } => None, + Self::Serialized(_) => None, } } @@ -196,14 +203,14 @@ impl Element { *self = Self::new_temp(insert_with()); } } - Self::Serialized { .. } => { + Self::Serialized(_) => { *self = Self::new_temp(insert_with()); } } match self { Self::Value { value, .. } => value.downcast_mut().unwrap(), // This unwrap will never panic because we already converted object to required type - Self::Serialized { .. } => unreachable!(), + Self::Serialized(_) => unreachable!(), } } @@ -220,19 +227,19 @@ impl Element { } #[cfg(feature = "persistence")] - Self::Serialized { ron, .. } => { + Self::Serialized(SerializedElement { ron, .. }) => { *self = Self::new_persisted(from_ron_str::(ron).unwrap_or_else(insert_with)); } #[cfg(not(feature = "persistence"))] - Self::Serialized { .. } => { + Self::Serialized(_) => { *self = Self::new_persisted(insert_with()); } } match self { Self::Value { value, .. } => value.downcast_mut().unwrap(), // This unwrap will never panic because we already converted object to required type - Self::Serialized { .. } => unreachable!(), + Self::Serialized(_) => unreachable!(), } } @@ -241,17 +248,17 @@ impl Element { Self::Value { value, .. } => value.downcast_mut(), #[cfg(feature = "persistence")] - Self::Serialized { ron, .. } => { + Self::Serialized(SerializedElement { ron, .. }) => { *self = Self::new_persisted(from_ron_str::(ron)?); match self { Self::Value { value, .. } => value.downcast_mut(), - Self::Serialized { .. } => unreachable!(), + Self::Serialized(_) => unreachable!(), } } #[cfg(not(feature = "persistence"))] - Self::Serialized { .. } => None, + Self::Serialized(_) => None, } } @@ -268,15 +275,13 @@ impl Element { Some(SerializedElement { type_id: (**value).type_id().into(), ron: ron.into(), + generation: 1, }) } else { None } } - Self::Serialized { type_id, ron } => Some(SerializedElement { - type_id: *type_id, - ron: ron.clone(), - }), + Self::Serialized(element) => Some(element.clone()), } } } @@ -341,23 +346,36 @@ use crate::Id; /// assert_eq!(map.get_persisted::(b), Some(13.37)); /// assert_eq!(map.get_temp::(b), Some("Hello World".to_owned())); /// ``` -#[derive(Clone, Debug, Default)] -// We store use `id XOR typeid` as a key, so we don't need to hash again! -pub struct IdTypeMap(nohash_hasher::IntMap); +#[derive(Clone, Debug)] +// We use `id XOR typeid` as a key, so we don't need to hash again! +pub struct IdTypeMap { + map: nohash_hasher::IntMap, + + max_bytes_per_type: usize, +} + +impl Default for IdTypeMap { + fn default() -> Self { + Self { + map: Default::default(), + max_bytes_per_type: 256 * 1024, + } + } +} impl IdTypeMap { /// Insert a value that will not be persisted. #[inline] pub fn insert_temp(&mut self, id: Id, value: T) { let hash = hash(TypeId::of::(), id); - self.0.insert(hash, Element::new_temp(value)); + self.map.insert(hash, Element::new_temp(value)); } /// Insert a value that will be persisted next time you start the app. #[inline] pub fn insert_persisted(&mut self, id: Id, value: T) { let hash = hash(TypeId::of::(), id); - self.0.insert(hash, Element::new_persisted(value)); + self.map.insert(hash, Element::new_persisted(value)); } /// Read a value without trying to deserialize a persisted value. @@ -366,7 +384,7 @@ impl IdTypeMap { #[inline] pub fn get_temp(&self, id: Id) -> Option { let hash = hash(TypeId::of::(), id); - self.0.get(&hash).and_then(|x| x.get_temp()).cloned() + self.map.get(&hash).and_then(|x| x.get_temp()).cloned() } /// Read a value, optionally deserializing it if available. @@ -378,7 +396,7 @@ impl IdTypeMap { #[inline] pub fn get_persisted(&mut self, id: Id) -> Option { let hash = hash(TypeId::of::(), id); - self.0 + self.map .get_mut(&hash) .and_then(|x| x.get_mut_persisted()) .cloned() @@ -418,7 +436,7 @@ impl IdTypeMap { ) -> &mut T { let hash = hash(TypeId::of::(), id); use std::collections::hash_map::Entry; - match self.0.entry(hash) { + match self.map.entry(hash) { Entry::Vacant(vacant) => vacant .insert(Element::new_temp(insert_with())) .get_mut_temp() @@ -436,7 +454,7 @@ impl IdTypeMap { ) -> &mut T { let hash = hash(TypeId::of::(), id); use std::collections::hash_map::Entry; - match self.0.entry(hash) { + match self.map.entry(hash) { Entry::Vacant(vacant) => vacant .insert(Element::new_persisted(insert_with())) .get_mut_persisted() @@ -447,17 +465,28 @@ impl IdTypeMap { } } + /// For tests + #[cfg(feature = "persistence")] + #[allow(unused)] + fn get_generation(&self, id: Id) -> Option { + let element = self.map.get(&hash(TypeId::of::(), id))?; + match element { + Element::Value { .. } => Some(0), + Element::Serialized(SerializedElement { generation, .. }) => Some(*generation), + } + } + /// Remove the state of this type an id. #[inline] pub fn remove(&mut self, id: Id) { let hash = hash(TypeId::of::(), id); - self.0.remove(&hash); + self.map.remove(&hash); } /// Note all state of the given type. pub fn remove_by_type(&mut self) { let key = TypeId::of::(); - self.0.retain(|_, e| { + self.map.retain(|_, e| { let e: &Element = e; e.type_id() != key }); @@ -465,32 +494,32 @@ impl IdTypeMap { #[inline] pub fn clear(&mut self) { - self.0.clear(); + self.map.clear(); } #[inline] pub fn is_empty(&self) -> bool { - self.0.is_empty() + self.map.is_empty() } #[inline] pub fn len(&self) -> usize { - self.0.len() + self.map.len() } /// Count how many values are stored but not yet deserialized. #[inline] pub fn count_serialized(&self) -> usize { - self.0 + self.map .values() - .filter(|e| matches!(e, Element::Serialized { .. })) + .filter(|e| matches!(e, Element::Serialized(_))) .count() } /// Count the number of values are stored with the given type. pub fn count(&self) -> usize { let key = TypeId::of::(); - self.0 + self.map .iter() .filter(|(_, e)| { let e: &Element = e; @@ -498,6 +527,28 @@ impl IdTypeMap { }) .count() } + + /// The maximum number of bytes that will be used to + /// store the persisted state of a single widget type. + /// + /// Some egui widgets store persisted state that is + /// serialized to disk by some backends (e.g. `eframe`). + /// + /// Example of such widgets is `CollapsingHeader` and `Window`. + /// If you keep creating widgets with unique ids (e.g. `Windows` with many different names), + /// egui will use up more and more space for these widgets, until this limit is reached. + /// + /// Once this limit is reached, the state that was read the longest time ago will be dropped first. + /// + /// This value in itself will not be serialized. + pub fn max_bytes_per_type(&self) -> usize { + self.max_bytes_per_type + } + + /// See [`Self::max_bytes_per_type`]. + pub fn set_max_bytes_per_type(&mut self, max_bytes_per_type: usize) { + self.max_bytes_per_type = max_bytes_per_type; + } } #[inline(always)] @@ -516,25 +567,94 @@ struct PersistedMap(Vec<(u64, SerializedElement)>); impl PersistedMap { fn from_map(map: &IdTypeMap) -> Self { crate::profile_function!(); - // filter out the elements which cannot be serialized: - Self( - map.0 - .iter() - .filter_map(|(&hash, element)| Some((hash, element.to_serialize()?))) - .collect(), - ) + + use std::collections::BTreeMap; + + let mut types_map: nohash_hasher::IntMap = Default::default(); + #[derive(Default)] + struct TypeStats { + num_bytes: usize, + generations: BTreeMap, + } + #[derive(Default)] + struct GenerationStats { + num_bytes: usize, + elements: Vec<(u64, SerializedElement)>, + } + + let max_bytes_per_type = map.max_bytes_per_type; + + { + crate::profile_scope!("gather"); + for (hash, element) in &map.map { + if let Some(element) = element.to_serialize() { + let mut stats = types_map.entry(element.type_id).or_default(); + stats.num_bytes += element.ron.len(); + let mut generation_stats = + stats.generations.entry(element.generation).or_default(); + generation_stats.num_bytes += element.ron.len(); + generation_stats.elements.push((*hash, element)); + } else { + // temporary value that shouldn't be serialized + } + } + } + + let mut persisted = vec![]; + + { + crate::profile_scope!("gc"); + for stats in types_map.values() { + let mut bytes_written = 0; + + // Start with the most recently read values, and then go as far as we are allowed. + // Always include at least one generation. + for generation in stats.generations.values() { + if bytes_written == 0 + || bytes_written + generation.num_bytes <= max_bytes_per_type + { + persisted.append(&mut generation.elements.clone()); + bytes_written += generation.num_bytes; + } else { + // Omit the rest. The user hasn't read the values in a while. + break; + } + } + } + } + + Self(persisted) } fn into_map(self) -> IdTypeMap { crate::profile_function!(); - IdTypeMap( - self.0 - .into_iter() - .map(|(hash, SerializedElement { type_id, ron })| { - (hash, Element::Serialized { type_id, ron }) - }) - .collect(), - ) + let map = self + .0 + .into_iter() + .map( + |( + hash, + SerializedElement { + type_id, + ron, + generation, + }, + )| { + ( + hash, + Element::Serialized(SerializedElement { + type_id, + ron, + generation: generation + 1, // This is where we increment the generation! + }), + ) + }, + ) + .collect(); + IdTypeMap { + map, + ..Default::default() + } } } @@ -731,3 +851,129 @@ fn test_mix_serialize() { ); assert_eq!(map.get_temp::(id), Some(Serializable(555))); } + +#[cfg(feature = "persistence")] +#[test] +fn test_serialize_generations() { + use serde::{Deserialize, Serialize}; + + fn serialize_and_deserialize(map: &IdTypeMap) -> IdTypeMap { + let serialized = ron::to_string(map).unwrap(); + ron::from_str(&serialized).unwrap() + } + + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] + struct A(i32); + + let mut map: IdTypeMap = Default::default(); + for i in 0..3 { + map.insert_persisted(Id::new(i), A(i)); + } + for i in 0..3 { + assert_eq!(map.get_generation::(Id::new(i)), Some(0)); + } + + map = serialize_and_deserialize(&map); + + // We use generation 0 for non-serilized, + // 1 for things that have been serialized but never deserialized, + // and then we increment with 1 on each deserialize. + // So we should have generation 2 now: + for i in 0..3 { + assert_eq!(map.get_generation::(Id::new(i)), Some(2)); + } + + // Reading should reset: + assert_eq!(map.get_persisted::(Id::new(0)), Some(A(0))); + assert_eq!(map.get_generation::(Id::new(0)), Some(0)); + + // Generations should increment: + map = serialize_and_deserialize(&map); + assert_eq!(map.get_generation::(Id::new(0)), Some(2)); + assert_eq!(map.get_generation::(Id::new(1)), Some(3)); +} + +#[cfg(feature = "persistence")] +#[test] +fn test_serialize_gc() { + use serde::{Deserialize, Serialize}; + + fn serialize_and_deserialize(mut map: IdTypeMap, max_bytes_per_type: usize) -> IdTypeMap { + map.set_max_bytes_per_type(max_bytes_per_type); + let serialized = ron::to_string(&map).unwrap(); + ron::from_str(&serialized).unwrap() + } + + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] + struct A(usize); + + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] + struct B(usize); + + let mut map: IdTypeMap = Default::default(); + + let num_a = 1_000; + let num_b = 10; + + for i in 0..num_a { + map.insert_persisted(Id::new(i), A(i)); + } + for i in 0..num_b { + map.insert_persisted(Id::new(i), B(i)); + } + + map = serialize_and_deserialize(map, 100); + + // We always serialize at least one generation: + assert_eq!(map.count::(), num_a); + assert_eq!(map.count::(), num_b); + + // Create a new small generation: + map.insert_persisted(Id::new(1_000_000), A(1_000_000)); + map.insert_persisted(Id::new(1_000_000), B(1_000_000)); + + assert_eq!(map.count::(), num_a + 1); + assert_eq!(map.count::(), num_b + 1); + + // And read a value: + assert_eq!(map.get_persisted::(Id::new(0)), Some(A(0))); + assert_eq!(map.get_persisted::(Id::new(0)), Some(B(0))); + + map = serialize_and_deserialize(map, 100); + + assert_eq!( + map.count::(), + 2, + "We should have dropped the oldest generation, but kept the new value and the read value" + ); + assert_eq!( + map.count::(), + num_b + 1, + "B should fit under the byte limit" + ); + + // Create another small generation: + map.insert_persisted(Id::new(2_000_000), A(2_000_000)); + map.insert_persisted(Id::new(2_000_000), B(2_000_000)); + + map = serialize_and_deserialize(map, 100); + + assert_eq!(map.count::(), 3); // The read value, plus the two new ones + assert_eq!(map.count::(), num_b + 2); // all the old ones, plus two new ones + + // Lower the limit, and we should only have the latest generation: + + map = serialize_and_deserialize(map, 1); + + assert_eq!(map.count::(), 1); + assert_eq!(map.count::(), 1); + + assert_eq!( + map.get_persisted::(Id::new(2_000_000)), + Some(A(2_000_000)) + ); + assert_eq!( + map.get_persisted::(Id::new(2_000_000)), + Some(B(2_000_000)) + ); +} From e3362dafac2e01b9e92c6bcaee5d885e6e5fa606 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 5 Sep 2023 10:18:38 +0200 Subject: [PATCH 2/5] CI: Update to actions/checkout@v4 (#3304) --- .github/workflows/rust.yml | 10 +++++----- .github/workflows/typos.yml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b4920b3bf..800ca2422 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -15,7 +15,7 @@ jobs: name: Format + check + test runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: @@ -84,7 +84,7 @@ jobs: name: Check wasm32 + wasm-bindgen runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: toolchain: 1.67.0 @@ -142,7 +142,7 @@ jobs: name: cargo-deny ${{ matrix.target }} runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: EmbarkStudios/cargo-deny-action@v1 with: rust-version: "1.67.0" @@ -156,7 +156,7 @@ jobs: name: android runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: @@ -175,7 +175,7 @@ jobs: name: Check Windows runs-on: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: toolchain: 1.67.0 diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 4f3ae1d8b..6ed556936 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Actions Repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Check spelling of entire workspace uses: crate-ci/typos@master From 1b8e8cb38e109c8a9b44800f97f91999fae1ef81 Mon Sep 17 00:00:00 2001 From: Barugon <16503728+Barugon@users.noreply.github.com> Date: Tue, 5 Sep 2023 01:43:39 -0700 Subject: [PATCH 3/5] `eframe::Frame::info` returns a reference (#3301) * Get a reference to `IntegrationInfo` * Add doc comment * Change `info` to return a reference * Clone integration info * Remove `&` * Clone integration info in another place --- crates/eframe/src/epi/mod.rs | 4 ++-- crates/eframe/src/native/run.rs | 4 ++-- crates/egui_demo_app/src/backend_panel.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/eframe/src/epi/mod.rs b/crates/eframe/src/epi/mod.rs index 85433e7a3..597a9122c 100644 --- a/crates/eframe/src/epi/mod.rs +++ b/crates/eframe/src/epi/mod.rs @@ -762,8 +762,8 @@ impl Frame { } /// Information about the integration. - pub fn info(&self) -> IntegrationInfo { - self.info.clone() + pub fn info(&self) -> &IntegrationInfo { + &self.info } /// A place where you can store custom data in a way that persists when you restart the app. diff --git a/crates/eframe/src/native/run.rs b/crates/eframe/src/native/run.rs index c1db4492b..6a315d1aa 100644 --- a/crates/eframe/src/native/run.rs +++ b/crates/eframe/src/native/run.rs @@ -768,7 +768,7 @@ mod glow_integration { .expect("Single-use AppCreator has unexpectedly already been taken"); let mut app = app_creator(&epi::CreationContext { egui_ctx: integration.egui_ctx.clone(), - integration_info: integration.frame.info(), + integration_info: integration.frame.info().clone(), storage: integration.frame.storage(), gl: Some(gl.clone()), #[cfg(feature = "wgpu")] @@ -1242,7 +1242,7 @@ mod wgpu_integration { .expect("Single-use AppCreator has unexpectedly already been taken"); let mut app = app_creator(&epi::CreationContext { egui_ctx: integration.egui_ctx.clone(), - integration_info: integration.frame.info(), + integration_info: integration.frame.info().clone(), storage: integration.frame.storage(), #[cfg(feature = "glow")] gl: None, diff --git a/crates/egui_demo_app/src/backend_panel.rs b/crates/egui_demo_app/src/backend_panel.rs index 6480756f2..a8222aff2 100644 --- a/crates/egui_demo_app/src/backend_panel.rs +++ b/crates/egui_demo_app/src/backend_panel.rs @@ -155,7 +155,7 @@ impl BackendPanel { // On web, the browser controls `pixels_per_point`. let integration_controls_pixels_per_point = frame.is_web(); if !integration_controls_pixels_per_point { - self.pixels_per_point_ui(ui, &frame.info()); + self.pixels_per_point_ui(ui, frame.info()); } #[cfg(not(target_arch = "wasm32"))] From cf163cc95482afaa3cdacdc6d2229d5477e07e42 Mon Sep 17 00:00:00 2001 From: Barugon <16503728+Barugon@users.noreply.github.com> Date: Tue, 5 Sep 2023 01:44:23 -0700 Subject: [PATCH 4/5] Add `scroll_area::State::velocity` (#3300) * Implement `has_momentum` * Add doc comment * Call it `has_velocity` * Implement `velocity` method --------- Co-authored-by: Emil Ernerfeldt --- crates/egui/src/containers/scroll_area.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 5f7a9a035..0c2cead70 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -54,6 +54,11 @@ impl State { pub fn store(self, ctx: &Context, id: Id) { ctx.data_mut(|d| d.insert_persisted(id, self)); } + + /// Get the current kinetic scrolling velocity. + pub fn velocity(&self) -> Vec2 { + self.vel + } } pub struct ScrollAreaOutput { From 46ea72abe48e212e4c89b340e73e0300d09e6d4c Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 5 Sep 2023 10:45:11 +0200 Subject: [PATCH 5/5] Add control of line height and letter spacing (#3302) * Add `TextFormat::extra_letter_spacing` * Add control of line height * Add to text layout demo * Move the text layout demo to its own window in the demo app * Fix doclink * Better document points vs pixels * Better documentation and code cleanup --- crates/egui/src/lib.rs | 16 ++- crates/egui/src/widget_text.rs | 35 ++++- .../src/demo/demo_app_windows.rs | 3 +- .../src/demo/misc_demo_window.rs | 68 +-------- crates/egui_demo_lib/src/demo/mod.rs | 1 + crates/egui_demo_lib/src/demo/text_edit.rs | 10 +- crates/egui_demo_lib/src/demo/text_layout.rs | 135 ++++++++++++++++++ .../src/easy_mark/easy_mark_highlighter.rs | 1 + crates/epaint/src/lib.rs | 11 ++ crates/epaint/src/text/font.rs | 9 +- crates/epaint/src/text/fonts.rs | 2 +- crates/epaint/src/text/text_layout.rs | 53 ++++--- crates/epaint/src/text/text_layout_types.rs | 55 ++++++- 13 files changed, 286 insertions(+), 113 deletions(-) create mode 100644 crates/egui_demo_lib/src/demo/text_layout.rs diff --git a/crates/egui/src/lib.rs b/crates/egui/src/lib.rs index 2201afefb..f52eabc1a 100644 --- a/crates/egui/src/lib.rs +++ b/crates/egui/src/lib.rs @@ -92,14 +92,16 @@ //! # }); //! ``` //! -//! ## Conventions +//! ## Coordinate system +//! The left-top corner of the screen is `(0.0, 0.0)`, +//! with X increasing to the right and Y increasing downwards. //! -//! Conventions unless otherwise specified: +//! `egui` uses logical _points_ as its coordinate system. +//! Those related to physical _pixels_ by the `pixels_per_point` scale factor. +//! For example, a high-dpi screeen can have `pixels_per_point = 2.0`, +//! meaning there are two physical screen pixels for each logical point. //! -//! * angles are in radians -//! * `Vec2::X` is right and `Vec2::Y` is down. -//! * `Pos2::ZERO` is left top. -//! * Positions and sizes are measured in _points_. Each point may consist of many physical pixels. +//! Angles are in radians, and are measured clockwise from the X-axis, which has angle=0. //! //! # Integrating with egui //! @@ -352,7 +354,7 @@ pub mod text { pub use crate::text_edit::CCursorRange; pub use epaint::text::{ cursor::CCursor, FontData, FontDefinitions, FontFamily, Fonts, Galley, LayoutJob, - LayoutSection, TextFormat, TAB_SIZE, + LayoutSection, TextFormat, TextWrapping, TAB_SIZE, }; } diff --git a/crates/egui/src/widget_text.rs b/crates/egui/src/widget_text.rs index 3d252cb21..531f35820 100644 --- a/crates/egui/src/widget_text.rs +++ b/crates/egui/src/widget_text.rs @@ -1,5 +1,4 @@ -use std::borrow::Cow; -use std::sync::Arc; +use std::{borrow::Cow, sync::Arc}; use crate::{ style::WidgetVisuals, text::LayoutJob, Align, Color32, FontFamily, FontSelection, Galley, Pos2, @@ -25,6 +24,8 @@ use crate::{ pub struct RichText { text: String, size: Option, + extra_letter_spacing: f32, + line_height: Option, family: Option, text_style: Option, background_color: Color32, @@ -100,6 +101,32 @@ impl RichText { self } + /// Extra spacing between letters, in points. + /// + /// Default: 0.0. + /// + /// For even text it is recommended you round this to an even number of _pixels_, + /// e.g. using [`crate::Painter::round_to_pixel`]. + #[inline] + pub fn extra_letter_spacing(mut self, extra_letter_spacing: f32) -> Self { + self.extra_letter_spacing = extra_letter_spacing; + self + } + + /// 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_, + /// e.g. using [`crate::Painter::round_to_pixel`]. + #[inline] + pub fn line_height(mut self, line_height: Option) -> Self { + self.line_height = line_height; + self + } + /// Select the font family. /// /// This overrides the value from [`Self::text_style`]. @@ -253,6 +280,8 @@ impl RichText { let Self { text, size, + extra_letter_spacing, + line_height, family, text_style, background_color, @@ -309,6 +338,8 @@ impl RichText { let text_format = crate::text::TextFormat { font_id, + extra_letter_spacing, + line_height, color: text_color, background: background_color, italics, diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index 354bb44e6..02c88357c 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -35,7 +35,8 @@ impl Default for Demos { Box::::default(), Box::::default(), Box::::default(), - Box::::default(), + Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), 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 b48b85c2a..195b79e22 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -1,5 +1,5 @@ use super::*; -use egui::{epaint::text::TextWrapping, *}; +use egui::*; /// Showcase some ui code #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -7,8 +7,6 @@ use egui::{epaint::text::TextWrapping, *}; pub struct MiscDemoWindow { num_columns: usize, - text_break: TextBreakDemo, - widgets: Widgets, colors: ColorWidgets, custom_collapsing_header: CustomCollapsingHeader, @@ -24,8 +22,6 @@ impl Default for MiscDemoWindow { MiscDemoWindow { num_columns: 2, - text_break: Default::default(), - widgets: Default::default(), colors: Default::default(), custom_collapsing_header: Default::default(), @@ -72,8 +68,6 @@ impl View for MiscDemoWindow { .default_open(false) .show(ui, |ui| { text_layout_demo(ui); - ui.separator(); - self.text_break.ui(ui); ui.vertical_centered(|ui| { ui.add(crate::egui_github_link_file_line!()); }); @@ -644,63 +638,3 @@ fn text_layout_demo(ui: &mut Ui) { ui.label(job); } - -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -#[cfg_attr(feature = "serde", serde(default))] -struct TextBreakDemo { - break_anywhere: bool, - max_rows: usize, - overflow_character: Option, -} - -impl Default for TextBreakDemo { - fn default() -> Self { - Self { - max_rows: 1, - break_anywhere: true, - overflow_character: Some('…'), - } - } -} - -impl TextBreakDemo { - pub fn ui(&mut self, ui: &mut Ui) { - let Self { - break_anywhere, - max_rows, - overflow_character, - } = self; - - use egui::text::LayoutJob; - - ui.horizontal(|ui| { - ui.add(DragValue::new(max_rows)); - ui.label("Max rows"); - }); - - ui.horizontal(|ui| { - ui.label("Line-break:"); - ui.radio_value(break_anywhere, false, "word boundaries"); - ui.radio_value(break_anywhere, true, "anywhere"); - }); - - ui.horizontal(|ui| { - ui.selectable_value(overflow_character, None, "None"); - ui.selectable_value(overflow_character, Some('…'), "…"); - ui.selectable_value(overflow_character, Some('—'), "—"); - ui.selectable_value(overflow_character, Some('-'), " - "); - ui.label("Overflow character"); - }); - - let mut job = - LayoutJob::single_section(crate::LOREM_IPSUM_LONG.to_owned(), TextFormat::default()); - job.wrap = TextWrapping { - max_rows: *max_rows, - break_anywhere: *break_anywhere, - overflow_character: *overflow_character, - ..Default::default() - }; - - ui.label(job); // `Label` overrides some of the wrapping settings, e.g. wrap width - } -} diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index dddf215d0..220fbbcd1 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -26,6 +26,7 @@ pub mod strip_demo; pub mod table_demo; pub mod tests; pub mod text_edit; +pub mod text_layout; pub mod toggle_switch; pub mod widget_gallery; pub mod window_options; diff --git a/crates/egui_demo_lib/src/demo/text_edit.rs b/crates/egui_demo_lib/src/demo/text_edit.rs index 6ad3f2420..d8f681e0c 100644 --- a/crates/egui_demo_lib/src/demo/text_edit.rs +++ b/crates/egui_demo_lib/src/demo/text_edit.rs @@ -1,12 +1,12 @@ -/// Showcase [`TextEdit`]. +/// Showcase [`egui::TextEdit`]. #[derive(PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] -pub struct TextEdit { +pub struct TextEditDemo { pub text: String, } -impl Default for TextEdit { +impl Default for TextEditDemo { fn default() -> Self { Self { text: "Edit this text".to_owned(), @@ -14,7 +14,7 @@ impl Default for TextEdit { } } -impl super::Demo for TextEdit { +impl super::Demo for TextEditDemo { fn name(&self) -> &'static str { "🖹 TextEdit" } @@ -30,7 +30,7 @@ impl super::Demo for TextEdit { } } -impl super::View for TextEdit { +impl super::View for TextEditDemo { fn ui(&mut self, ui: &mut egui::Ui) { let Self { text } = self; diff --git a/crates/egui_demo_lib/src/demo/text_layout.rs b/crates/egui_demo_lib/src/demo/text_layout.rs new file mode 100644 index 000000000..01a5c7787 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/text_layout.rs @@ -0,0 +1,135 @@ +/// Showcase text layout +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct TextLayoutDemo { + break_anywhere: bool, + max_rows: usize, + overflow_character: Option, + extra_letter_spacing_pixels: i32, + line_height_pixels: u32, +} + +impl Default for TextLayoutDemo { + fn default() -> Self { + Self { + max_rows: 3, + break_anywhere: true, + overflow_character: Some('…'), + extra_letter_spacing_pixels: 0, + line_height_pixels: 0, + } + } +} + +impl super::Demo for TextLayoutDemo { + fn name(&self) -> &'static str { + "🖹 Text Layout" + } + + fn show(&mut self, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(self.name()) + .open(open) + .resizable(true) + .show(ctx, |ui| { + use super::View as _; + self.ui(ui); + }); + } +} + +impl super::View for TextLayoutDemo { + fn ui(&mut self, ui: &mut egui::Ui) { + let Self { + break_anywhere, + max_rows, + overflow_character, + extra_letter_spacing_pixels, + line_height_pixels, + } = self; + + use egui::text::LayoutJob; + + let pixels_per_point = ui.ctx().pixels_per_point(); + let points_per_pixel = 1.0 / pixels_per_point; + + ui.vertical_centered(|ui| { + ui.add(crate::egui_github_link_file_line!()); + }); + + ui.add_space(12.0); + + egui::Grid::new("TextLayoutDemo") + .num_columns(2) + .show(ui, |ui| { + ui.label("Max rows:"); + ui.add(egui::DragValue::new(max_rows)); + ui.end_row(); + + ui.label("Line-break:"); + ui.horizontal(|ui| { + ui.radio_value(break_anywhere, false, "word boundaries"); + ui.radio_value(break_anywhere, true, "anywhere"); + }); + ui.end_row(); + + ui.label("Overflow character:"); + ui.horizontal(|ui| { + ui.selectable_value(overflow_character, None, "None"); + ui.selectable_value(overflow_character, Some('…'), "…"); + ui.selectable_value(overflow_character, Some('—'), "—"); + ui.selectable_value(overflow_character, Some('-'), " - "); + }); + ui.end_row(); + + ui.label("Extra letter spacing:"); + ui.add(egui::DragValue::new(extra_letter_spacing_pixels).suffix(" pixels")); + ui.end_row(); + + ui.label("Line height:"); + ui.horizontal(|ui| { + if ui + .selectable_label(*line_height_pixels == 0, "Default") + .clicked() + { + *line_height_pixels = 0; + } + if ui + .selectable_label(*line_height_pixels != 0, "Custom") + .clicked() + { + *line_height_pixels = (pixels_per_point * 20.0).round() as _; + } + if *line_height_pixels != 0 { + ui.add(egui::DragValue::new(line_height_pixels).suffix(" pixels")); + } + }); + ui.end_row(); + }); + + ui.add_space(12.0); + + egui::ScrollArea::vertical().show(ui, |ui| { + let extra_letter_spacing = points_per_pixel * *extra_letter_spacing_pixels as f32; + let line_height = + (*line_height_pixels != 0).then_some(points_per_pixel * *line_height_pixels as f32); + + let mut job = LayoutJob::single_section( + crate::LOREM_IPSUM_LONG.to_owned(), + egui::TextFormat { + extra_letter_spacing, + line_height, + ..Default::default() + }, + ); + job.wrap = egui::text::TextWrapping { + max_rows: *max_rows, + break_anywhere: *break_anywhere, + overflow_character: *overflow_character, + ..Default::default() + }; + + // NOTE: `Label` overrides some of the wrapping settings, e.g. wrap width + ui.label(job); + }); + } +} diff --git a/crates/egui_demo_lib/src/easy_mark/easy_mark_highlighter.rs b/crates/egui_demo_lib/src/easy_mark/easy_mark_highlighter.rs index c15a285b9..7431accc5 100644 --- a/crates/egui_demo_lib/src/easy_mark/easy_mark_highlighter.rs +++ b/crates/egui_demo_lib/src/easy_mark/easy_mark_highlighter.rs @@ -188,5 +188,6 @@ fn format_from_style( underline, strikethrough, valign, + ..Default::default() } } diff --git a/crates/epaint/src/lib.rs b/crates/epaint/src/lib.rs index 1662c94a1..6fb4bdaff 100644 --- a/crates/epaint/src/lib.rs +++ b/crates/epaint/src/lib.rs @@ -5,6 +5,17 @@ //! Create some [`Shape`]:s and pass them to [`tessellate_shapes`] to generate [`Mesh`]:es //! that you can then paint using some graphics API of your choice (e.g. OpenGL). //! +//! ## Coordinate system +//! The left-top corner of the screen is `(0.0, 0.0)`, +//! with X increasing to the right and Y increasing downwards. +//! +//! `epaint` uses logical _points_ as its coordinate system. +//! Those related to physical _pixels_ by the `pixels_per_point` scale factor. +//! For example, a high-dpi screeen can have `pixels_per_point = 2.0`, +//! meaning there are two physical screen pixels for each logical point. +//! +//! Angles are in radians, and are measured clockwise from the X-axis, which has angle=0. +//! //! ## Feature flags #![cfg_attr(feature = "document-features", doc = document_features::document_features!())] //! diff --git a/crates/epaint/src/text/font.rs b/crates/epaint/src/text/font.rs index dfcb52a11..e6b495c23 100644 --- a/crates/epaint/src/text/font.rs +++ b/crates/epaint/src/text/font.rs @@ -49,11 +49,6 @@ pub struct GlyphInfo { /// Unit: points. pub ascent: f32, - /// row height computed from the font metrics. - /// - /// Unit: points. - pub row_height: f32, - /// Texture coordinates. pub uv_rect: UvRect, } @@ -65,7 +60,6 @@ impl Default for GlyphInfo { id: ab_glyph::GlyphId(0), advance_width: 0.0, ascent: 0.0, - row_height: 0.0, uv_rect: Default::default(), } } @@ -250,7 +244,7 @@ impl FontImpl { / self.pixels_per_point } - /// Height of one row of text. In points + /// Height of one row of text in points. #[inline(always)] pub fn row_height(&self) -> f32 { self.height_in_points @@ -312,7 +306,6 @@ impl FontImpl { id: glyph_id, advance_width: advance_width_in_points, ascent: self.ascent, - row_height: self.row_height(), uv_rect, } } diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index 474d6e70e..ab08fbe83 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -653,7 +653,7 @@ impl FontsImpl { self.font(font_id).has_glyphs(s) } - /// Height of one row of text. In points + /// Height of one row of text in points. fn row_height(&mut self, font_id: &FontId) -> f32 { self.font(font_id).row_height() } diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 5aab349e2..5586bfa9a 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -113,11 +113,15 @@ fn layout_section( format, } = section; let font = fonts.font(&format.font_id); - let font_height = font.row_height(); + 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 = font_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? + paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? } paragraph.cursor_x += leading_space; @@ -128,19 +132,20 @@ fn layout_section( if job.break_on_newline && chr == '\n' { out_paragraphs.push(Paragraph::default()); paragraph = out_paragraphs.last_mut().unwrap(); - paragraph.empty_paragraph_height = font_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? + 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.glyph_info_and_font_impl(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), - size: vec2(glyph_info.advance_width, glyph_info.row_height), + size: vec2(glyph_info.advance_width, line_height), ascent: glyph_info.ascent, uv_rect: glyph_info.uv_rect, section_index, @@ -328,8 +333,12 @@ fn replace_last_glyph_with_overflow_character( }; 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); - let font_height = font.row_height(); + let line_height = section + .format + .line_height + .unwrap_or_else(|| font.row_height()); let prev_glyph_id = prev_glyph.map(|prev_glyph| { let (_, prev_glyph_info) = font.glyph_info_and_font_impl(prev_glyph.chr); @@ -338,23 +347,29 @@ fn replace_last_glyph_with_overflow_character( // undo kerning with previous glyph let (font_impl, glyph_info) = font.glyph_info_and_font_impl(last_glyph.chr); - last_glyph.pos.x -= font_impl - .zip(prev_glyph_id) - .map(|(font_impl, prev_glyph_id)| font_impl.pair_kerning(prev_glyph_id, glyph_info.id)) - .unwrap_or_default(); + last_glyph.pos.x -= extra_letter_spacing + + font_impl + .zip(prev_glyph_id) + .map(|(font_impl, prev_glyph_id)| { + font_impl.pair_kerning(prev_glyph_id, glyph_info.id) + }) + .unwrap_or_default(); // replace the glyph last_glyph.chr = overflow_character; let (font_impl, glyph_info) = font.glyph_info_and_font_impl(last_glyph.chr); - last_glyph.size = vec2(glyph_info.advance_width, font_height); + last_glyph.size = vec2(glyph_info.advance_width, line_height); last_glyph.uv_rect = glyph_info.uv_rect; last_glyph.ascent = glyph_info.ascent; // reapply kerning - last_glyph.pos.x += font_impl - .zip(prev_glyph_id) - .map(|(font_impl, prev_glyph_id)| font_impl.pair_kerning(prev_glyph_id, glyph_info.id)) - .unwrap_or_default(); + last_glyph.pos.x += extra_letter_spacing + + font_impl + .zip(prev_glyph_id) + .map(|(font_impl, prev_glyph_id)| { + font_impl.pair_kerning(prev_glyph_id, glyph_info.id) + }) + .unwrap_or_default(); row.rect.max.x = last_glyph.max_x(); @@ -474,7 +489,7 @@ fn galley_from_rows( let mut min_x: f32 = 0.0; let mut max_x: f32 = 0.0; for row in &mut rows { - let mut row_height = first_row_min_height.max(row.rect.height()); + let mut line_height = first_row_min_height.max(row.rect.height()); let mut row_ascent = 0.0f32; first_row_min_height = 0.0; @@ -484,10 +499,10 @@ fn galley_from_rows( .iter() .max_by(|a, b| a.size.y.partial_cmp(&b.size.y).unwrap()) { - row_height = glyph.size.y; + line_height = glyph.size.y; row_ascent = glyph.ascent; } - row_height = point_scale.round_to_pixel(row_height); + line_height = point_scale.round_to_pixel(line_height); // Now positions each glyph: for glyph in &mut row.glyphs { @@ -503,11 +518,11 @@ fn galley_from_rows( } row.rect.min.y = cursor_y; - row.rect.max.y = cursor_y + row_height; + row.rect.max.y = cursor_y + line_height; min_x = min_x.min(row.rect.min.x); max_x = max_x.max(row.rect.max.x); - cursor_y += row_height; + cursor_y += line_height; cursor_y = point_scale.round_to_pixel(cursor_y); } diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 2a73bd60f..96475b278 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -221,11 +221,28 @@ impl std::hash::Hash for LayoutSection { // ---------------------------------------------------------------------------- -#[derive(Clone, Debug, Hash, PartialEq)] +/// 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, @@ -248,6 +265,8 @@ impl Default for TextFormat { fn default() -> Self { Self { font_id: FontId::default(), + extra_letter_spacing: 0.0, + line_height: None, color: Color32::GRAY, background: Color32::TRANSPARENT, italics: false, @@ -258,6 +277,34 @@ impl Default for TextFormat { } } +impl std::hash::Hash for TextFormat { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + font_id, + extra_letter_spacing, + line_height, + color, + background, + italics, + underline, + strikethrough, + valign, + } = self; + font_id.hash(state); + crate::f32_hash(state, *extra_letter_spacing); + if let Some(line_height) = *line_height { + crate::f32_hash(state, line_height); + } + color.hash(state); + background.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 { @@ -486,10 +533,12 @@ pub struct Glyph { /// `ascent` value from the font pub ascent: f32, - /// Advance width and font row height. + /// Advance width and line height. + /// + /// Does not control the visual size of the glyph (see [`Self::uv_rect`] for that). pub size: Vec2, - /// Position of the glyph in the font texture, in texels. + /// Position and size of the glyph in the font texture, in texels. pub uv_rect: UvRect, /// Index into [`LayoutJob::sections`]. Decides color etc.