904 lines
28 KiB
Rust
904 lines
28 KiB
Rust
//! 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<Color>,
|
|
pub border_color: Option<Color>,
|
|
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<CacheKey>,
|
|
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<TextLayoutData>` 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<ElementId>,
|
|
pub text: String,
|
|
pub origin: Point,
|
|
pub bounds: Option<UiSize>,
|
|
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<TextLayoutData>,
|
|
}
|
|
|
|
/// 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<PreparedTextLine>,
|
|
pub glyphs: Vec<GlyphInstance>,
|
|
/// 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<ElementId>,
|
|
text: String,
|
|
origin: Point,
|
|
bounds: Option<UiSize>,
|
|
font_size: f32,
|
|
line_height: f32,
|
|
default_color: Color,
|
|
selectable: bool,
|
|
selection_style: TextSelectionStyle,
|
|
layout: Arc<TextLayoutData>,
|
|
) -> 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<String>,
|
|
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<usize> {
|
|
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<Rect> {
|
|
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::<f32>;
|
|
let mut right = None::<f32>;
|
|
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<Rect> {
|
|
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<usize> {
|
|
Some(self.line_for_offset(offset)?.text_start)
|
|
}
|
|
|
|
pub fn line_end_offset(&self, offset: usize) -> Option<usize> {
|
|
Some(self.line_for_offset(offset)?.text_end)
|
|
}
|
|
|
|
pub fn vertical_offset(&self, offset: usize, line_delta: isize) -> Option<usize> {
|
|
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<usize> {
|
|
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<TextLayoutData>` 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<ElementId>,
|
|
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<DisplayItem>,
|
|
}
|
|
|
|
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<H: std::hash::Hasher>(&self, state: &mut H) {
|
|
self.x.to_bits().hash(state);
|
|
self.y.to_bits().hash(state);
|
|
}
|
|
}
|
|
|
|
impl std::hash::Hash for UiSize {
|
|
fn hash<H: std::hash::Hasher>(&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);
|
|
}
|
|
}
|