Skip to main content

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}