1use std::cell::RefCell;
2use std::rc::Rc;
3use std::sync::Arc;
4
5use geometry_core::Rect;
6use layout_core::{LayoutError, LayoutStyle};
7use platform_core::Event;
8use reactive_core::{Effect, effect};
9use renderer_core::TextStyle;
10use ui_tree::{Component, EventResult, RenderNode};
11
12use crate::context::mark_dirty;
13use crate::impl_leaf_widget;
14use crate::layout_leaf::LayoutLeaf;
15
16const REFERENCE: &str = "Hxg";
20const REFERENCE_WIDTH: f32 = 1_000.0;
22
23pub struct Text {
24 content: Rc<dyn Fn() -> String>,
25 cached_content: RefCell<(String, Arc<str>)>,
26 cached_ink: RefCell<Option<(u32, f32, f32, f32)>>,
29 style: Rc<dyn Fn() -> TextStyle>,
30 leaf: LayoutLeaf,
31 _remeasure: Option<Effect>,
33}
34
35impl Text {
36 pub fn new(
37 content_fn: impl Fn() -> String + 'static,
38 layout_style: LayoutStyle,
39 style_fn: impl Fn() -> TextStyle + 'static,
40 ) -> Result<Self, LayoutError> {
41 let leaf = LayoutLeaf::register(layout_style.align_self_stretch())?;
43 Ok(Self {
44 content: Rc::new(content_fn),
45 cached_content: RefCell::new((String::new(), Arc::from(""))),
46 cached_ink: RefCell::new(None),
47 style: Rc::new(style_fn),
48 leaf,
49 _remeasure: None,
50 })
51 }
52
53 pub fn auto(
57 content_fn: impl Fn() -> String + 'static,
58 layout_style: LayoutStyle,
59 style_fn: impl Fn() -> TextStyle + 'static,
60 ) -> Result<Self, LayoutError> {
61 let content_fn: Rc<dyn Fn() -> String> = Rc::new(content_fn);
62 let style: Rc<dyn Fn() -> TextStyle> = Rc::new(style_fn);
63
64 let measure_content = Rc::clone(&content_fn);
65 let measure_style = Rc::clone(&style);
66 let measure = Box::new(move |max_width: f32| {
67 let s = (measure_style)();
68 crate::text_metrics::measure_text(&(measure_content)(), max_width, &s)
69 });
70
71 let (node, rect) =
72 crate::context::new_measured_leaf(layout_style.align_self_stretch(), measure)?;
73 let dirty_content = Rc::clone(&content_fn);
75 let measured = RefCell::new(Option::<String>::None);
76 let remeasure = effect(move || {
77 let next = (dirty_content)();
78 if measured.borrow().as_deref() == Some(next.as_str()) {
79 return;
80 }
81 *measured.borrow_mut() = Some(next);
82 mark_dirty(node).ok();
83 });
84 Ok(Self {
85 content: content_fn,
86 cached_content: RefCell::new((String::new(), Arc::from(""))),
87 cached_ink: RefCell::new(None),
88 style,
89 leaf: LayoutLeaf { node, rect },
90 _remeasure: Some(remeasure),
91 })
92 }
93
94 pub fn single_line(
95 content_fn: impl Fn() -> String + 'static,
96 style_fn: impl Fn() -> TextStyle + 'static,
97 ) -> Result<Self, LayoutError> {
98 let height = style_fn().font_size * 1.4;
99 Text::new(content_fn, LayoutStyle::new().height(height), style_fn)
100 }
101}
102
103impl Component for Text {
104 fn view(&self) -> RenderNode {
105 let r = self.leaf.rect.get();
106 let text: Arc<str> = {
107 let new_str = (self.content)();
108 let mut cache = self.cached_content.borrow_mut();
109 if cache.0 != new_str {
110 let rc = Arc::from(new_str.as_str());
111 *cache = (new_str, Arc::clone(&rc));
112 rc
113 } else {
114 Arc::clone(&cache.1)
115 }
116 };
117 let style = (self.style)();
118 let (ink_top, ink_height, reference_line) = {
129 let key = style.font_size.to_bits();
130 let mut cache = self.cached_ink.borrow_mut();
131 match cache.as_ref() {
132 Some((k, top, h, line)) if *k == key => (*top, *h, *line),
133 _ => {
134 let (top, h) =
135 crate::text_metrics::measure_ink_bounds(REFERENCE, REFERENCE_WIDTH, &style);
136 let (_, line) =
137 crate::text_metrics::measure_text(REFERENCE, REFERENCE_WIDTH, &style);
138 *cache = Some((key, top, h, line));
139 (top, h, line)
140 }
141 }
142 };
143 let (_, text_height) = crate::text_metrics::measure_text(&text, r.width, &style);
149 let nudge = if ink_height > 0.0 {
150 reference_line / 2.0 - ink_top - ink_height / 2.0
151 } else {
152 0.0
153 };
154 let slack = (r.height - text_height).max(0.0);
164 let y = ((slack / 2.0 + nudge).clamp(0.0, slack)).round();
165 let line_height = text_height;
167 self.leaf.at_layout_position(RenderNode::text(
168 text,
169 Rect {
170 x: 0.0,
171 y,
172 width: r.width,
173 height: line_height,
174 },
175 style,
176 ))
177 }
178
179 fn on_event(&mut self, _event: &Event) -> EventResult {
180 EventResult::Ignored
181 }
182
183 fn debug_name(&self) -> &'static str {
184 "Text"
185 }
186}
187
188impl_leaf_widget!(Text);
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::context::{
194 compute_layout, new_container, relayout_if_dirty, reset_layout_runtime, track_layout,
195 };
196 use crate::layout_item::LayoutItem;
197 use layout_core::AvailableSpace;
198 use reactive_core::signal;
199 use renderer_core::Color;
200
201 #[test]
204 fn auto_text_height_grows_when_narrower() {
205 let long = "This is a deliberately long paragraph of text that wraps onto several \
206 lines when the available width is small, and fewer lines when it is wide.";
207 let height_at = |w: f32| -> f32 {
208 reset_layout_runtime();
209 let t = Text::auto(
210 move || long.to_string(),
211 LayoutStyle::new(),
212 || TextStyle::new(16.0, Color::BLACK),
213 )
214 .unwrap();
215 let node = t.layout_node();
216 compute_layout(
217 node,
218 AvailableSpace::Definite(w),
219 AvailableSpace::MaxContent,
220 )
221 .unwrap();
222 track_layout(node).unwrap().get().height
223 };
224 let narrow = height_at(200.0);
225 let wide = height_at(800.0);
226 assert!(
227 narrow > wide + 20.0,
228 "narrow text should be taller: narrow={narrow} wide={wide}"
229 );
230 }
231
232 #[test]
239 fn two_labels_of_one_style_share_a_baseline_whatever_letters_they_have() {
240 reset_layout_runtime();
241 let drawn = |content: &'static str| {
242 let text = Text::new(
243 move || content.to_string(),
244 LayoutStyle::new().width(200.0).height(30.0),
245 || TextStyle::new(13.0, Color::BLACK),
246 )
247 .unwrap();
248 let root = new_container(
249 LayoutStyle::new().flex_column().width(200.0).height(30.0),
250 &[text.layout_node()],
251 )
252 .unwrap();
253 compute_layout(
254 root,
255 AvailableSpace::Definite(200.0),
256 AvailableSpace::Definite(30.0),
257 )
258 .unwrap();
259 fn text_y(node: &RenderNode) -> Option<f32> {
261 match node {
262 RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
263 Some(rect.y)
264 }
265 RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
266 children.iter().find_map(text_y)
267 }
268 _ => None,
269 }
270 }
271 text_y(&text.view()).expect("a text leaf draws text")
272 };
273 let (with_tail, without) = (drawn("Setup"), drawn("Simulation"));
275 assert!(
276 (with_tail - without).abs() < 0.01,
277 "a descender must not move the line: {with_tail} vs {without}"
278 );
279 }
280
281 #[test]
290 fn text_lands_on_a_whole_pixel_row() {
291 fn text_y(node: &RenderNode) -> Option<f32> {
292 match node {
293 RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
294 Some(rect.y)
295 }
296 RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
297 children.iter().find_map(text_y)
298 }
299 _ => None,
300 }
301 }
302 reset_layout_runtime();
303 for height in [29.0_f32, 30.0, 31.0, 44.5] {
305 let text = Text::new(
306 || "Setup".to_string(),
307 LayoutStyle::new().width(200.0).height(height),
308 || TextStyle::new(13.0, Color::BLACK),
309 )
310 .unwrap();
311 let root = new_container(
312 LayoutStyle::new().flex_column().width(200.0).height(height),
313 &[text.layout_node()],
314 )
315 .unwrap();
316 compute_layout(
317 root,
318 AvailableSpace::Definite(200.0),
319 AvailableSpace::Definite(height),
320 )
321 .unwrap();
322 let y = text_y(&text.view()).expect("a text leaf draws text");
323 assert_eq!(y, y.round(), "a {height}px box put the text at {y}");
324 }
325 }
326
327 #[test]
333 fn text_stays_inside_a_box_that_is_exactly_one_line_tall() {
334 fn text_rect(node: &RenderNode) -> Option<Rect> {
335 match node {
336 RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => Some(*rect),
337 RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
338 children.iter().find_map(text_rect)
339 }
340 _ => None,
341 }
342 }
343 reset_layout_runtime();
344 for size in [11.0_f32, 13.0, 15.0, 24.0] {
345 let text = Text::auto(
346 || "Ag".to_string(),
347 LayoutStyle::new().width(200.0),
348 move || TextStyle::new(size, Color::BLACK),
349 )
350 .unwrap();
351 let root = new_container(
352 LayoutStyle::new().flex_column().width(200.0),
353 &[text.layout_node()],
354 )
355 .unwrap();
356 compute_layout(
357 root,
358 AvailableSpace::Definite(200.0),
359 AvailableSpace::MaxContent,
360 )
361 .unwrap();
362 let rect = text_rect(&text.view()).expect("a text leaf draws text");
363 assert!(
364 rect.y >= 0.0,
365 "at {size}px the text starts {}px above its own box",
366 -rect.y
367 );
368 }
369 }
370
371 #[test]
378 fn a_wrapped_block_is_not_pushed_down_by_the_lines_it_gained() {
379 reset_layout_runtime();
380 let style = || TextStyle::new(13.0, Color::BLACK);
381 let text = Text::auto(
382 || "Name regions and say what the model is made of".to_string(),
383 LayoutStyle::new(),
384 style,
385 )
386 .unwrap();
387 let root = new_container(
388 LayoutStyle::new().flex_column().width(150.0),
389 &[text.layout_node()],
390 )
391 .unwrap();
392 compute_layout(
393 root,
394 AvailableSpace::Definite(150.0),
395 AvailableSpace::MaxContent,
396 )
397 .unwrap();
398
399 let (_, one_line) = crate::text_metrics::measure_text("Hxg", 1_000.0, &style());
400 let (_, block) = crate::text_metrics::measure_text(
401 "Name regions and say what the model is made of",
402 150.0,
403 &style(),
404 );
405 assert!(
406 block > one_line * 1.5,
407 "the test needs a string that actually wraps"
408 );
409
410 fn text_y(node: &RenderNode) -> Option<f32> {
411 match node {
412 RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
413 Some(rect.y)
414 }
415 RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
416 children.iter().find_map(text_y)
417 }
418 _ => None,
419 }
420 }
421 let y = text_y(&text.view()).expect("a text leaf draws text");
422 assert!(
423 y.abs() < one_line / 3.0,
424 "a block in a box its own size starts at the top: y = {y} against a {one_line}px line"
425 );
426 }
427
428 #[test]
435 fn a_measured_label_re_measures_when_its_content_changes() {
436 reset_layout_runtime();
437 let title = signal(String::from("Desktop"));
438 let read = title.read_only();
439 let label = Text::auto(
440 move || read.get(),
441 LayoutStyle::new(),
442 || TextStyle::new(13.0, Color::BLACK),
443 )
444 .unwrap();
445 let node = label.layout_node();
446 let root = new_container(
447 LayoutStyle::new().flex_row().width(1920.0).height(32.0),
448 &[node],
449 )
450 .unwrap();
451 let space = || {
452 compute_layout(
453 root,
454 AvailableSpace::Definite(1920.0),
455 AvailableSpace::Definite(32.0),
456 )
457 .unwrap()
458 };
459
460 space();
461 let short = label.leaf.rect.get().width;
462
463 title.set("hyprshell - Rust - Visual Studio Code".to_string());
464 relayout_if_dirty();
465 let long = label.leaf.rect.get().width;
466
467 assert!(
468 long > short,
469 "a title five times longer still measured {long}px, the width \"Desktop\" wanted ({short}px) — \
470 it will be wrapped into a box built for the old text"
471 );
472 }
473}