1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use geometry_core::Rect;
use layout_core::{LayoutError, LayoutStyle};
use platform_core::Event;
use reactive_core::{Effect, effect};
use renderer_core::TextStyle;
use ui_tree::{Component, EventResult, RenderNode};
use crate::context::mark_dirty;
use crate::impl_leaf_widget;
use crate::layout_leaf::LayoutLeaf;
/// The run the glyph band is measured from: a capital, an x-height letter and a descender, which between
/// them span the extent a Latin face actually draws in. Any string of the same style is then centred by the
/// same amount, which is what puts a row of labels on one baseline.
const REFERENCE: &str = "Hxg";
/// Room the reference cannot fill: it is three characters, and cosmic-text overflows on an unbounded one.
const REFERENCE_WIDTH: f32 = 1_000.0;
pub struct Text {
content: Rc<dyn Fn() -> String>,
cached_content: RefCell<(String, Arc<str>)>,
// Glyph-band memo for optical vertical centering: font_size bits -> (ink_top, ink_height, line_height).
// Keyed on the size and not on the text, because the band is measured from a reference run — see `view`.
cached_ink: RefCell<Option<(u32, f32, f32, f32)>>,
style: Rc<dyn Fn() -> TextStyle>,
leaf: LayoutLeaf,
// Held for its subscription: without it a measured leaf keeps the width the previous string wanted, and `view` shapes the new one into that box — a label that grew soft-wraps into a slot built for the old text. `None` for `Text::new`, whose size is its style.
_remeasure: Option<Effect>,
}
impl Text {
pub fn new(
content_fn: impl Fn() -> String + 'static,
layout_style: LayoutStyle,
style_fn: impl Fn() -> TextStyle + 'static,
) -> Result<Self, LayoutError> {
// Stretch overrides any parent align-items (e.g. center) so text always fills the parent's cross-axis width instead of collapsing to 0.
let leaf = LayoutLeaf::register(layout_style.align_self_stretch())?;
Ok(Self {
content: Rc::new(content_fn),
cached_content: RefCell::new((String::new(), Arc::from(""))),
cached_ink: RefCell::new(None),
style: Rc::new(style_fn),
leaf,
_remeasure: None,
})
}
/// Like [`Text::new`], but the leaf's height is measured from the content at its
/// resolved width, so the box grows to fit however many lines the text wraps
/// into and pushes following siblings down instead of overflowing onto them.
pub fn auto(
content_fn: impl Fn() -> String + 'static,
layout_style: LayoutStyle,
style_fn: impl Fn() -> TextStyle + 'static,
) -> Result<Self, LayoutError> {
let content_fn: Rc<dyn Fn() -> String> = Rc::new(content_fn);
let style: Rc<dyn Fn() -> TextStyle> = Rc::new(style_fn);
let measure_content = Rc::clone(&content_fn);
let measure_style = Rc::clone(&style);
let measure = Box::new(move |max_width: f32| {
let s = (measure_style)();
crate::text_metrics::measure_text(&(measure_content)(), max_width, &s)
});
let (node, rect) =
crate::context::new_measured_leaf(layout_style.align_self_stretch(), measure)?;
// Reads through the measure closure so it subscribes to exactly the signals the measure depends on, and keeps the string it last dirtied for: a signal re-set to its own value would otherwise cost a shaping pass and a relayout of the surface for nothing.
let dirty_content = Rc::clone(&content_fn);
let measured = RefCell::new(Option::<String>::None);
let remeasure = effect(move || {
let next = (dirty_content)();
if measured.borrow().as_deref() == Some(next.as_str()) {
return;
}
*measured.borrow_mut() = Some(next);
mark_dirty(node).ok();
});
Ok(Self {
content: content_fn,
cached_content: RefCell::new((String::new(), Arc::from(""))),
cached_ink: RefCell::new(None),
style,
leaf: LayoutLeaf { node, rect },
_remeasure: Some(remeasure),
})
}
pub fn single_line(
content_fn: impl Fn() -> String + 'static,
style_fn: impl Fn() -> TextStyle + 'static,
) -> Result<Self, LayoutError> {
let height = style_fn().font_size * 1.4;
Text::new(content_fn, LayoutStyle::new().height(height), style_fn)
}
}
impl Component for Text {
fn view(&self) -> RenderNode {
let r = self.leaf.rect.get();
let text: Arc<str> = {
let new_str = (self.content)();
let mut cache = self.cached_content.borrow_mut();
if cache.0 != new_str {
let rc = Arc::from(new_str.as_str());
*cache = (new_str, Arc::clone(&rc));
rc
} else {
Arc::clone(&cache.1)
}
};
let style = (self.style)();
// Optically center the glyph band within the leaf. A text leaf stretches to fill its parent's cross
// axis (`align_self_stretch`), and the font's line box reserves ascent room for accents that a run
// never uses — so line-box-centered text sits visibly high next to an icon.
//
// The band is measured from a fixed REFERENCE run and not from this text, and that distinction is
// the whole point: it makes the offset a property of the *font at this size*, which every label in
// the same style then shares. Centering each string on its own ink instead moved it by whether it
// happened to contain a descender — so `Modeling` and `Setup` sat on one baseline and `Simulation`
// and `Results` sat 1.5px below it, in the same row of tabs. A row of labels that does not share a
// baseline is the kind of wrong that is obvious once seen and invisible until then.
let (ink_top, ink_height, reference_line) = {
let key = style.font_size.to_bits();
let mut cache = self.cached_ink.borrow_mut();
match cache.as_ref() {
Some((k, top, h, line)) if *k == key => (*top, *h, *line),
_ => {
let (top, h) =
crate::text_metrics::measure_ink_bounds(REFERENCE, REFERENCE_WIDTH, &style);
let (_, line) =
crate::text_metrics::measure_text(REFERENCE, REFERENCE_WIDTH, &style);
*cache = Some((key, top, h, line));
(top, h, line)
}
}
};
// Centre the whole block, then nudge it by how far the glyph band sits off the middle of **one**
// line box. Splitting it that way is what makes it work for more than one line: the nudge is a
// property of the font at this size, so it applies once however many lines there are, while
// centring against the band alone would push an N-line block down by (N-1)/2 lines — which is what
// a two-line tooltip did, sinking its second line out of the bubble.
let (_, text_height) = crate::text_metrics::measure_text(&text, r.width, &style);
let nudge = if ink_height > 0.0 {
reference_line / 2.0 - ink_top - ink_height / 2.0
} else {
0.0
};
// Rounded onto the pixel grid. Centring lands on a half pixel whenever the box and the text differ
// by an odd amount, and a glyph drawn half a row down is resampled across two rows: it does not move,
// it goes **soft**. Which is why it looked like a placement bug — the same bubble was crisp beside a
// button and blurred under one, because the sideways placement centres on the trigger and contributed
// its own half pixel, cancelling this one. Vertical position is the axis to snap; horizontal subpixel
// placement is what keeps letter spacing even, and the shaper bins it on purpose.
// Within the leaf, always. The nudge is a *centring* refinement, and a box no taller than one line
// has nothing to centre in: applying it there walks the glyphs out through the top of the box the
// layout reserved for them, so a `pad:6` label inked at row 5.
let slack = (r.height - text_height).max(0.0);
let y = ((slack / 2.0 + nudge).clamp(0.0, slack)).round();
// Render the full line box so nothing clips.
let line_height = text_height;
self.leaf.at_layout_position(RenderNode::text(
text,
Rect {
x: 0.0,
y,
width: r.width,
height: line_height,
},
style,
))
}
fn on_event(&mut self, _event: &Event) -> EventResult {
EventResult::Ignored
}
fn debug_name(&self) -> &'static str {
"Text"
}
}
impl_leaf_widget!(Text);
#[cfg(test)]
mod tests {
use super::*;
use crate::context::{
compute_layout, new_container, relayout_if_dirty, reset_layout_runtime, track_layout,
};
use crate::layout_item::LayoutItem;
use layout_core::AvailableSpace;
use reactive_core::signal;
use renderer_core::Color;
// Auto-height text must reserve more vertical space when it is narrower (more wrapped lines), so
// following content is pushed down instead of overlapped.
#[test]
fn auto_text_height_grows_when_narrower() {
let long = "This is a deliberately long paragraph of text that wraps onto several \
lines when the available width is small, and fewer lines when it is wide.";
let height_at = |w: f32| -> f32 {
reset_layout_runtime();
let t = Text::auto(
move || long.to_string(),
LayoutStyle::new(),
|| TextStyle::new(16.0, Color::BLACK),
)
.unwrap();
let node = t.layout_node();
compute_layout(
node,
AvailableSpace::Definite(w),
AvailableSpace::MaxContent,
)
.unwrap();
track_layout(node).unwrap().get().height
};
let narrow = height_at(200.0);
let wide = height_at(800.0);
assert!(
narrow > wide + 20.0,
"narrow text should be taller: narrow={narrow} wide={wide}"
);
}
/// Two labels of the same style sit on the same baseline, whatever letters they happen to contain.
///
/// They did not: the optical centring measured each string's own ink, and a string with a descender has
/// ink reaching lower than one without — so `Setup` and `Simulation`, side by side in a row of tabs at
/// the same size in the same box, were drawn 2.5px apart. Measuring the band from a reference run makes
/// the offset a property of the font at that size, which every label in the style then shares.
#[test]
fn two_labels_of_one_style_share_a_baseline_whatever_letters_they_have() {
reset_layout_runtime();
let drawn = |content: &'static str| {
let text = Text::new(
move || content.to_string(),
LayoutStyle::new().width(200.0).height(30.0),
|| TextStyle::new(13.0, Color::BLACK),
)
.unwrap();
let root = new_container(
LayoutStyle::new().flex_column().width(200.0).height(30.0),
&[text.layout_node()],
)
.unwrap();
compute_layout(
root,
AvailableSpace::Definite(200.0),
AvailableSpace::Definite(30.0),
)
.unwrap();
// The leaf places itself with a transform, so the text command sits under it.
fn text_y(node: &RenderNode) -> Option<f32> {
match node {
RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
Some(rect.y)
}
RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
children.iter().find_map(text_y)
}
_ => None,
}
}
text_y(&text.view()).expect("a text leaf draws text")
};
// `Setup` has a descender and `Simulation` has none — the exact pair that drifted.
let (with_tail, without) = (drawn("Setup"), drawn("Simulation"));
assert!(
(with_tail - without).abs() < 0.01,
"a descender must not move the line: {with_tail} vs {without}"
);
}
/// Text lands on a whole pixel row, whatever its box measures.
///
/// Centring puts it on a half pixel whenever the box and the text differ by an odd amount, and a glyph
/// drawn half a row down does not move — it is resampled across two rows and goes **soft**. It read as a
/// *placement* bug: the same bubble was crisp beside a button and blurred under one, because the sideways
/// placement centres on its trigger and happened to contribute a second half pixel that cancelled this
/// one. Only the vertical axis is snapped; horizontal subpixel placement is what keeps letter spacing
/// even, and the shaper bins it deliberately.
#[test]
fn text_lands_on_a_whole_pixel_row() {
fn text_y(node: &RenderNode) -> Option<f32> {
match node {
RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
Some(rect.y)
}
RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
children.iter().find_map(text_y)
}
_ => None,
}
}
reset_layout_runtime();
// Odd and fractional box heights, where an unsnapped centre lands on the half.
for height in [29.0_f32, 30.0, 31.0, 44.5] {
let text = Text::new(
|| "Setup".to_string(),
LayoutStyle::new().width(200.0).height(height),
|| TextStyle::new(13.0, Color::BLACK),
)
.unwrap();
let root = new_container(
LayoutStyle::new().flex_column().width(200.0).height(height),
&[text.layout_node()],
)
.unwrap();
compute_layout(
root,
AvailableSpace::Definite(200.0),
AvailableSpace::Definite(height),
)
.unwrap();
let y = text_y(&text.view()).expect("a text leaf draws text");
assert_eq!(y, y.round(), "a {height}px box put the text at {y}");
}
}
/// The optical nudge never walks the glyphs out of the box the layout reserved for them.
///
/// The nudge centres the glyph band, and a box no taller than one line has nothing to centre in — so
/// applying it there put the text at a negative `y`. A `pad:6` status line then inked at row 5, one row
/// above its own padding, which is how an out-of-tree app found this.
#[test]
fn text_stays_inside_a_box_that_is_exactly_one_line_tall() {
fn text_rect(node: &RenderNode) -> Option<Rect> {
match node {
RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => Some(*rect),
RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
children.iter().find_map(text_rect)
}
_ => None,
}
}
reset_layout_runtime();
for size in [11.0_f32, 13.0, 15.0, 24.0] {
let text = Text::auto(
|| "Ag".to_string(),
LayoutStyle::new().width(200.0),
move || TextStyle::new(size, Color::BLACK),
)
.unwrap();
let root = new_container(
LayoutStyle::new().flex_column().width(200.0),
&[text.layout_node()],
)
.unwrap();
compute_layout(
root,
AvailableSpace::Definite(200.0),
AvailableSpace::MaxContent,
)
.unwrap();
let rect = text_rect(&text.view()).expect("a text leaf draws text");
assert!(
rect.y >= 0.0,
"at {size}px the text starts {}px above its own box",
-rect.y
);
}
}
/// A block that wraps sits where its box is, not half a line below it.
///
/// Optical centring works on the glyph band, and the band is measured from a one-line reference — so
/// centring an N-line block against it pushes the block down by (N-1)/2 lines. A two-line tooltip
/// description came out sunk, with its second line hanging out of the bubble. Centring the *block* and
/// nudging by the one-line correction is what makes the two cases the same case.
#[test]
fn a_wrapped_block_is_not_pushed_down_by_the_lines_it_gained() {
reset_layout_runtime();
let style = || TextStyle::new(13.0, Color::BLACK);
let text = Text::auto(
|| "Name regions and say what the model is made of".to_string(),
LayoutStyle::new(),
style,
)
.unwrap();
let root = new_container(
LayoutStyle::new().flex_column().width(150.0),
&[text.layout_node()],
)
.unwrap();
compute_layout(
root,
AvailableSpace::Definite(150.0),
AvailableSpace::MaxContent,
)
.unwrap();
let (_, one_line) = crate::text_metrics::measure_text("Hxg", 1_000.0, &style());
let (_, block) = crate::text_metrics::measure_text(
"Name regions and say what the model is made of",
150.0,
&style(),
);
assert!(
block > one_line * 1.5,
"the test needs a string that actually wraps"
);
fn text_y(node: &RenderNode) -> Option<f32> {
match node {
RenderNode::Primitive(renderer_core::DrawCommand::Text { rect, .. }) => {
Some(rect.y)
}
RenderNode::Transform { children, .. } | RenderNode::Group { children } => {
children.iter().find_map(text_y)
}
_ => None,
}
}
let y = text_y(&text.view()).expect("a text leaf draws text");
assert!(
y.abs() < one_line / 3.0,
"a block in a box its own size starts at the top: y = {y} against a {one_line}px line"
);
}
/// A label that grows re-measures, instead of being shaped into the width the previous string wanted.
///
/// The regression it guards is invisible in the widget tree and obvious on screen: a measured leaf is
/// dirtied by the layout runtime, never by a content closure, so a bar chip whose title went from
/// "Desktop" to a full window title kept the narrow box the short one had measured — and `view` soft-wrapped
/// the long title into it, spilling several lines out of a chip one line tall.
#[test]
fn a_measured_label_re_measures_when_its_content_changes() {
reset_layout_runtime();
let title = signal(String::from("Desktop"));
let read = title.read_only();
let label = Text::auto(
move || read.get(),
LayoutStyle::new(),
|| TextStyle::new(13.0, Color::BLACK),
)
.unwrap();
let node = label.layout_node();
let root = new_container(
LayoutStyle::new().flex_row().width(1920.0).height(32.0),
&[node],
)
.unwrap();
let space = || {
compute_layout(
root,
AvailableSpace::Definite(1920.0),
AvailableSpace::Definite(32.0),
)
.unwrap()
};
space();
let short = label.leaf.rect.get().width;
title.set("hyprshell - Rust - Visual Studio Code".to_string());
relayout_if_dirty();
let long = label.leaf.rect.get().width;
assert!(
long > short,
"a title five times longer still measured {long}px, the width \"Desktop\" wanted ({short}px) — \
it will be wrapped into a box built for the old text"
);
}
}