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 font_revision: AtomicU32,
26 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 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
210pub fn read_font_system() -> ArcRwLockReadGuard<RawRwLock, glyphon::FontSystem> {
212 tessera_ui::__private::runtime_resource::<TextResources>()
213 .font_system
214 .read_arc()
215}
216
217pub 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
236pub 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 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 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 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#[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#[derive(Debug, Clone, PartialEq, Copy)]
397#[allow(dead_code)]
398pub struct TextMeasureInfo {
399 pub size: [u32; 2],
401 pub first_baseline: f32,
403 pub last_baseline: f32,
405 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 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 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 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}