Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): typography, strings table, text primitives

Lewis: May this revision serve well! <lu5a@proton.me>

+416 -14
+4
crates/bone-ui/Cargo.toml
··· 6 6 rust-version.workspace = true 7 7 8 8 [dependencies] 9 + bone-text = { workspace = true } 10 + lyon_tessellation = { workspace = true } 9 11 palette = { workspace = true } 10 12 serde = { workspace = true } 13 + swash = { workspace = true } 11 14 taffy = { workspace = true } 12 15 thiserror = { workspace = true } 16 + unicode-segmentation = { workspace = true } 13 17 uom = { workspace = true } 14 18 15 19 [dev-dependencies]
+5
crates/bone-ui/src/layout/geometry.rs
··· 92 92 } 93 93 94 94 impl LayoutSize { 95 + pub const ZERO: Self = Self { 96 + width: LayoutPx::ZERO, 97 + height: LayoutPx::ZERO, 98 + }; 99 + 95 100 #[must_use] 96 101 pub const fn new(width: LayoutPx, height: LayoutPx) -> Self { 97 102 assert!(
+8
crates/bone-ui/src/lib.rs
··· 4 4 pub mod hotkey; 5 5 pub mod input; 6 6 pub mod layout; 7 + pub mod strings; 8 + pub mod text; 7 9 pub mod theme; 8 10 mod widget_id; 9 11 ··· 21 23 pub use input::{ 22 24 ClickCount, DoubleClickWindow, DragThreshold, FrameInstant, InputSnapshot, KeyChar, KeyCode, 23 25 KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample, 26 + }; 27 + pub use strings::{StringKey, StringTable}; 28 + pub use text::{ 29 + AtlasEntry, CaretMove, GlyphId, MaxWidth, OutlineTessellator, SdfAtlas, SdfAtlasError, 30 + SdfAtlasParams, Selection, SelectionAction, ShapeRequest, ShapedText, Shaper, SourceByteIndex, 31 + TessellatedGlyph, TextLayout, TextPrimitive, TextRole, request_for, 24 32 }; 25 33 pub use theme::{ 26 34 BlurRadius, Border, CadColors, Color, ColorError, Colors, Easing, ElevationLevel,
+131
crates/bone-ui/src/strings.rs
··· 1 + use core::fmt; 2 + use std::collections::HashMap; 3 + 4 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] 5 + pub struct StringKey(&'static str); 6 + 7 + impl StringKey { 8 + #[must_use] 9 + pub const fn new(id: &'static str) -> Self { 10 + Self(id) 11 + } 12 + 13 + #[must_use] 14 + pub const fn id(self) -> &'static str { 15 + self.0 16 + } 17 + } 18 + 19 + impl fmt::Display for StringKey { 20 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 21 + f.write_str(self.0) 22 + } 23 + } 24 + 25 + #[derive(Clone, Debug, Default)] 26 + pub struct StringTable { 27 + entries: HashMap<StringKey, String>, 28 + } 29 + 30 + impl StringTable { 31 + #[must_use] 32 + pub fn new() -> Self { 33 + Self::default() 34 + } 35 + 36 + #[must_use] 37 + pub fn from_entries<I>(iter: I) -> Self 38 + where 39 + I: IntoIterator<Item = (StringKey, String)>, 40 + { 41 + Self { 42 + entries: iter.into_iter().collect(), 43 + } 44 + } 45 + 46 + pub fn insert(&mut self, key: StringKey, value: String) -> Option<String> { 47 + self.entries.insert(key, value) 48 + } 49 + 50 + #[must_use] 51 + pub fn resolve(&self, key: StringKey) -> &str { 52 + self.entries.get(&key).map_or(key.0, String::as_str) 53 + } 54 + 55 + #[must_use] 56 + pub fn contains(&self, key: StringKey) -> bool { 57 + self.entries.contains_key(&key) 58 + } 59 + 60 + #[must_use] 61 + pub fn len(&self) -> usize { 62 + self.entries.len() 63 + } 64 + 65 + #[must_use] 66 + pub fn is_empty(&self) -> bool { 67 + self.entries.is_empty() 68 + } 69 + } 70 + 71 + #[cfg(test)] 72 + mod tests { 73 + use super::{StringKey, StringTable}; 74 + 75 + const GREETING: StringKey = StringKey::new("dialog.greeting"); 76 + const FAREWELL: StringKey = StringKey::new("dialog.farewell"); 77 + 78 + #[test] 79 + fn key_round_trips_id() { 80 + assert_eq!(GREETING.id(), "dialog.greeting"); 81 + } 82 + 83 + #[test] 84 + fn key_displays_id() { 85 + assert_eq!(format!("{GREETING}"), "dialog.greeting"); 86 + } 87 + 88 + #[test] 89 + fn distinct_keys_compare_distinct() { 90 + assert_ne!(GREETING, FAREWELL); 91 + assert_eq!(GREETING, StringKey::new("dialog.greeting")); 92 + } 93 + 94 + #[test] 95 + fn empty_table_falls_back_to_id() { 96 + let table = StringTable::new(); 97 + assert_eq!(table.resolve(GREETING), "dialog.greeting"); 98 + assert!(table.is_empty()); 99 + assert_eq!(table.len(), 0); 100 + } 101 + 102 + #[test] 103 + fn populated_table_returns_entry() { 104 + let table = StringTable::from_entries([(GREETING, "Hello".to_owned())]); 105 + assert_eq!(table.resolve(GREETING), "Hello"); 106 + assert_eq!(table.resolve(FAREWELL), "dialog.farewell"); 107 + assert!(table.contains(GREETING)); 108 + assert!(!table.contains(FAREWELL)); 109 + } 110 + 111 + #[test] 112 + fn insert_replaces_existing() { 113 + let mut table = StringTable::new(); 114 + assert!(table.insert(GREETING, "Hi".to_owned()).is_none()); 115 + assert_eq!( 116 + table.insert(GREETING, "Hello".to_owned()), 117 + Some("Hi".to_owned()), 118 + ); 119 + assert_eq!(table.resolve(GREETING), "Hello"); 120 + } 121 + 122 + #[test] 123 + fn len_reflects_inserted_entries() { 124 + let table = StringTable::from_entries([ 125 + (GREETING, "Hello".to_owned()), 126 + (FAREWELL, "Bye".to_owned()), 127 + ]); 128 + assert_eq!(table.len(), 2); 129 + assert!(!table.is_empty()); 130 + } 131 + }
+13
crates/bone-ui/src/text/mod.rs
··· 1 + mod primitive; 2 + pub mod raster; 3 + mod selection; 4 + 5 + pub use bone_text::{ 6 + GlyphId, MaxWidth, ShapeRequest, ShapedGlyph, ShapedLine, ShapedRun, ShapedText, Shaper, 7 + SourceByteIndex, 8 + }; 9 + pub use primitive::{TextLayout, TextPrimitive, TextRole, request_for}; 10 + pub use raster::{ 11 + AtlasEntry, OutlineTessellator, SdfAtlas, SdfAtlasError, SdfAtlasParams, TessellatedGlyph, 12 + }; 13 + pub use selection::{CaretMove, Selection, SelectionAction};
+248
crates/bone-ui/src/text/primitive.rs
··· 1 + use bone_text::{MaxWidth, ShapeRequest, ShapedText, Shaper}; 2 + 3 + use crate::strings::{StringKey, StringTable}; 4 + use crate::theme::{Theme, TypographyRole}; 5 + 6 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 7 + pub enum TextRole { 8 + Body, 9 + Label, 10 + Heading, 11 + Mono, 12 + } 13 + 14 + #[derive(Copy, Clone, Debug, PartialEq)] 15 + pub enum TextLayout { 16 + Line, 17 + Wrap(MaxWidth), 18 + } 19 + 20 + #[derive(Clone, Debug, PartialEq)] 21 + pub struct TextPrimitive { 22 + pub role: TextRole, 23 + pub style: TypographyRole, 24 + pub layout: TextLayout, 25 + pub shaped: ShapedText, 26 + } 27 + 28 + impl TextPrimitive { 29 + #[must_use] 30 + pub fn for_role( 31 + shaper: &mut Shaper, 32 + theme: &Theme, 33 + table: &StringTable, 34 + key: StringKey, 35 + layout: TextLayout, 36 + role: TextRole, 37 + ) -> Self { 38 + let style = typography_for(theme, role); 39 + let max_width = match layout { 40 + TextLayout::Line => None, 41 + TextLayout::Wrap(w) => Some(w), 42 + }; 43 + let out = shaper.shape(table.resolve(key), request_for(style, max_width)); 44 + Self { 45 + role, 46 + style, 47 + layout, 48 + shaped: out, 49 + } 50 + } 51 + 52 + #[must_use] 53 + pub fn body( 54 + shaper: &mut Shaper, 55 + theme: &Theme, 56 + table: &StringTable, 57 + key: StringKey, 58 + layout: TextLayout, 59 + ) -> Self { 60 + Self::for_role(shaper, theme, table, key, layout, TextRole::Body) 61 + } 62 + 63 + #[must_use] 64 + pub fn label( 65 + shaper: &mut Shaper, 66 + theme: &Theme, 67 + table: &StringTable, 68 + key: StringKey, 69 + layout: TextLayout, 70 + ) -> Self { 71 + Self::for_role(shaper, theme, table, key, layout, TextRole::Label) 72 + } 73 + 74 + #[must_use] 75 + pub fn heading( 76 + shaper: &mut Shaper, 77 + theme: &Theme, 78 + table: &StringTable, 79 + key: StringKey, 80 + layout: TextLayout, 81 + ) -> Self { 82 + Self::for_role(shaper, theme, table, key, layout, TextRole::Heading) 83 + } 84 + 85 + #[must_use] 86 + pub fn mono( 87 + shaper: &mut Shaper, 88 + theme: &Theme, 89 + table: &StringTable, 90 + key: StringKey, 91 + layout: TextLayout, 92 + ) -> Self { 93 + Self::for_role(shaper, theme, table, key, layout, TextRole::Mono) 94 + } 95 + } 96 + 97 + #[must_use] 98 + pub fn request_for(style: TypographyRole, max_width: Option<MaxWidth>) -> ShapeRequest { 99 + ShapeRequest { 100 + face: style.face, 101 + size_px: style.size.as_px_f32(), 102 + weight: style.weight, 103 + line_height_px: style.line_height.as_px_f32(), 104 + letter_spacing_px: style.letter_spacing.as_px_f32(), 105 + max_width, 106 + } 107 + } 108 + 109 + fn typography_for(theme: &Theme, role: TextRole) -> TypographyRole { 110 + match role { 111 + TextRole::Body => theme.typography.body, 112 + TextRole::Label => theme.typography.label, 113 + TextRole::Heading => theme.typography.heading, 114 + TextRole::Mono => theme.typography.mono, 115 + } 116 + } 117 + 118 + #[cfg(test)] 119 + mod tests { 120 + use super::{TextLayout, TextPrimitive, TextRole, request_for}; 121 + use crate::strings::{StringKey, StringTable}; 122 + use crate::theme::{FontFace, Theme}; 123 + use bone_text::{MaxWidth, Shaper}; 124 + 125 + const GREETING: StringKey = StringKey::new("primitive.greeting"); 126 + const LONG: StringKey = StringKey::new("primitive.long"); 127 + const FI: StringKey = StringKey::new("primitive.fi"); 128 + 129 + fn fixture() -> (Shaper, Theme, StringTable) { 130 + let table = StringTable::from_entries([ 131 + (GREETING, "Hello world".to_owned()), 132 + ( 133 + LONG, 134 + "the quick brown fox jumps over the lazy dog".to_owned(), 135 + ), 136 + (FI, "fi".to_owned()), 137 + ]); 138 + (Shaper::new(), Theme::light(), table) 139 + } 140 + 141 + fn cap_60px() -> MaxWidth { 142 + let Some(cap) = MaxWidth::new(60.0) else { 143 + panic!("60px must be a positive width") 144 + }; 145 + cap 146 + } 147 + 148 + #[test] 149 + fn body_carries_body_role_and_style() { 150 + let (mut shaper, theme, table) = fixture(); 151 + let p = TextPrimitive::body(&mut shaper, &theme, &table, GREETING, TextLayout::Line); 152 + assert_eq!(p.role, TextRole::Body); 153 + assert_eq!(p.style, theme.typography.body); 154 + assert_eq!(p.shaped.face, theme.typography.body.face); 155 + } 156 + 157 + #[test] 158 + fn label_carries_label_role_and_style() { 159 + let (mut shaper, theme, table) = fixture(); 160 + let p = TextPrimitive::label(&mut shaper, &theme, &table, GREETING, TextLayout::Line); 161 + assert_eq!(p.role, TextRole::Label); 162 + assert_eq!(p.style, theme.typography.label); 163 + } 164 + 165 + #[test] 166 + fn heading_carries_heading_role_and_style() { 167 + let (mut shaper, theme, table) = fixture(); 168 + let p = TextPrimitive::heading(&mut shaper, &theme, &table, GREETING, TextLayout::Line); 169 + assert_eq!(p.role, TextRole::Heading); 170 + assert_eq!(p.style, theme.typography.heading); 171 + } 172 + 173 + #[test] 174 + fn mono_carries_mono_role_and_uses_mono_face() { 175 + let (mut shaper, theme, table) = fixture(); 176 + let p = TextPrimitive::mono(&mut shaper, &theme, &table, FI, TextLayout::Line); 177 + assert_eq!(p.role, TextRole::Mono); 178 + assert_eq!(p.style, theme.typography.mono); 179 + assert_eq!(p.shaped.face, FontFace::Mono); 180 + assert_eq!(p.shaped.glyph_count(), 2, "mono face must not ligate fi"); 181 + } 182 + 183 + #[test] 184 + fn single_line_layout_does_not_wrap_long_text() { 185 + let (mut shaper, theme, table) = fixture(); 186 + let p = TextPrimitive::body(&mut shaper, &theme, &table, LONG, TextLayout::Line); 187 + assert_eq!(p.layout, TextLayout::Line); 188 + assert_eq!(p.shaped.line_count(), 1); 189 + } 190 + 191 + #[test] 192 + fn wrap_layout_breaks_long_text_into_multiple_lines() { 193 + let (mut shaper, theme, table) = fixture(); 194 + let cap = cap_60px(); 195 + let p = TextPrimitive::body(&mut shaper, &theme, &table, LONG, TextLayout::Wrap(cap)); 196 + assert_eq!(p.layout, TextLayout::Wrap(cap)); 197 + assert!( 198 + p.shaped.line_count() >= 2, 199 + "expected wrap, got {}", 200 + p.shaped.line_count(), 201 + ); 202 + } 203 + 204 + #[test] 205 + fn missing_string_key_falls_back_to_id_and_still_shapes() { 206 + let mut shaper = Shaper::new(); 207 + let theme = Theme::light(); 208 + let table = StringTable::new(); 209 + let p = TextPrimitive::label( 210 + &mut shaper, 211 + &theme, 212 + &table, 213 + StringKey::new("absent.key"), 214 + TextLayout::Line, 215 + ); 216 + assert!(p.shaped.glyph_count() > 0); 217 + } 218 + 219 + #[test] 220 + fn font_size_round_trips_from_theme_to_shaped_text() { 221 + let (mut shaper, theme, table) = fixture(); 222 + let p = TextPrimitive::heading(&mut shaper, &theme, &table, GREETING, TextLayout::Line); 223 + assert!((p.shaped.font_size_px - theme.typography.heading.size.as_px_f32()).abs() < 0.01); 224 + } 225 + 226 + #[test] 227 + fn distinct_roles_select_distinct_styles() { 228 + let (mut shaper, theme, table) = fixture(); 229 + let body = TextPrimitive::body(&mut shaper, &theme, &table, GREETING, TextLayout::Line); 230 + let heading = 231 + TextPrimitive::heading(&mut shaper, &theme, &table, GREETING, TextLayout::Line); 232 + assert_ne!(body.style, heading.style); 233 + assert_ne!(body.role, heading.role); 234 + } 235 + 236 + #[test] 237 + fn request_for_round_trips_typography_role() { 238 + let theme = Theme::light(); 239 + let style = theme.typography.heading; 240 + let r = request_for(style, None); 241 + assert_eq!(r.face, style.face); 242 + assert!((r.size_px - style.size.as_px_f32()).abs() < 1e-3); 243 + assert_eq!(r.weight, style.weight); 244 + assert!((r.line_height_px - style.line_height.as_px_f32()).abs() < 1e-3); 245 + assert!((r.letter_spacing_px - style.letter_spacing.as_px_f32()).abs() < 1e-3); 246 + assert!(r.max_width.is_none()); 247 + } 248 + }
+7 -14
crates/bone-ui/src/theme/typography.rs
··· 1 + pub use bone_text::{FontFace, FontWeight}; 1 2 use serde::{Deserialize, Deserializer, Serialize, Serializer}; 2 3 use uom::si::f64::Length; 3 4 use uom::si::length::{meter, millimeter}; ··· 38 39 } 39 40 40 41 #[must_use] 42 + #[allow(clippy::cast_possible_truncation)] 43 + pub fn as_px_f32(self) -> f32 { 44 + length_to_px(self.0) as f32 45 + } 46 + 47 + #[must_use] 41 48 pub fn length(self) -> Length { 42 49 self.0 43 50 } ··· 66 73 length_newtype!(FontSize); 67 74 length_newtype!(LineHeight); 68 75 length_newtype!(LetterSpacing); 69 - 70 - #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 71 - pub enum FontWeight { 72 - Regular, 73 - Medium, 74 - Semibold, 75 - Bold, 76 - } 77 - 78 - #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 79 - pub enum FontFace { 80 - Sans, 81 - Mono, 82 - } 83 76 84 77 #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] 85 78 pub struct TypographyRole {