yakui_widgets/widgets/
render_textbox.rs

1use std::cell::RefCell;
2use std::fmt;
3use std::rc::Rc;
4
5use fontdue::layout::{CoordinateSystem, Layout, LayoutSettings, TextStyle as FontdueTextStyle};
6use yakui_core::geometry::{Color, Constraints, Rect, Vec2};
7use yakui_core::paint::PaintRect;
8use yakui_core::widget::{LayoutContext, PaintContext, Widget};
9use yakui_core::Response;
10
11use crate::font::Fonts;
12use crate::style::TextStyle;
13use crate::util::widget;
14
15use super::render_text::{get_text_layout_size, paint_text};
16
17/**
18Rendering and layout logic for a textbox, holding no state.
19
20Responds with [RenderTextBoxResponse].
21*/
22#[derive(Debug, Clone)]
23#[non_exhaustive]
24#[must_use = "yakui widgets do nothing if you don't `show` them"]
25pub struct RenderTextBox {
26    pub text: String,
27    pub style: TextStyle,
28    pub selected: bool,
29    pub cursor: usize,
30}
31
32impl RenderTextBox {
33    pub fn new<S: Into<String>>(text: S) -> Self {
34        Self {
35            text: text.into(),
36            style: TextStyle::label(),
37            selected: false,
38            cursor: 0,
39        }
40    }
41
42    pub fn show(self) -> Response<RenderTextBoxResponse> {
43        widget::<RenderTextBoxWidget>(self)
44    }
45}
46
47pub struct RenderTextBoxWidget {
48    props: RenderTextBox,
49    cursor_pos_size: RefCell<(Vec2, f32)>,
50    layout: Rc<RefCell<Layout>>,
51}
52
53pub struct RenderTextBoxResponse {
54    /// The fontdue text layout from this text box. This layout will be reset
55    /// and updated every time the widget updates.
56    pub layout: Rc<RefCell<Layout>>,
57}
58
59impl Widget for RenderTextBoxWidget {
60    type Props<'a> = RenderTextBox;
61    type Response = RenderTextBoxResponse;
62
63    fn new() -> Self {
64        let layout = Layout::new(CoordinateSystem::PositiveYDown);
65
66        Self {
67            props: RenderTextBox::new(""),
68            cursor_pos_size: RefCell::new((Vec2::ZERO, 0.0)),
69            layout: Rc::new(RefCell::new(layout)),
70        }
71    }
72
73    fn update(&mut self, props: Self::Props<'_>) -> Self::Response {
74        self.props = props;
75        RenderTextBoxResponse {
76            layout: self.layout.clone(),
77        }
78    }
79
80    fn layout(&self, ctx: LayoutContext<'_>, input: Constraints) -> Vec2 {
81        let fonts = ctx.dom.get_global_or_init(Fonts::default);
82        let font = match fonts.get(&self.props.style.font) {
83            Some(font) => font,
84            None => {
85                // TODO: Log once that we were unable to find this font.
86                return input.min;
87            }
88        };
89
90        let text = &self.props.text;
91
92        let (max_width, max_height) = if input.is_bounded() {
93            (
94                Some(input.max.x * ctx.layout.scale_factor()),
95                Some(input.max.y * ctx.layout.scale_factor()),
96            )
97        } else {
98            (None, None)
99        };
100
101        let font_size = (self.props.style.font_size * ctx.layout.scale_factor()).ceil();
102
103        let mut text_layout = self.layout.borrow_mut();
104        text_layout.reset(&LayoutSettings {
105            max_width,
106            max_height,
107            ..LayoutSettings::default()
108        });
109        text_layout.append(&[&*font], &FontdueTextStyle::new(text, font_size, 0));
110
111        let lines = text_layout.lines().map(|x| x.as_slice()).unwrap_or(&[]);
112        let glyphs = text_layout.glyphs();
113
114        // TODO: This code doesn't account for graphemes with multiple glyphs.
115        // We should accumulate the total bounding box of all glyphs that
116        // contribute to a given grapheme.
117        let cursor_x = if self.props.cursor >= self.props.text.len() {
118            // If the cursor is after the last character, we can position it at
119            // the right edge of the last glyph.
120            text_layout
121                .glyphs()
122                .last()
123                .map(|glyph| glyph.x + glyph.width as f32 + 1.0)
124        } else {
125            // ...otherwise, we'll position the cursor just behind the next
126            // character after the cursor.
127            text_layout.glyphs().iter().find_map(|glyph| {
128                if glyph.byte_offset != self.props.cursor {
129                    return None;
130                }
131
132                Some(glyph.x - 2.0)
133            })
134        };
135
136        let cursor_line = lines
137            .iter()
138            .find(|line| {
139                let start_byte = glyphs[line.glyph_start].byte_offset;
140                let end_byte = glyphs[line.glyph_end].byte_offset;
141                self.props.cursor >= start_byte && self.props.cursor <= end_byte
142            })
143            .or_else(|| lines.last());
144        let cursor_y = cursor_line
145            .map(|line| line.baseline_y - line.max_ascent)
146            .unwrap_or(0.0);
147
148        let metrics = font.vertical_line_metrics(font_size);
149        let ascent = metrics.map(|m| m.ascent).unwrap_or(font_size) / ctx.layout.scale_factor();
150        let cursor_size = ascent;
151
152        let cursor_pos = Vec2::new(cursor_x.unwrap_or(0.0), cursor_y) / ctx.layout.scale_factor();
153        *self.cursor_pos_size.borrow_mut() = (cursor_pos, cursor_size);
154
155        let mut size = get_text_layout_size(&text_layout, ctx.layout.scale_factor());
156        size = size.max(Vec2::new(0.0, ascent));
157
158        input.constrain(size)
159    }
160
161    fn paint(&self, mut ctx: PaintContext<'_>) {
162        let text_layout = self.layout.borrow_mut();
163        let layout_node = ctx.layout.get(ctx.dom.current()).unwrap();
164
165        paint_text(
166            &mut ctx,
167            &self.props.style.font,
168            layout_node.rect.pos(),
169            &text_layout,
170            self.props.style.color,
171        );
172
173        if self.props.selected {
174            let (pos, size) = *self.cursor_pos_size.borrow();
175
176            let cursor_pos = layout_node.rect.pos() + pos;
177            let cursor_size = Vec2::new(1.0, size);
178
179            let mut rect = PaintRect::new(Rect::from_pos_size(cursor_pos, cursor_size));
180            rect.color = Color::RED;
181            rect.add(ctx.paint);
182        }
183    }
184}
185
186impl fmt::Debug for RenderTextBoxWidget {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.debug_struct("RenderTextBoxWidget")
189            .field("props", &self.props)
190            .field("layout", &"(no debug impl)")
191            .finish()
192    }
193}