frus_widgets/baseline.rs
1//! [`Baseline`] and [`IgnoreBaseline`]: the two widgets that say something about
2//! **the line text sits on** rather than about a box.
3//!
4//! A baseline is the only measurement that makes two runs of different sizes read as
5//! one line. Align two labels by their tops and the bigger one hangs; by their
6//! bottoms and it hangs the other way; by their middles and neither sits anywhere a
7//! reader recognises. By their baselines, they are simply on the same line.
8//!
9//! The alignment itself is not here — it is `Align::Baseline` on a row, which is
10//! where a row's children are arranged. These two are the fine adjustments beside it:
11//! one puts a baseline at a chosen height, the other takes a subtree out of the
12//! reckoning.
13
14use frus_core::{Rect, Scene};
15use frus_layout::Style;
16
17use crate::interaction::Status;
18use crate::theme::Theme;
19use crate::widget::Widget;
20
21/// Positions its child so that the **child's baseline** lands `baseline` logical
22/// pixels below the top of this box, then grows to contain it.
23///
24/// It is the manual version of what `Align::Baseline` does automatically: a way to
25/// pin one piece of text to a line decided elsewhere — a caption on a rule, a label
26/// on a grid, a figure on a chart's axis.
27///
28/// If the child's own baseline is already lower than `baseline` there is nowhere to
29/// push it up to, and the child is **top-aligned** instead. A child with no text in
30/// it has no baseline; its bottom edge is used, which is what a plain box amounts to.
31///
32/// ```ignore
33/// Baseline::new(24.0).child(Text::new("on the line"))
34/// ```
35///
36/// There is no choice of baseline *kind*. The measurement here is the alphabetic
37/// baseline — the line Latin, Greek and Cyrillic sit on — because that is the one the
38/// text engine reports. An ideographic baseline is a different number and offering a
39/// name for it that resolved to the same value would be worse than not offering it.
40pub struct Baseline<Msg = crate::callback::Callback> {
41 baseline: f32,
42 children: Vec<Box<dyn Widget<Msg>>>,
43}
44
45impl<Msg> Baseline<Msg> {
46 /// Puts the child's baseline `baseline` pixels below the top of this box.
47 pub fn new(baseline: f32) -> Self {
48 Self {
49 baseline,
50 children: Vec::new(),
51 }
52 }
53
54 /// Sets the positioned child.
55 pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
56 self.children.clear();
57 self.children.push(Box::new(child));
58 self
59 }
60}
61
62impl<Msg: Clone> Widget<Msg> for Baseline<Msg> {
63 fn style(&self) -> Style {
64 // The shift is applied at layout time, as top padding, once the child's own
65 // baseline has been measured — see `build_layout`.
66 Style::default()
67 }
68
69 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
70 &self.children
71 }
72
73 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
74
75 fn on_click(&self) -> Option<Msg> {
76 None
77 }
78
79 fn baseline_target(&self) -> Option<f32> {
80 Some(self.baseline)
81 }
82}
83
84/// Takes its subtree **out of a parent's baseline alignment**: the text inside still
85/// has a baseline, and the row above is to pretend it does not.
86///
87/// A row aligned on baselines lines up on the *lowest* one it can find, so a single
88/// tall or oddly-placed piece of text drags everything else down with it. This is how
89/// to say which text the line belongs to: an icon's label, a superscript, a badge —
90/// present in the row, not consulted about where the line is.
91///
92/// ```ignore
93/// Flex::row()
94/// .align(Align::Baseline)
95/// .child(Text::styled("12", theme.text.display_large))
96/// .child(IgnoreBaseline::new().child(Text::styled("beta", theme.text.label_small)))
97/// ```
98///
99/// It changes nothing else: the subtree lays out, paints and responds exactly as it
100/// would have.
101pub struct IgnoreBaseline<Msg = crate::callback::Callback> {
102 children: Vec<Box<dyn Widget<Msg>>>,
103}
104
105impl<Msg> IgnoreBaseline<Msg> {
106 /// Hides the subtree's baseline from whatever is above it.
107 pub fn new() -> Self {
108 Self {
109 children: Vec::new(),
110 }
111 }
112
113 /// Sets the subtree.
114 pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
115 self.children.clear();
116 self.children.push(Box::new(child));
117 self
118 }
119}
120
121impl<Msg> Default for IgnoreBaseline<Msg> {
122 fn default() -> Self {
123 Self::new()
124 }
125}
126
127impl<Msg: Clone> Widget<Msg> for IgnoreBaseline<Msg> {
128 fn style(&self) -> Style {
129 Style::default()
130 }
131
132 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
133 &self.children
134 }
135
136 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
137
138 fn on_click(&self) -> Option<Msg> {
139 None
140 }
141
142 fn ignores_baseline(&self) -> bool {
143 true
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::{Align, Container, Flex, Text};
151 use frus_core::{Color, Primitive, Size, TextStyle};
152
153 fn rects(root: &impl Widget<()>, size: Size) -> Vec<Rect> {
154 let rt = crate::runtime::Runtime::default();
155 let theme = Theme::dark();
156 let ui = crate::ui::build_ui(root, size, &rt, &theme);
157 ui.scene()
158 .primitives()
159 .iter()
160 .filter_map(|p| match p {
161 Primitive::Text { position, size, .. } => {
162 Some(Rect::new(position.x, position.y, 0.0, *size))
163 }
164 _ => None,
165 })
166 .collect()
167 }
168
169 /// The whole point, in one assertion: two labels of very different sizes in a
170 /// baseline-aligned row sit on **one line**, which top alignment would not give.
171 #[test]
172 fn a_baseline_row_puts_two_sizes_on_one_line() {
173 let row = |align: Align| {
174 Flex::<()>::row()
175 .align(align)
176 .child(Text::styled("48", TextStyle::new(48.0)))
177 .child(Text::styled("px", TextStyle::new(12.0)))
178 };
179 let baseline_of = |align: Align| {
180 let boxes = rects(&row(align), Size::new(300.0, 120.0));
181 assert_eq!(boxes.len(), 2, "two labels");
182 // The painted position is the top of the line box; the baseline is that
183 // plus the font's ascent, which is what the two are supposed to share.
184 let line = |r: &Rect| {
185 r.y + frus_text::baseline(r.height, frus_core::FontWeight::Regular, false)
186 };
187 (line(&boxes[0]), line(&boxes[1]))
188 };
189 let (big, small) = baseline_of(Align::Baseline);
190 assert!((big - small).abs() < 1.0, "one line: {big} and {small}");
191 // And without it they are not on one line at all, which is what makes the
192 // assertion above worth making.
193 let (big, small) = baseline_of(Align::Start);
194 assert!(
195 big - small > 10.0,
196 "top-aligned, they are not: {big} {small}"
197 );
198 }
199
200 /// `IgnoreBaseline` takes a subtree out of the reckoning: the row lines up on the
201 /// label that is left, and the ignored one keeps the place top alignment gives it.
202 #[test]
203 fn an_ignored_subtree_does_not_move_the_line() {
204 let row = |ignore: bool| {
205 let tall = Text::styled("48", TextStyle::new(48.0));
206 let mut flex = Flex::<()>::row()
207 .align(Align::Baseline)
208 .child(Text::styled("px", TextStyle::new(12.0)));
209 flex = if ignore {
210 flex.child(IgnoreBaseline::new().child(tall))
211 } else {
212 flex.child(tall)
213 };
214 flex
215 };
216 let small_y = |ignore: bool| rects(&row(ignore), Size::new(300.0, 120.0))[0].y;
217 // Consulted, the tall label drags the small one down to meet it.
218 assert!(small_y(false) > 1.0, "pushed down: {}", small_y(false));
219 // Ignored, there is nothing left to line up with and the small label stays put.
220 assert!(small_y(true) < 1.0, "left alone: {}", small_y(true));
221 }
222
223 /// `Baseline` puts the child's baseline at the height asked for.
224 #[test]
225 fn a_baseline_box_pins_the_line_it_was_given() {
226 let root = Baseline::<()>::new(40.0).child(Text::styled("x", TextStyle::new(16.0)));
227 let boxes = rects(&root, Size::new(200.0, 120.0));
228 let line = boxes[0].y + frus_text::baseline(16.0, frus_core::FontWeight::Regular, false);
229 assert!((line - 40.0).abs() < 1.0, "the line is at 40: {line}");
230 }
231
232 /// Asked for a line the child is already past, there is nowhere to push it up to
233 /// and it is top-aligned instead — the answer the reference gives.
234 #[test]
235 fn a_baseline_above_the_child_leaves_it_at_the_top() {
236 let root = Baseline::<()>::new(2.0).child(Text::styled("x", TextStyle::new(16.0)));
237 let boxes = rects(&root, Size::new(200.0, 120.0));
238 assert!(boxes[0].y < 0.5, "top-aligned: {}", boxes[0].y);
239 }
240
241 /// A child with no text has no baseline, and a row of such children is arranged
242 /// exactly as a start-aligned row would be — no shifting, no surprises.
243 #[test]
244 fn boxes_without_text_are_not_moved() {
245 let boxed = |align: Align| {
246 let root = Flex::<()>::row()
247 .align(align)
248 .child(
249 Container::new()
250 .width(20.0)
251 .height(50.0)
252 .color(Color::WHITE),
253 )
254 .child(
255 Container::new()
256 .width(20.0)
257 .height(20.0)
258 .color(Color::WHITE),
259 );
260 let rt = crate::runtime::Runtime::default();
261 let theme = Theme::dark();
262 let ui = crate::ui::build_ui(&root, Size::new(200.0, 120.0), &rt, &theme);
263 ui.scene()
264 .primitives()
265 .iter()
266 .filter_map(|p| match p {
267 Primitive::Rect { rect, .. } => Some(rect.y),
268 _ => None,
269 })
270 .collect::<Vec<f32>>()
271 };
272 assert_eq!(boxed(Align::Baseline), boxed(Align::Start));
273 }
274}