all_is_cubes_ui/vui/widgets/
text.rs

1use all_is_cubes::{listen, universe};
2use alloc::boxed::Box;
3use alloc::sync::Arc;
4
5use all_is_cubes::arcstr::ArcStr;
6use all_is_cubes::block::text::{self, Text as BlockText};
7use all_is_cubes::block::{self, Resolution::*};
8use all_is_cubes::drawing::embedded_graphics::{
9    Drawable,
10    mono_font::MonoTextStyle,
11    prelude::{Dimensions, Point},
12    text::{Text as EgText, TextStyle},
13};
14use all_is_cubes::drawing::{VoxelBrush, rectangle_to_aab};
15use all_is_cubes::math::{GridAab, GridSize, Gridgid};
16use all_is_cubes::space::{CubeTransaction, SpaceTransaction};
17
18use crate::vui::{self, LayoutGrant, LayoutRequest, Layoutable, Widget, WidgetController, widgets};
19
20// -------------------------------------------------------------------------------------------------
21
22/// Widget which draws text using a block per font pixel.
23///
24/// It is “large” in that it is not building blocks that fit entire characters.
25///
26/// TODO: Give this a more precise name, and a nice constructor...
27#[derive(Clone, Debug)]
28#[expect(clippy::exhaustive_structs)] // TODO: find a better strategy
29pub struct LargeText {
30    /// Text to be displayed.
31    pub text: ArcStr,
32    /// Font with which to draw the text.
33    pub font: text::Font,
34    /// Brush with which to draw the text.
35    pub brush: VoxelBrush<'static>,
36    /// Text positioning within the bounds of the widget.
37    pub text_style: TextStyle,
38}
39
40impl LargeText {
41    fn drawable(&self) -> EgText<'_, MonoTextStyle<'_, &VoxelBrush<'_>>> {
42        EgText::with_text_style(
43            &self.text,
44            Point::new(0, 0),
45            MonoTextStyle::new(self.font.eg_font(), &self.brush),
46            self.text_style,
47        )
48    }
49
50    fn bounds(&self) -> GridAab {
51        // TODO: this conversion should be less fiddly
52        rectangle_to_aab(
53            self.drawable().bounding_box(),
54            Gridgid::FLIP_Y,
55            self.brush.bounds().unwrap_or(GridAab::ORIGIN_CUBE),
56        )
57    }
58}
59
60impl Layoutable for LargeText {
61    fn requirements(&self) -> LayoutRequest {
62        LayoutRequest {
63            minimum: self.bounds().size(),
64        }
65    }
66}
67
68impl Widget for LargeText {
69    fn controller(self: Arc<Self>, position: &LayoutGrant) -> Box<dyn WidgetController> {
70        let mut txn = SpaceTransaction::default();
71        let drawable = self.drawable();
72        let draw_bounds = self.bounds();
73        drawable
74            .draw(&mut txn.draw_target(
75                Gridgid::from_translation(
76                    position.shrink_to(draw_bounds.size(), false).bounds.lower_bounds()
77                        - draw_bounds.lower_bounds(),
78                ) * Gridgid::FLIP_Y,
79            ))
80            .unwrap();
81
82        widgets::OneshotController::new(txn)
83    }
84}
85
86// -------------------------------------------------------------------------------------------------
87
88/// Widget which draws a static [`Text`](BlockText) for use as a text label in UI.
89///
90/// It is also used as part of [`ButtonLabel`](crate::vui::widgets::ButtonLabel)s.
91///
92/// For changing text, use [`TextBox`] instead.
93#[derive(Clone, Debug, Eq, Hash, PartialEq)]
94pub struct Label {
95    text: ArcStr,
96    font: text::Font,
97    positioning: Option<text::Positioning>,
98}
99
100impl Label {
101    /// Constructs a [`Label`] that draws the given text, with the standard UI label font.
102    pub fn new(string: ArcStr) -> Self {
103        Self {
104            text: string,
105            font: text::Font::System16,
106            positioning: None,
107        }
108    }
109
110    /// Constructs a [`Label`] that draws the given text, with a specified font.
111    //---
112    // TODO: undecided what's a good API
113    #[cfg_attr(not(feature = "session"), expect(dead_code))]
114    pub(crate) fn with_font(
115        string: ArcStr,
116        font: text::Font,
117        positioning: text::Positioning,
118    ) -> Self {
119        Self {
120            text: string,
121            font,
122            positioning: Some(positioning),
123        }
124    }
125
126    /// Creates and returns the [`Text`] object this widget displays.
127    pub(crate) fn text(&self, gravity: vui::Gravity) -> text::Text {
128        text_for_widget(
129            self.text.clone(),
130            self.font.clone(),
131            self.positioning.unwrap_or_else(|| gravity_to_positioning(gravity, true)),
132        )
133    }
134}
135
136impl Layoutable for Label {
137    fn requirements(&self) -> LayoutRequest {
138        // TODO: memoize
139
140        // Note that we use `Align::Low` (or we could equivalently use `Align::High`).
141        // If we were to use `Center`, then we might create bounds 1 block wider than is actually
142        // needed because the underlying text rendering is (by default) centering within a cube,
143        // so for example a 1.5-cube-long text would occupy 3 cubes by sticking out 0.25 on each
144        // end, when it would actually fit in 2.
145        //
146        // When we actually go to render text, we'll use the actual gravity and bounds, so this
147        // alignment choice won't matter.
148        LayoutRequest {
149            minimum: self.text(vui::Gravity::splat(vui::Align::Low)).bounding_blocks().size(),
150        }
151    }
152}
153
154impl Widget for Label {
155    fn controller(self: Arc<Self>, grant: &LayoutGrant) -> Box<dyn WidgetController> {
156        // TODO: memoize `Text` construction for slightly more efficient reuse of widget
157        // (this will only matter once `Text` memoizes glyph layout)
158
159        widgets::OneshotController::new(draw_text_txn(&self.text(grant.gravity), grant, true))
160    }
161}
162
163impl From<ArcStr> for Label {
164    /// Constructs a [`Label`] that draws the given text, with the standard UI label font.
165    fn from(value: ArcStr) -> Self {
166        Self::new(value)
167    }
168}
169
170impl universe::VisitHandles for Label {
171    fn visit_handles(&self, visitor: &mut dyn universe::HandleVisitor) {
172        let Self {
173            text,
174            font,
175            positioning: _,
176        } = self;
177        text.visit_handles(visitor);
178        font.visit_handles(visitor);
179    }
180}
181
182// -------------------------------------------------------------------------------------------------
183
184/// Widget which draws [`Text`](BlockText) that can change, to be used for textual content,
185/// or labels which change.
186///
187// TODO: And editable text, eventually.
188///
189/// For text which does not change, use [`Label`] instead.
190//---
191// TODO: better name
192// TODO: have some common styling/layout structure we can share with Label
193#[derive(Clone, Debug)]
194pub struct TextBox {
195    text_source: listen::DynSource<ArcStr>,
196    font: text::Font,
197    positioning: Option<text::Positioning>,
198    // TODO: offer minimum size in terms of a sample string?
199    minimum_size: GridSize,
200    // TODO: framed: bool,
201}
202
203impl TextBox {
204    /// Constructs a [`TextBox`] that draws the given text, with the standard UI label font
205    /// and no border.
206    pub fn dynamic_label(text_source: listen::DynSource<ArcStr>, minimum_size: GridSize) -> Self {
207        Self {
208            text_source,
209            font: text::Font::System16,
210            positioning: None,
211            minimum_size,
212        }
213    }
214}
215
216impl Layoutable for TextBox {
217    fn requirements(&self) -> LayoutRequest {
218        LayoutRequest {
219            minimum: self.minimum_size,
220        }
221    }
222}
223
224impl Widget for TextBox {
225    fn controller(self: Arc<Self>, grant: &LayoutGrant) -> Box<dyn WidgetController> {
226        Box::new(TextBoxController {
227            todo: listen::Flag::listening(false, &self.text_source),
228            grant: *grant,
229            definition: self,
230        })
231    }
232}
233
234impl universe::VisitHandles for TextBox {
235    fn visit_handles(&self, visitor: &mut dyn universe::HandleVisitor) {
236        let Self {
237            text_source: _,
238            font,
239            positioning: _,
240            minimum_size: _,
241        } = self;
242        font.visit_handles(visitor);
243    }
244}
245
246/// [`WidgetController`] for [`TextBox`].
247#[derive(Debug)]
248struct TextBoxController {
249    definition: Arc<TextBox>,
250    todo: listen::Flag,
251    grant: LayoutGrant,
252}
253
254impl TextBoxController {
255    fn draw_txn(&self) -> vui::WidgetTransaction {
256        draw_text_txn(
257            &text_for_widget(
258                self.definition.text_source.get(),
259                self.definition.font.clone(),
260                self.definition
261                    .positioning
262                    .unwrap_or_else(|| gravity_to_positioning(self.grant.gravity, true)),
263            ),
264            &self.grant,
265            false, // overwrite any previous text
266        )
267    }
268}
269
270impl WidgetController for TextBoxController {
271    fn initialize(
272        &mut self,
273        _: &vui::WidgetContext<'_, '_>,
274    ) -> Result<vui::WidgetTransaction, vui::InstallVuiError> {
275        Ok(self.draw_txn())
276    }
277
278    fn step(&mut self, _: &vui::WidgetContext<'_, '_>) -> Result<vui::StepSuccess, vui::StepError> {
279        Ok((
280            if self.todo.get_and_clear() {
281                self.draw_txn()
282            } else {
283                SpaceTransaction::default()
284            },
285            // TODO: use waking
286            vui::Then::Step,
287        ))
288    }
289}
290
291impl universe::VisitHandles for TextBoxController {
292    fn visit_handles(&self, visitor: &mut dyn universe::HandleVisitor) {
293        let Self {
294            definition,
295            todo: _,
296            grant: _,
297        } = self;
298        definition.visit_handles(visitor);
299    }
300}
301
302// -------------------------------------------------------------------------------------------------
303
304fn text_for_widget(text: ArcStr, font: text::Font, positioning: text::Positioning) -> text::Text {
305    text::Text::builder()
306        .resolution(R32)
307        .string(text)
308        .font(font)
309        .positioning(positioning)
310        .build()
311}
312
313fn gravity_to_positioning(gravity: vui::Gravity, ignore_y: bool) -> text::Positioning {
314    text::Positioning {
315        x: match gravity.x {
316            vui::Align::Low => text::PositioningX::Left,
317            vui::Align::Center => text::PositioningX::Center,
318            vui::Align::High => text::PositioningX::Right,
319        },
320        line_y: if ignore_y {
321            text::PositioningY::BodyMiddle
322        } else {
323            match gravity.y {
324                vui::Align::Low => text::PositioningY::BodyBottom,
325                vui::Align::Center => text::PositioningY::BodyMiddle,
326                vui::Align::High => text::PositioningY::BodyTop,
327            }
328        },
329        z: match gravity.z {
330            vui::Align::Low | vui::Align::Center => text::PositioningZ::Back,
331            vui::Align::High => text::PositioningZ::Front,
332        },
333    }
334}
335
336/// Produce a transaction for a widget to draw text in its grant.
337///
338/// If `shrink` is true, does not affect cubes outside the bounds of the text.
339/// If `shrink` is false, draws to the entire grant.
340pub(crate) fn draw_text_txn(
341    text: &BlockText,
342    full_grant: &LayoutGrant,
343    shrink: bool,
344) -> SpaceTransaction {
345    let text_aabb = text.bounding_blocks();
346    let shrunk_grant = full_grant.shrink_to(text_aabb.size(), true);
347    let translation = shrunk_grant.bounds.lower_bounds() - text_aabb.lower_bounds();
348
349    // This is like `BlockText::installation()` but if the text ends up too big it is truncated.
350    // TODO: But it shouldn't necessarily be truncated to the lower-left corner which is what
351    // our choice of translation calculation is doing
352    SpaceTransaction::filling(
353        if shrink {
354            shrunk_grant.bounds
355        } else {
356            full_grant.bounds
357        },
358        |cube| {
359            let block = if !shrink && !shrunk_grant.bounds.contains_cube(cube) {
360                // The text doesn't touch this cube, so don't use the text primitive.
361                block::AIR
362            } else {
363                block::Block::from_primitive(block::Primitive::Text {
364                    text: text.clone(),
365                    offset: cube.lower_bounds().to_vector() - translation,
366                })
367            };
368            CubeTransaction::replacing(None, Some(block))
369        },
370    )
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use all_is_cubes::arcstr::literal;
377    use all_is_cubes::block::text::Font;
378    use all_is_cubes::euclid::size3;
379    use all_is_cubes::math::{GridSizeCoord, Rgba};
380    use all_is_cubes::space::{self, SpacePhysics};
381    use all_is_cubes::universe::ReadTicket;
382
383    #[test]
384    fn large_text_size() {
385        let text = "abc";
386        let widget = LargeText {
387            text: text.into(),
388            font: Font::Logo,
389            brush: VoxelBrush::single(block::from_color!(Rgba::WHITE)),
390            text_style: TextStyle::default(),
391        };
392        assert_eq!(
393            widget.requirements(),
394            LayoutRequest {
395                minimum: size3(9 * GridSizeCoord::try_from(text.len()).unwrap(), 15, 1)
396            }
397        );
398    }
399
400    #[test]
401    fn label_layout() {
402        let tree: vui::WidgetTree = vui::leaf_widget(Label::new(literal!("hi")));
403
404        // to_space() serves as a widget building sanity check. TODO: make a proper widget tester
405        tree.to_space(
406            ReadTicket::stub(),
407            space::Builder::default().physics(SpacePhysics::DEFAULT_FOR_BLOCK),
408            vui::Gravity::new(vui::Align::Center, vui::Align::Center, vui::Align::Low),
409        )
410        .unwrap();
411    }
412
413    #[test]
414    fn label_requirements() {
415        // In the current system font and scale, this is exactly 1.5 blocks wide.
416        let string = literal!("abcdef");
417
418        let tree = vui::leaf_widget(Label::new(string));
419
420        // A previous bug would cause this to be 3 wide.
421        assert_eq!(
422            tree.requirements(),
423            LayoutRequest {
424                minimum: size3(2, 1, 1)
425            }
426        );
427    }
428}