//! Renderer-oriented scene snapshot types. use std::ops::{Deref, Range}; use std::sync::Arc; use cosmic_text::CacheKey; use tracing::debug; use crate::ImageResource; use crate::text::TextSelectionStyle; use crate::trace_targets; use crate::tree::{BoxShadowKind, ElementId}; pub type SceneVersion = u64; #[derive(Clone, Copy, Debug, PartialEq)] pub struct Point { pub x: f32, pub y: f32, } impl Point { pub const fn new(x: f32, y: f32) -> Self { Self { x, y } } } #[derive(Clone, Copy, Debug, PartialEq)] pub struct UiSize { pub width: f32, pub height: f32, } impl UiSize { pub const fn new(width: f32, height: f32) -> Self { Self { width, height } } } #[derive(Clone, Copy, Debug, PartialEq)] pub struct Rect { pub origin: Point, pub size: UiSize, } impl Rect { pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self { Self { origin: Point::new(x, y), size: UiSize::new(width, height), } } pub fn contains(self, point: Point) -> bool { point.x >= self.origin.x && point.y >= self.origin.y && point.x < self.origin.x + self.size.width && point.y < self.origin.y + self.size.height } pub fn intersects(self, other: Rect) -> bool { self.origin.x < other.origin.x + other.size.width && self.origin.x + self.size.width > other.origin.x && self.origin.y < other.origin.y + other.size.height && self.origin.y + self.size.height > other.origin.y } } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct Color { pub r: u8, pub g: u8, pub b: u8, pub a: u8, } impl Color { /// Sentinel value meaning "use the PreparedText default_color at render time". /// Fully transparent black (a == 0) is never a useful visible color, so it is /// safe to reserve as a sentinel. No user-facing API sets this value directly. pub const SENTINEL: Self = Self::rgba(0, 0, 0, 0); pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { Self { r, g, b, a } } pub const fn rgb(r: u8, g: u8, b: u8) -> Self { Self::rgba(r, g, b, 255) } /// Returns `true` when this color is the sentinel "use default" marker. #[inline] pub const fn is_sentinel(self) -> bool { self.a == 0 } } #[derive(Clone, Copy, Debug, PartialEq)] pub struct Translation { pub x: f32, pub y: f32, } impl Translation { pub const fn new(x: f32, y: f32) -> Self { Self { x, y } } } #[derive(Clone, Copy, Debug, PartialEq)] pub struct Quad { pub rect: Rect, pub color: Color, } impl Quad { pub const fn new(rect: Rect, color: Color) -> Self { Self { rect, color } } } #[derive(Clone, Copy, Debug, PartialEq)] pub struct RoundedRect { pub rect: Rect, pub fill: Option, pub border_color: Option, pub border_width: f32, pub radius: f32, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct ShadowRect { pub rect: Rect, pub source_rect: Rect, pub color: Color, pub blur: f32, pub radius: f32, pub source_radius: f32, pub kind: BoxShadowKind, } #[derive(Clone, Copy, Debug, PartialEq)] pub struct ClipRegion { pub rect: Rect, pub radius: f32, } #[derive(Clone, Debug, PartialEq)] pub struct GlyphInstance { pub position: Point, pub advance: f32, /// Glyph color. When equal to `Color::SENTINEL` the renderer uses the /// enclosing `PreparedText::default_color`. This allows the text layout /// cache to be color-independent: the same shaped data can be reused for /// the same text displayed in different colors. pub color: Color, pub cache_key: Option, pub text_start: usize, pub text_end: usize, } #[derive(Clone, Debug, PartialEq)] pub struct PreparedTextLine { pub rect: Rect, pub text_start: usize, pub text_end: usize, pub glyph_start: usize, pub glyph_end: usize, } /// Shaped, positioned text ready for rendering. /// /// Glyphs and line rects are stored in **local (origin-relative) coordinates**: /// add `self.origin` to convert to absolute window coords. This keeps the /// `Arc` from the text shape cache shareable across different /// on-screen positions — no per-frame translation is required. /// /// The `lines` and `glyphs` are stored behind an `Arc` so that cloning a /// `PreparedText` (e.g. to put the same shaped text into both the scene /// snapshot and the interaction tree) is O(1). The `Arc` is shared as long /// as no mutation is needed; `apply_selected_text_color` uses /// `Arc::make_mut` and clones on first write. /// /// Glyph colors may be `Color::SENTINEL` — the renderer substitutes /// `default_color` for those glyphs. #[derive(Clone, Debug, PartialEq)] pub struct PreparedText { pub element_id: Option, pub text: String, pub origin: Point, pub bounds: Option, pub font_size: f32, pub line_height: f32, /// Default color applied to sentinel-colored glyphs at render time. pub default_color: Color, pub selectable: bool, pub selection_style: TextSelectionStyle, layout: Arc, } /// Shaped glyph and line data shared between scene and interaction-tree copies /// of the same `PreparedText`. Coordinates are LOCAL (relative to `PreparedText::origin`). /// Add `origin` to convert to absolute window coords. #[derive(Clone, Debug, PartialEq)] pub struct TextLayoutData { pub lines: Vec, pub glyphs: Vec, /// Measured (unclamped) size of the laid-out text. pub size: UiSize, } impl Deref for PreparedText { type Target = TextLayoutData; fn deref(&self) -> &TextLayoutData { &self.layout } } impl PreparedText { /// Construct a `PreparedText` from shaped data. Called by `TextSystem::prepare_spans`. pub(crate) fn from_layout( element_id: Option, text: String, origin: Point, bounds: Option, font_size: f32, line_height: f32, default_color: Color, selectable: bool, selection_style: TextSelectionStyle, layout: Arc, ) -> Self { Self { element_id, text, origin, bounds, font_size, line_height, default_color, selectable, selection_style, layout, } } /// Returns a raw pointer to the underlying `TextLayoutData` allocation. /// Used in tests to verify Arc sharing between PreparedTexts. #[cfg(test)] pub(crate) fn layout_ptr(&self) -> *const TextLayoutData { Arc::as_ptr(&self.layout) } /// Create a monospace `PreparedText` without going through the text system. /// Used for testing and low-level terminal-style rendering. pub fn monospace( text: impl Into, origin: Point, font_size: f32, advance: f32, color: Color, ) -> Self { let text = text.into(); // Glyphs are stored in LOCAL (origin-relative) coordinates. let mut local_x = 0.0f32; let mut glyphs = Vec::with_capacity(text.chars().count()); for (text_start, ch) in text.char_indices() { let text_end = text_start + ch.len_utf8(); // monospace() builds glyphs directly; use the actual color (not sentinel) // because this path does not go through the text cache. glyphs.push(GlyphInstance { position: Point::new(local_x, 0.0), advance, color, cache_key: None, text_start, text_end, }); local_x += advance; } let line = PreparedTextLine { rect: Rect::new(0.0, 0.0, local_x, font_size), text_start: 0, text_end: glyphs.last().map_or(0, |glyph| glyph.text_end), glyph_start: 0, glyph_end: glyphs.len(), }; let size = UiSize::new(local_x, font_size); Self { element_id: None, text, origin, bounds: None, font_size, line_height: font_size, default_color: color, selectable: true, selection_style: TextSelectionStyle::DEFAULT, layout: Arc::new(TextLayoutData { lines: vec![line], glyphs, size, }), } } /// Translate this text by `offset`. O(1): only `self.origin` is updated. /// Glyphs are stored in local coords and are unaffected. pub fn translated(mut self, offset: Point) -> Self { self.origin.x += offset.x; self.origin.y += offset.y; self } /// Return the byte offset within `self.text` for an absolute window-space point. pub fn byte_offset_for_position(&self, point: Point) -> usize { // Convert to local coords before delegating to the local-space helpers. let local_x = point.x - self.origin.x; let local_y = point.y - self.origin.y; let Some(line) = self.line_for_position(local_y) else { return 0; }; self.byte_offset_for_line_position(line, local_x) } pub fn selection_range(&self, start: usize, end: usize) -> Range { let start = start.min(self.text.len()); let end = end.min(self.text.len()); if start <= end { start..end } else { end..start } } pub fn selected_text(&self, start: usize, end: usize) -> Option<&str> { let range = self.selection_range(start, end); self.text.get(range) } pub fn selection_rects(&self, start: usize, end: usize) -> Vec { let range = self.selection_range(start, end); if range.is_empty() { return Vec::new(); } let mut rects = Vec::new(); for line in &self.lines { let mut left = None::; let mut right = None::; for glyph in &self.glyphs[line.glyph_start..line.glyph_end] { if glyph.text_end <= range.start || glyph.text_start >= range.end { continue; } let glyph_left = glyph.position.x; let glyph_right = glyph.position.x + glyph.advance.max(0.0); left = Some(left.map_or(glyph_left, |current| current.min(glyph_left))); right = Some(right.map_or(glyph_right, |current| current.max(glyph_right))); } if let (Some(left), Some(right)) = (left, right) { // Glyph x/y and line rect are in local coords; add origin for absolute result. rects.push(Rect::new( self.origin.x + left, self.origin.y + line.rect.origin.y, (right - left).max(0.0), line.rect.size.height, )); } } rects } pub fn caret_rect(&self, offset: usize, width: f32) -> Option { let width = width.max(0.0); let line = self.line_for_offset(offset)?; // caret_x_for_line_offset returns local x; add origin for absolute result. let local_x = self.caret_x_for_line_offset(line, offset); Some(Rect::new( self.origin.x + local_x, self.origin.y + line.rect.origin.y, width, line.rect.size.height, )) } pub fn previous_char_boundary(&self, offset: usize) -> usize { let offset = offset.min(self.text.len()); self.text[..offset] .char_indices() .last() .map(|(index, _)| index) .unwrap_or(0) } pub fn next_char_boundary(&self, offset: usize) -> usize { let offset = offset.min(self.text.len()); if offset >= self.text.len() { return self.text.len(); } self.text .char_indices() .find_map(|(index, _)| (index > offset).then_some(index)) .unwrap_or(self.text.len()) } pub fn line_start_offset(&self, offset: usize) -> Option { Some(self.line_for_offset(offset)?.text_start) } pub fn line_end_offset(&self, offset: usize) -> Option { Some(self.line_for_offset(offset)?.text_end) } pub fn vertical_offset(&self, offset: usize, line_delta: isize) -> Option { if line_delta == 0 { return Some(offset.min(self.text.len())); } let offset = offset.min(self.text.len()); let line = self.line_for_offset(offset)?; let current_index = self.lines.iter().position(|candidate| candidate == line)?; let next_index = current_index.checked_add_signed(line_delta)?; let next_line = self.lines.get(next_index)?; let target_x = self.caret_x_for_line_offset(line, offset); Some(self.byte_offset_for_line_position(next_line, target_x)) } pub fn word_range_for_offset(&self, offset: usize) -> Range { if self.text.is_empty() { return 0..0; } let offset = offset.min(self.text.len()); let target = self.word_class_offset(offset); let Some((target_start, target_ch)) = self.char_at(target) else { return 0..self.text.len(); }; let target_class = classify_word_char(target_ch); let mut start = target_start; while let Some((previous_start, previous_ch)) = self.char_before(start) { if classify_word_char(previous_ch) != target_class { break; } start = previous_start; } let mut end = target_start + target_ch.len_utf8(); while let Some((next_start, next_ch)) = self.char_at(end) { if classify_word_char(next_ch) != target_class { break; } end = next_start + next_ch.len_utf8(); } start..end } /// Apply `selection_style.text_color` to glyphs in `[start, end)`. /// Clones the inner `Arc` on first call if it is shared. pub fn apply_selected_text_color(&mut self, start: usize, end: usize) { let Some(selected_color) = self.selection_style.text_color else { return; }; let range = self.selection_range(start, end); if range.is_empty() { return; } for glyph in Arc::make_mut(&mut self.layout).glyphs.iter_mut() { if glyph.text_end > range.start && glyph.text_start < range.end { glyph.color = selected_color; } } } fn line_for_position(&self, y: f32) -> Option<&PreparedTextLine> { let mut lines = self.lines.iter(); let first = lines.next()?; if y < first.rect.origin.y { return Some(first); } let mut last = first; for line in std::iter::once(first).chain(lines) { last = line; if y < line.rect.origin.y + line.rect.size.height { return Some(line); } } Some(last) } fn line_for_offset(&self, offset: usize) -> Option<&PreparedTextLine> { let offset = offset.min(self.text.len()); let mut lines = self.lines.iter(); let first = lines.next()?; for line in std::iter::once(first).chain(lines) { if offset <= line.text_end { return Some(line); } } Some(self.lines.last().unwrap_or(first)) } fn byte_offset_for_line_position(&self, line: &PreparedTextLine, x: f32) -> usize { if line.glyph_start == line.glyph_end { return line.text_start; } let line_glyphs = &self.glyphs[line.glyph_start..line.glyph_end]; let first_glyph = &line_glyphs[0]; if x <= first_glyph.position.x { return first_glyph.text_start; } for glyph in line_glyphs { let glyph_left = glyph.position.x; let glyph_right = glyph.position.x + glyph.advance.max(0.0); let midpoint = glyph_left + glyph.advance.max(0.0) * 0.5; if x < midpoint { return glyph.text_start; } if x < glyph_right { return glyph.text_end; } } line.text_end } fn caret_x_for_line_offset(&self, line: &PreparedTextLine, offset: usize) -> f32 { if line.glyph_start == line.glyph_end { return line.rect.origin.x; } let offset = offset.min(self.text.len()); let line_glyphs = &self.glyphs[line.glyph_start..line.glyph_end]; let first_glyph = &line_glyphs[0]; if offset <= first_glyph.text_start { return first_glyph.position.x; } for glyph in line_glyphs { if offset <= glyph.text_start { return glyph.position.x; } if offset <= glyph.text_end { return glyph.position.x + glyph.advance.max(0.0); } } line_glyphs .last() .map(|glyph| glyph.position.x + glyph.advance.max(0.0)) .unwrap_or(line.rect.origin.x) } fn char_at(&self, offset: usize) -> Option<(usize, char)> { if offset >= self.text.len() { return None; } self.text[offset..].chars().next().map(|ch| (offset, ch)) } fn char_before(&self, offset: usize) -> Option<(usize, char)> { let offset = offset.min(self.text.len()); self.text[..offset].char_indices().last() } fn word_class_offset(&self, offset: usize) -> usize { if offset >= self.text.len() { return self.previous_char_boundary(offset); } if offset > 0 && let (Some((_, previous)), Some((_, current))) = (self.char_before(offset), self.char_at(offset)) && classify_word_char(current) == WordClass::Whitespace && classify_word_char(previous) == WordClass::Word { return self.previous_char_boundary(offset); } offset } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum WordClass { Word, Whitespace, Punctuation, } fn classify_word_char(ch: char) -> WordClass { if ch.is_whitespace() { WordClass::Whitespace } else if ch.is_alphanumeric() || ch == '_' { WordClass::Word } else { WordClass::Punctuation } } #[derive(Clone, Debug, PartialEq)] pub struct PreparedImage { pub element_id: Option, pub resource: ImageResource, pub rect: Rect, pub uv_rect: (f32, f32, f32, f32), } #[derive(Clone, Debug, PartialEq)] pub enum DisplayItem { Quad(Quad), RoundedRect(RoundedRect), ShadowRect(ShadowRect), Image(PreparedImage), Text(PreparedText), PushClip(ClipRegion), PopClip, PushTransform(Translation), PopTransform, LayerBegin { opacity: f32 }, LayerEnd, } impl DisplayItem { /// Return a copy of this item with all positions shifted by `offset`. pub fn translated(&self, offset: Point) -> Self { fn translate_rect(r: Rect, o: Point) -> Rect { Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height) } match self { Self::Quad(q) => Self::Quad(Quad { rect: translate_rect(q.rect, offset), ..*q }), Self::RoundedRect(r) => Self::RoundedRect(RoundedRect { rect: translate_rect(r.rect, offset), ..*r }), Self::ShadowRect(s) => Self::ShadowRect(ShadowRect { rect: translate_rect(s.rect, offset), source_rect: translate_rect(s.source_rect, offset), ..*s }), Self::Image(img) => Self::Image(PreparedImage { rect: translate_rect(img.rect, offset), ..img.clone() }), Self::Text(text) => Self::Text(text.clone().translated(offset)), Self::PushClip(clip) => Self::PushClip(ClipRegion { rect: translate_rect(clip.rect, offset), ..*clip }), // These items carry no position data or are balanced markers. Self::PopClip => Self::PopClip, Self::PushTransform(t) => Self::PushTransform(*t), Self::PopTransform => Self::PopTransform, Self::LayerBegin { opacity } => Self::LayerBegin { opacity: *opacity }, Self::LayerEnd => Self::LayerEnd, } } } #[derive(Clone, Debug, PartialEq)] pub struct SceneSnapshot { pub version: SceneVersion, pub logical_size: UiSize, pub items: Vec, } impl SceneSnapshot { pub fn new(version: SceneVersion, logical_size: UiSize) -> Self { debug!( target: trace_targets::SCENE, event = "create_scene", version, width = logical_size.width, height = logical_size.height, "creating scene snapshot" ); Self { version, logical_size, items: Vec::new(), } } pub fn item_count(&self) -> usize { self.items.len() } pub fn push_item(&mut self, item: DisplayItem) -> &mut Self { self.items.push(item); self } pub fn push_quad(&mut self, rect: Rect, color: Color) -> &mut Self { self.push_item(DisplayItem::Quad(Quad::new(rect, color))) } pub fn push_rounded_rect(&mut self, rounded_rect: RoundedRect) -> &mut Self { self.push_item(DisplayItem::RoundedRect(rounded_rect)) } pub fn push_shadow_rect(&mut self, shadow_rect: ShadowRect) -> &mut Self { self.push_item(DisplayItem::ShadowRect(shadow_rect)) } pub fn push_text(&mut self, text: PreparedText) -> &mut Self { self.push_item(DisplayItem::Text(text)) } pub fn push_image(&mut self, image: PreparedImage) -> &mut Self { self.push_item(DisplayItem::Image(image)) } pub fn push_clip(&mut self, rect: Rect, radius: f32) -> &mut Self { self.push_item(DisplayItem::PushClip(ClipRegion { rect, radius })) } pub fn pop_clip(&mut self) -> &mut Self { self.push_item(DisplayItem::PopClip) } pub fn push_transform(&mut self, translation: Translation) -> &mut Self { self.push_item(DisplayItem::PushTransform(translation)) } pub fn pop_transform(&mut self) -> &mut Self { self.push_item(DisplayItem::PopTransform) } pub fn begin_layer(&mut self, opacity: f32) -> &mut Self { self.push_item(DisplayItem::LayerBegin { opacity }) } pub fn end_layer(&mut self) -> &mut Self { self.push_item(DisplayItem::LayerEnd) } } // --------------------------------------------------------------------------- // Hash implementations for f32-containing types. impl std::hash::Hash for Point { fn hash(&self, state: &mut H) { self.x.to_bits().hash(state); self.y.to_bits().hash(state); } } impl std::hash::Hash for UiSize { fn hash(&self, state: &mut H) { self.width.to_bits().hash(state); self.height.to_bits().hash(state); } } #[cfg(test)] mod tests { use super::{Color, Point, PreparedText, Rect}; use crate::TextSelectionStyle; use crate::{TextStyle, TextSystem, TextWrap}; #[test] fn prepared_text_hit_testing_clamps_to_nearest_cluster_boundary() { let text = PreparedText::monospace( "abcd", Point::new(10.0, 20.0), 16.0, 8.0, Color::rgb(0xFF, 0xFF, 0xFF), ); assert_eq!(text.byte_offset_for_position(Point::new(6.0, 24.0)), 0); assert_eq!(text.byte_offset_for_position(Point::new(13.0, 24.0)), 0); assert_eq!(text.byte_offset_for_position(Point::new(17.0, 24.0)), 1); assert_eq!(text.byte_offset_for_position(Point::new(33.0, 24.0)), 3); assert_eq!(text.byte_offset_for_position(Point::new(50.0, 24.0)), 4); } #[test] fn prepared_text_selection_rects_cover_selected_glyphs() { let mut text = PreparedText::monospace( "abcd", Point::new(10.0, 20.0), 16.0, 8.0, Color::rgb(0xFF, 0xFF, 0xFF), ); text.selection_style = TextSelectionStyle::new(Color::rgba(0x44, 0x66, 0xFF, 0xAA)) .with_text_color(Color::rgb(0x11, 0x12, 0x1A)); assert_eq!( text.selection_rects(1, 3), vec![Rect::new(18.0, 20.0, 16.0, 16.0)] ); assert_eq!(text.selected_text(1, 3), Some("bc")); text.apply_selected_text_color(1, 3); assert_eq!(text.glyphs[0].color, Color::rgb(0xFF, 0xFF, 0xFF)); assert_eq!(text.glyphs[1].color, Color::rgb(0x11, 0x12, 0x1A)); assert_eq!(text.glyphs[2].color, Color::rgb(0x11, 0x12, 0x1A)); } #[test] fn prepared_text_caret_rect_tracks_cluster_boundaries() { let text = PreparedText::monospace( "abcd", Point::new(10.0, 20.0), 16.0, 8.0, Color::rgb(0xFF, 0xFF, 0xFF), ); assert_eq!( text.caret_rect(0, 2.0), Some(Rect::new(10.0, 20.0, 2.0, 16.0)) ); assert_eq!( text.caret_rect(2, 2.0), Some(Rect::new(26.0, 20.0, 2.0, 16.0)) ); assert_eq!( text.caret_rect(4, 2.0), Some(Rect::new(42.0, 20.0, 2.0, 16.0)) ); } #[test] fn prepared_text_word_range_tracks_words_and_punctuation() { let text = PreparedText::monospace( "alpha beta, gamma", Point::new(10.0, 20.0), 16.0, 8.0, Color::rgb(0xFF, 0xFF, 0xFF), ); assert_eq!(text.word_range_for_offset(1), 0..5); assert_eq!(text.word_range_for_offset(6), 6..10); assert_eq!(text.word_range_for_offset(10), 10..11); assert_eq!(text.word_range_for_offset(text.text.len()), 12..17); } #[test] fn prepared_text_vertical_offset_moves_between_lines() { use std::sync::Arc; use super::{PreparedTextLine, TextLayoutData}; let mut text = PreparedText::monospace( "abcdwxyz", Point::new(10.0, 20.0), 16.0, 8.0, Color::rgb(0xFF, 0xFF, 0xFF), ); // Lines and glyphs use LOCAL (origin-relative) coordinates. let lines = vec![ PreparedTextLine { rect: Rect::new(0.0, 0.0, 32.0, 16.0), text_start: 0, text_end: 4, glyph_start: 0, glyph_end: 4, }, PreparedTextLine { rect: Rect::new(0.0, 16.0, 32.0, 16.0), text_start: 4, text_end: 8, glyph_start: 4, glyph_end: 8, }, ]; let mut glyphs = text.glyphs.to_vec(); for (index, glyph) in glyphs.iter_mut().enumerate() { if index >= 4 { glyph.position.y = 16.0; glyph.position.x = (index - 4) as f32 * 8.0; } } let orig_size = text.layout.size; text.layout = Arc::new(TextLayoutData { lines, glyphs, size: orig_size }); assert_eq!(text.vertical_offset(2, 1), Some(6)); assert_eq!(text.vertical_offset(6, -1), Some(2)); assert_eq!(text.line_start_offset(6), Some(4)); assert_eq!(text.line_end_offset(2), Some(4)); } #[test] fn prepared_text_multiline_selection_stays_on_target_line() { let mut text_system = TextSystem::new(); let style = TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_wrap(TextWrap::None); let text = text_system.prepare("ab\ncd\nef", Point::new(10.0, 20.0), &style); assert_eq!(text.lines.len(), 3); let target_line = &text.lines[1]; // Lines store LOCAL coords; add text.origin to get absolute window coords for the query. let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5; let start = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y)); let end = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x + 16.0, y)); let rects = text.selection_rects(start, end); assert_eq!(rects.len(), 1); // selection_rects returns absolute coords; compare against absolute line y. assert_eq!(rects[0].origin.y, text.origin.y + target_line.rect.origin.y); } }