yakui_widgets/widgets/
textbox.rs

1use std::cell::RefCell;
2use std::mem;
3use std::rc::Rc;
4
5use fontdue::layout::{Layout, LinePosition};
6use yakui_core::event::{EventInterest, EventResponse, WidgetEvent};
7use yakui_core::geometry::{Color, Constraints, Vec2};
8use yakui_core::input::{KeyCode, MouseButton};
9use yakui_core::widget::{EventContext, LayoutContext, PaintContext, Widget};
10use yakui_core::Response;
11
12use crate::ignore_debug::IgnoreDebug;
13use crate::shapes::RoundedRectangle;
14use crate::style::TextStyle;
15use crate::util::widget;
16use crate::{colors, pad, shapes};
17
18use super::{Pad, RenderTextBox};
19
20/**
21Text that can be edited.
22
23Responds with [TextBoxResponse].
24*/
25#[derive(Debug, Clone)]
26#[non_exhaustive]
27#[must_use = "yakui widgets do nothing if you don't `show` them"]
28pub struct TextBox {
29    pub text: String,
30    pub style: TextStyle,
31    pub padding: Pad,
32    pub fill: Option<Color>,
33    /// Drawn when no text has been set
34    pub placeholder: String,
35}
36
37impl TextBox {
38    pub fn new<S: Into<String>>(text: S) -> Self {
39        Self {
40            text: text.into(),
41            style: TextStyle::label(),
42            padding: Pad::all(8.0),
43            fill: Some(colors::BACKGROUND_3),
44            placeholder: String::new(),
45        }
46    }
47
48    pub fn show(self) -> Response<TextBoxResponse> {
49        widget::<TextBoxWidget>(self)
50    }
51}
52
53#[derive(Debug)]
54pub struct TextBoxWidget {
55    props: TextBox,
56    updated_text: Option<String>,
57    selected: bool,
58    cursor: usize,
59    text_layout: Option<IgnoreDebug<Rc<RefCell<Layout>>>>,
60    activated: bool,
61    lost_focus: bool,
62}
63
64pub struct TextBoxResponse {
65    pub text: Option<String>,
66    /// Whether the user pressed "Enter" in this box
67    pub activated: bool,
68    /// Whether the box lost focus
69    pub lost_focus: bool,
70}
71
72impl Widget for TextBoxWidget {
73    type Props<'a> = TextBox;
74    type Response = TextBoxResponse;
75
76    fn new() -> Self {
77        Self {
78            props: TextBox::new(""),
79            updated_text: None,
80            selected: false,
81            cursor: 0,
82            text_layout: None,
83            activated: false,
84            lost_focus: false,
85        }
86    }
87
88    fn update(&mut self, props: Self::Props<'_>) -> Self::Response {
89        self.props = props;
90
91        let mut text = self.updated_text.as_ref().unwrap_or(&self.props.text);
92        let use_placeholder = text.is_empty();
93        if use_placeholder {
94            text = &self.props.placeholder;
95        }
96
97        // Make sure the cursor is within bounds if the text has changed
98        self.cursor = self.cursor.min(text.len());
99
100        let mut render = RenderTextBox::new(text.clone());
101        render.style = self.props.style.clone();
102        render.selected = self.selected;
103        if !use_placeholder {
104            render.cursor = self.cursor;
105        }
106        if use_placeholder {
107            // Dim towards background
108            render.style.color = self
109                .props
110                .style
111                .color
112                .lerp(&self.props.fill.unwrap_or(Color::CLEAR), 0.75);
113        }
114
115        pad(self.props.padding, || {
116            let res = render.show();
117            self.text_layout = Some(IgnoreDebug(res.into_inner().layout));
118        });
119
120        Self::Response {
121            text: self.updated_text.take(),
122            activated: mem::take(&mut self.activated),
123            lost_focus: mem::take(&mut self.lost_focus),
124        }
125    }
126
127    fn layout(&self, ctx: LayoutContext<'_>, constraints: Constraints) -> Vec2 {
128        ctx.layout.enable_clipping(ctx.dom);
129        self.default_layout(ctx, constraints)
130    }
131
132    fn paint(&self, mut ctx: PaintContext<'_>) {
133        let layout_node = ctx.layout.get(ctx.dom.current()).unwrap();
134
135        if let Some(fill_color) = self.props.fill {
136            let mut bg = RoundedRectangle::new(layout_node.rect, 6.0);
137            bg.color = fill_color;
138            bg.add(ctx.paint);
139        }
140
141        let node = ctx.dom.get_current();
142        for &child in &node.children {
143            ctx.paint(child);
144        }
145
146        if self.selected {
147            shapes::selection_halo(ctx.paint, layout_node.rect);
148        }
149    }
150
151    fn event_interest(&self) -> EventInterest {
152        EventInterest::MOUSE_INSIDE | EventInterest::FOCUSED_KEYBOARD
153    }
154
155    fn event(&mut self, ctx: EventContext<'_>, event: &WidgetEvent) -> EventResponse {
156        match event {
157            WidgetEvent::FocusChanged(focused) => {
158                self.selected = *focused;
159                if !*focused {
160                    self.lost_focus = true;
161                }
162                EventResponse::Sink
163            }
164
165            WidgetEvent::MouseButtonChanged {
166                button: MouseButton::One,
167                inside: true,
168                down,
169                position,
170                ..
171            } => {
172                if !down {
173                    return EventResponse::Sink;
174                }
175
176                ctx.input.set_selection(Some(ctx.dom.current()));
177
178                if let Some(layout) = ctx.layout.get(ctx.dom.current()) {
179                    if let Some(text_layout) = &self.text_layout {
180                        let text_layout = text_layout.borrow();
181
182                        let scale_factor = ctx.layout.scale_factor();
183                        let relative_pos =
184                            *position - layout.rect.pos() - self.props.padding.offset();
185                        let glyph_pos = relative_pos * scale_factor;
186
187                        let Some(line) = pick_text_line(&text_layout, glyph_pos.y) else {
188                            return EventResponse::Sink;
189                        };
190
191                        self.cursor = pick_character_on_line(
192                            &text_layout,
193                            line.glyph_start,
194                            line.glyph_end,
195                            glyph_pos.x,
196                        );
197                    }
198                }
199
200                EventResponse::Sink
201            }
202
203            WidgetEvent::KeyChanged { key, down, .. } => match key {
204                KeyCode::ArrowLeft => {
205                    if *down {
206                        self.move_cursor(-1);
207                    }
208                    EventResponse::Sink
209                }
210
211                KeyCode::ArrowRight => {
212                    if *down {
213                        self.move_cursor(1);
214                    }
215                    EventResponse::Sink
216                }
217
218                KeyCode::Backspace => {
219                    if *down {
220                        self.delete(-1);
221                    }
222                    EventResponse::Sink
223                }
224
225                KeyCode::Delete => {
226                    if *down {
227                        self.delete(1);
228                    }
229                    EventResponse::Sink
230                }
231
232                KeyCode::Home => {
233                    if *down {
234                        self.home();
235                    }
236                    EventResponse::Sink
237                }
238
239                KeyCode::End => {
240                    if *down {
241                        self.end();
242                    }
243                    EventResponse::Sink
244                }
245
246                KeyCode::Enter | KeyCode::NumpadEnter => {
247                    if *down {
248                        ctx.input.set_selection(None);
249                        self.activated = true;
250                    }
251                    EventResponse::Sink
252                }
253
254                KeyCode::Escape => {
255                    if *down {
256                        ctx.input.set_selection(None);
257                    }
258                    EventResponse::Sink
259                }
260                _ => EventResponse::Sink,
261            },
262            WidgetEvent::TextInput(c) => {
263                if c.is_control() {
264                    return EventResponse::Bubble;
265                }
266
267                let text = self
268                    .updated_text
269                    .get_or_insert_with(|| self.props.text.clone());
270
271                // Before trying to input text, make sure that our cursor fits
272                // in the string and is not in the middle of a codepoint!
273                self.cursor = self.cursor.min(text.len());
274                while !text.is_char_boundary(self.cursor) {
275                    self.cursor = self.cursor.saturating_sub(1);
276                }
277
278                if text.is_empty() {
279                    text.push(*c);
280                } else {
281                    text.insert(self.cursor, *c);
282                }
283
284                self.cursor += c.len_utf8();
285
286                EventResponse::Sink
287            }
288            _ => EventResponse::Bubble,
289        }
290    }
291}
292
293impl TextBoxWidget {
294    fn move_cursor(&mut self, delta: i32) {
295        let text = self.updated_text.as_ref().unwrap_or(&self.props.text);
296        let mut cursor = self.cursor as i32;
297        let mut remaining = delta.abs();
298
299        while remaining > 0 {
300            cursor = cursor.saturating_add(delta.signum());
301            cursor = cursor.min(self.props.text.len() as i32);
302            cursor = cursor.max(0);
303            self.cursor = cursor as usize;
304
305            if text.is_char_boundary(self.cursor) {
306                remaining -= 1;
307            }
308        }
309    }
310
311    fn home(&mut self) {
312        self.cursor = 0;
313    }
314
315    fn end(&mut self) {
316        let text = self.updated_text.as_ref().unwrap_or(&self.props.text);
317        self.cursor = text.len();
318    }
319
320    fn delete(&mut self, dir: i32) {
321        let text = self
322            .updated_text
323            .get_or_insert_with(|| self.props.text.clone());
324
325        let anchor = self.cursor as i32;
326        let mut end = anchor;
327        let mut remaining = dir.abs();
328        let mut len = 0;
329
330        while remaining > 0 {
331            end = end.saturating_add(dir.signum());
332            end = end.min(self.props.text.len() as i32);
333            end = end.max(0);
334            len += 1;
335
336            if text.is_char_boundary(end as usize) {
337                remaining -= 1;
338            }
339        }
340
341        if dir < 0 {
342            self.cursor = self.cursor.saturating_sub(len);
343        }
344
345        let min = anchor.min(end) as usize;
346        let max = anchor.max(end) as usize;
347        text.replace_range(min..max, "");
348    }
349}
350
351fn pick_text_line(layout: &Layout, pos_y: f32) -> Option<&LinePosition> {
352    let lines = layout.lines()?;
353
354    let mut closest_line = 0;
355    let mut closest_line_dist = f32::INFINITY;
356    for (index, line) in lines.iter().enumerate() {
357        let dist = (pos_y - line.baseline_y).abs();
358        if dist < closest_line_dist {
359            closest_line = index;
360            closest_line_dist = dist;
361        }
362    }
363
364    lines.get(closest_line)
365}
366
367fn pick_character_on_line(
368    layout: &Layout,
369    line_glyph_start: usize,
370    line_glyph_end: usize,
371    pos_x: f32,
372) -> usize {
373    let mut closest_byte_offset = 0;
374    let mut closest_dist = f32::INFINITY;
375
376    let possible_positions = layout
377        .glyphs()
378        .iter()
379        .skip(line_glyph_start)
380        .take(line_glyph_end + 1 - line_glyph_start)
381        .flat_map(|glyph| {
382            let before = Vec2::new(glyph.x, glyph.y);
383            let after = Vec2::new(glyph.x + glyph.width as f32, glyph.y);
384            [
385                (glyph.byte_offset, before),
386                (glyph.byte_offset + glyph.parent.len_utf8(), after),
387            ]
388        });
389
390    for (byte_offset, glyph_pos) in possible_positions {
391        let dist = (pos_x - glyph_pos.x).abs();
392        if dist < closest_dist {
393            closest_byte_offset = byte_offset;
394            closest_dist = dist;
395        }
396    }
397
398    closest_byte_offset
399}