Skip to main content

tessera_components/pipelines/text/
pipeline.rs

1use std::{
2    num::NonZeroUsize,
3    sync::{
4        Arc, Weak,
5        atomic::{AtomicU32, Ordering},
6    },
7};
8
9use glyphon::fontdb;
10use lru::LruCache;
11use parking_lot::{ArcRwLockReadGuard, ArcRwLockWriteGuard, Mutex, RawRwLock, RwLock};
12use tessera_ui::{
13    Color, PxPosition,
14    renderer::drawer::pipeline::{DrawContext, DrawablePipeline, FrameContext},
15    wgpu,
16};
17
18use crate::theme::TextStyle;
19
20use super::command::{TextCommand, TextConstraint};
21
22struct TextResources {
23    font_system: Arc<RwLock<glyphon::FontSystem>>,
24    // Read and advanced under the font-system write lock.
25    font_revision: AtomicU32,
26    // A cache entry must not extend the lifetime of the glyphs it indexes.
27    short_layouts: Mutex<LruCache<TextLayoutKey, Weak<ShapedText>>>,
28}
29
30const SHORT_LAYOUT_CAPACITY: NonZeroUsize = NonZeroUsize::new(1024).unwrap();
31const SHORT_TEXT_BYTES: usize = 64;
32
33#[derive(PartialEq, Eq, Hash)]
34struct TextLayoutKey {
35    text: Arc<str>,
36    metrics: [u32; 3],
37    weight: u16,
38    family: Option<&'static str>,
39    bounds: [Option<u32>; 2],
40    single_line: bool,
41}
42
43impl TextLayoutKey {
44    fn new(text: &Arc<str>, format: TextFormat, constraint: &TextConstraint) -> Self {
45        let TextFormat {
46            font_size,
47            line_height,
48            font,
49        } = format;
50        Self {
51            text: Arc::clone(text),
52            metrics: [font_size, line_height, font.tracking].map(f32::to_bits),
53            weight: font.weight,
54            family: font.family,
55            bounds: [constraint.max_width, constraint.max_height]
56                .map(|bound| bound.map(f32::to_bits)),
57            single_line: constraint.single_line,
58        }
59    }
60}
61
62impl TextResources {
63    fn cached_layout(&self, key: Option<&TextLayoutKey>) -> Option<Arc<ShapedText>> {
64        key.and_then(|key| self.short_layouts.lock().get(key).and_then(Weak::upgrade))
65    }
66
67    fn create_layout(
68        &self,
69        key: Option<TextLayoutKey>,
70        create: impl FnOnce(&mut glyphon::FontSystem, u32) -> Arc<ShapedText>,
71    ) -> Arc<ShapedText> {
72        let mut fonts = self.font_system.write();
73        let revision = self.font_revision.load(Ordering::Relaxed);
74        let layout = create(&mut fonts, revision);
75        if let Some(key) = key {
76            self.short_layouts.lock().put(key, Arc::downgrade(&layout));
77        }
78        layout
79    }
80}
81
82#[cfg(not(target_family = "wasm"))]
83static PRELOADED_FONTS: std::sync::Mutex<Option<std::thread::JoinHandle<glyphon::FontSystem>>> =
84    std::sync::Mutex::new(None);
85
86pub(crate) fn preload_system_fonts() {
87    #[cfg(not(target_family = "wasm"))]
88    {
89        let mut preloaded = PRELOADED_FONTS.lock().expect("font preload lock");
90        if preloaded.is_none() {
91            *preloaded = std::thread::Builder::new()
92                .name("tessera-fonts".into())
93                .spawn(|| {
94                    let mut fonts = system_fonts();
95                    // Matching examines installed variable-font axes. Prepare common UI
96                    // weights while the renderer initializes instead of on first layout.
97                    for weight in [
98                        fontdb::Weight::NORMAL,
99                        fontdb::Weight::MEDIUM,
100                        fontdb::Weight::BOLD,
101                    ] {
102                        fonts.get_font_matches(&glyphon::Attrs::new().weight(weight));
103                    }
104                    fonts
105                })
106                .ok();
107        }
108    }
109}
110
111fn system_fonts() -> glyphon::FontSystem {
112    use glyphon::cosmic_text::{Fallback, PlatformFallback};
113
114    let mut fonts = glyphon::FontSystem::new();
115    let db = fonts.db_mut();
116    #[cfg(target_family = "wasm")]
117    db.load_font_source(fontdb::Source::Binary(Arc::new(
118        &include_bytes!("../../../assets/Roboto-Regular.ttf")[..],
119    )));
120    let fallback = PlatformFallback.common_fallback().first().copied();
121    #[cfg(target_os = "android")]
122    db.load_fonts_dir("/system/fonts");
123    #[cfg(target_os = "android")]
124    let fallback = fallback.or(Some("Roboto"));
125    #[cfg(target_os = "ios")]
126    let fallback = [".SF UI", ".SF NS", "Helvetica Neue", "Helvetica", "Arial"]
127        .into_iter()
128        .find(|name| {
129            db.query(&fontdb::Query {
130                families: &[fontdb::Family::Name(name)],
131                ..Default::default()
132            })
133            .is_some()
134        })
135        .or(fallback);
136    if let Some(family) = fallback {
137        db.set_sans_serif_family(family);
138    }
139    fonts
140}
141
142impl Default for TextResources {
143    fn default() -> Self {
144        #[cfg(not(target_family = "wasm"))]
145        let preloaded = PRELOADED_FONTS
146            .lock()
147            .expect("font preload lock")
148            .take()
149            .and_then(|scan| scan.join().ok());
150        #[cfg(target_family = "wasm")]
151        let preloaded = None;
152        Self {
153            font_system: Arc::new(RwLock::new(preloaded.unwrap_or_else(system_fonts))),
154            font_revision: AtomicU32::new(1),
155            short_layouts: Mutex::new(LruCache::sparse(SHORT_LAYOUT_CAPACITY)),
156        }
157    }
158}
159
160fn linear_to_srgb_channel(value: f32) -> f32 {
161    let value = value.clamp(0.0, 1.0);
162    if value <= 0.003_130_8 {
163        value * 12.92
164    } else {
165        1.055 * value.powf(1.0 / 2.4) - 0.055
166    }
167}
168
169pub(crate) fn color_to_glyphon(color: Color) -> glyphon::Color {
170    let r = (linear_to_srgb_channel(color.r) * 255.0 + 0.5) as u8;
171    let g = (linear_to_srgb_channel(color.g) * 255.0 + 0.5) as u8;
172    let b = (linear_to_srgb_channel(color.b) * 255.0 + 0.5) as u8;
173    let a = (color.a.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
174    glyphon::Color::rgba(r, g, b, a)
175}
176
177#[derive(Clone, Copy, Debug, PartialEq)]
178struct TextFont {
179    weight: u16,
180    tracking: f32,
181    family: Option<&'static str>,
182}
183
184#[derive(Clone, Copy, Debug, PartialEq)]
185pub(crate) struct TextFormat {
186    font_size: f32,
187    line_height: f32,
188    font: TextFont,
189}
190
191impl From<TextStyle> for TextFormat {
192    fn from(style: TextStyle) -> Self {
193        let font_size = style.font_size.to_pixels_f32();
194        Self {
195            font_size,
196            line_height: style.line_height.unwrap_or(style.font_size).to_pixels_f32(),
197            font: TextFont {
198                weight: style.font_weight.clamp(1, 1000),
199                tracking: if font_size > 0.0 {
200                    style.letter_spacing.to_pixels_f32() / font_size
201                } else {
202                    0.0
203                },
204                family: style.font_family,
205            },
206        }
207    }
208}
209
210/// Borrows the font system owned by the active runtime session.
211pub fn read_font_system() -> ArcRwLockReadGuard<RawRwLock, glyphon::FontSystem> {
212    tessera_ui::__private::runtime_resource::<TextResources>()
213        .font_system
214        .read_arc()
215}
216
217/// Mutably borrows the font system owned by the active runtime session.
218pub fn write_font_system() -> ArcRwLockWriteGuard<RawRwLock, glyphon::FontSystem> {
219    let resources = tessera_ui::__private::runtime_resource::<TextResources>();
220    let fonts = resources.font_system.write_arc();
221    let revision = resources.font_revision.load(Ordering::Relaxed);
222    resources.font_revision.store(
223        revision.checked_add(1).expect("font revision exhausted"),
224        Ordering::Relaxed,
225    );
226    resources.short_layouts.lock().clear();
227    fonts
228}
229
230pub(crate) fn shaping_fonts() -> ArcRwLockWriteGuard<RawRwLock, glyphon::FontSystem> {
231    tessera_ui::__private::runtime_resource::<TextResources>()
232        .font_system
233        .write_arc()
234}
235
236/// Renders shaped text with a shared glyph atlas.
237pub struct GlyphonTextRender {
238    atlas: glyphon::TextAtlas,
239    cache: glyphon::Cache,
240    swash_cache: glyphon::SwashCache,
241    msaa: wgpu::MultisampleState,
242    renderers: Vec<TextBatchResources>,
243    next_renderer: usize,
244}
245
246struct TextBatchResources {
247    renderer: glyphon::TextRenderer,
248    viewport: glyphon::Viewport,
249}
250
251impl GlyphonTextRender {
252    /// Creates a text pipeline for the target format and sample count.
253    pub fn new(
254        gpu: &wgpu::Device,
255        queue: &wgpu::Queue,
256        config: &wgpu::SurfaceConfiguration,
257        sample_count: u32,
258    ) -> Self {
259        let cache = glyphon::Cache::new(gpu);
260        let atlas = glyphon::TextAtlas::new(gpu, queue, &cache, config.format);
261        let swash_cache = glyphon::SwashCache::new();
262        let msaa = wgpu::MultisampleState {
263            count: sample_count,
264            mask: !0,
265            alpha_to_coverage_enabled: false,
266        };
267        let mut pipeline = Self {
268            atlas,
269            cache,
270            swash_cache,
271            msaa,
272            renderers: Vec::new(),
273            next_renderer: 0,
274        };
275        // Compiles the text render pipeline now rather than on the first text draw.
276        pipeline.push_renderer(gpu);
277        pipeline
278    }
279
280    fn push_renderer(&mut self, device: &wgpu::Device) {
281        self.renderers.push(TextBatchResources {
282            renderer: glyphon::TextRenderer::new(
283                &mut self.atlas,
284                device,
285                self.msaa,
286                tessera_ui::ui_stencil_state(),
287            ),
288            viewport: glyphon::Viewport::new(device, &self.cache),
289        });
290    }
291}
292
293impl DrawablePipeline<TextCommand> for GlyphonTextRender {
294    fn begin_frame(&mut self, _context: &FrameContext<'_>) {
295        self.next_renderer = 0;
296        self.atlas.trim();
297    }
298
299    fn draw(&mut self, context: &mut DrawContext<TextCommand>) {
300        if context.commands.is_empty() {
301            return;
302        }
303
304        let text_areas = context.commands.iter().map(|(command, geometry)| {
305            let origin = geometry.local_to_window.map_point(command.offset);
306            command
307                .data
308                .text_area(origin, geometry.local_to_window.scale())
309        });
310
311        // Batches in one submission need distinct buffers because queued
312        // uploads precede all of its draw commands. Reuse them across frames.
313        if self.next_renderer == self.renderers.len() {
314            self.push_renderer(context.device);
315        }
316        let resources = &mut self.renderers[self.next_renderer];
317        self.next_renderer += 1;
318        resources.viewport.update(
319            context.queue,
320            glyphon::Resolution {
321                width: context.target_size.width.positive(),
322                height: context.target_size.height.positive(),
323            },
324        );
325        let renderer = &mut resources.renderer;
326        let mut font_system = shaping_fonts();
327        renderer
328            .prepare(
329                context.device,
330                context.queue,
331                &mut font_system,
332                &mut self.atlas,
333                &resources.viewport,
334                text_areas,
335                &mut self.swash_cache,
336            )
337            .expect("glyphon prepare failed");
338
339        renderer
340            .render(&self.atlas, &resources.viewport, context.render_pass)
341            .expect("glyphon render failed");
342    }
343}
344
345/// Text data for rendering, including buffer and size.
346#[derive(Debug, Clone)]
347pub struct TextData {
348    layout: Arc<ShapedText>,
349    base_color: Color,
350    current_color: Color,
351    text: Arc<str>,
352    format: TextFormat,
353    styled_identity: Option<Arc<Vec<StyledLineIdentity>>>,
354    base_span_colors: Option<Arc<Vec<Option<glyphon::Color>>>>,
355    span_opacity: f32,
356}
357
358#[derive(Debug, Clone)]
359struct ShapedText {
360    buffer: glyphon::Buffer,
361    measure: TextMeasureInfo,
362    font_revision: u32,
363}
364
365impl ShapedText {
366    fn new(buffer: glyphon::Buffer, font_revision: u32) -> Self {
367        let measure = TextMeasureInfo::from_buffer(&buffer);
368        Self {
369            buffer,
370            measure,
371            font_revision,
372        }
373    }
374
375    fn reshape(&mut self, fonts: &mut glyphon::FontSystem) {
376        self.buffer.shape_until_scroll(fonts, false);
377        self.measure = TextMeasureInfo::from_buffer(&self.buffer);
378    }
379
380    fn resize(&mut self, constraint: &TextConstraint, fonts: &mut glyphon::FontSystem) {
381        self.buffer
382            .set_size(constraint.max_width, constraint.max_height);
383        self.reshape(fonts);
384    }
385}
386
387#[derive(Debug, Clone, PartialEq)]
388struct StyledLineIdentity {
389    text: String,
390    attrs: glyphon::AttrsList,
391    alignment: Option<glyphon::cosmic_text::Align>,
392    layout: Vec<glyphon::LayoutLine>,
393}
394
395/// Measurements of the visible glyph layout.
396#[derive(Debug, Clone, PartialEq, Copy)]
397#[allow(dead_code)]
398pub struct TextMeasureInfo {
399    /// The computed size of the text area.
400    pub size: [u32; 2],
401    /// Baseline offset of the first visible line.
402    pub first_baseline: f32,
403    /// Baseline offset of the last visible line.
404    pub last_baseline: f32,
405    /// Number of visible layout lines.
406    pub line_count: u32,
407}
408
409impl TextMeasureInfo {
410    fn from_buffer(buffer: &glyphon::Buffer) -> Self {
411        let mut width = 0.0_f32;
412        let mut first_baseline = 0.0;
413        let mut last_baseline = 0.0;
414        let mut line_count = 0;
415        for run in buffer.layout_runs() {
416            width = width.max(run.line_w);
417            if line_count == 0 {
418                first_baseline = run.line_y;
419            }
420            last_baseline = run.line_y;
421            line_count += 1;
422        }
423        if buffer.lines.iter().any(|line| {
424            matches!(
425                line.align(),
426                Some(
427                    glyphon::cosmic_text::Align::Center
428                        | glyphon::cosmic_text::Align::Right
429                        | glyphon::cosmic_text::Align::End
430                )
431            )
432        }) {
433            width = buffer.size().0.unwrap_or(width);
434        }
435        Self {
436            size: [
437                width.ceil() as u32,
438                (line_count as f32 * buffer.metrics().line_height).ceil() as u32,
439            ],
440            first_baseline,
441            last_baseline,
442            line_count,
443        }
444    }
445}
446
447impl PartialEq for TextData {
448    fn eq(&self, other: &Self) -> bool {
449        self.layout.measure == other.layout.measure
450            && self.base_color == other.base_color
451            && self.current_color == other.current_color
452            && self.text == other.text
453            && self.format == other.format
454            && self.styled_identity == other.styled_identity
455            && self.span_opacity == other.span_opacity
456            && self.layout.buffer.wrap() == other.layout.buffer.wrap()
457            && self.layout.buffer.ellipsize() == other.layout.buffer.ellipsize()
458            && self.layout.buffer.scroll() == other.layout.buffer.scroll()
459    }
460}
461
462impl TextData {
463    /// Shapes text using the active session's font resources.
464    pub(crate) fn shape(
465        text: String,
466        color: Color,
467        style: TextStyle,
468        constraint: TextConstraint,
469    ) -> Self {
470        crate::fonts::prepare(&text, style);
471        Self::shape_prepared(text.into(), color, style.into(), constraint)
472    }
473
474    pub(crate) fn shape_prepared(
475        text: Arc<str>,
476        color: Color,
477        format: TextFormat,
478        constraint: TextConstraint,
479    ) -> Self {
480        let resources = tessera_ui::__private::runtime_resource::<TextResources>();
481        let key = (text.len() <= SHORT_TEXT_BYTES)
482            .then(|| TextLayoutKey::new(&text, format, &constraint));
483        let layout = resources.cached_layout(key.as_ref()).unwrap_or_else(|| {
484            resources.create_layout(key, |fonts, revision| {
485                Self::build_layout(fonts, revision, &text, format, &constraint)
486            })
487        });
488        Self {
489            layout,
490            base_color: color,
491            current_color: color,
492            text,
493            format,
494            styled_identity: None,
495            base_span_colors: None,
496            span_opacity: 1.0,
497        }
498    }
499
500    pub(crate) fn matches_text(&self, text: &str, format: TextFormat) -> bool {
501        self.text.as_ref() == text && self.format == format
502    }
503
504    pub(crate) fn relayout(&mut self, constraint: &TextConstraint) {
505        let resources = tessera_ui::__private::runtime_resource::<TextResources>();
506        let key = (self.text.len() <= SHORT_TEXT_BYTES)
507            .then(|| TextLayoutKey::new(&self.text, self.format, constraint));
508        self.layout = resources.cached_layout(key.as_ref()).unwrap_or_else(|| {
509            resources.create_layout(key, |fonts, revision| {
510                if self.layout.font_revision == revision {
511                    Arc::make_mut(&mut self.layout).resize(constraint, fonts);
512                    Arc::clone(&self.layout)
513                } else {
514                    Self::build_layout(fonts, revision, &self.text, self.format, constraint)
515                }
516            })
517        });
518    }
519
520    /// Builds [`TextData`] directly from a pre-shaped glyphon buffer.
521    pub fn from_buffer(text_buffer: glyphon::Buffer) -> Self {
522        let styled_identity = text_buffer
523            .lines
524            .iter()
525            .map(|line| StyledLineIdentity {
526                text: line.text().to_owned(),
527                attrs: line.attrs_list().clone(),
528                alignment: line.align(),
529                layout: line.layout_opt().cloned().unwrap_or_default(),
530            })
531            .collect();
532        let base_span_colors: Vec<_> = text_buffer
533            .lines
534            .iter()
535            .flat_map(|line| line.layout_opt().into_iter().flatten())
536            .flat_map(|line| line.glyphs.iter().map(|glyph| glyph.color_opt))
537            .collect();
538        let metrics = text_buffer.metrics();
539        Self {
540            layout: Arc::new(ShapedText::new(text_buffer, 0)),
541            base_color: Color::WHITE,
542            current_color: Color::WHITE,
543            text: Arc::from(""),
544            format: TextFormat {
545                font_size: metrics.font_size,
546                line_height: metrics.line_height,
547                font: TextFont {
548                    weight: 400,
549                    tracking: 0.0,
550                    family: None,
551                },
552            },
553            styled_identity: Some(Arc::new(styled_identity)),
554            base_span_colors: base_span_colors
555                .iter()
556                .any(Option::is_some)
557                .then(|| Arc::new(base_span_colors)),
558            span_opacity: 1.0,
559        }
560    }
561
562    /// Get the glyphon text area from the text data
563    fn text_area(&'_ self, start_pos: PxPosition, scale: f32) -> glyphon::TextArea<'_> {
564        let [width, height] = self.layout.measure.size;
565        let bounds = glyphon::TextBounds {
566            left: start_pos.x.floor() as i32,
567            top: start_pos.y.floor() as i32,
568            right: (start_pos.x + width as f32 * scale).ceil() as i32,
569            bottom: (start_pos.y + height as f32 * scale).ceil() as i32,
570        };
571        glyphon::TextArea {
572            buffer: &self.layout.buffer,
573            left: start_pos.x - self.layout.buffer.scroll().horizontal * scale,
574            top: start_pos.y,
575            scale,
576            bounds,
577            default_color: color_to_glyphon(self.current_color),
578            custom_glyphs: &[],
579        }
580    }
581
582    pub(crate) fn set_scroll(&mut self, scroll: glyphon::cosmic_text::Scroll) {
583        if self.layout.buffer.scroll() == scroll {
584            return;
585        }
586        let layout = Arc::make_mut(&mut self.layout);
587        layout.buffer.set_scroll(scroll);
588        layout.reshape(&mut shaping_fonts());
589    }
590
591    fn build_layout(
592        font_system: &mut glyphon::FontSystem,
593        font_revision: u32,
594        text: &str,
595        format: TextFormat,
596        constraint: &TextConstraint,
597    ) -> Arc<ShapedText> {
598        let TextFormat {
599            font_size,
600            line_height,
601            font,
602        } = format;
603        let mut text_buffer =
604            glyphon::Buffer::new_empty(glyphon::Metrics::new(font_size, line_height));
605        if constraint.single_line {
606            text_buffer.set_wrap(glyphon::Wrap::None);
607            text_buffer.set_ellipsize(glyphon::cosmic_text::Ellipsize::End(
608                glyphon::cosmic_text::EllipsizeHeightLimit::Lines(1),
609            ));
610        } else {
611            text_buffer.set_wrap(glyphon::Wrap::WordOrGlyph);
612        }
613        let single_line = (constraint.single_line && text.contains(['\r', '\n']))
614            .then(|| text.replace(['\r', '\n'], " "));
615        text_buffer.set_size(constraint.max_width, constraint.max_height);
616        text_buffer.set_text(
617            single_line.as_deref().unwrap_or(text),
618            &glyphon::Attrs::new()
619                .family(
620                    font.family
621                        .map(fontdb::Family::Name)
622                        .unwrap_or(fontdb::Family::SansSerif),
623                )
624                .weight(glyphon::Weight(font.weight))
625                .letter_spacing(font.tracking),
626            glyphon::Shaping::Advanced,
627            None,
628        );
629        text_buffer.shape_until_scroll(font_system, false);
630        Arc::new(ShapedText::new(text_buffer, font_revision))
631    }
632
633    pub(crate) fn set_color(&mut self, color: Color) {
634        self.base_color = color;
635        self.current_color = color;
636    }
637
638    pub(crate) fn measure_info(&self) -> TextMeasureInfo {
639        self.layout.measure
640    }
641
642    pub(crate) fn apply_opacity(&mut self, opacity: f32) {
643        let opacity = opacity.clamp(0.0, 1.0);
644        let target_alpha = (self.base_color.a * opacity).clamp(0.0, 1.0);
645        let target_color = self.base_color.with_alpha(target_alpha);
646        if (target_color.a - self.current_color.a).abs() <= f32::EPSILON
647            && (target_color.r - self.current_color.r).abs() <= f32::EPSILON
648            && (target_color.g - self.current_color.g).abs() <= f32::EPSILON
649            && (target_color.b - self.current_color.b).abs() <= f32::EPSILON
650            && self.span_opacity == opacity
651        {
652            return;
653        }
654
655        self.current_color = target_color;
656        if let Some(colors) = &self.base_span_colors {
657            let mut colors = colors.iter();
658            for line in &mut Arc::make_mut(&mut self.layout).buffer.lines {
659                line.map_layout_glyph_colors(|_| {
660                    colors.next().copied().flatten().map(|color| {
661                        glyphon::Color::rgba(
662                            color.r(),
663                            color.g(),
664                            color.b(),
665                            (f32::from(color.a()) * opacity).round() as u8,
666                        )
667                    })
668                });
669            }
670        }
671        self.span_opacity = opacity;
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use std::sync::Arc;
678
679    use tessera_ui::Color;
680
681    use crate::theme::MaterialTypography;
682
683    use super::{TextConstraint, TextData, write_font_system};
684
685    #[test]
686    fn independent_short_text_layouts_share_glyphs_until_constraints_or_fonts_change() {
687        let session = tessera_ui::RuntimeSession::new();
688        let _session = session.enter();
689        let style = MaterialTypography::default().body_large;
690        let shape = |color, max_width| {
691            TextData::shape(
692                "Shared label".into(),
693                color,
694                style,
695                TextConstraint {
696                    max_width,
697                    ..Default::default()
698                },
699            )
700        };
701        let original = shape(Color::BLACK, None);
702        let other_color = shape(Color::WHITE, None);
703        assert!(Arc::ptr_eq(&original.layout, &other_color.layout));
704        assert_eq!(original.measure_info(), other_color.measure_info());
705        let origin = tessera_ui::PxPosition::ZERO;
706        assert_ne!(
707            original.text_area(origin, 1.0).default_color,
708            other_color.text_area(origin, 1.0).default_color
709        );
710
711        let mut resized = original.clone();
712        let narrow = TextConstraint {
713            max_width: Some(10.0),
714            ..Default::default()
715        };
716        resized.relayout(&narrow);
717        let narrower = shape(Color::BLACK, Some(10.0));
718        assert!(Arc::ptr_eq(&resized.layout, &narrower.layout));
719        assert_eq!(narrower.layout.buffer.size().0, Some(10.0));
720        assert_eq!(original.layout.buffer.size().0, None);
721        let previous_layout = Arc::downgrade(&original.layout);
722        drop(original);
723        drop(other_color);
724        assert!(previous_layout.upgrade().is_none());
725
726        let mut original = shape(Color::BLACK, None);
727        let revision_before = original.layout.font_revision;
728        write_font_system()
729            .db_mut()
730            .set_sans_serif_family("Tessera test family");
731        let updated = shape(Color::BLACK, None);
732        assert!(!Arc::ptr_eq(&original.layout, &updated.layout));
733        original.relayout(&narrow);
734        assert_ne!(original.layout.font_revision, revision_before);
735        assert!(Arc::ptr_eq(
736            &original.layout,
737            &shape(Color::BLACK, Some(10.0)).layout
738        ));
739
740        tessera_ui::__private::runtime_resource::<super::TextResources>()
741            .short_layouts
742            .lock()
743            .clear();
744        let allocation = Arc::as_ptr(&original.layout);
745        original.relayout(&TextConstraint {
746            max_width: Some(20.0),
747            ..Default::default()
748        });
749        assert_eq!(Arc::as_ptr(&original.layout), allocation);
750    }
751
752    #[test]
753    fn retained_text_copies_share_shapes_and_isolate_scroll_and_opacity() {
754        let session = tessera_ui::RuntimeSession::new();
755        let _session = session.enter();
756        let shaped = TextData::shape(
757            "Shared shaped glyphs".into(),
758            Color::BLACK,
759            MaterialTypography::default().body_large,
760            TextConstraint::default(),
761        );
762        let origin = tessera_ui::PxPosition::from_f32_xy(20.0, 30.0);
763        let mut buffer = (*shaped.text_area(origin, 1.0).buffer).clone();
764        for line in &mut buffer.lines {
765            line.map_layout_glyph_colors(|_| Some(glyphon::Color::rgba(120, 80, 40, 255)));
766        }
767        let original = TextData::from_buffer(buffer);
768        let mut faded = original.clone();
769        assert!(
770            std::ptr::eq(
771                original.text_area(origin, 1.0).buffer,
772                faded.text_area(origin, 1.0).buffer
773            ),
774            "retained command copies must share their shaped glyph storage"
775        );
776        let mut scrolled = original.clone();
777        let mut scroll = scrolled.text_area(origin, 1.0).buffer.scroll();
778        scroll.horizontal = 13.5;
779        scrolled.set_scroll(scroll);
780        assert_eq!(scrolled.text_area(origin, 1.0).left, 6.5);
781        assert_eq!(original.text_area(origin, 1.0).left, 20.0);
782        faded.apply_opacity(0.5);
783        let alpha = |data: &TextData| {
784            data.text_area(origin, 1.0)
785                .buffer
786                .layout_runs()
787                .next()
788                .unwrap()
789                .glyphs[0]
790                .color_opt
791                .unwrap()
792                .a()
793        };
794        assert_eq!(alpha(&faded), 128);
795        assert_eq!(alpha(&original), 255);
796        faded.apply_opacity(0.25);
797        assert_eq!(alpha(&faded), 64);
798        assert_eq!(alpha(&original), 255);
799        assert_eq!(alpha(&scrolled), 255);
800    }
801
802    #[test]
803    fn horizontal_buffer_scroll_moves_glyph_origin_without_moving_clip_bounds() {
804        let session = tessera_ui::RuntimeSession::new();
805        let _session = session.enter();
806        let shaped = TextData::shape(
807            "Scrollable editor text".into(),
808            Color::BLACK,
809            MaterialTypography::default().body_large,
810            TextConstraint {
811                max_width: Some(60.0),
812                max_height: Some(24.0),
813                ..Default::default()
814            },
815        );
816        let before = TextData::from_buffer(shaped.layout.buffer.clone());
817        let mut buffer = shaped.layout.buffer.clone();
818        let mut scroll = buffer.scroll();
819        scroll.horizontal = 13.5;
820        buffer.set_scroll(scroll);
821        let after = TextData::from_buffer(buffer);
822        let origin = tessera_ui::PxPosition::from_f32_xy(80.25, 50.5);
823        let original_area = before.text_area(origin, 1.5);
824        let scrolled_area = after.text_area(origin, 1.5);
825        assert_eq!(scrolled_area.left, 60.0);
826        assert_eq!(scrolled_area.top, 50.5);
827        assert_eq!(scrolled_area.bounds, original_area.bounds);
828        assert_ne!(
829            before, after,
830            "scroll changes must invalidate retained text draws"
831        );
832    }
833
834    #[test]
835    fn wrapping_keeps_words_intact_relays_out_like_fresh_shaping_and_breaks_long_words() {
836        let session = tessera_ui::RuntimeSession::new();
837        let _session = session.enter();
838        let style = MaterialTypography::default().body_medium;
839        let alpha = TextData::shape(
840            "alpha ".to_string(),
841            Color::BLACK,
842            style,
843            TextConstraint::default(),
844        );
845        let beta = TextData::shape(
846            "beta".to_string(),
847            Color::BLACK,
848            style,
849            TextConstraint::default(),
850        );
851        let width = alpha.measure_info().size[0] as f32 + beta.measure_info().size[0] as f32 / 2.0;
852        let narrow = TextConstraint {
853            max_width: Some(width),
854            ..Default::default()
855        };
856        let phrase = TextData::shape("alpha beta".into(), Color::BLACK, style, narrow.clone());
857        let runs: Vec<_> = phrase.layout.buffer.layout_runs().collect();
858        assert_eq!(runs.len(), 2);
859        assert!(runs[0].glyphs.iter().all(|glyph| glyph.end <= 6));
860        assert_eq!(runs[1].glyphs[0].start, 6);
861        let unconstrained = TextData::shape(
862            "alpha beta".to_string(),
863            Color::BLACK,
864            style,
865            TextConstraint::default(),
866        );
867        let mut relaid = unconstrained.clone();
868        relaid.relayout(&narrow);
869        assert!(Arc::ptr_eq(&relaid.layout, &phrase.layout));
870        assert_eq!(
871            relaid.measure_info(),
872            phrase.measure_info(),
873            "relayout must wrap like a fresh shape"
874        );
875        relaid.relayout(&TextConstraint::default());
876        assert_eq!(relaid.measure_info(), unconstrained.measure_info());
877        let long = TextData::shape(
878            "supercalifragilisticexpialidocious".to_string(),
879            Color::BLACK,
880            style,
881            TextConstraint {
882                max_width: Some(width),
883                max_height: None,
884                ..Default::default()
885            },
886        );
887        let runs: Vec<_> = long.layout.buffer.layout_runs().collect();
888        assert!(runs.len() > 1);
889        assert!(runs.iter().all(|run| run.line_w <= width + 1.0));
890    }
891}