feather_ui/component/
textbox.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: 2025 Fundament Research Institute <https://fundament.institute>
3
4use super::StateMachine;
5use crate::color::sRGB;
6use crate::editor::Editor;
7use crate::input::{ModifierKeys, MouseButton, MouseState, RawEvent, RawEventKind};
8use crate::layout::{Layout, base, leaf};
9use crate::text::{Change, EditBuffer};
10use crate::{Dispatchable, Error, InputResult, PxRect, SourceID, layout};
11use cosmic_text::{Action, Buffer, Cursor};
12use derive_where::derive_where;
13use enum_variant_type::EnumVariantType;
14use feather_macro::Dispatch;
15use smallvec::SmallVec;
16use std::rc::Rc;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use winit::keyboard::{Key, KeyCode, NamedKey, PhysicalKey};
20
21#[derive(Debug, Dispatch, EnumVariantType, Clone, PartialEq, Eq)]
22#[evt(derive(Clone), module = "mouse_area_event")]
23pub enum TextBoxEvent {
24    Edit(SmallVec<[Change; 1]>),
25}
26
27struct TextBoxState {
28    last_x_offset: Option<f32>, /* Last cursor x offset when something other than up or down
29                                 * navigation happened */
30    history: Vec<SmallVec<[Change; 1]>>,
31    undo_index: usize,
32    insert_mode: bool,
33    text_count: AtomicUsize,
34    cursor_count: AtomicUsize,
35    focused: bool,
36    editor: Editor,
37    props: Rc<dyn Prop + 'static>,
38    align: Option<cosmic_text::Align>,
39}
40
41impl TextBoxState {
42    fn translate(e: RawEvent) -> RawEvent {
43        match e {
44            RawEvent::Key {
45                device_id,
46                physical_key: PhysicalKey::Code(key),
47                location,
48                down,
49                logical_key,
50                modifiers,
51            } => {
52                let k = match (key, down, modifiers & ModifierKeys::Control as u8 != 0) {
53                    (KeyCode::KeyA, true, true) => Key::Named(NamedKey::Select),
54                    (KeyCode::KeyC, true, true) => Key::Named(NamedKey::Copy),
55                    (KeyCode::KeyX, true, true) => Key::Named(NamedKey::Cut),
56                    (KeyCode::KeyV, true, true) => Key::Named(NamedKey::Paste),
57                    (KeyCode::KeyZ, true, true) => Key::Named(NamedKey::Undo),
58                    (KeyCode::KeyY, true, true) => Key::Named(NamedKey::Redo),
59                    _ => logical_key,
60                };
61                RawEvent::Key {
62                    device_id,
63                    physical_key: PhysicalKey::Code(key),
64                    location,
65                    down,
66                    logical_key: k,
67                    modifiers,
68                }
69            }
70            _ => e,
71        }
72    }
73}
74
75impl super::EventRouter for TextBoxState {
76    type Input = RawEvent;
77    type Output = TextBoxEvent;
78
79    fn process(
80        mut this: crate::AccessCell<Self>,
81        input: Self::Input,
82        area: PxRect,
83        _: PxRect,
84        dpi: crate::RelDim,
85        driver: &std::sync::Weak<crate::Driver>,
86    ) -> InputResult<SmallVec<[Self::Output; 1]>> {
87        let obj = this.props.textedit().obj.clone();
88        let buffer = &mut obj.buffer.borrow_mut();
89        let align = this.align;
90        match Self::translate(input) {
91            RawEvent::Focus { acquired, window } => {
92                this.focused = acquired;
93                window.set_ime_allowed(acquired);
94                if acquired {
95                    window.set_ime_purpose(winit::window::ImePurpose::Normal);
96                    //window.set_ime_cursor_area(position, size);
97                }
98            }
99            RawEvent::Key {
100                down,
101                logical_key,
102                modifiers,
103                ..
104            } => match logical_key {
105                Key::Named(named_key) => {
106                    if down && let Some(driver) = driver.upgrade() {
107                        let change = match named_key {
108                            NamedKey::Enter => this.editor.action(
109                                &mut driver.font_system.write(),
110                                buffer,
111                                Action::Enter,
112                                align,
113                            ),
114                            NamedKey::Tab => this.editor.action(
115                                &mut driver.font_system.write(),
116                                buffer,
117                                if (modifiers & ModifierKeys::Shift as u8) != 0 {
118                                    Action::Unindent
119                                } else {
120                                    Action::Indent
121                                },
122                                align,
123                            ),
124                            NamedKey::Space => this.editor.action(
125                                &mut driver.font_system.write(),
126                                buffer,
127                                Action::Insert(' '),
128                                align,
129                            ),
130                            NamedKey::ArrowLeft
131                            | NamedKey::ArrowRight
132                            | NamedKey::ArrowDown
133                            | NamedKey::ArrowUp
134                            | NamedKey::End
135                            | NamedKey::Home
136                            | NamedKey::PageDown
137                            | NamedKey::PageUp => {
138                                let ctrl = (modifiers & ModifierKeys::Control as u8) != 0;
139                                let shift = (modifiers & ModifierKeys::Shift as u8) != 0;
140                                let font_system = &mut driver.font_system.write();
141                                if !shift {
142                                    let line_height = buffer.metrics().line_height;
143                                    match (named_key, ctrl) {
144                                        (NamedKey::ArrowUp, true) | (NamedKey::ArrowDown, true) => {
145                                            this.editor.action(
146                                                font_system,
147                                                buffer,
148                                                Action::Scroll {
149                                                    pixels: if named_key == NamedKey::ArrowUp {
150                                                        -line_height
151                                                    } else {
152                                                        line_height
153                                                    },
154                                                },
155                                                align,
156                                            );
157                                            return InputResult::Consume(SmallVec::new());
158                                        }
159                                        _ => (),
160                                    }
161
162                                    if let Some((start, end)) = this.editor.selection_bounds(buffer)
163                                    {
164                                        if named_key == NamedKey::ArrowLeft {
165                                            this.editor.set_cursor(buffer, start);
166                                        } else if named_key == NamedKey::ArrowRight {
167                                            this.editor.set_cursor(buffer, end);
168                                        }
169                                    }
170                                    this.editor
171                                        .action(font_system, buffer, Action::Escape, align);
172                                } else if this.editor.selection() == cosmic_text::Selection::None {
173                                    // if a selection doesn't exist, make one.
174                                    let cursor = this.editor.cursor();
175                                    this.editor.set_selection(
176                                        buffer,
177                                        cosmic_text::Selection::Normal(cursor),
178                                    );
179                                }
180                                this.editor.action(
181                                    font_system,
182                                    buffer,
183                                    Action::Motion(match (named_key, ctrl) {
184                                        (NamedKey::ArrowLeft, false) => {
185                                            cosmic_text::Motion::Previous
186                                        }
187                                        (NamedKey::ArrowRight, false) => cosmic_text::Motion::Next,
188                                        (NamedKey::ArrowUp, false) => cosmic_text::Motion::Up,
189                                        (NamedKey::ArrowDown, false) => cosmic_text::Motion::Down,
190                                        (NamedKey::Home, false) => cosmic_text::Motion::Home,
191                                        (NamedKey::End, false) => cosmic_text::Motion::End,
192                                        (NamedKey::PageUp, false) => cosmic_text::Motion::PageUp,
193                                        (NamedKey::PageDown, false) => {
194                                            cosmic_text::Motion::PageDown
195                                        }
196                                        (NamedKey::ArrowLeft, true) => {
197                                            cosmic_text::Motion::PreviousWord
198                                        }
199                                        (NamedKey::ArrowRight, true) => {
200                                            cosmic_text::Motion::NextWord
201                                        }
202                                        (NamedKey::Home, true) => cosmic_text::Motion::BufferStart,
203                                        (NamedKey::End, true) => cosmic_text::Motion::BufferEnd,
204                                        _ => return InputResult::Consume(SmallVec::new()),
205                                    }),
206                                    align,
207                                )
208                            }
209                            NamedKey::Select => {
210                                // Represents a Select All operation
211                                this.editor.set_selection(
212                                    buffer,
213                                    cosmic_text::Selection::Normal(Cursor {
214                                        line: 0,
215                                        index: 0,
216                                        affinity: cosmic_text::Affinity::Before,
217                                    }),
218                                );
219                                this.editor.action(
220                                    &mut driver.font_system.write(),
221                                    buffer,
222                                    Action::Motion(cosmic_text::Motion::BufferEnd),
223                                    align,
224                                );
225                                SmallVec::new()
226                            }
227                            NamedKey::Backspace => this.editor.action(
228                                &mut driver.font_system.write(),
229                                buffer,
230                                Action::Backspace,
231                                align,
232                            ),
233                            NamedKey::Delete => this.editor.action(
234                                &mut driver.font_system.write(),
235                                buffer,
236                                Action::Delete,
237                                align,
238                            ),
239                            NamedKey::Clear => {
240                                let change = this
241                                    .editor
242                                    .delete_selection(&mut driver.font_system.write(), buffer)
243                                    .map(|x| SmallVec::from_buf([x]))
244                                    .unwrap_or_default();
245                                this.editor.shape_as_needed(
246                                    &mut driver.font_system.write(),
247                                    buffer,
248                                    false,
249                                );
250                                change
251                            }
252                            NamedKey::EraseEof => {
253                                let cursor = this.editor.cursor();
254                                this.editor
255                                    .set_selection(buffer, cosmic_text::Selection::Normal(cursor));
256                                this.editor.action(
257                                    &mut driver.font_system.write(),
258                                    buffer,
259                                    Action::Motion(cosmic_text::Motion::BufferEnd),
260                                    align,
261                                );
262                                let change = this
263                                    .editor
264                                    .delete_selection(&mut driver.font_system.write(), buffer)
265                                    .map(|x| SmallVec::from_buf([x]))
266                                    .unwrap_or_default();
267                                this.editor.shape_as_needed(
268                                    &mut driver.font_system.write(),
269                                    buffer,
270                                    false,
271                                );
272                                change
273                            }
274                            NamedKey::Insert => {
275                                this.insert_mode = !this.insert_mode;
276                                SmallVec::new()
277                            }
278                            NamedKey::Cut | NamedKey::Copy => {
279                                if modifiers & ModifierKeys::Held as u8 == 0
280                                    && let Some(s) = this.editor.copy_selection(buffer)
281                                    && let Ok(mut clipboard) = arboard::Clipboard::new()
282                                    && clipboard.set_text(&s).is_ok()
283                                    && named_key == NamedKey::Cut
284                                {
285                                    let font_system = &mut driver.font_system.write();
286                                    // Only delete the text for a cut command if the operation
287                                    // succeeds
288                                    if let Some(c) =
289                                        this.editor.delete_selection(font_system, buffer)
290                                    {
291                                        this.editor.shape_as_needed(font_system, buffer, false);
292                                        this.append(SmallVec::from_buf([c]))
293                                    }
294                                }
295                                SmallVec::new()
296                            }
297                            NamedKey::Paste => {
298                                if let Ok(mut clipboard) = arboard::Clipboard::new()
299                                    && let Ok(s) = clipboard.get_text()
300                                {
301                                    let c = this.editor.insert_string(
302                                        &mut driver.font_system.write(),
303                                        buffer,
304                                        &s,
305                                        None,
306                                        align,
307                                    );
308                                    this.editor.shape_as_needed(
309                                        &mut driver.font_system.write(),
310                                        buffer,
311                                        false,
312                                    );
313                                    this.append(SmallVec::from_buf([c]))
314                                }
315                                SmallVec::new()
316                            }
317                            NamedKey::Redo => {
318                                if this.undo_index > 0 {
319                                    this.undo_index =
320                                        this.redo(&mut driver.font_system.write(), buffer)
321                                }
322                                SmallVec::new()
323                            }
324                            NamedKey::Undo => {
325                                if this.undo_index > 0 {
326                                    this.undo_index =
327                                        this.undo(&mut driver.font_system.write(), buffer)
328                                }
329                                SmallVec::new()
330                            }
331                            // Do not capture key events we don't recognize
332                            _ => return InputResult::Forward(SmallVec::new()),
333                        };
334
335                        this.append(change);
336                        obj.set_selection(
337                            EditBuffer::from_cursor(buffer, this.editor.selection_or_cursor()),
338                            EditBuffer::from_cursor(buffer, this.editor.cursor()),
339                        );
340                        return InputResult::Consume(SmallVec::new());
341                    }
342                    // Always capture the key event if we recognize it even if we don't do anything
343                    // with it
344                    return InputResult::Consume(SmallVec::new());
345                }
346                Key::Character(c) => {
347                    if down {
348                        if let Some(driver) = driver.upgrade() {
349                            let c = this.editor.insert_string(
350                                &mut driver.font_system.write(),
351                                buffer,
352                                &c,
353                                None,
354                                align,
355                            );
356                            this.append(SmallVec::from_buf([c]));
357
358                            this.editor.shape_as_needed(
359                                &mut driver.font_system.write(),
360                                buffer,
361                                false,
362                            );
363                            obj.set_selection(
364                                EditBuffer::from_cursor(buffer, this.editor.selection_or_cursor()),
365                                EditBuffer::from_cursor(buffer, this.editor.cursor()),
366                            );
367                        }
368                        return InputResult::Consume(SmallVec::new());
369                    }
370                }
371                _ => (),
372            },
373            RawEvent::MouseMove {
374                pos, all_buttons, ..
375            } => {
376                if let Some(d) = driver.upgrade() {
377                    *d.cursor.write() = winit::window::CursorIcon::Text;
378                    let p =
379                        area.topleft() + this.props.padding().resolve(dpi).topleft().to_vector();
380
381                    if (all_buttons & MouseButton::Left as u16) != 0 {
382                        this.editor.action(
383                            &mut d.font_system.write(),
384                            buffer,
385                            Action::Drag {
386                                x: (pos.x - p.x).round() as i32,
387                                y: (pos.y - p.y).round() as i32,
388                            },
389                            align,
390                        );
391                    }
392                }
393                obj.set_selection(
394                    EditBuffer::from_cursor(buffer, this.editor.selection_or_cursor()),
395                    EditBuffer::from_cursor(buffer, this.editor.cursor()),
396                );
397                return InputResult::Consume(SmallVec::new());
398            }
399            RawEvent::Mouse {
400                pos, state, button, ..
401            } => {
402                if let Some(d) = driver.upgrade() {
403                    let p =
404                        area.topleft() + this.props.padding().resolve(dpi).topleft().to_vector();
405
406                    let action = match (state, button) {
407                        (MouseState::Down, MouseButton::Left) => Action::Click {
408                            x: (pos.x - p.x).round() as i32,
409                            y: (pos.y - p.y).round() as i32,
410                        },
411                        (MouseState::DblClick, MouseButton::Left) => Action::DoubleClick {
412                            x: (pos.x - p.x).round() as i32,
413                            y: (pos.y - p.y).round() as i32,
414                        },
415                        _ => return InputResult::Consume(SmallVec::new()),
416                    };
417                    this.editor
418                        .action(&mut d.font_system.write(), buffer, action, align);
419                }
420                obj.set_selection(
421                    EditBuffer::from_cursor(buffer, this.editor.selection_or_cursor()),
422                    EditBuffer::from_cursor(buffer, this.editor.cursor()),
423                );
424                return InputResult::Consume(SmallVec::new());
425            }
426            RawEvent::MouseScroll { delta, .. } => {
427                if let Some(d) = driver.upgrade() {
428                    let line_height = buffer.metrics().line_height;
429                    match delta {
430                        Ok(dist) => {
431                            let mut scroll = buffer.scroll();
432                            //TODO: align to layout lines
433                            scroll.vertical += dist.y;
434                            buffer.set_scroll(scroll);
435                        }
436                        Err(dist) => {
437                            this.editor.action(
438                                &mut d.font_system.write(),
439                                buffer,
440                                Action::Scroll {
441                                    pixels: -dist.y * line_height,
442                                },
443                                align,
444                            );
445                        }
446                    }
447                }
448            }
449            _ => (),
450        }
451        InputResult::Forward(SmallVec::new())
452    }
453}
454
455impl Clone for TextBoxState {
456    fn clone(&self) -> Self {
457        Self {
458            last_x_offset: self.last_x_offset,
459            history: self.history.clone(),
460            undo_index: self.undo_index,
461            insert_mode: self.insert_mode,
462            text_count: self.text_count.load(Ordering::Relaxed).into(),
463            cursor_count: self.cursor_count.load(Ordering::Relaxed).into(),
464            focused: self.focused,
465            editor: self.editor.clone(),
466            props: self.props.clone(),
467            align: self.align,
468        }
469    }
470}
471
472impl PartialEq for TextBoxState {
473    fn eq(&self, other: &Self) -> bool {
474        self.last_x_offset == other.last_x_offset
475            && self.history == other.history
476            && self.undo_index == other.undo_index
477            && self.insert_mode == other.insert_mode
478            && self.text_count.load(Ordering::Relaxed) == other.text_count.load(Ordering::Relaxed)
479            && self.cursor_count.load(Ordering::Relaxed)
480                == other.cursor_count.load(Ordering::Relaxed)
481            && self.editor == other.editor
482            && self.align == other.align
483            && Rc::ptr_eq(&self.props, &other.props)
484    }
485}
486
487pub trait Prop: leaf::Padded + base::TextEdit {}
488
489#[derive_where(Clone)]
490pub struct TextBox<T> {
491    id: Arc<SourceID>,
492    props: Rc<T>,
493    pub font_size: f32,
494    pub line_height: f32,
495    pub font: cosmic_text::FamilyOwned,
496    pub color: sRGB,
497    pub weight: cosmic_text::Weight,
498    pub style: cosmic_text::Style,
499    pub wrap: cosmic_text::Wrap,
500    pub align: Option<cosmic_text::Align>,
501    pub slots: [Option<crate::Slot>; TextBoxEvent::SIZE],
502}
503
504impl TextBoxState {
505    fn redo(&mut self, font_system: &mut cosmic_text::FontSystem, buffer: &mut Buffer) -> usize {
506        // Redo the current Edit event (or execute cursor events until we find one) then
507        // run all Cursor events after it until the next Edit event
508        if self.undo_index < self.history.len() {
509            self.history[self.undo_index] = self.editor.apply_change(
510                font_system,
511                buffer,
512                &self.history[self.undo_index],
513                self.align,
514            );
515            self.undo_index + 1
516        } else {
517            self.undo_index
518        }
519    }
520
521    fn undo(&mut self, font_system: &mut cosmic_text::FontSystem, buffer: &mut Buffer) -> usize {
522        if self.undo_index > 0 {
523            self.history[self.undo_index - 1] = self.editor.apply_change(
524                font_system,
525                buffer,
526                &self.history[self.undo_index - 1],
527                self.align,
528            );
529            self.undo_index - 1
530        } else {
531            self.undo_index
532        }
533    }
534
535    fn append(&mut self, change: SmallVec<[Change; 1]>) {
536        self.history.truncate(self.undo_index);
537        self.undo_index += 1;
538        self.history.push(change);
539    }
540}
541
542impl<T: Prop + 'static> TextBox<T> {
543    pub fn new(
544        id: Arc<SourceID>,
545        props: T,
546        font_size: f32,
547        line_height: f32,
548        font: cosmic_text::FamilyOwned,
549        color: sRGB,
550        weight: cosmic_text::Weight,
551        style: cosmic_text::Style,
552        wrap: cosmic_text::Wrap,
553        align: Option<cosmic_text::Align>,
554    ) -> Self {
555        Self {
556            id: id.clone(),
557            props: props.into(),
558            font_size,
559            line_height,
560            font,
561            color,
562            weight,
563            style,
564            wrap,
565            align,
566            slots: [None],
567        }
568    }
569}
570
571impl<T: Prop + 'static> crate::StateMachineChild for TextBox<T> {
572    fn id(&self) -> Arc<SourceID> {
573        self.id.clone()
574    }
575
576    fn init(
577        &self,
578        _: &std::sync::Weak<crate::Driver>,
579    ) -> Result<Box<dyn super::StateMachineWrapper>, Error> {
580        let statemachine = StateMachine {
581            state: TextBoxState {
582                editor: Editor::new(),
583                last_x_offset: Default::default(),
584                history: Default::default(),
585                undo_index: Default::default(),
586                insert_mode: Default::default(),
587                text_count: Default::default(),
588                cursor_count: Default::default(),
589                focused: Default::default(),
590                props: self.props.clone(),
591                align: self.align,
592            },
593            input_mask: RawEventKind::Focus as u64
594                | RawEventKind::Mouse as u64
595                | RawEventKind::MouseMove as u64
596                | RawEventKind::MouseScroll as u64
597                | RawEventKind::Touch as u64
598                | RawEventKind::Key as u64,
599            output: self.slots.clone(),
600            changed: true,
601        };
602        Ok(Box::new(statemachine))
603    }
604}
605
606impl<T: Prop + 'static> super::Component for TextBox<T> {
607    type Props = T;
608
609    fn layout(
610        &self,
611        manager: &mut crate::StateManager,
612        driver: &crate::graphics::Driver,
613        window: &Arc<SourceID>,
614    ) -> Box<dyn Layout<T>> {
615        let dpi = manager
616            .get::<super::window::WindowStateMachine>(window)
617            .map(|x| x.state.dpi)
618            .unwrap_or(crate::BASE_DPI);
619        let mut font_system = driver.font_system.write();
620
621        let textstate: &mut StateMachine<TextBoxState, { TextBoxEvent::SIZE }> =
622            manager.get_mut(&self.id).unwrap();
623        let textstate = &mut textstate.state;
624        textstate.props = self.props.clone();
625        textstate.align = self.align;
626
627        if self.props.textedit().obj.reflow.load(Ordering::Acquire)
628            || self.props.textedit().dpi != dpi
629        {
630            let attrs = cosmic_text::Attrs::new()
631                .family(self.font.as_family())
632                .color(self.color.into())
633                .weight(self.weight)
634                .style(self.style);
635            self.props.textedit().obj.flowtext(
636                &mut font_system,
637                self.font_size,
638                self.line_height,
639                self.wrap,
640                self.align,
641                dpi,
642                attrs,
643            );
644        }
645
646        let instance = crate::render::textbox::Instance {
647            text_buffer: self.props.textedit().obj.buffer.clone(),
648            padding: self.props.padding().as_perimeter(dpi),
649            selection: textstate
650                .editor
651                .selection_bounds(&self.props.textedit().obj.buffer.borrow()),
652            color: self.color,
653            cursor_color: if textstate.focused {
654                self.color
655            } else {
656                sRGB::transparent()
657            },
658            cursor: textstate.editor.cursor(),
659            selection_bg: sRGB::new(0.2, 0.2, 0.5, 1.0),
660            selection_color: self.color,
661            scale: 1.0,
662        };
663
664        Box::new(layout::text::Node::<T> {
665            props: self.props.clone(),
666            id: Arc::downgrade(&self.id),
667            renderable: Rc::new(instance),
668            buffer: self.props.textedit().obj.buffer.clone(),
669            realign: self.align.is_some_and(|x| x != cosmic_text::Align::Left),
670        })
671    }
672}