From dd776dcbd763c927206cc2674161b44118f5510a Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Sun, 25 May 2025 14:09:29 +0100 Subject: [PATCH 01/12] Add trait TextClass --- crates/kas-core/Cargo.toml | 1 + crates/kas-core/src/text/class.rs | 172 +++++++++++++++++++++++++++++ crates/kas-core/src/text/mod.rs | 4 + crates/kas-core/src/theme/style.rs | 63 ----------- crates/kas-core/src/theme/text.rs | 3 +- 5 files changed, 178 insertions(+), 65 deletions(-) create mode 100644 crates/kas-core/src/text/class.rs diff --git a/crates/kas-core/Cargo.toml b/crates/kas-core/Cargo.toml index 76e91c904..92af0ba66 100644 --- a/crates/kas-core/Cargo.toml +++ b/crates/kas-core/Cargo.toml @@ -106,6 +106,7 @@ async-global-executor = { version = "3.1.0", optional = true } cfg-if = "1.0.0" smol_str = "0.3.2" hash_hasher = "2.0.4" +const-fnv1a-hash = "1.1.0" accesskit = { version = "0.21.0", optional = true } accesskit_winit = { version = "0.29.0", optional = true } diff --git a/crates/kas-core/src/text/class.rs b/crates/kas-core/src/text/class.rs new file mode 100644 index 000000000..534c6c470 --- /dev/null +++ b/crates/kas-core/src/text/class.rs @@ -0,0 +1,172 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License in the LICENSE-APACHE file or at: +// https://www.apache.org/licenses/LICENSE-2.0 + +//! Text classes + +use std::hash::Hasher; +use std::cmp::{Ordering, PartialEq, PartialOrd}; + +/// Class key +/// +/// This is a name plus a pre-computed hash value. It is used for font mapping. +#[derive(Copy, Clone, Debug, Eq)] +pub struct Key(&'static str, u64); + +impl Key { + /// Construct a key + pub const fn new(name: &'static str) -> Self { + let hash = const_fnv1a_hash::fnv1a_hash_str_64(name); + Key(name, hash) + } +} + +impl PartialEq for Key { + fn eq(&self, rhs: &Self) -> bool { + // NOTE: if we test for collisions we could skip testing against field 0 + self.1 == rhs.1 && self.0 == rhs.0 + } +} + +impl PartialOrd for Key { + fn partial_cmp(&self, rhs: &Key) -> Option { + self.1.partial_cmp(&rhs.1) + } +} + +impl Ord for Key { + fn cmp(&self, rhs: &Key) -> Ordering { + self.1.cmp(&rhs.1) + } +} + +impl std::hash::Hash for Key { + fn hash(&self, state: &mut H) { + state.write_u64(self.1); + } +} + +/// A [`Hasher`] optimized for [`Key`] +/// +/// Warning: this hasher should only be used for keys of type [`Key`]. +/// In most other cases it will panic or give poor results. +#[derive(Default)] +pub(crate) struct KeyHasher(u64); + +impl Hasher for KeyHasher { + #[inline] + fn finish(&self) -> u64 { + self.0 + } + + fn write(&mut self, _: &[u8]) { + unimplemented!() + } + + #[inline] + fn write_u64(&mut self, i: u64) { + debug_assert!(self.0 == 0); + self.0 = i; + } +} + +/// A hash builder for [`KeyHasher`] +pub(crate) type BuildKeyHasher = std::hash::BuildHasherDefault; + +bitflags! { + /// Text class properties + #[must_use] + #[derive(Copy, Clone, Debug, Default)] + pub struct Properties: u32 { + /// Perform line-wrapping + /// + /// If `true`, long lines are broken at appropriate positions (see + /// Unicode UAX 14) to respect some maximum line length. + /// + /// If `false`, only explicit line breaks result in new lines. + const WRAP = 1 << 0; + + /// Is an access key + /// + /// If `true`, then text decorations (underline, strikethrough) are only + /// drawn when access key mode is active (usually, this means + /// Alt is held). + const ACCESS = 1 << 8; + + /// Limit minimum size + /// + /// This is used to prevent empty edit-fields from collapsing to nothing. + const LIMIT_MIN_SIZE = 1 << 9; + } +} + +/// A text class +pub trait TextClass { + /// Each text class must have a unique key, used for lookups + const KEY: Key; + + /// Get text properties + fn properties(&self) -> Properties; + + // /// Whether to wrap even where plenty of space is available + // /// + // /// If `self.wrap() && self.restrict_width()`, then lines will be wrapped + // /// at some sensible (theme-defined) maximum paragraph width even when + // /// plenty of space is availble. + // fn restrict_width(&self) -> bool; +} + +/// Text class: label +/// +/// This will wrap long lines if and only if its field is true. +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] +pub struct Label(pub bool); + +impl TextClass for Label { + const KEY = Key::new("Label"); + + fn properties(&self) -> Properties { + if self.0 { + Properties::WRAP + } else { + Properties::empty() + } + } +} + +/// Text class: access label +/// +/// This is identical to [`Label`] except that effects are only drawn if +/// access key mode is activated (usually the `Alt` key). +/// +/// This will wrap long lines if and only if its field is true. +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] +pub struct AccessLabel(pub bool); + +/// Text class: scrollable label +/// +/// The occupied vertical space may be less than the height of the text object. +/// This will wrap long lines. +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] +pub struct ScrollLabel(pub bool); + +/// Text class: menu label +/// +/// This is equivalent to [`AccessLabel`] `(false)`, but may use different +/// styling and does not stretch to fill extra space. +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] +pub struct MenuLabel; + +/// Text class: button +/// +/// This is equivalent to [`AccessLabel`] `(false)`, but may use different +/// styling. +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] +pub struct Button; + +/// Text class: edit field +/// +/// This is a multi-line edit field if and only if its field is true. +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] +pub struct Edit(pub bool); diff --git a/crates/kas-core/src/text/mod.rs b/crates/kas-core/src/text/mod.rs index bb305708a..2d0ebd6ba 100644 --- a/crates/kas-core/src/text/mod.rs +++ b/crates/kas-core/src/text/mod.rs @@ -18,6 +18,10 @@ pub use kas_text::{ OwningVecIter, Status, Text, TextDisplay, Vec2, fonts, format, }; +mod class; +pub(crate) use class::{BuildKeyHasher, KeyHasher}; +pub use class::{Key, TextClass}; + mod selection; pub use selection::SelectionHelper; diff --git a/crates/kas-core/src/theme/style.rs b/crates/kas-core/src/theme/style.rs index bfe83e461..60a42c6b1 100644 --- a/crates/kas-core/src/theme/style.rs +++ b/crates/kas-core/src/theme/style.rs @@ -120,66 +120,3 @@ impl SelectionStyle { matches!(self, SelectionStyle::Frame | SelectionStyle::Both) } } - -/// Class of text drawn -/// -/// Themes choose font, font size, colour, and alignment based on this. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum TextClass { - /// Label text is drawn over the background colour - /// - /// This takes one parameter: `multi_line`. Text is wrapped only if true. - Label(bool), - /// Scrollable label - /// - /// This is similar to `Label(true)`, but may occupy less vertical space. - /// Usually it also implies that the text is both scrollable and selectable, - /// but these are characteristics of the widget, not the text object. - LabelScroll, - /// Label with access keys - /// - /// This takes one parameter: `multi_line`. Text is wrapped only if true. - /// - /// This is identical to `Label` except that effects are only drawn if - /// access key mode is activated (usually the `Alt` key). - AccessLabel(bool), - /// Button text is drawn over a button - /// - /// Same as `AccessLabel(false)`, though theme may differentiate. - Button, - /// Menu label (single line, does not stretch) - /// - /// Similar to `AccessLabel(false)`, but with horizontal stretching disabled. - MenuLabel, - /// Editable text, usually encapsulated in some type of box - /// - /// This takes one parameter: `multi_line`. Text is wrapped only if true. - Edit(bool), -} - -impl TextClass { - /// True if text is single-line only - #[inline] - pub fn single_line(self) -> bool { - !self.multi_line() - } - - /// True if text is multi-line and should automatically line-wrap - #[inline] - pub fn multi_line(self) -> bool { - use TextClass::*; - matches!( - self, - Label(true) | LabelScroll | AccessLabel(true) | Edit(true) - ) - } - - /// True if text effects should only be shown dependant on access key - /// mode being active - #[inline] - pub fn is_access_key(self) -> bool { - use TextClass::*; - matches!(self, AccessLabel(_) | Button | MenuLabel) - } -} diff --git a/crates/kas-core/src/theme/text.rs b/crates/kas-core/src/theme/text.rs index b49480acb..76bc5aa8d 100644 --- a/crates/kas-core/src/theme/text.rs +++ b/crates/kas-core/src/theme/text.rs @@ -5,7 +5,6 @@ //! Theme-applied Text element -use super::TextClass; #[allow(unused)] use super::{DrawCx, SizeCx}; use crate::Layout; use crate::cast::Cast; @@ -100,7 +99,7 @@ impl Text { /// /// This struct must be made ready for usage by calling [`Text::prepare`]. #[inline] - pub fn new(text: T, class: TextClass) -> Self { + pub fn new(text: T, class: impl TextClass) -> Self { Text { rect: Rect::default(), font: FontSelector::default(), From ef018dd4f4e9082de8570aba319e4881875678a6 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 13 May 2025 08:29:34 +0100 Subject: [PATCH 02/12] TEMP Add parley=0.6 optional dependency TODO: test with / without parley, if it remains optional --- Cargo.toml | 5 ++++- crates/kas-core/Cargo.toml | 4 ++++ crates/kas-wgpu/Cargo.toml | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index bd3d5446f..c438e8a1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ minimal = ["wayland", "vulkan", "dx12", "metal"] # Minimal + strongly recommended features default = ["minimal", "clipboard", "shaping"] # All standard test target features -stable = ["default", "view", "image-default-formats", "canvas", "svg", "markdown", "spawn", "x11", "toml", "yaml", "json", "ron", "macros_log"] +stable = ["default", "view", "image-default-formats", "canvas", "svg", "markdown", "spawn", "x11", "toml", "yaml", "json", "ron", "macros_log", "parley"] # Enables all "recommended" features for nightly rustc nightly = ["stable", "nightly-diagnostics", "kas-core/nightly"] # Additional, less recommendation-worthy features @@ -56,6 +56,9 @@ spec = ["kas-core/spec"] # Enable view widgets view = ["dep:kas-view"] +# Enable Parley text backend +parley = ["kas-core/parley", "kas-wgpu?/parley"] + #Enable WGPU backend: wgpu = ["dep:kas-wgpu"] diff --git a/crates/kas-core/Cargo.toml b/crates/kas-core/Cargo.toml index 92af0ba66..fc117472c 100644 --- a/crates/kas-core/Cargo.toml +++ b/crates/kas-core/Cargo.toml @@ -82,6 +82,9 @@ dark-light = ["dep:dark-light"] # Support spawning async tasks spawn = ["dep:async-global-executor"] +# Enable Parley text backend +parley = ["dep:parley"] + # Optimize Node using unsafe code unsafe_node = [] @@ -109,6 +112,7 @@ hash_hasher = "2.0.4" const-fnv1a-hash = "1.1.0" accesskit = { version = "0.21.0", optional = true } accesskit_winit = { version = "0.29.0", optional = true } +parley = { version = "0.6", optional = true } [target.'cfg(any(target_os="linux", target_os="dragonfly", target_os="freebsd", target_os="netbsd", target_os="openbsd"))'.dependencies] smithay-clipboard = { version = "0.7.0", optional = true } diff --git a/crates/kas-wgpu/Cargo.toml b/crates/kas-wgpu/Cargo.toml index f51b30b3e..6bdeba9bf 100644 --- a/crates/kas-wgpu/Cargo.toml +++ b/crates/kas-wgpu/Cargo.toml @@ -18,6 +18,9 @@ features = ["kas/wayland"] [features] default = [] +# Enable Parley text backend +parley = ["dep:parley"] + # WGPU backends vulkan = ["wgpu/vulkan"] gles = ["wgpu/gles"] @@ -37,6 +40,7 @@ thiserror = "2.0.3" guillotiere = "0.6.0" rustc-hash = "2.0" ab_glyph = { version = "0.2.10", optional = true } +parley = { version = "0.6.0", optional = true } [dependencies.kas] # Rename package purely for convenience: From b179d98e718d80e5378e19c878fd5580d3c76b09 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 13 May 2025 17:17:03 +0100 Subject: [PATCH 03/12] Add SpriteFaceId --- crates/kas-wgpu/src/draw/text_pipe.rs | 35 ++++++++++++++++----------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/kas-wgpu/src/draw/text_pipe.rs b/crates/kas-wgpu/src/draw/text_pipe.rs index b4d6ca36e..d7c44ae60 100644 --- a/crates/kas-wgpu/src/draw/text_pipe.rs +++ b/crates/kas-wgpu/src/draw/text_pipe.rs @@ -46,13 +46,26 @@ impl Default for Rasterer { #[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct SpriteDescriptor(u64); +#[derive(Copy, Clone, Eq, PartialEq, Hash)] +struct SpriteFaceId(u16); + +impl From for SpriteFaceId { + #[inline] + fn from(face: FaceId) -> Self { + let face_id: u16 = face.get().cast(); + assert!(face_id < 0x8000); + SpriteFaceId(face_id) + } +} + impl std::fmt::Debug for SpriteDescriptor { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let face = self.0 as u16; let dpem_steps = ((self.0 & 0x00FF_FFFF_0000_0000) >> 32) as u32; let x_steps = ((self.0 & 0x0F00_0000_0000_0000) >> 56) as u8; let y_steps = ((self.0 & 0xF000_0000_0000_0000) >> 60) as u8; f.debug_struct("SpriteDescriptor") - .field("face", &self.face()) + .field("face", &face) .field("glyph", &self.glyph()) .field("dpem_steps", &dpem_steps) .field("offset_steps", &(x_steps, y_steps)) @@ -72,11 +85,10 @@ impl SpriteDescriptor { } } - /// Construct + /// Construct for a `kas_text` font /// /// Most parameters come from [`TextDisplay::glyphs`] output. See also [`raster`]. - pub fn new(config: &Config, face: FaceId, glyph: Glyph, dpem: f32) -> Self { - let face: u16 = face.get().cast(); + fn new(config: &Config, face_id: SpriteFaceId, glyph: Glyph, dpem: f32) -> Self { let glyph_id: u16 = glyph.id.0; let steps = Self::sub_pixel_from_dpem(config, dpem); let mult = f32::conv(steps); @@ -84,7 +96,7 @@ impl SpriteDescriptor { let x_off = u8::conv_trunc(glyph.position.0.fract() * mult) % steps; let y_off = u8::conv_trunc(glyph.position.1.fract() * mult) % steps; assert!(dpem & 0xFF00_0000 == 0 && x_off & 0xF0 == 0 && y_off & 0xF0 == 0); - let packed = face as u64 + let packed = face_id.0 as u64 | ((glyph_id as u64) << 16) | ((dpem as u64) << 32) | ((x_off as u64) << 56) @@ -92,11 +104,6 @@ impl SpriteDescriptor { SpriteDescriptor(packed) } - /// Get `FaceId` descriptor - pub fn face(self) -> FaceId { - FaceId::from((self.0 & 0x0000_0000_0000_FFFF) as u32) - } - /// Get `GlyphId` descriptor pub fn glyph(self) -> GlyphId { GlyphId(((self.0 & 0x0000_0000_FFFF_0000) >> 16).cast()) @@ -226,7 +233,7 @@ impl Pipeline { let face_store = fonts::library().get_face_store(face_id); for glyph in glyphs { - let desc = SpriteDescriptor::new(&self.text.config, face_id, glyph, dpem); + let desc = SpriteDescriptor::new(&self.text.config, face_id.into(), glyph, dpem); if self.text.glyphs.contains_key(&desc) { continue; } @@ -331,7 +338,7 @@ impl Pipeline { let embolden = if synthesis.embolden() { dpem * 0.02 } else { 0.0 }; for glyph in glyphs { - let desc = SpriteDescriptor::new(&self.text.config, face_id, glyph, dpem); + let desc = SpriteDescriptor::new(&self.text.config, face_id.into(), glyph, dpem); if self.text.glyphs.contains_key(&desc) { continue; } @@ -469,7 +476,7 @@ impl Window { let face = run.face_id(); let dpem = run.dpem(); for glyph in run.glyphs() { - let desc = SpriteDescriptor::new(&pipe.text.config, face, glyph, dpem); + let desc = SpriteDescriptor::new(&pipe.text.config, face.into(), glyph, dpem); let sprite = match pipe.text.glyphs.get(&desc) { Some(sprite) => sprite, None => { @@ -513,7 +520,7 @@ impl Window { let face = run.face_id(); let dpem = run.dpem(); let for_glyph = |glyph: Glyph, e: u16| { - let desc = SpriteDescriptor::new(&pipe.text.config, face, glyph, dpem); + let desc = SpriteDescriptor::new(&pipe.text.config, face.into(), glyph, dpem); let sprite = match pipe.text.glyphs.get(&desc) { Some(sprite) => sprite, None => { From 3c4aadd6f7e507f2fc05b2a0e2c22e7bf33cf153 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 13 May 2025 17:17:03 +0100 Subject: [PATCH 04/12] Adjust raster_swash --- crates/kas-wgpu/src/draw/text_pipe.rs | 46 ++++++++++++++++----------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/crates/kas-wgpu/src/draw/text_pipe.rs b/crates/kas-wgpu/src/draw/text_pipe.rs index d7c44ae60..739fa0fe6 100644 --- a/crates/kas-wgpu/src/draw/text_pipe.rs +++ b/crates/kas-wgpu/src/draw/text_pipe.rs @@ -217,7 +217,29 @@ impl Pipeline { match self.text.rasterer { #[cfg(feature = "ab_glyph")] Rasterer::AbGlyph => self.raster_ab_glyph(face_id, dpem, &mut glyphs), - Rasterer::Swash => self.raster_swash(face_id, dpem, &mut glyphs), + Rasterer::Swash => { + let face = fonts::library().get_face_store(face_id); + let font = face.swash(); + let synthesis = face.synthesis(); + + let hint = self.text.hint; + self.raster_swash( + face_id.into(), + |scale_cx| { + scale_cx + .builder(font) + .size(dpem) + .hint(hint) + .variations(synthesis.variation_settings().iter().map( + |(tag, value)| (swash::tag_from_bytes(&tag.to_be_bytes()), *value), + )) + .build() + }, + *synthesis, + dpem, + &mut glyphs, + ); + } } } @@ -297,30 +319,16 @@ impl Pipeline { // NOTE: using dyn Iterator over impl Iterator is slightly slower but saves 2-4kB fn raster_swash( &mut self, - face_id: FaceId, + face_id: SpriteFaceId, + scaler: impl Fn(&mut swash::scale::ScaleContext) -> swash::scale::Scaler<'_>, + synthesis: parley::fontique::Synthesis, dpem: f32, glyphs: &mut dyn Iterator, ) { use swash::scale::{Render, Source, StrikeWith, image::Content}; use swash::zeno::{Angle, Format, Transform}; - let face = fonts::library().get_face_store(face_id); - let font = face.swash(); - let synthesis = face.synthesis(); - - let mut scaler = self - .text - .scale_cx - .builder(font) - .size(dpem) - .hint(self.text.hint) - .variations( - synthesis - .variation_settings() - .iter() - .map(|(tag, value)| (swash::tag_from_bytes(&tag.to_be_bytes()), *value)), - ) - .build(); + let mut scaler = scaler(&mut self.text.scale_cx); let sources = &[ // TODO: Support coloured rendering? These can replace Source::Bitmap From 43ee0d34665c1a4f44e27296df0876625485cce9 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Sat, 17 May 2025 11:13:03 +0100 Subject: [PATCH 05/12] kas_wgpu: support for drawing a parley::Layout --- crates/kas-wgpu/src/draw/text_pipe.rs | 163 +++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/crates/kas-wgpu/src/draw/text_pipe.rs b/crates/kas-wgpu/src/draw/text_pipe.rs index 739fa0fe6..b5faa0e03 100644 --- a/crates/kas-wgpu/src/draw/text_pipe.rs +++ b/crates/kas-wgpu/src/draw/text_pipe.rs @@ -58,6 +58,17 @@ impl From for SpriteFaceId { } } +impl From<&parley::FontData> for SpriteFaceId { + #[inline] + fn from(face: &parley::FontData) -> Self { + let font_id: u16 = face.data.id().cast(); + assert!(font_id < 0x1000); + assert!(face.index < 0x8); + let face_id = 0x8000 + u16::conv(face.index) << 12 + font_id; + SpriteFaceId(face_id) + } +} + impl std::fmt::Debug for SpriteDescriptor { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { let face = self.0 as u16; @@ -317,10 +328,10 @@ impl Pipeline { } // NOTE: using dyn Iterator over impl Iterator is slightly slower but saves 2-4kB - fn raster_swash( - &mut self, + fn raster_swash<'a, 'b: 'a>( + &'b mut self, face_id: SpriteFaceId, - scaler: impl Fn(&mut swash::scale::ScaleContext) -> swash::scale::Scaler<'_>, + scaler: impl Fn(&'b mut swash::scale::ScaleContext) -> swash::scale::Scaler<'a>, synthesis: parley::fontique::Synthesis, dpem: f32, glyphs: &mut dyn Iterator, @@ -424,6 +435,152 @@ impl Pipeline { } impl Window { + #[cfg(feature = "parley")] + #[inline(never)] + fn raster_glyph_run<'a>( + &mut self, + pipe: &mut Pipeline, + glyph_run: &'a parley::layout::GlyphRun<'a, ()>, + ) { + let mut run_x = glyph_run.offset(); + let run_y = glyph_run.baseline(); + + let font = glyph_run.run().font(); + let dpem = glyph_run.run().font_size(); + let normalized_coords = glyph_run.run().normalized_coords(); + + // TODO: this creates a new CacheKey. Does that matter? It seems we never use this, so maybe not? + let font_ref = swash::FontRef::from_index(font.data.as_ref(), font.index as usize).unwrap(); + + pipe.raster_swash( + font.into(), + |scale_cx| { + scale_cx + .builder(font_ref) + .size(dpem) + .hint(true) + .normalized_coords(normalized_coords) + .build() + }, + glyph_run.run().synthesis(), + dpem, + &mut glyph_run.glyphs().map(move |glyph| { + let position = kas_text::Vec2(run_x + glyph.x, run_y - glyph.y); + run_x += glyph.advance; + Glyph { + index: 0, // not used + id: GlyphId(glyph.id.cast()), + position, + } + }), + ); + } + + #[cfg(feature = "parley")] + fn render_glyph_run( + &mut self, + pipe: &mut Pipeline, + pass: PassId, + rect: Quad, + glyph_run: &parley::layout::GlyphRun<'_, ()>, + mut draw_quad: impl FnMut(Quad, Rgba), + ) { + let mut run_x = glyph_run.offset(); + let run_y = glyph_run.baseline(); + // NOTE: can we assume this? If so we can simplify below. + debug_assert!(run_x.fract() == 0.0 && run_y.fract() == 0.0); + + let col = Rgba::BLACK; + let font = glyph_run.run().font(); + let dpem = glyph_run.run().font_size(); + + let mut rastered = false; + + // Iterates over the glyphs in the GlyphRun + for glyph in glyph_run.glyphs() { + let position = kas_text::Vec2(run_x + glyph.x, run_y - glyph.y); + run_x += glyph.advance; + + let glyph = Glyph { + index: 0, // not used + id: GlyphId(glyph.id.cast()), + position, + }; + let desc = SpriteDescriptor::new(&pipe.text.config, font.into(), glyph, dpem); + let sprite = if let Some(sprite) = pipe.text.glyphs.get(&desc).cloned() { + sprite + } else if !rastered { + // NOTE: this branch is *rare*. We push rastering to another + // function to optimise for the common case. + self.raster_glyph_run(pipe, glyph_run); + rastered = true; + + // Try again: + pipe.text.glyphs.get(&desc).cloned().unwrap_or_default() + } else { + Sprite::default() + }; + + if sprite.is_valid() { + let a = rect.a + Vec2::from(position).floor() + sprite.offset; + let b = a + sprite.size; + let (ta, tb) = (sprite.tex_quad.a, sprite.tex_quad.b); + let instance = InstanceA { a, b, ta, tb, col }; + self.atlas_a.rect(pass, sprite.atlas, instance); + } + } + + // Draw decorations: underline & strikethrough + let metrics = glyph_run.run().metrics(); + if let Some(decoration) = glyph_run.style().underline.as_ref() { + let offset = decoration.offset.unwrap_or(metrics.underline_offset); + let size = decoration.size.unwrap_or(metrics.underline_size); + + let y0 = glyph_run.baseline() - offset; + let x0 = glyph_run.offset(); + let a = Vec2(x0, y0); + let b = Vec2(x0 + glyph_run.advance(), y0 + size); + draw_quad(Quad::from_coords(a, b), col); + } + if let Some(decoration) = glyph_run.style().strikethrough.as_ref() { + let offset = decoration.offset.unwrap_or(metrics.strikethrough_offset); + let size = decoration.size.unwrap_or(metrics.strikethrough_size); + + let y0 = glyph_run.baseline() - offset; + let x0 = glyph_run.offset(); + let a = Vec2(x0, y0); + let b = Vec2(x0 + glyph_run.advance(), y0 + size); + draw_quad(Quad::from_coords(a, b), col); + } + } + + /// Render a [`parley::Layout`] + /// + /// It is assumed that the `layout` has already had lines broken and been + /// aligned. + #[cfg(feature = "parley")] + pub fn parley( + &mut self, + pipe: &mut Pipeline, + pass: PassId, + rect: Quad, + layout: &parley::Layout<()>, + mut draw_quad: impl FnMut(Quad, Rgba), + ) { + for line in layout.lines() { + for item in line.items() { + match item { + parley::PositionedLayoutItem::GlyphRun(run) => { + self.render_glyph_run(pipe, pass, rect, &run, |rect, col| { + draw_quad(rect, col) + }); + } + parley::PositionedLayoutItem::InlineBox(_) => todo!(), + } + } + } + } + fn push_sprite( &mut self, pass: PassId, From c9f449495ebb72539433ed4c521861514ce80017 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Tue, 20 May 2025 13:37:14 +0100 Subject: [PATCH 06/12] Add kas::theme::TextBrush --- crates/kas-core/src/theme/colors.rs | 21 +++++++++++++++++++++ crates/kas-core/src/theme/mod.rs | 2 +- crates/kas-wgpu/src/draw/text_pipe.rs | 6 +++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/kas-core/src/theme/colors.rs b/crates/kas-core/src/theme/colors.rs index e7985d26e..d50b401b2 100644 --- a/crates/kas-core/src/theme/colors.rs +++ b/crates/kas-core/src/theme/colors.rs @@ -14,6 +14,27 @@ const MULT_DEPRESS: f32 = 0.75; const MULT_HIGHLIGHT: f32 = 1.25; const MIN_HIGHLIGHT: f32 = 0.2; +/// A text color specifier +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Hash)] +pub enum TextBrush { + /// The theme-specified contextual text color + #[default] + Default, + /// A specified sRGB color + Srgb(Rgba8Srgb), +} + +impl TextBrush { + /// Resolve to a specific color + pub fn resolve(self, colors: &ColorsLinear, is_disabled: bool) -> Rgba { + match self { + _ if is_disabled => colors.text_disabled, + TextBrush::Default => colors.text, + TextBrush::Srgb(srgb) => srgb.into(), + } + } +} + bitflags::bitflags! { /// Input and highlighting state of a widget /// diff --git a/crates/kas-core/src/theme/mod.rs b/crates/kas-core/src/theme/mod.rs index 3ac6a528d..e471f5974 100644 --- a/crates/kas-core/src/theme/mod.rs +++ b/crates/kas-core/src/theme/mod.rs @@ -28,7 +28,7 @@ mod traits; #[cfg_attr(docsrs, doc(cfg(internal_doc)))] pub mod dimensions; -pub use colors::{Colors, ColorsLinear, ColorsSrgb, InputState}; +pub use colors::{Colors, ColorsLinear, ColorsSrgb, InputState, TextBrush}; pub use draw::{Background, DrawCx}; pub use flat_theme::FlatTheme; pub use multi::{MultiTheme, MultiThemeBuilder}; diff --git a/crates/kas-wgpu/src/draw/text_pipe.rs b/crates/kas-wgpu/src/draw/text_pipe.rs index b5faa0e03..3168dd22b 100644 --- a/crates/kas-wgpu/src/draw/text_pipe.rs +++ b/crates/kas-wgpu/src/draw/text_pipe.rs @@ -440,7 +440,7 @@ impl Window { fn raster_glyph_run<'a>( &mut self, pipe: &mut Pipeline, - glyph_run: &'a parley::layout::GlyphRun<'a, ()>, + glyph_run: &'a parley::layout::GlyphRun<'a, kas::theme::TextBrush>, ) { let mut run_x = glyph_run.offset(); let run_y = glyph_run.baseline(); @@ -482,7 +482,7 @@ impl Window { pipe: &mut Pipeline, pass: PassId, rect: Quad, - glyph_run: &parley::layout::GlyphRun<'_, ()>, + glyph_run: &parley::layout::GlyphRun<'_, kas::theme::TextBrush>, mut draw_quad: impl FnMut(Quad, Rgba), ) { let mut run_x = glyph_run.offset(); @@ -564,7 +564,7 @@ impl Window { pipe: &mut Pipeline, pass: PassId, rect: Quad, - layout: &parley::Layout<()>, + layout: &parley::Layout, mut draw_quad: impl FnMut(Quad, Rgba), ) { for line in layout.lines() { From ea946b84b6ec0a06b628e98b775c50a490a4a702 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Thu, 22 May 2025 14:01:06 +0100 Subject: [PATCH 07/12] Support drawing parley::Layout --- crates/kas-core/src/draw/draw.rs | 21 ++++++++++++ crates/kas-core/src/draw/draw_shared.rs | 11 +++++++ crates/kas-core/src/theme/draw.rs | 17 ++++++++++ crates/kas-core/src/theme/simple_theme.rs | 18 +++++++++++ crates/kas-macros/src/extends.rs | 10 ++++++ crates/kas-wgpu/Cargo.toml | 2 +- crates/kas-wgpu/src/draw/draw_pipe.rs | 16 ++++++++++ crates/kas-wgpu/src/draw/text_pipe.rs | 39 ++++------------------- 8 files changed, 101 insertions(+), 33 deletions(-) diff --git a/crates/kas-core/src/draw/draw.rs b/crates/kas-core/src/draw/draw.rs index 3c7bf878b..f43e1f471 100644 --- a/crates/kas-core/src/draw/draw.rs +++ b/crates/kas-core/src/draw/draw.rs @@ -202,6 +202,15 @@ pub trait Draw { /// Draw the image in the given `rect` fn image(&mut self, id: ImageId, rect: Quad); + /// Draw Parley text + #[cfg(feature = "parley")] + fn parley_run( + &mut self, + rect: Quad, + col: Rgba, + run: &parley::layout::GlyphRun<'_, crate::theme::TextBrush>, + ); + /// Draw text with a colour /// /// Text is drawn from `pos` and clipped to `bounding_box`. @@ -278,6 +287,18 @@ impl<'a, DS: DrawSharedImpl> Draw for DrawIface<'a, DS> { self.shared.draw.draw_image(self.draw, self.pass, id, rect); } + #[cfg(feature = "parley")] + fn parley_run( + &mut self, + rect: Quad, + col: Rgba, + run: &parley::layout::GlyphRun<'_, crate::theme::TextBrush>, + ) { + self.shared + .draw + .parley_run(self.draw, self.pass, rect, col, run); + } + fn text(&mut self, pos: Vec2, bb: Quad, text: &TextDisplay, col: Rgba) { self.shared .draw diff --git a/crates/kas-core/src/draw/draw_shared.rs b/crates/kas-core/src/draw/draw_shared.rs index 1b1160000..aa494b699 100644 --- a/crates/kas-core/src/draw/draw_shared.rs +++ b/crates/kas-core/src/draw/draw_shared.rs @@ -173,6 +173,17 @@ pub trait DrawSharedImpl: Any { /// Draw the image in the given `rect` fn draw_image(&self, draw: &mut Self::Draw, pass: PassId, id: ImageId, rect: Quad); + /// Draw Parley text + #[cfg(feature = "parley")] + fn parley_run( + &mut self, + draw: &mut Self::Draw, + pass: PassId, + rect: Quad, + col: Rgba, + run: &parley::layout::GlyphRun<'_, crate::theme::TextBrush>, + ); + /// Draw text with a colour fn draw_text( &mut self, diff --git a/crates/kas-core/src/theme/draw.rs b/crates/kas-core/src/theme/draw.rs index 2b872d7bf..c0080298b 100644 --- a/crates/kas-core/src/theme/draw.rs +++ b/crates/kas-core/src/theme/draw.rs @@ -6,6 +6,7 @@ //! Widget-facing high-level draw API use winit::keyboard::Key; +#[cfg(feature = "parley")] use super::TextBrush; use super::{FrameStyle, MarkStyle, SelectionStyle, SizeCx, Text, ThemeSize}; use crate::dir::Direction; @@ -268,6 +269,15 @@ impl<'a> DrawCx<'a> { self.h.selection(rect, style); } + /// Render a [`parley::Layout`] over [`TextBrush`] + /// + /// It is assumed that the `layout` has already had lines broken and been + /// aligned. + #[cfg(feature = "parley")] + pub fn parley(&mut self, rect: Rect, layout: &parley::Layout) { + self.h.parley(&self.id, rect, layout); + } + /// Draw text with effects /// /// Text is drawn from `rect.pos` and clipped to `rect`. @@ -508,6 +518,13 @@ pub trait ThemeDraw { /// Draw a selection highlight / frame fn selection(&mut self, rect: Rect, style: SelectionStyle); + /// Render a [`parley::Layout`] over [`TextBrush`] + /// + /// It is assumed that the `layout` has already had lines broken and been + /// aligned. + #[cfg(feature = "parley")] + fn parley(&mut self, id: &Id, rect: Rect, layout: &parley::Layout); + /// Draw text /// /// [`ConfigCx::text_configure`] should be called prior to this method to diff --git a/crates/kas-core/src/theme/simple_theme.rs b/crates/kas-core/src/theme/simple_theme.rs index b519d87f6..e5f7baeeb 100644 --- a/crates/kas-core/src/theme/simple_theme.rs +++ b/crates/kas-core/src/theme/simple_theme.rs @@ -442,6 +442,24 @@ where } } + #[cfg(feature = "parley")] + fn parley(&mut self, id: &Id, rect: Rect, layout: &parley::Layout) { + let rect = Quad::conv(rect); + let is_disabled = self.ev.is_disabled(id); + + for line in layout.lines() { + for item in line.items() { + match item { + parley::PositionedLayoutItem::GlyphRun(run) => { + let color = run.style().brush.resolve(&self.cols, is_disabled); + self.draw.parley_run(rect, color, &run); + } + parley::PositionedLayoutItem::InlineBox(_) => todo!(), + } + } + } + } + fn check_box(&mut self, id: &Id, rect: Rect, checked: bool, _: Option) { let state = InputState::new_all(self.ev, id); let outer = Quad::conv(rect); diff --git a/crates/kas-macros/src/extends.rs b/crates/kas-macros/src/extends.rs index 2379ca8ce..754c44753 100644 --- a/crates/kas-macros/src/extends.rs +++ b/crates/kas-macros/src/extends.rs @@ -80,6 +80,16 @@ impl Extends { (#base).selection(rect, style); } + #[cfg(feature = "parley")] + fn parley( + &mut self, + id: &::kas::Id, + rect: Rect, + layout: &::parley::Layout<::kas::theme::TextBrush>, + ) { + (#base).parley(id, rect, layout); + } + fn text(&mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay) { (#base).text(id, pos, rect, text); } diff --git a/crates/kas-wgpu/Cargo.toml b/crates/kas-wgpu/Cargo.toml index 6bdeba9bf..5652abcd1 100644 --- a/crates/kas-wgpu/Cargo.toml +++ b/crates/kas-wgpu/Cargo.toml @@ -19,7 +19,7 @@ features = ["kas/wayland"] default = [] # Enable Parley text backend -parley = ["dep:parley"] +parley = ["dep:parley", "kas/parley"] # WGPU backends vulkan = ["wgpu/vulkan"] diff --git a/crates/kas-wgpu/src/draw/draw_pipe.rs b/crates/kas-wgpu/src/draw/draw_pipe.rs index 31ab7b3c7..777540dc5 100644 --- a/crates/kas-wgpu/src/draw/draw_pipe.rs +++ b/crates/kas-wgpu/src/draw/draw_pipe.rs @@ -346,6 +346,22 @@ impl DrawSharedImpl for DrawPipe { }; } + #[cfg(feature = "parley")] + #[inline] + fn parley_run( + &mut self, + draw: &mut Self::Draw, + pass: PassId, + rect: Quad, + col: Rgba, + run: &parley::layout::GlyphRun<'_, kas::theme::TextBrush>, + ) { + draw.images + .parley_run(&mut self.images, pass, rect, col, run, |quad| { + draw.shaded_square.rect(pass, quad, col); + }); + } + #[inline] fn draw_text( &mut self, diff --git a/crates/kas-wgpu/src/draw/text_pipe.rs b/crates/kas-wgpu/src/draw/text_pipe.rs index 3168dd22b..a11e336f9 100644 --- a/crates/kas-wgpu/src/draw/text_pipe.rs +++ b/crates/kas-wgpu/src/draw/text_pipe.rs @@ -476,21 +476,23 @@ impl Window { ); } + /// Render a [`parley::layout::GlyphRun`] with the given `color` #[cfg(feature = "parley")] - fn render_glyph_run( + pub fn parley_run( &mut self, pipe: &mut Pipeline, pass: PassId, rect: Quad, + color: Rgba, glyph_run: &parley::layout::GlyphRun<'_, kas::theme::TextBrush>, - mut draw_quad: impl FnMut(Quad, Rgba), + mut draw_quad: impl FnMut(Quad), ) { let mut run_x = glyph_run.offset(); let run_y = glyph_run.baseline(); // NOTE: can we assume this? If so we can simplify below. debug_assert!(run_x.fract() == 0.0 && run_y.fract() == 0.0); + let col = color; - let col = Rgba::BLACK; let font = glyph_run.run().font(); let dpem = glyph_run.run().font_size(); @@ -540,7 +542,7 @@ impl Window { let x0 = glyph_run.offset(); let a = Vec2(x0, y0); let b = Vec2(x0 + glyph_run.advance(), y0 + size); - draw_quad(Quad::from_coords(a, b), col); + draw_quad(Quad::from_coords(a, b)); } if let Some(decoration) = glyph_run.style().strikethrough.as_ref() { let offset = decoration.offset.unwrap_or(metrics.strikethrough_offset); @@ -550,34 +552,7 @@ impl Window { let x0 = glyph_run.offset(); let a = Vec2(x0, y0); let b = Vec2(x0 + glyph_run.advance(), y0 + size); - draw_quad(Quad::from_coords(a, b), col); - } - } - - /// Render a [`parley::Layout`] - /// - /// It is assumed that the `layout` has already had lines broken and been - /// aligned. - #[cfg(feature = "parley")] - pub fn parley( - &mut self, - pipe: &mut Pipeline, - pass: PassId, - rect: Quad, - layout: &parley::Layout, - mut draw_quad: impl FnMut(Quad, Rgba), - ) { - for line in layout.lines() { - for item in line.items() { - match item { - parley::PositionedLayoutItem::GlyphRun(run) => { - self.render_glyph_run(pipe, pass, rect, &run, |rect, col| { - draw_quad(rect, col) - }); - } - parley::PositionedLayoutItem::InlineBox(_) => todo!(), - } - } + draw_quad(Quad::from_coords(a, b)); } } From 1def74a0934dcc1cf339d32de474788dd37bcf5a Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Thu, 22 May 2025 17:32:20 +0100 Subject: [PATCH 08/12] FIXME: Add ParleyContext; add Layout builders to ConfigCx --- crates/kas-core/src/event/config_cx.rs | 29 ++++++++++++++++++++++-- crates/kas-core/src/runner/event_loop.rs | 2 +- crates/kas-core/src/runner/mod.rs | 2 +- crates/kas-core/src/runner/shared.rs | 17 +++++++++++++- crates/kas-core/src/runner/window.rs | 10 ++++---- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/kas-core/src/event/config_cx.rs b/crates/kas-core/src/event/config_cx.rs index cba23d324..d0bf63e8c 100644 --- a/crates/kas-core/src/event/config_cx.rs +++ b/crates/kas-core/src/event/config_cx.rs @@ -7,8 +7,9 @@ use crate::event::EventState; use crate::text::format::FormattableText; -use crate::theme::{SizeCx, Text, ThemeSize}; +use crate::theme::{SizeCx, Text, ThemeSize, TextBrush}; use crate::{ActionResize, Id, Node}; +use crate::runner::ParleyContext; use std::any::TypeId; use std::fmt::Debug; use std::ops::{Deref, DerefMut}; @@ -22,6 +23,7 @@ use std::ops::{Deref, DerefMut}; #[must_use] pub struct ConfigCx<'a> { pub(super) theme: &'a dyn ThemeSize, + pub(super) parley: &'a mut ParleyContext, pub(crate) state: &'a mut EventState, pub(crate) resize: ActionResize, pub(crate) redraw: bool, @@ -31,9 +33,11 @@ impl<'a> ConfigCx<'a> { /// Construct #[cfg_attr(not(feature = "internal_doc"), doc(hidden))] #[cfg_attr(docsrs, doc(cfg(internal_doc)))] - pub fn new(sh: &'a dyn ThemeSize, ev: &'a mut EventState) -> Self { + pub fn new(sh: &'a dyn ThemeSize, + parley: &'a mut ParleyContext, ev: &'a mut EventState) -> Self { ConfigCx { theme: sh, + parley, state: ev, resize: ActionResize(false), redraw: false, @@ -80,6 +84,27 @@ impl<'a> ConfigCx<'a> { self.resize |= start_resize; } + /// Create a ranged style builder for a [`parley::Layout`] + pub fn ranged_builder<'b>(&'b mut self, text: &'b str) -> parley::RangedBuilder<'b, TextBrush> { + let scale = self.config.scale_factor(); + let quantize = true; + self.pcx + .lcx + .ranged_builder(&mut self.pcx.fcx, text, scale, quantize) + } + + /// Create a tree style builder for a [`parley::Layout`] + pub fn tree_builder<'b>( + &'b mut self, + raw_style: &parley::style::TextStyle<'_, TextBrush>, + ) -> parley::TreeBuilder<'b, TextBrush> { + let scale = self.config.scale_factor(); + let quantize = true; + self.pcx + .lcx + .tree_builder(&mut self.pcx.fcx, scale, quantize, raw_style) + } + /// Configure a text object /// /// This selects a font given the [`TextClass`][crate::theme::TextClass], diff --git a/crates/kas-core/src/runner/event_loop.rs b/crates/kas-core/src/runner/event_loop.rs index af84a7bf0..83d4d7863 100644 --- a/crates/kas-core/src/runner/event_loop.rs +++ b/crates/kas-core/src/runner/event_loop.rs @@ -277,7 +277,7 @@ where win_id = id; } if let Some(window) = self.windows.get_mut(&win_id) { - window.send_close(target); + window.send_close(&mut self.state, target); } } Pending::Exit => close_all = true, diff --git a/crates/kas-core/src/runner/mod.rs b/crates/kas-core/src/runner/mod.rs index 92ac4c1f4..915d968f6 100644 --- a/crates/kas-core/src/runner/mod.rs +++ b/crates/kas-core/src/runner/mod.rs @@ -15,10 +15,10 @@ use crate::ConfigAction; use crate::messages::Erased; use crate::window::{BoxedWindow, PopupDescriptor, WindowId}; use event_loop::Loop; -pub(crate) use shared::RunnerT; use shared::Shared; use std::fmt::Debug; pub use window::Window; +pub(crate) use shared::{ParleyContext, RunnerT}; pub(crate) use window::WindowDataErased; pub use common::{Error, Platform, Result}; diff --git a/crates/kas-core/src/runner/shared.rs b/crates/kas-core/src/runner/shared.rs index 1b1b71a46..420501aa3 100644 --- a/crates/kas-core/src/runner/shared.rs +++ b/crates/kas-core/src/runner/shared.rs @@ -11,7 +11,7 @@ use super::{ use crate::config::Config; use crate::draw::{DrawShared, DrawSharedImpl, SharedState}; use crate::messages::Erased; -use crate::theme::Theme; +use crate::theme::{TextBrush, Theme}; #[cfg(feature = "clipboard")] use crate::util::warn_about_error; use crate::window::{PopupDescriptor, Window as WindowWidget, WindowId, WindowIdFactory}; @@ -25,6 +25,12 @@ use std::task::Waker; #[cfg(feature = "clipboard")] use arboard::Clipboard; +#[derive(Default)] +pub(crate) struct ParleyContext { + pub(crate) fcx: parley::FontContext, + pub(crate) lcx: parley::LayoutContext, +} + /// Runner state shared by all windows and used by [`RunnerT`] pub(super) struct Shared> { pub(super) platform: Platform, @@ -35,6 +41,7 @@ pub(super) struct Shared pub(super) instance: G, pub(super) draw: Option>, pub(super) theme: T, + pub(super) parley: ParleyContext, pub(super) messages: MessageStack, pub(super) pending: VecDeque>, pub(super) send_queue: VecDeque<(Id, Erased)>, @@ -77,6 +84,7 @@ where instance, draw: None, theme, + parley: ParleyContext::default(), messages: MessageStack::new(), pending: Default::default(), send_queue: Default::default(), @@ -238,6 +246,9 @@ pub(crate) trait RunnerT { /// Access the [`DrawShared`] object fn draw_shared(&mut self) -> &mut dyn DrawShared; + /// Access the [`ParleyContext`] + fn parley_context(&mut self) -> &mut ParleyContext; + /// Access a Waker fn waker(&self) -> &std::task::Waker; } @@ -384,6 +395,10 @@ impl> RunnerT for Shared self.draw.as_mut().unwrap() } + fn parley_context(&mut self) -> &mut ParleyContext { + &mut self.parley + } + #[inline] fn waker(&self) -> &std::task::Waker { &self.waker diff --git a/crates/kas-core/src/runner/window.rs b/crates/kas-core/src/runner/window.rs index 887da2fe9..f851a2130 100644 --- a/crates/kas-core/src/runner/window.rs +++ b/crates/kas-core/src/runner/window.rs @@ -123,8 +123,10 @@ impl> Window { let mut theme = shared.theme.new_window(config); let mut node = self.widget.as_node(data); - let _: ActionResize = self.ev_state.full_configure(theme.size(), node.re()); + let _: ActionResize = self.ev_state.full_configure(theme.size(), node.re(), + &mut shared.parley); + let mut cx = SizeCx::new(&mut self.ev_state, theme.size()); let mut solve_cache = SolveCache::default(); solve_cache.find_constraints(node, &mut cx); @@ -526,7 +528,7 @@ impl> Window { self.widget.add_popup(&mut cx, data, id, popup); } - pub(super) fn send_close(&mut self, id: WindowId) { + pub(super) fn send_close(&mut self, shared: &mut Shared, id: WindowId) { if id == self.ev_state.window_id { self.ev_state.close_own_window(); } else if let Some((ref theme, _)) = self.theme_and_window { @@ -555,14 +557,14 @@ impl> Window { log::trace!(target: "kas_perf::wgpu::window", "reconfigure: {}µs", time.elapsed().as_micros()); } - pub(super) fn update(&mut self, data: &A) { + pub(super) fn update(&mut self, shared: &mut Shared, data: &A) { let time = Instant::now(); let Some((ref theme, ref mut window)) = self.theme_and_window else { return; }; let size = theme.size(); - let mut cx = ConfigCx::new(&size, &mut self.ev_state); + let mut cx = ConfigCx::new(&mut self.ev_state, &mut shared.parley, size); cx.update(self.widget.as_node(data)); if *cx.resize { self.apply_size(data, false, true); From e237c975b77c5f4a619a743b61fd69ced374c58e Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Thu, 22 May 2025 17:43:08 +0100 Subject: [PATCH 09/12] Add fn SizeCx::parley_rules --- crates/kas-core/src/theme/dimensions.rs | 76 ++++++++++++++++++++++--- crates/kas-core/src/theme/size.rs | 23 +++++++- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/crates/kas-core/src/theme/dimensions.rs b/crates/kas-core/src/theme/dimensions.rs index abdfbbe2b..ab0affb8c 100644 --- a/crates/kas-core/src/theme/dimensions.rs +++ b/crates/kas-core/src/theme/dimensions.rs @@ -11,7 +11,9 @@ use std::f32; use std::rc::Rc; use super::anim::AnimState; -use super::{Feature, FrameStyle, MarginStyle, MarkStyle, SizableText, TextClass, ThemeSize}; +use super::{ + Feature, FrameStyle, MarginStyle, MarkStyle, SizableText, TextBrush, TextClass, ThemeSize, +}; use crate::cast::traits::*; use crate::config::{Config, WindowConfig}; use crate::dir::Directional; @@ -87,7 +89,11 @@ pub struct Dimensions { pub scale: f32, pub dpem: f32, pub mark_line: f32, - pub min_line_length: i32, + /// Minimum length of a wrapped line + /// + /// This prevents empty edit boxes from being zero-sized. + pub min_line_len: i32, + pub ideal_line_len: i32, pub m_inner: u16, pub m_tiny: u16, pub m_small: u16, @@ -111,6 +117,8 @@ pub struct Dimensions { impl Dimensions { pub fn new(params: &Parameters, scale: f32, dpem: f32) -> Self { + let min_line_len = (8.0 * dpem).cast_nearest(); + let text_m0 = (params.m_text.0 * scale).cast_nearest(); let text_m1 = (params.m_text.1 * scale).cast_nearest(); @@ -121,7 +129,8 @@ impl Dimensions { scale, dpem, mark_line: (1.6 * scale).round().max(1.0), - min_line_length: (8.0 * dpem).cast_nearest(), + min_line_len, + ideal_line_len: min_line_len * 2, m_inner: (params.m_inner * scale).cast_nearest(), m_tiny: (params.m_tiny * scale).cast_nearest(), m_small: (params.m_small * scale).cast_nearest(), @@ -203,7 +212,7 @@ impl ThemeSize for Window { if axis_is_vertical { (self.dims.dpem * 3.0).cast_ceil() } else { - self.dims.min_line_length + self.dims.min_line_len } } @@ -347,8 +356,8 @@ impl ThemeSize for Window { let rules = if axis.is_horizontal() { if wrap { - let min = self.dims.min_line_length; - let limit = 2 * min; + let min = self.dims.min_line_len; + let limit = self.dims.ideal_line_len; let bound: i32 = text.measure_width(limit.cast()).cast_ceil(); // NOTE: using different variable-width stretch policies here can @@ -364,8 +373,12 @@ impl ThemeSize for Window { .then(|| axis.other().map(|w| w.cast())) .flatten() .unwrap_or(f32::INFINITY); - let bound: i32 = text.measure_height(wrap_width).cast_ceil(); - SizeRules::new(bound, bound, Stretch::Filler) + let mut size: i32 = text.measure_height(wrap_width).cast_ceil(); + if class.editable() { + let line_height = self.dims.dpem.cast_ceil(); + size = size.max(line_height); + } + SizeRules::new(size, size, Stretch::Filler) }; rules.with_margin(match axis.is_horizontal() { @@ -373,4 +386,51 @@ impl ThemeSize for Window { false => self.dims.m_text.1, }) } + + fn parley_rules( + &self, + text: &mut parley::Layout, + class: TextClass, + axis: AxisInfo, + ) -> SizeRules { + let margin = match axis.is_horizontal() { + true => self.dims.m_text.0, + false => self.dims.m_text.1, + }; + let margins = (margin, margin); + + let wrap = class.multi_line(); + let editable = class.editable(); + + if axis.is_horizontal() { + if wrap { + let content = text.content_widths(); + let mut min: i32 = content.min.cast_ceil(); + let ideal = self.dims.ideal_line_len.min(content.max.cast_ceil()); + + if editable { + min = min.max(self.dims.min_line_len); + } + + // NOTE: using different variable-width stretch policies here can + // cause problems (e.g. edit boxes greedily consuming too much + // space). This is a hard layout problem; for now don't do this. + SizeRules::new(min, ideal, margins, Stretch::Filler) + } else { + let bound = text.max_content_width().cast_ceil(); + SizeRules::new(bound, bound, margins, Stretch::Filler) + } + } else { + let wrap_width = axis.other().map(|w| w.cast()).unwrap_or(f32::INFINITY); + text.break_all_lines(Some(wrap_width)); + let mut size: i32 = text.height().cast_ceil(); + + if editable { + let line_height = self.dims.dpem.cast_ceil(); + size = size.max(line_height) + } + + SizeRules::new(size, size, margins, Stretch::Filler) + } + } } diff --git a/crates/kas-core/src/theme/size.rs b/crates/kas-core/src/theme/size.rs index 14c6cb6df..749c32a50 100644 --- a/crates/kas-core/src/theme/size.rs +++ b/crates/kas-core/src/theme/size.rs @@ -5,7 +5,7 @@ //! "Handle" types used by themes -use super::{Feature, FrameStyle, MarginStyle, SizableText, Text, TextClass}; +use super::{Feature, FrameStyle, MarginStyle, SizableText, Text, TextBrush, TextClass}; use crate::autoimpl; use crate::dir::Directional; use crate::event::EventState; @@ -189,6 +189,19 @@ impl<'a> SizeCx<'a> { let class = text.class(); self.w.text_rules(text, class, axis) } + + /// Get [`SizeRules`] for a Parley text element + /// + /// This method will run line-breaking to determine vertical size. + #[inline] + fn parley_rules( + &self, + text: &mut parley::Layout, + class: TextClass, + axis: AxisInfo, + ) -> SizeRules { + self.0.parley_rules(text, class, axis) + } } /// Theme sizing implementation @@ -238,4 +251,12 @@ pub trait ThemeSize { /// theme-defined margins. fn text_rules(&self, text: &mut dyn SizableText, class: TextClass, axis: AxisInfo) -> SizeRules; + + /// Get [`SizeRules`] for a Parley text element + fn parley_rules( + &self, + text: &mut parley::Layout, + class: TextClass, + axis: AxisInfo, + ) -> SizeRules; } From ed550afd8a27a8787b12bb360cbf1800217fa784 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Fri, 23 May 2025 09:50:53 +0100 Subject: [PATCH 10/12] Remove TextClass parameter from trait ThemeDraw methods --- crates/kas-core/src/theme/draw.rs | 24 ++++++++--------------- crates/kas-core/src/theme/simple_theme.rs | 1 + 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/crates/kas-core/src/theme/draw.rs b/crates/kas-core/src/theme/draw.rs index c0080298b..785dd2dd9 100644 --- a/crates/kas-core/src/theme/draw.rs +++ b/crates/kas-core/src/theme/draw.rs @@ -321,12 +321,13 @@ impl<'a> DrawCx<'a> { text: &Text, effects: &[Effect], ) { + let is_access_key = text.class().is_access_key(); if let Ok(display) = text.display() { if effects.is_empty() { // Use the faster and simpler implementation when we don't have effects self.h.text(&self.id, pos, rect, display); } else { - self.h.text_effects(&self.id, pos, rect, display, effects); + self.h.text_effects(&self.id, pos, rect, display, effects, is_access_key); } } } @@ -351,7 +352,7 @@ impl<'a> DrawCx<'a> { return; }; - self.h + self.h.text_selected_range(&self.id, rect, display, range); .text_selected_range(&self.id, pos, rect, display, range); } @@ -527,8 +528,7 @@ pub trait ThemeDraw { /// Draw text /// - /// [`ConfigCx::text_configure`] should be called prior to this method to - /// select a font, font size and wrap options (based on the [`TextClass`]). + /// Text should be fully prepared before calling this method. fn text(&mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay); /// Draw text with effects @@ -540,8 +540,7 @@ pub trait ThemeDraw { /// If `effects` is empty or all [`Effect::flags`] are default then it is /// equivalent (and faster) to call [`Self::text`] instead. /// - /// [`ConfigCx::text_configure`] should be called prior to this method to - /// select a font, font size and wrap options (based on the [`TextClass`]). + /// Text should be fully prepared before calling this method. fn text_effects( &mut self, id: &Id, @@ -549,22 +548,15 @@ pub trait ThemeDraw { rect: Rect, text: &TextDisplay, effects: &[Effect], + is_access_key: bool, ); /// Method used to implement [`DrawCx::text_selected`] - fn text_selected_range( - &mut self, - id: &Id, + fn text_selected_range(&mut self, id: &Id, rect: Rect, text: &TextDisplay, range: Range); pos: Coord, - rect: Rect, - text: &TextDisplay, - range: Range, - ); /// Draw an edit marker at the given `byte` index on this `text` - /// - /// [`ConfigCx::text_configure`] should be called prior to this method to - /// select a font, font size and wrap options (based on the [`TextClass`]). + /// Text should be fully prepared before calling this method. fn text_cursor(&mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay, byte: usize); /// Draw UI element: check box diff --git a/crates/kas-core/src/theme/simple_theme.rs b/crates/kas-core/src/theme/simple_theme.rs index e5f7baeeb..7743fdf45 100644 --- a/crates/kas-core/src/theme/simple_theme.rs +++ b/crates/kas-core/src/theme/simple_theme.rs @@ -344,6 +344,7 @@ where rect: Rect, text: &TextDisplay, effects: &[Effect], + is_access_key: bool, ) { let bb = Quad::conv(rect); let col = if self.ev.is_disabled(id) { From c0b98fb532e9c6942bacc9fe79743815db4560ee Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Fri, 23 May 2025 10:05:06 +0100 Subject: [PATCH 11/12] Add trait TextClass for usage by Parley --- crates/kas-core/Cargo.toml | 3 +- crates/kas-core/src/config/font.rs | 22 +++- crates/kas-core/src/text/class.rs | 171 +++++++++++++++-------------- crates/kas-core/src/text/mod.rs | 5 +- 4 files changed, 114 insertions(+), 87 deletions(-) diff --git a/crates/kas-core/Cargo.toml b/crates/kas-core/Cargo.toml index fc117472c..fe54c5d31 100644 --- a/crates/kas-core/Cargo.toml +++ b/crates/kas-core/Cargo.toml @@ -24,7 +24,7 @@ minimal = ["wayland"] # All standard test target features stable = ["minimal", "clipboard", "markdown", "spawn", "x11", "serde", "toml", "yaml", "json", "ron", "macros_log"] # Enables all "recommended" features for nightly rustc -nightly = ["stable", "nightly-diagnostics"] +nightly = ["stable", "nightly-diagnostics", "serde"] # Additional, less recommendation-worthy features experimental = ["dark-light", "unsafe_node"] @@ -113,6 +113,7 @@ const-fnv1a-hash = "1.1.0" accesskit = { version = "0.21.0", optional = true } accesskit_winit = { version = "0.29.0", optional = true } parley = { version = "0.6", optional = true } +const-fnv1a-hash = "1.1.0" [target.'cfg(any(target_os="linux", target_os="dragonfly", target_os="freebsd", target_os="netbsd", target_os="openbsd"))'.dependencies] smithay-clipboard = { version = "0.7.0", optional = true } diff --git a/crates/kas-core/src/config/font.rs b/crates/kas-core/src/config/font.rs index 0e8d4a3ed..078e4b893 100644 --- a/crates/kas-core/src/config/font.rs +++ b/crates/kas-core/src/config/font.rs @@ -7,7 +7,9 @@ use crate::ConfigAction; use crate::text::fonts::FontSelector; +use crate::text::class; use crate::theme::TextClass; +use std::borrow::Cow; use std::collections::BTreeMap; /// A message which may be used to update [`FontConfig`] @@ -31,9 +33,15 @@ pub struct FontConfig { /// Standard fonts /// /// TODO: read/write support. - #[cfg_attr(feature = "serde", serde(skip, default))] + #[cfg_attr(feature = "serde", serde(skip, default = "defaults::fonts"))] pub fonts: BTreeMap, + /// Standard font for each text class + /// + /// Accepts a font family specifier in CSS format for each text class key. + #[cfg_attr(feature = "serde", serde(default))] + pub class_fonts: BTreeMap>, + /// Text glyph rastering settings #[cfg_attr(feature = "serde", serde(default))] pub raster: RasterConfig, @@ -177,7 +185,17 @@ mod defaults { (TextClass::Edit(false), serif.into()), (TextClass::Edit(true), serif.into()), ]; - list.iter().cloned().collect() + list.into_iter().collect() + } + + pub fn class_fonts() -> BTreeMap> { + let ui = "system-ui, ui-sans-serif, sans-serif"; + let serif = "ui-serif, serif"; + let list = [ + (::KEY, ui.into()), + (::KEY, serif.into()), + ]; + list.into_iter().collect() } pub fn mode() -> u8 { diff --git a/crates/kas-core/src/text/class.rs b/crates/kas-core/src/text/class.rs index 534c6c470..57d5db0cd 100644 --- a/crates/kas-core/src/text/class.rs +++ b/crates/kas-core/src/text/class.rs @@ -5,39 +5,46 @@ //! Text classes +use std::borrow::Cow; use std::hash::Hasher; use std::cmp::{Ordering, PartialEq, PartialOrd}; /// Class key /// /// This is a name plus a pre-computed hash value. It is used for font mapping. -#[derive(Copy, Clone, Debug, Eq)] -pub struct Key(&'static str, u64); +// +// NOTE: the requirement to serialize this makes things much more complex: +// either we need an owning variant or we need a registry of all possible values +// before deserialization (which happens before the UI is constructed). +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(into = "SerializeName"))] +#[derive(Clone, Debug, Eq)] +pub struct Key(Cow<'static, str>, u64); impl Key { /// Construct a key pub const fn new(name: &'static str) -> Self { let hash = const_fnv1a_hash::fnv1a_hash_str_64(name); - Key(name, hash) + Key(Cow::Borrowed(name), hash) } } impl PartialEq for Key { - fn eq(&self, rhs: &Self) -> bool { - // NOTE: if we test for collisions we could skip testing against field 0 - self.1 == rhs.1 && self.0 == rhs.0 + fn eq(&self, other: &Self) -> bool { + // TODO: this requires that we check for collisions somewhere + self.1 == other.1 } } impl PartialOrd for Key { - fn partial_cmp(&self, rhs: &Key) -> Option { - self.1.partial_cmp(&rhs.1) + fn partial_cmp(&self, other: &Key) -> Option { + self.1.partial_cmp(&other.1) } } impl Ord for Key { - fn cmp(&self, rhs: &Key) -> Ordering { - self.1.cmp(&rhs.1) + fn cmp(&self, other: &Key) -> Ordering { + self.1.cmp(&other.1) } } @@ -47,12 +54,37 @@ impl std::hash::Hash for Key { } } +#[cfg(feature = "serde")] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +struct SerializeName(String); + +#[cfg(feature = "serde")] +impl From for SerializeName { + fn from(key: Key) -> Self { + SerializeName(key.0.to_string()) + } +} + +#[cfg(feature = "serde")] +#[cfg_attr(feature = "serde", derive(serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +struct SerializedName(String); + +#[cfg(feature = "serde")] +impl From for Key { + fn from(name: SerializedName) -> Self { + let hash = const_fnv1a_hash::fnv1a_hash_str_64(&name.0); + Key(Cow::Owned(name.0), hash) + } +} + /// A [`Hasher`] optimized for [`Key`] /// /// Warning: this hasher should only be used for keys of type [`Key`]. /// In most other cases it will panic or give poor results. #[derive(Default)] -pub(crate) struct KeyHasher(u64); +pub struct KeyHasher(u64); impl Hasher for KeyHasher { #[inline] @@ -72,42 +104,27 @@ impl Hasher for KeyHasher { } /// A hash builder for [`KeyHasher`] -pub(crate) type BuildKeyHasher = std::hash::BuildHasherDefault; - -bitflags! { - /// Text class properties - #[must_use] - #[derive(Copy, Clone, Debug, Default)] - pub struct Properties: u32 { - /// Perform line-wrapping - /// - /// If `true`, long lines are broken at appropriate positions (see - /// Unicode UAX 14) to respect some maximum line length. - /// - /// If `false`, only explicit line breaks result in new lines. - const WRAP = 1 << 0; - - /// Is an access key - /// - /// If `true`, then text decorations (underline, strikethrough) are only - /// drawn when access key mode is active (usually, this means - /// Alt is held). - const ACCESS = 1 << 8; - - /// Limit minimum size - /// - /// This is used to prevent empty edit-fields from collapsing to nothing. - const LIMIT_MIN_SIZE = 1 << 9; - } -} +pub type BuildKeyHasher = std::hash::BuildHasherDefault; + +// /// Text wrap mode +// enum WrapMode { +// /// Do not break long lines +// None, +// +// } /// A text class pub trait TextClass { /// Each text class must have a unique key, used for lookups const KEY: Key; - /// Get text properties - fn properties(&self) -> Properties; + /// Whether to perform line-breaking + /// + /// If `true`, long lines are broken at appropriate positions (see Unicode + /// UAX 14) to respect some maximum line length. + /// + /// If `false`, only explicit line breaks result in new lines. + fn wrap(&self) -> bool; // /// Whether to wrap even where plenty of space is available // /// @@ -115,58 +132,50 @@ pub trait TextClass { // /// at some sensible (theme-defined) maximum paragraph width even when // /// plenty of space is availble. // fn restrict_width(&self) -> bool; + + /// Whether to enforce a minimum size + /// + /// Default value: `false`. + fn editable(&self) -> bool { + false + } + + /// Access key mode + /// + /// If `true`, then text decorations (underline, strikethrough) are only + /// drawn when access key mode is active (usually, this means Alt + /// is held). + /// + /// Default value: `false`. + fn is_access_key(&self) -> bool { + false + } } /// Text class: label /// -/// This will wrap long lines if and only if its field is true. +/// This will wrap text if and only if the field is true. #[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] pub struct Label(pub bool); impl TextClass for Label { - const KEY = Key::new("Label"); - - fn properties(&self) -> Properties { - if self.0 { - Properties::WRAP - } else { - Properties::empty() - } + const KEY: Key = Key::new("kas::Label"); + + fn wrap(&self) -> bool { + self.0 } } -/// Text class: access label -/// -/// This is identical to [`Label`] except that effects are only drawn if -/// access key mode is activated (usually the `Alt` key). -/// -/// This will wrap long lines if and only if its field is true. -#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] -pub struct AccessLabel(pub bool); - -/// Text class: scrollable label -/// -/// The occupied vertical space may be less than the height of the text object. -/// This will wrap long lines. -#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] -pub struct ScrollLabel(pub bool); - -/// Text class: menu label +/// Text class: edit field /// -/// This is equivalent to [`AccessLabel`] `(false)`, but may use different -/// styling and does not stretch to fill extra space. +/// This will wrap text if and only if the field is true. #[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] -pub struct MenuLabel; +pub struct EditField(pub bool); -/// Text class: button -/// -/// This is equivalent to [`AccessLabel`] `(false)`, but may use different -/// styling. -#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] -pub struct Button; +impl TextClass for EditField { + const KEY: Key = Key::new("kas::EditField"); -/// Text class: edit field -/// -/// This is a multi-line edit field if and only if its field is true. -#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] -pub struct Edit(pub bool); + fn wrap(&self) -> bool { + self.0 + } +} diff --git a/crates/kas-core/src/text/mod.rs b/crates/kas-core/src/text/mod.rs index 2d0ebd6ba..a7ae30b6f 100644 --- a/crates/kas-core/src/text/mod.rs +++ b/crates/kas-core/src/text/mod.rs @@ -18,9 +18,8 @@ pub use kas_text::{ OwningVecIter, Status, Text, TextDisplay, Vec2, fonts, format, }; -mod class; -pub(crate) use class::{BuildKeyHasher, KeyHasher}; -pub use class::{Key, TextClass}; +pub mod class; +pub use class::TextClass; mod selection; pub use selection::SelectionHelper; From 5619e9d6847814ad328a6f422ffaae0b39c29646 Mon Sep 17 00:00:00 2001 From: Diggory Hardy Date: Thu, 22 May 2025 18:19:39 +0100 Subject: [PATCH 12/12] Add ParleyText widget --- crates/kas-core/Cargo.toml | 2 + crates/kas-core/src/config/font.rs | 4 ++ crates/kas-core/src/lib.rs | 3 ++ crates/kas-core/src/text/mod.rs | 3 ++ crates/kas-core/src/text/parley.rs | 54 +++++++++++++++++++++++++ crates/kas-core/src/theme/dimensions.rs | 2 + crates/kas-core/src/theme/size.rs | 2 +- crates/kas-view/src/driver.rs | 2 +- crates/kas-wgpu/src/draw/text_pipe.rs | 3 +- examples/gallery.rs | 2 +- 10 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 crates/kas-core/src/text/parley.rs diff --git a/crates/kas-core/Cargo.toml b/crates/kas-core/Cargo.toml index fe54c5d31..485b58b2c 100644 --- a/crates/kas-core/Cargo.toml +++ b/crates/kas-core/Cargo.toml @@ -21,6 +21,8 @@ rustdoc-args = ["--cfg", "docsrs"] # The minimal feature set needed to build basic applications (with assumptions # about target platforms). minimal = ["wayland"] +# HACK +default = ["minimal", "parley"] # All standard test target features stable = ["minimal", "clipboard", "markdown", "spawn", "x11", "serde", "toml", "yaml", "json", "ron", "macros_log"] # Enables all "recommended" features for nightly rustc diff --git a/crates/kas-core/src/config/font.rs b/crates/kas-core/src/config/font.rs index 078e4b893..07593567d 100644 --- a/crates/kas-core/src/config/font.rs +++ b/crates/kas-core/src/config/font.rs @@ -180,8 +180,12 @@ mod defaults { } pub fn fonts() -> BTreeMap { + let sans_serifrif = FamilySelector::SANS_SERIF; let serif = FamilySelector::SERIF; let list = [ + (TextClass::Label(false), sans_serif.into()), + (TextClass::Label(true), sans_serif.into()), + (TextClass::LabelScroll, sans_serif.into()), (TextClass::Edit(false), serif.into()), (TextClass::Edit(true), serif.into()), ]; diff --git a/crates/kas-core/src/lib.rs b/crates/kas-core/src/lib.rs index 6d09e8ee4..896064477 100644 --- a/crates/kas-core/src/lib.rs +++ b/crates/kas-core/src/lib.rs @@ -20,6 +20,9 @@ extern crate self as kas; #[doc(inline)] pub extern crate easy_cast as cast; +// TODO: this is temporary +pub extern crate parley; + // internal modules: #[cfg(feature = "accesskit")] pub(crate) mod accesskit; mod action; diff --git a/crates/kas-core/src/text/mod.rs b/crates/kas-core/src/text/mod.rs index a7ae30b6f..a7ad7e6e9 100644 --- a/crates/kas-core/src/text/mod.rs +++ b/crates/kas-core/src/text/mod.rs @@ -26,3 +26,6 @@ pub use selection::SelectionHelper; mod string; pub use string::AccessString; + +mod parley; +pub use parley::ParleyText; diff --git a/crates/kas-core/src/text/parley.rs b/crates/kas-core/src/text/parley.rs new file mode 100644 index 000000000..a5dc7005c --- /dev/null +++ b/crates/kas-core/src/text/parley.rs @@ -0,0 +1,54 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License in the LICENSE-APACHE file or at: +// https://www.apache.org/licenses/LICENSE-2.0 + +//! Parley text + +use cast::Cast; +use kas_text::Align; +use parley::{Alignment, AlignmentOptions}; +use crate::event::ConfigCx; +use crate::geom::Rect; +use crate::layout::{AlignHints, AxisInfo, SizeRules}; +use crate::theme::{DrawCx, SizeCx, TextBrush, TextClass}; + +#[derive(Clone)] +pub struct ParleyText { + class: TextClass, + rect: Rect, + layout: parley::Layout, +} + +impl crate::Layout for ParleyText { + #[inline] + fn rect(&self) -> Rect { + self.rect + } + + #[inline] + fn size_rules(&mut self, sizer: SizeCx, axis: AxisInfo) -> SizeRules { + // Measure size and perform line-breaking (vertical axis only): + sizer.parley_rules(&mut self.layout, self.class, axis) + } + + fn set_rect(&mut self, _: &mut ConfigCx, rect: Rect, hints: AlignHints) { + self.rect = rect; + + // TODO: revise alignment; allow local override? + let alignment = match hints.horiz { + None => Alignment::Start, + Some(Align::Default) => Alignment::Start, + Some(Align::TL) => Alignment::Left, + Some(Align::Center) => Alignment::Middle, + Some(Align::BR) => Alignment::Right, + Some(Align::Stretch) => Alignment::Justified, + }; + let options = AlignmentOptions { align_when_overflowing: true }; + self.layout.align(Some(rect.size.0.cast()), alignment, options); + } + + fn draw(&self, mut draw: DrawCx) { + draw.parley(self.rect, &self.layout); + } +} diff --git a/crates/kas-core/src/theme/dimensions.rs b/crates/kas-core/src/theme/dimensions.rs index ab0affb8c..afeedf209 100644 --- a/crates/kas-core/src/theme/dimensions.rs +++ b/crates/kas-core/src/theme/dimensions.rs @@ -117,6 +117,7 @@ pub struct Dimensions { impl Dimensions { pub fn new(params: &Parameters, scale: f32, dpem: f32) -> Self { + eprintln!("dpem: {dpem}"); let min_line_len = (8.0 * dpem).cast_nearest(); let text_m0 = (params.m_text.0 * scale).cast_nearest(); @@ -423,6 +424,7 @@ impl ThemeSize for Window { } else { let wrap_width = axis.other().map(|w| w.cast()).unwrap_or(f32::INFINITY); text.break_all_lines(Some(wrap_width)); + let mut size: i32 = text.height().cast_ceil(); if editable { diff --git a/crates/kas-core/src/theme/size.rs b/crates/kas-core/src/theme/size.rs index 749c32a50..b9d003ee1 100644 --- a/crates/kas-core/src/theme/size.rs +++ b/crates/kas-core/src/theme/size.rs @@ -194,7 +194,7 @@ impl<'a> SizeCx<'a> { /// /// This method will run line-breaking to determine vertical size. #[inline] - fn parley_rules( + pub fn parley_rules( &self, text: &mut parley::Layout, class: TextClass, diff --git a/crates/kas-view/src/driver.rs b/crates/kas-view/src/driver.rs index e38a2850a..4e3f6e871 100644 --- a/crates/kas-view/src/driver.rs +++ b/crates/kas-view/src/driver.rs @@ -116,7 +116,7 @@ macro_rules! impl_via_to_string { ($t:ty) => { impl Driver for View { const TAB_NAVIGABLE: bool = false; - type Widget = Text<$t, String>; + type Widget = Text<$t>; fn make(&mut self, _: &Key) -> Self::Widget { Text::new_gen(|_, data: &$t| data.to_string()) diff --git a/crates/kas-wgpu/src/draw/text_pipe.rs b/crates/kas-wgpu/src/draw/text_pipe.rs index a11e336f9..5b90f9227 100644 --- a/crates/kas-wgpu/src/draw/text_pipe.rs +++ b/crates/kas-wgpu/src/draw/text_pipe.rs @@ -490,7 +490,8 @@ impl Window { let mut run_x = glyph_run.offset(); let run_y = glyph_run.baseline(); // NOTE: can we assume this? If so we can simplify below. - debug_assert!(run_x.fract() == 0.0 && run_y.fract() == 0.0); + // NOTE: yes, provided that quantize == true? + // debug_assert!(run_x.fract() == 0.0 && run_y.fract() == 0.0); let col = color; let font = glyph_run.run().font(); diff --git a/examples/gallery.rs b/examples/gallery.rs index 4484884c6..a0a9ad7ad 100644 --- a/examples/gallery.rs +++ b/examples/gallery.rs @@ -112,7 +112,7 @@ fn widgets() -> Page { #[layout(row! [self.text, Button::label_msg("&Edit", MsgEdit)])] struct { core: widget_core!(), - #[widget] text: Text = format_data!(data: &Data, "{}", &data.text), + #[widget] text: Text = format_data!(data: &Data, "{}", &data.text), #[widget(&())] popup: Popup = Popup::new(TextEdit::new("", true), Direction::Down), } impl Events for Self {