yakui_widgets/widgets/
textbox.rs1use 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#[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 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 pub activated: bool,
68 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 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 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 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}