1use densa_core::{Color, Point, Rect, Size};
2use densa_draw::Canvas;
3use densa_paragraph::{
4 Motion, ParagraphError, ParagraphStyle, TextEditor, TextSelection, TextSystem,
5};
6use densa_ui::{
7 AccessAction, AccessNode, AccessRequest, ActionKind, Constraints, Cursor, Event, EventCx,
8 Handled, Ime, Key, KeyEvent, LayoutCx, NamedKey, PaintCx, PointerButton, PointerKind, Role,
9 Widget,
10};
11
12use crate::paint::outline;
13use crate::text::Text;
14
15const PADDING_X: f32 = 6.0;
17const PADDING_Y: f32 = 5.0;
18const WIDTH: f32 = 180.0;
20
21#[derive(Debug)]
35pub struct TextInput {
36 initial: String,
38 placeholder: Text,
39 editor: Option<TextEditor>,
40 shaped: Option<(f32, Color)>,
42 scroll: f32,
44}
45
46impl TextInput {
47 pub fn new(text: impl Into<String>) -> Self {
49 Self {
50 initial: text.into(),
51 placeholder: Text::new(""),
52 editor: None,
53 shaped: None,
54 scroll: 0.0,
55 }
56 }
57
58 #[must_use]
60 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
61 self.placeholder = Text::new(placeholder);
62 self
63 }
64
65 pub fn text(&self) -> &str {
67 self.editor
68 .as_ref()
69 .map_or(self.initial.as_str(), TextEditor::text)
70 }
71
72 pub fn set_text(
78 &mut self,
79 cx: &mut EventCx<'_>,
80 text: impl Into<String>,
81 ) -> Result<(), ParagraphError> {
82 let text = text.into();
83 match &mut self.editor {
84 Some(editor) => editor.set_text(cx.text_system(), &text)?,
85 None => self.initial = text,
86 }
87 self.follow_caret(cx);
88 Ok(())
89 }
90
91 pub fn has_selection(&self) -> bool {
93 self.editor
94 .as_ref()
95 .is_some_and(|e| !e.selection().is_caret())
96 }
97
98 pub fn can_undo(&self) -> bool {
100 self.editor.as_ref().is_some_and(TextEditor::can_undo)
101 }
102
103 pub fn can_redo(&self) -> bool {
105 self.editor.as_ref().is_some_and(TextEditor::can_redo)
106 }
107
108 pub fn copy(&mut self, cx: &mut EventCx<'_>) -> bool {
111 let Some(editor) = self.editor.as_ref().filter(|e| e.composition().is_none()) else {
112 return false;
113 };
114 let selected = editor.selected_text();
115 !selected.is_empty() && cx.clipboard().set_text(selected).is_ok()
116 }
117
118 pub fn cut(&mut self, cx: &mut EventCx<'_>) -> bool {
122 self.copy(cx) && self.edit(cx, |e, s| e.delete(s, Motion::Previous).map(|()| true))
123 }
124
125 pub fn paste(&mut self, cx: &mut EventCx<'_>) -> bool {
128 let Ok(text) = cx.clipboard().text() else {
129 return false;
130 };
131 let line = one_line(&text);
132 !line.is_empty() && self.edit(cx, |e, s| e.paste(s, &line).map(|()| true))
133 }
134
135 pub fn undo(&mut self, cx: &mut EventCx<'_>) -> bool {
137 self.edit(cx, TextEditor::undo)
138 }
139
140 pub fn redo(&mut self, cx: &mut EventCx<'_>) -> bool {
142 self.edit(cx, TextEditor::redo)
143 }
144
145 fn edit(
147 &mut self,
148 cx: &mut EventCx<'_>,
149 edit: impl FnOnce(&mut TextEditor, &mut TextSystem) -> Result<bool, ParagraphError>,
150 ) -> bool {
151 let Some(editor) = &mut self.editor else {
152 return false;
153 };
154 let changed = edit(editor, cx.text_system()).unwrap_or(false);
155 if changed {
156 cx.emit(ActionKind::Changed);
157 }
158 self.follow_caret(cx);
159 changed
160 }
161
162 fn command(&mut self, cx: &mut EventCx<'_>, key: &KeyEvent<'_>) -> Option<()> {
164 let m = key.modifiers;
165 let command = match key.key {
166 Key::Character(c) if m.command() && !m.alt() => {
167 let letters = [
168 ("x", Command::Cut),
169 ("c", Command::Copy),
170 ("v", Command::Paste),
171 ("z", Command::Undo),
172 ("y", Command::Redo),
173 ];
174 match letters
175 .into_iter()
176 .find(|(l, _)| c.eq_ignore_ascii_case(l))?
177 {
178 (_, Command::Undo) if m.shift() => Command::Redo,
179 (_, command) => command,
180 }
181 }
182 Key::Named(NamedKey::Delete) if m.shift() && !m.control() && self.has_selection() => {
184 Command::Cut
185 }
186 Key::Named(NamedKey::Insert) if m.control() && !m.shift() => Command::Copy,
187 Key::Named(NamedKey::Insert) if m.shift() && !m.control() => Command::Paste,
188 _ => return None,
189 };
190 let _ = match command {
191 Command::Cut => self.cut(cx),
192 Command::Copy => self.copy(cx),
193 Command::Paste => self.paste(cx),
194 Command::Undo => self.undo(cx),
195 Command::Redo => self.redo(cx),
196 };
197 Some(())
198 }
199
200 fn prepare(&mut self, cx: &mut LayoutCx<'_>) {
202 let px = cx.px(cx.theme().font_size);
203 let color = cx.theme().text;
204 if self.shaped == Some((px, color)) && self.editor.is_some() {
205 return;
206 }
207 let style = ParagraphStyle::new(px).with_color(color);
208 match &mut self.editor {
209 Some(editor) => {
210 if editor.set_style(cx.text_system(), style).is_err() {
211 return;
212 }
213 }
214 None => {
215 let text = std::mem::take(&mut self.initial);
216 match TextEditor::new(cx.text_system(), text.clone(), style) {
217 Ok(editor) => self.editor = Some(editor),
218 Err(_) => {
219 self.initial = text;
220 return;
221 }
222 }
223 }
224 }
225 self.shaped = Some((px, color));
226 }
227
228 fn inner(content: Rect, scale: f32) -> Rect {
230 let pad = PADDING_X * scale;
231 Rect::from_edges(
232 content.left() + pad,
233 content.top(),
234 content.right() - pad,
235 content.bottom(),
236 )
237 }
238
239 fn origin(&self, inner: Rect) -> Point {
241 let height = self.editor.as_ref().map_or(0.0, |e| e.paragraph().height());
242 Point::new(
243 inner.left() - self.scroll,
244 (inner.top() + (inner.height() - height) / 2.0).round(),
245 )
246 }
247
248 fn follow_caret(&mut self, cx: &mut EventCx<'_>) {
250 let Some(editor) = &self.editor else {
251 return;
252 };
253 let inner = Self::inner(cx.content_rect(), cx.scale());
254 let paragraph = editor.paragraph();
255 let caret = editor.selection().caret_rect(paragraph, cx.px(1.0));
256 let room = inner.width().max(0.0);
257 if caret.left() < self.scroll {
258 self.scroll = caret.left();
259 } else if caret.right() > self.scroll + room {
260 self.scroll = caret.right() - room;
261 }
262 let longest = (paragraph.width() + caret.width() - room).max(0.0);
263 self.scroll = self.scroll.clamp(0.0, longest);
264 let origin = self.origin(inner);
265 cx.set_ime_area(Some(editor.ime_area().translate(origin)));
266 cx.request_paint();
267 }
268
269 fn pointer(
270 &mut self,
271 cx: &mut EventCx<'_>,
272 kind: PointerKind,
273 at: Point,
274 clicks: u8,
275 shift: bool,
276 ) -> Handled {
277 let inner = Self::inner(cx.content_rect(), cx.scale());
278 let origin = self.origin(inner);
279 let Some(editor) = &mut self.editor else {
280 return Handled::No;
281 };
282 let local = Point::new(at.x - origin.x, at.y - origin.y);
283 let paragraph = editor.paragraph();
284 let selection = match kind {
285 PointerKind::Down(PointerButton::Primary) => match clicks {
286 1 if shift => editor.selection().shift_click(paragraph, local),
287 1 => TextSelection::at_point(paragraph, local),
288 2 => TextSelection::word_at_point(paragraph, local),
289 _ => TextSelection::select_all(paragraph),
290 },
291 PointerKind::Move if cx.is_pressed() => {
292 editor.selection().extend_to_point(paragraph, local)
293 }
294 _ => return Handled::No,
295 };
296 editor.set_selection(selection);
297 self.follow_caret(cx);
298 Handled::Yes
299 }
300
301 fn key(&mut self, cx: &mut EventCx<'_>, key: &KeyEvent<'_>) -> Handled {
302 if self.editor.is_none() {
303 return Handled::No;
304 }
305 if self.command(cx, key).is_some() {
308 return Handled::Yes;
309 }
310 let Some(editor) = &mut self.editor else {
311 return Handled::No;
312 };
313 let (command, shift) = (key.modifiers.command(), key.modifiers.shift());
314 let by_word = |word: Motion, single: Motion| if command { word } else { single };
315 let types = !command || key.modifiers.alt();
317 let edited = match key.key {
318 Key::Named(NamedKey::Backspace) => editor.delete(
319 cx.text_system(),
320 by_word(Motion::PreviousWord, Motion::Previous),
321 ),
322 Key::Named(NamedKey::Delete) => {
323 editor.delete(cx.text_system(), by_word(Motion::NextWord, Motion::Next))
324 }
325 Key::Named(named) => {
326 let motion = match named {
327 NamedKey::ArrowLeft => by_word(Motion::WordLeft, Motion::Left),
328 NamedKey::ArrowRight => by_word(Motion::WordRight, Motion::Right),
329 NamedKey::Home => Motion::LineStart,
330 NamedKey::End => Motion::LineEnd,
331 NamedKey::Enter => {
332 cx.emit(ActionKind::Submitted);
333 return Handled::Yes;
334 }
335 _ => match key.text.filter(|_| types) {
337 Some(text) => {
338 let edited = editor.insert(cx.text_system(), text);
339 if edited.is_ok() {
340 cx.emit(ActionKind::Changed);
341 }
342 self.follow_caret(cx);
343 return Handled::Yes;
344 }
345 None => return Handled::No,
346 },
347 };
348 editor.move_caret(motion, shift);
349 self.follow_caret(cx);
350 return Handled::Yes;
351 }
352 Key::Character(c) if command && c.eq_ignore_ascii_case("a") => {
353 let all = TextSelection::select_all(editor.paragraph());
354 editor.set_selection(all);
355 self.follow_caret(cx);
356 return Handled::Yes;
357 }
358 _ => match key.text.filter(|_| types) {
359 Some(text) => editor.insert(cx.text_system(), text),
360 None => return Handled::No,
361 },
362 };
363 if edited.is_ok() {
364 cx.emit(ActionKind::Changed);
365 }
366 self.follow_caret(cx);
367 Handled::Yes
368 }
369
370 fn ime(&mut self, cx: &mut EventCx<'_>, ime: &Ime<'_>) -> Handled {
371 let Some(editor) = &mut self.editor else {
372 return Handled::No;
373 };
374 let changed = match *ime {
377 Ime::Preedit { text, cursor } => {
378 let cursor = cursor.map(|(start, end)| start..end);
379 let _ = editor.set_composition(cx.text_system(), text, cursor);
380 false
381 }
382 Ime::Commit(text) => editor.insert(cx.text_system(), text).is_ok(),
383 Ime::Disabled => {
384 let _ = editor.set_composition(cx.text_system(), "", None);
385 false
386 }
387 _ => false,
388 };
389 if changed {
390 cx.emit(ActionKind::Changed);
391 }
392 self.follow_caret(cx);
393 Handled::Yes
394 }
395}
396
397#[derive(Debug, Clone, Copy)]
399enum Command {
400 Cut,
401 Copy,
402 Paste,
403 Undo,
404 Redo,
405}
406
407fn one_line(text: &str) -> String {
410 let text = text.trim_end_matches(['\r', '\n']);
411 text.replace("\r\n", " ").replace(['\r', '\n'], " ")
412}
413
414impl Widget for TextInput {
415 fn accessibility(&self, _: &PaintCx<'_>, node: &mut AccessNode) {
416 node.set_role(Role::TextInput);
417 node.set_value(self.text());
418 if !self.placeholder.as_str().is_empty() {
419 node.set_placeholder(self.placeholder.as_str());
420 }
421 node.add_action(AccessAction::SetValue);
422 }
423
424 fn access_action(&mut self, cx: &mut EventCx<'_>, request: &AccessRequest<'_>) -> Handled {
425 let (AccessAction::SetValue, Some(value)) = (request.action, request.value) else {
426 return Handled::No;
427 };
428 if self.set_text(cx, value).is_err() {
429 return Handled::No;
430 }
431 cx.emit(ActionKind::Changed);
432 Handled::Yes
433 }
434
435 fn measure(&mut self, cx: &mut LayoutCx<'_>, _: Constraints) -> Size {
436 self.prepare(cx);
437 let line = self
438 .editor
439 .as_ref()
440 .map_or(0.0, |e| e.paragraph().height())
441 .max(cx.px(cx.theme().font_size) * 1.2);
442 Size::new(cx.px(WIDTH), (line + 2.0 * cx.px(PADDING_Y)).ceil())
443 }
444
445 fn layout(&mut self, cx: &mut LayoutCx<'_>, size: Size) {
446 self.prepare(cx);
447 if let Some(editor) = &self.editor {
449 let room = (size.width - 2.0 * cx.px(PADDING_X)).max(0.0);
450 let longest = (editor.paragraph().width() + cx.px(1.0) - room).max(0.0);
451 self.scroll = self.scroll.clamp(0.0, longest);
452 }
453 let dim = cx.theme().text_dim;
454 self.placeholder.fit(cx, f32::INFINITY, dim);
455 }
456
457 fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
458 let theme = cx.theme();
459 let rect = cx.rect();
460 canvas.fill_rect(rect, theme.raised);
461 let border = if cx.is_focused() {
462 theme.focus
463 } else {
464 theme.border
465 };
466 outline(canvas, rect, cx.px(1.0).round(), border);
467 let inner = Self::inner(cx.content_rect(), cx.scale());
468 let origin = self.origin(inner);
469 let Some(editor) = &self.editor else {
470 return;
471 };
472 let saved = canvas.state();
473 canvas.clip(inner);
474 if editor.text().is_empty() && editor.composition().is_none() {
475 let size = self.placeholder.size();
476 let y = (inner.top() + (inner.height() - size.height) / 2.0).round();
477 self.placeholder.paint(canvas, Point::new(inner.left(), y));
478 }
479 let paragraph = editor.paragraph();
480 let selection = editor.selection();
481 if cx.is_focused() && !selection.is_caret() {
482 selection.for_each_rect(paragraph, |r| {
483 canvas.fill_rect(r.translate(origin), theme.selection);
484 });
485 }
486 let underline = cx.px(1.0).round();
487 editor.for_each_composition_rect(|r| {
488 let r = r.translate(origin);
489 canvas.fill_rect(
490 Rect::from_edges(r.left(), r.bottom() - underline, r.right(), r.bottom()),
491 theme.text,
492 );
493 });
494 for run in paragraph.runs() {
495 canvas.draw_run(run, origin);
496 }
497 if cx.is_focused() && editor.shows_caret() {
498 let caret = selection.caret_rect(paragraph, cx.px(1.0).round());
499 canvas.fill_rect(caret.translate(origin), theme.text);
500 }
501 canvas.restore(saved);
502 }
503
504 fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
505 match event {
506 Event::Pointer(pointer) => match pointer.kind {
507 PointerKind::Enter => {
508 cx.set_cursor(Some(Cursor::Text));
509 Handled::Yes
510 }
511 PointerKind::Up(PointerButton::Primary) => Handled::Yes,
512 kind => {
513 let shift = pointer.modifiers.shift();
514 self.pointer(cx, kind, pointer.position, pointer.clicks, shift)
515 }
516 },
517 Event::Key(key) if key.pressed => self.key(cx, key),
518 Event::Ime(ime) => self.ime(cx, ime),
519 Event::FocusGained | Event::FocusLost => {
520 self.follow_caret(cx);
521 Handled::Yes
522 }
523 _ => Handled::No,
524 }
525 }
526
527 fn accepts_focus(&self) -> bool {
528 true
529 }
530}