Skip to main content

ps_blitz_dom/node/
text.rs

1use blitz_traits::{
2    events::{BlitzImeEvent, BlitzKeyEvent},
3    shell::ShellProvider,
4};
5use keyboard_types::{Code, Key, Modifiers};
6use parley::{ContentWidths, FontContext, LayoutContext};
7
8use crate::util::{ACTION_MOD, has_clipboard_modifier};
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11enum ClipboardCommand {
12    Copy,
13    Cut,
14    Paste,
15}
16
17fn clipboard_command(event: &BlitzKeyEvent) -> Option<ClipboardCommand> {
18    if !has_clipboard_modifier(event.modifiers) {
19        return None;
20    }
21    match event.code {
22        Code::KeyC => Some(ClipboardCommand::Copy),
23        Code::KeyX => Some(ClipboardCommand::Cut),
24        Code::KeyV => Some(ClipboardCommand::Paste),
25        _ => match &event.key {
26            Key::Character(c) if c.eq_ignore_ascii_case("c") => Some(ClipboardCommand::Copy),
27            Key::Character(c) if c.eq_ignore_ascii_case("x") => Some(ClipboardCommand::Cut),
28            Key::Character(c) if c.eq_ignore_ascii_case("v") => Some(ClipboardCommand::Paste),
29            _ => None,
30        },
31    }
32}
33
34#[derive(Debug, Clone, Copy, Default, PartialEq)]
35/// Parley Brush type for Blitz which contains the Blitz node id
36pub struct TextBrush {
37    /// The node id for the span
38    pub id: usize,
39}
40
41impl TextBrush {
42    pub(crate) fn from_id(id: usize) -> Self {
43        Self { id }
44    }
45}
46
47/// A [`ContentWidths`] result together with the inline box widths it was derived from.
48///
49/// Only ever produced by [`TextLayout::content_widths`]. Invalidation sites set
50/// [`TextLayout::content_widths`] to `None` rather than constructing this.
51#[derive(Clone, Debug)]
52pub struct CachedContentWidths {
53    /// The `width` of every inline box in the layout, as raw bit patterns, at the moment
54    /// `widths` was computed. Stored as bits so the comparison is exact rather than
55    /// approximate, and boxed so that the overwhelmingly common "no inline boxes" case does
56    /// not allocate.
57    inline_box_widths: Box<[u32]>,
58    widths: ContentWidths,
59}
60
61#[derive(Clone, Default)]
62pub struct TextLayout {
63    pub text: String,
64    pub content_widths: Option<CachedContentWidths>,
65    pub layout: parley::layout::Layout<TextBrush>,
66}
67
68impl TextLayout {
69    pub fn new() -> Self {
70        Default::default()
71    }
72
73    /// The layout's min-content and max-content widths, recomputed only when the inputs to
74    /// that computation have actually changed.
75    ///
76    /// WHY this is cached: `Layout::calculate_content_widths` walks every shaped cluster in
77    /// the layout, and block layout asks for the content widths two or three times per pass
78    /// (once under a min-content constraint, once under max-content, then again for the
79    /// definite measure), so the same scan is repeated over the same data.
80    ///
81    /// WHY it is safe: the result is a pure function of exactly two things, the shaped runs
82    /// and the current width of each inline box.
83    ///
84    /// The shaped runs only change when the inline layout is rebuilt, and every rebuild goes
85    /// through `build_inline_layout_into`, which clears this cache. Damage propagation clears
86    /// it too, in the same places it clears the Taffy layout cache, so a text edit or a style
87    /// change affecting font, size, weight, letter/word spacing or white-space collapsing
88    /// always re-measures.
89    ///
90    /// The inline box widths are the reason this cannot be a plain one-shot cache: they are
91    /// re-measured on every pass, and an inline box legitimately measures differently under a
92    /// min-content constraint than under a max-content one, so the same shaped text can yield
93    /// different content widths from one call to the next. Rather than guess which constraint
94    /// a cached entry belongs to, we record the box widths the entry was computed from and
95    /// reuse it only when they are bit-for-bit identical. A layout containing no inline
96    /// boxes, which is the common case and where the scan cost is concentrated, therefore
97    /// hits the cache on every pass after the first.
98    pub fn content_widths(&mut self) -> ContentWidths {
99        // `InlineBox::kind` is fixed when the layout is built (and a change to it goes via a
100        // rebuild, which invalidates this cache), so the widths alone identify the inline box
101        // state that `calculate_content_widths` reads.
102        let inline_box_widths: Box<[u32]> = self
103            .layout
104            .inline_boxes()
105            .iter()
106            .map(|ibox| ibox.width.to_bits())
107            .collect();
108
109        if let Some(cached) = &self.content_widths
110            && cached.inline_box_widths == inline_box_widths
111        {
112            return cached.widths;
113        }
114
115        let widths = self.layout.calculate_content_widths();
116        self.content_widths = Some(CachedContentWidths {
117            inline_box_widths,
118            widths,
119        });
120        widths
121    }
122}
123
124impl std::fmt::Debug for TextLayout {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        write!(f, "TextLayout")
127    }
128}
129
130// TODO: support keypress events
131pub enum GeneratedTextInputEvent {
132    Input,
133    Select,
134    PreEditChange,
135    Submit,
136}
137
138pub struct TextInputData {
139    /// A parley TextEditor instance
140    pub editor: Box<parley::PlainEditor<TextBrush>>,
141    /// Shaped placeholder text, painted only while the editable value is empty.
142    pub placeholder_editor: Option<Box<parley::PlainEditor<TextBrush>>>,
143    /// Whether the input is a singleline or multiline input
144    pub is_multiline: bool,
145    /// The scroll offset of the text content within the input, in CSS (unscaled) pixels.
146    ///
147    /// For single-line inputs this is a horizontal offset; for multi-line inputs it is a
148    /// vertical offset. It is kept up to date so that the caret remains visible within the
149    /// input's content box.
150    pub scroll_offset: f32,
151    pub layout_width: Option<f32>,
152}
153
154// FIXME: Implement Clone for PlainEditor
155impl Clone for TextInputData {
156    fn clone(&self) -> Self {
157        TextInputData::new(self.is_multiline)
158    }
159}
160
161impl TextInputData {
162    pub fn new(is_multiline: bool) -> Self {
163        let editor = Box::new(parley::PlainEditor::new(16.0));
164        Self {
165            editor,
166            placeholder_editor: None,
167            is_multiline,
168            scroll_offset: 0.0,
169            layout_width: None,
170        }
171    }
172
173    pub fn sync_multiline_width(
174        &mut self,
175        font_ctx: &mut FontContext,
176        layout_ctx: &mut LayoutContext<TextBrush>,
177        width: f32,
178    ) {
179        if !self.is_multiline || width <= 0.0 {
180            return;
181        }
182        if self
183            .layout_width
184            .is_some_and(|current| (current - width).abs() < 0.01)
185        {
186            return;
187        }
188        self.layout_width = Some(width);
189        self.editor.set_width(Some(width));
190        self.editor.driver(font_ctx, layout_ctx).refresh_layout();
191        if let Some(placeholder) = self.placeholder_editor.as_mut() {
192            placeholder.set_width(Some(width));
193            placeholder.driver(font_ctx, layout_ctx).refresh_layout();
194        }
195    }
196
197    pub fn set_text(
198        &mut self,
199        font_ctx: &mut FontContext,
200        layout_ctx: &mut LayoutContext<TextBrush>,
201        text: &str,
202    ) {
203        if self.editor.text() != text {
204            self.editor.set_text(text);
205            self.editor.driver(font_ctx, layout_ctx).refresh_layout();
206        }
207    }
208
209    /// Recompute [`Self::scroll_offset`] so that the caret stays visible within the input's
210    /// content box.
211    ///
212    /// `content_box_width` and `content_box_height` are the dimensions of the input's content
213    /// box in CSS (unscaled) pixels.
214    pub fn clamp_scroll_offset(&mut self, content_box_width: f32, content_box_height: f32) {
215        let Some(layout) = self.editor.try_layout() else {
216            return;
217        };
218        // Parley lays out at the editor's scale, so its geometry is in scaled (device) pixels.
219        // We convert into CSS (unscaled) pixels to match `scroll_offset` and the content box.
220        let scale = layout.scale();
221
222        // The caret geometry relative to the start of the text content.
223        let Some(caret) = self.editor.cursor_geometry(1.5) else {
224            return;
225        };
226
227        // Caret bounds and content/viewport extents along the scrolling axis (CSS pixels).
228        let (caret_start, caret_end, content, viewport) = if self.is_multiline {
229            (
230                caret.y0 as f32 / scale,
231                caret.y1 as f32 / scale,
232                layout.height() / scale,
233                content_box_height,
234            )
235        } else {
236            (
237                caret.x0 as f32 / scale,
238                caret.x1 as f32 / scale,
239                layout.full_width() / scale,
240                content_box_width,
241            )
242        };
243
244        let mut offset = self.scroll_offset;
245
246        // Scroll so that both edges of the caret are within the visible region.
247        if caret_end > offset + viewport {
248            offset = caret_end - viewport;
249        }
250        if caret_start < offset {
251            offset = caret_start;
252        }
253
254        // Never scroll past the content, and never scroll into negative space. The content
255        // extent includes the caret so that a caret at the very end remains fully visible
256        // (its rendered width extends slightly past the text).
257        let max_offset = (content.max(caret_end) - viewport).max(0.0);
258        self.scroll_offset = offset.clamp(0.0, max_offset);
259    }
260
261    /// The maximum valid value of [`Self::scroll_offset`] (in CSS pixels) given the input's
262    /// content box, i.e. the extent by which the text content overflows the content box along
263    /// the input's scroll axis.
264    ///
265    /// `content_box_width` and `content_box_height` are the dimensions of the input's content
266    /// box in CSS (unscaled) pixels.
267    pub fn max_scroll_offset(&self, content_box_width: f32, content_box_height: f32) -> f32 {
268        let Some(layout) = self.editor.try_layout() else {
269            return 0.0;
270        };
271        let scale = layout.scale();
272        let (content, viewport) = if self.is_multiline {
273            (layout.height() / scale, content_box_height)
274        } else {
275            (layout.full_width() / scale, content_box_width)
276        };
277        (content - viewport).max(0.0)
278    }
279
280    /// Scroll the input's text content by `delta` CSS pixels along its scroll axis (horizontal
281    /// for single-line inputs, vertical for multi-line inputs), clamping to the scrollable
282    /// range.
283    ///
284    /// Returns the portion of `delta` that could not be consumed (because the input was already
285    /// scrolled to its limit), so the caller can bubble it up to an ancestor scroller.
286    pub fn scroll_by(
287        &mut self,
288        delta: f32,
289        content_box_width: f32,
290        content_box_height: f32,
291    ) -> f32 {
292        let max_offset = self.max_scroll_offset(content_box_width, content_box_height);
293        if max_offset <= 0.0 {
294            return delta;
295        }
296
297        // Match the sign convention used for block scrolling: a positive delta decreases the
298        // scroll offset.
299        let new_offset = (self.scroll_offset - delta).clamp(0.0, max_offset);
300        let consumed = self.scroll_offset - new_offset;
301        self.scroll_offset = new_offset;
302        delta - consumed
303    }
304
305    pub(crate) fn apply_keypress_event(
306        &mut self,
307        font_ctx: &mut FontContext,
308        layout_ctx: &mut LayoutContext<TextBrush>,
309        shell_provider: &dyn ShellProvider,
310        event: BlitzKeyEvent,
311    ) -> Option<GeneratedTextInputEvent> {
312        // Do nothing if it is a keyup event
313        if !event.state.is_pressed() {
314            return None;
315        }
316
317        let mods = event.modifiers;
318        let shift = mods.contains(Modifiers::SHIFT);
319        let action_mod = mods.contains(ACTION_MOD);
320        let word_mod = mods.contains(Modifiers::ALT);
321        let is_multiline = self.is_multiline;
322        let editor = &mut self.editor;
323        let mut driver = editor.driver(font_ctx, layout_ctx);
324        if let Some(command) = clipboard_command(&event) {
325            match command {
326                ClipboardCommand::Copy => {
327                    if let Some(text) = driver.editor.selected_text() {
328                        let _ = shell_provider.set_clipboard_text(text.to_owned());
329                    }
330                }
331                ClipboardCommand::Cut => {
332                    if let Some(text) = driver.editor.selected_text() {
333                        let _ = shell_provider.set_clipboard_text(text.to_owned());
334                        driver.delete_selection()
335                    }
336                }
337                ClipboardCommand::Paste => {
338                    let text = shell_provider.get_clipboard_text().unwrap_or_default();
339                    driver.insert_or_replace_selection(&text)
340                }
341            }
342
343            return Some(GeneratedTextInputEvent::Input);
344        }
345        match event.key {
346            Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => {
347                if shift {
348                    driver.collapse_selection()
349                } else {
350                    driver.select_all()
351                }
352                return Some(GeneratedTextInputEvent::Select);
353            }
354            Key::ArrowLeft => {
355                if action_mod {
356                    if shift {
357                        driver.select_to_line_start()
358                    } else {
359                        driver.move_to_line_start()
360                    }
361                } else if word_mod {
362                    if shift {
363                        driver.select_word_left()
364                    } else {
365                        driver.move_word_left()
366                    }
367                } else if shift {
368                    driver.select_left()
369                } else {
370                    driver.move_left()
371                }
372                return Some(GeneratedTextInputEvent::Select);
373            }
374            Key::ArrowRight => {
375                if action_mod {
376                    if shift {
377                        driver.select_to_line_end()
378                    } else {
379                        driver.move_to_line_end()
380                    }
381                } else if word_mod {
382                    if shift {
383                        driver.select_word_right()
384                    } else {
385                        driver.move_word_right()
386                    }
387                } else if shift {
388                    driver.select_right()
389                } else {
390                    driver.move_right()
391                }
392                return Some(GeneratedTextInputEvent::Select);
393            }
394            Key::ArrowUp => {
395                if action_mod && shift {
396                    driver.select_to_text_start()
397                } else if action_mod {
398                    driver.move_to_text_start()
399                } else if shift {
400                    driver.select_up()
401                } else {
402                    driver.move_up()
403                }
404                return Some(GeneratedTextInputEvent::Select);
405            }
406            Key::ArrowDown => {
407                if action_mod && shift {
408                    driver.select_to_text_end()
409                } else if action_mod {
410                    driver.move_to_text_end()
411                } else if shift {
412                    driver.select_down()
413                } else {
414                    driver.move_down()
415                }
416                return Some(GeneratedTextInputEvent::Select);
417            }
418            Key::Home => {
419                if action_mod {
420                    if shift {
421                        driver.select_to_text_start()
422                    } else {
423                        driver.move_to_text_start()
424                    }
425                } else if shift {
426                    driver.select_to_line_start()
427                } else {
428                    driver.move_to_line_start()
429                }
430                return Some(GeneratedTextInputEvent::Select);
431            }
432            Key::End => {
433                if action_mod {
434                    if shift {
435                        driver.select_to_text_end()
436                    } else {
437                        driver.move_to_text_end()
438                    }
439                } else if shift {
440                    driver.select_to_line_end()
441                } else {
442                    driver.move_to_line_end()
443                }
444                return Some(GeneratedTextInputEvent::Select);
445            }
446            Key::Delete => {
447                #[cfg(target_os = "macos")]
448                if mods.contains(Modifiers::SUPER) {
449                    if driver.editor.raw_selection().is_collapsed() {
450                        driver.select_to_line_end();
451                    }
452                    driver.delete_selection();
453                } else if mods.contains(Modifiers::ALT) {
454                    driver.delete_word();
455                } else {
456                    driver.delete();
457                }
458                #[cfg(not(target_os = "macos"))]
459                if action_mod {
460                    driver.delete_word();
461                } else {
462                    driver.delete();
463                }
464                return Some(GeneratedTextInputEvent::Input);
465            }
466            Key::Backspace => {
467                #[cfg(target_os = "macos")]
468                if mods.contains(Modifiers::SUPER) {
469                    if driver.editor.raw_selection().is_collapsed() {
470                        driver.select_to_line_start();
471                    }
472                    driver.delete_selection();
473                } else if mods.contains(Modifiers::ALT) {
474                    driver.backdelete_word();
475                } else {
476                    driver.backdelete();
477                }
478                #[cfg(not(target_os = "macos"))]
479                if action_mod {
480                    driver.backdelete_word();
481                } else {
482                    driver.backdelete();
483                }
484                return Some(GeneratedTextInputEvent::Input);
485            }
486
487            Key::Character(c) if c == "\n" => {
488                if is_multiline {
489                    driver.insert_or_replace_selection("\n");
490                    return Some(GeneratedTextInputEvent::Input);
491                } else {
492                    return Some(GeneratedTextInputEvent::Submit);
493                }
494            }
495            Key::Enter => {
496                if is_multiline {
497                    driver.insert_or_replace_selection("\n");
498                    return Some(GeneratedTextInputEvent::Input);
499                } else {
500                    return Some(GeneratedTextInputEvent::Submit);
501                }
502            }
503            Key::Character(s)
504                if !mods.contains(Modifiers::CONTROL) && !mods.contains(Modifiers::SUPER) =>
505            {
506                driver.insert_or_replace_selection(&s);
507                return Some(GeneratedTextInputEvent::Input);
508            }
509            _ => {}
510        };
511
512        None
513    }
514
515    pub(crate) fn apply_apple_standard_keybinding(
516        &mut self,
517        font_ctx: &mut FontContext,
518        layout_ctx: &mut LayoutContext<TextBrush>,
519        shell_provider: &dyn ShellProvider,
520        command: &str,
521    ) -> Option<GeneratedTextInputEvent> {
522        let editor = &mut self.editor;
523        let mut driver = editor.driver(font_ctx, layout_ctx);
524        let is_multiline = self.is_multiline;
525
526        match command {
527            // Inserting Content
528
529            // Inserts a backtab character.
530            "insertBacktab:" => {}
531            // Inserts a container break, such as a new page break.
532            "insertContainerBreak:" => {}
533            // Inserts a double quotation mark without substituting a curly quotation mark.
534            "insertDoubleQuoteIgnoringSubstitution:" => {
535                driver.insert_or_replace_selection("\"");
536                return Some(GeneratedTextInputEvent::Input);
537            }
538            // Inserts a line break character.
539            "insertLineBreak:" => {
540                driver.insert_or_replace_selection("\n");
541                return Some(GeneratedTextInputEvent::Input);
542            }
543            // Inserts a newline character.
544            "insertNewline:" => {
545                if is_multiline {
546                    driver.insert_or_replace_selection("\n");
547                    return Some(GeneratedTextInputEvent::Input);
548                } else {
549                    return Some(GeneratedTextInputEvent::Submit);
550                }
551            }
552            // Inserts a newline character without invoking the field editor’s normal handling to end editing.
553            "insertNewlineIgnoringFieldEditor:" => {
554                driver.insert_or_replace_selection("\n");
555                return Some(GeneratedTextInputEvent::Input);
556            }
557            // Inserts a paragraph separator.
558            "insertParagraphSeparator:" => {
559                driver.insert_or_replace_selection("\n");
560                return Some(GeneratedTextInputEvent::Input);
561            }
562            "insertSingleQuoteIgnoringSubstitution:" => {
563                driver.insert_or_replace_selection("'");
564                return Some(GeneratedTextInputEvent::Input);
565            }
566            // Inserts a tab character.
567            "insertTab:" | "insertTabIgnoringFieldEditor:" => {
568                // Ignore for now seeing as parley has poor support for laying out tabs
569            }
570            // Inserts the text you specify.
571            "insertText:" => {}
572
573            // Deleting Content
574
575            // Deletes content moving backward from the current insertion point.
576            // Physical Backspace/Delete events are handled directly above. AppKit may
577            // deliver these selectors as well, but applying both would delete twice.
578            "deleteBackward:" | "deleteBackwardByDecomposingPreviousCharacter:" => {}
579            "deleteForward:" => {}
580            // Deletes content from the insertion point to the beginning of the current line.
581            "deleteToBeginningOfLine:" => {
582                if driver.editor.raw_selection().is_collapsed() {
583                    driver.select_to_line_start();
584                }
585                driver.delete_selection();
586                return Some(GeneratedTextInputEvent::Input);
587            }
588            // Deletes content from the insertion point to the beginning of the current paragraph.
589            "deleteToEndOfLine:" => {
590                if driver.editor.raw_selection().is_collapsed() {
591                    driver.select_to_line_end();
592                }
593                driver.delete_selection();
594                return Some(GeneratedTextInputEvent::Input);
595            }
596            "deleteToBeginningOfParagraph:" => {
597                if driver.editor.raw_selection().is_collapsed() {
598                    driver.select_to_hard_line_start();
599                }
600                driver.delete_selection();
601                return Some(GeneratedTextInputEvent::Input);
602            }
603
604            // Deletes content from the insertion point to the end of the current line.
605            "deleteToEndOfParagraph:" => {
606                if driver.editor.raw_selection().is_collapsed() {
607                    driver.select_to_hard_line_end();
608                }
609                driver.delete_selection();
610                return Some(GeneratedTextInputEvent::Input);
611            }
612            // Deletes content from the insertion point to the end of the current paragraph.
613            "deleteWordBackward:" => {}
614            // Deletes the word preceding the current insertion point.
615            "deleteWordForward:" => {}
616            // Deletes the current selection, placing it in a temporary buffer, such as the Clipboard.
617            "yank:" => {
618                if let Some(text) = driver.editor.selected_text() {
619                    let _ = shell_provider.set_clipboard_text(text.to_owned());
620                    driver.delete_selection();
621                    return Some(GeneratedTextInputEvent::Input);
622                }
623            }
624
625            // Moving the Insertion Pointer
626
627            // Moves the insertion pointer backward in the current content.
628            "moveBackward:" => {
629                driver.move_left(); // TODO: Bidi-aware
630                return Some(GeneratedTextInputEvent::Select);
631            }
632
633            // Moves the insertion pointer down in the current content.
634            "moveDown:" => {
635                driver.move_down();
636                return Some(GeneratedTextInputEvent::Select);
637            }
638            // Moves the insertion pointer forward in the current content.
639            "moveForward:" => {
640                driver.move_right();
641                return Some(GeneratedTextInputEvent::Select);
642            } // TODO: Bidi-aware
643
644            // Moves the insertion pointer left in the current content.
645            "moveLeft:" => {
646                driver.move_left();
647                return Some(GeneratedTextInputEvent::Select);
648            }
649            // Moves the insertion pointer right in the current content.
650            "moveRight:" => {
651                driver.move_right();
652                return Some(GeneratedTextInputEvent::Select);
653            }
654            // Moves the insertion pointer up in the current content.
655            "moveUp:" => {
656                driver.move_up();
657                return Some(GeneratedTextInputEvent::Select);
658            }
659
660            // Modifying the Selection
661
662            // Extends the selection to include the content before the current selection.
663            "moveBackwardAndModifySelection:" => {
664                driver.select_left(); // TODO: Bidi-aware
665                return Some(GeneratedTextInputEvent::Select);
666            }
667            // Extends the selection to include the content below the current selection.
668            "moveDownAndModifySelection:" => {
669                driver.select_down();
670                return Some(GeneratedTextInputEvent::Select);
671            }
672            // Extends the selection to include the content after the current selection.
673            "moveForwardAndModifySelection:" => {
674                driver.select_right(); // TODO: Bidi-aware
675                return Some(GeneratedTextInputEvent::Select);
676            }
677            // Extends the selection to include the content to the left of the current selection.
678            "moveLeftAndModifySelection:" => {
679                driver.select_left();
680                return Some(GeneratedTextInputEvent::Select);
681            }
682            // Extends the selection to include the content to the right of the current selection.
683            "moveRightAndModifySelection:" => {
684                driver.select_right();
685                return Some(GeneratedTextInputEvent::Select);
686            }
687            // Extends the selection to include the content above the current selection.
688            "moveUpAndModifySelection:" => {
689                driver.select_up();
690                return Some(GeneratedTextInputEvent::Select);
691            }
692
693            // Changing the Selection
694            "selectAll:" => {
695                driver.select_all();
696                return Some(GeneratedTextInputEvent::Select);
697            }
698            "selectLine:" => {
699                driver.move_to_line_start();
700                driver.select_to_line_end();
701                return Some(GeneratedTextInputEvent::Select);
702            }
703            "selectParagraph:" => {
704                driver.move_to_hard_line_start();
705                driver.select_to_hard_line_end();
706                return Some(GeneratedTextInputEvent::Select);
707            }
708            "selectWord:" => {
709                // TODO
710            }
711
712            // Moving the Selection in Documents
713            "moveToBeginningOfDocument:" => {
714                driver.move_to_text_start();
715                return Some(GeneratedTextInputEvent::Select);
716            }
717            "moveToBeginningOfDocumentAndModifySelection:" => {
718                driver.select_to_text_start();
719                return Some(GeneratedTextInputEvent::Select);
720            }
721            "moveToEndOfDocument:" => {
722                driver.move_to_text_end();
723                return Some(GeneratedTextInputEvent::Select);
724            }
725            "moveToEndOfDocumentAndModifySelection:" => {
726                driver.move_to_text_end();
727                return Some(GeneratedTextInputEvent::Select);
728            }
729
730            // Moving the Selection in Paragraphs
731            "moveParagraphBackwardAndModifySelection:" => {}
732            "moveParagraphForwardAndModifySelection:" => {}
733            "moveToBeginningOfParagraph:" => {
734                driver.move_to_hard_line_start();
735                return Some(GeneratedTextInputEvent::Select);
736            }
737            "moveToBeginningOfParagraphAndModifySelection:" => {
738                driver.select_to_hard_line_start();
739                return Some(GeneratedTextInputEvent::Select);
740            }
741            "moveToEndOfParagraph:" => {
742                driver.move_to_hard_line_end();
743                return Some(GeneratedTextInputEvent::Select);
744            }
745            "moveToEndOfParagraphAndModifySelection:" => {
746                driver.select_to_hard_line_end();
747                return Some(GeneratedTextInputEvent::Select);
748            }
749
750            // Moving the Selection in Lines of Text
751            "moveToBeginningOfLine:" => {
752                driver.move_to_line_start();
753                return Some(GeneratedTextInputEvent::Select);
754            }
755            "moveToBeginningOfLineAndModifySelection:" => {
756                driver.select_to_line_start();
757                return Some(GeneratedTextInputEvent::Select);
758            }
759            "moveToEndOfLine:" => {
760                driver.move_to_line_end();
761                return Some(GeneratedTextInputEvent::Select);
762            }
763            "moveToEndOfLineAndModifySelection:" => {
764                driver.select_to_line_end();
765                return Some(GeneratedTextInputEvent::Select);
766            }
767            "moveToLeftEndOfLine:" => {
768                driver.move_to_text_start();
769                return Some(GeneratedTextInputEvent::Select);
770            }
771            "moveToLeftEndOfLineAndModifySelection:" => {
772                driver.select_to_line_start();
773                return Some(GeneratedTextInputEvent::Select);
774            }
775            "moveToRightEndOfLine:" => {
776                driver.move_to_line_end();
777                return Some(GeneratedTextInputEvent::Select);
778            }
779            "moveToRightEndOfLineAndModifySelection:" => {
780                driver.select_to_line_end();
781                return Some(GeneratedTextInputEvent::Select);
782            }
783
784            // Moving the Selection by Word Boundaries
785            "moveWordBackward:" => {
786                driver.move_word_left();
787                return Some(GeneratedTextInputEvent::Select);
788            }
789            "moveWordBackwardAndModifySelection:" => {
790                driver.select_word_left();
791                return Some(GeneratedTextInputEvent::Select);
792            }
793            "moveWordForward:" => {
794                driver.move_word_right();
795                return Some(GeneratedTextInputEvent::Select);
796            }
797            "moveWordForwardAndModifySelection:" => {
798                driver.select_word_right();
799                return Some(GeneratedTextInputEvent::Select);
800            }
801            "moveWordLeft:" => {
802                driver.move_word_left();
803                return Some(GeneratedTextInputEvent::Select);
804            }
805            "moveWordLeftAndModifySelection:" => {
806                driver.select_word_left();
807                return Some(GeneratedTextInputEvent::Select);
808            }
809            "moveWordRight:" => {
810                driver.move_word_right();
811                return Some(GeneratedTextInputEvent::Select);
812            }
813            "moveWordRightAndModifySelection:" => {
814                driver.select_word_right();
815                return Some(GeneratedTextInputEvent::Select);
816            }
817
818            // Scrolling Content
819
820            // Scrolls the content down by a page.
821            "scrollPageDown:" => {}
822            // Scrolls the content up by a page.
823            "scrollPageUp:" => {}
824            // Scrolls the content down by a line.
825            "scrollLineDown:" => {}
826            // Scrolls the content up by a line.
827            "scrollLineUp:" => {}
828            // Scrolls the content to the beginning of the document.
829            "scrollToBeginningOfDocument:" => {}
830            // Scrolls the content to the end of the document.
831            "scrollToEndOfDocument:" => {}
832            // Moves the visible content region down by a page.
833            "pageDown:" => {}
834            // Moves the visible content region up by a page.
835            "pageUp:" => {}
836            // Moves the visible content region down by a page, and extends the current selection.
837            "pageDownAndModifySelection:" => {}
838            // Moves the visible content region up by a page, and extends the current selection.
839            "pageUpAndModifySelection:" => {}
840            // Moves the visible content region so the current selection is visually centered.
841            "centerSelectionInVisibleArea:" => {}
842
843            // Transposing Elements
844
845            // Transposes the content around the current selection.
846            "transpose:" => {}
847            // Transposes the words around the current selection.
848            "transposeWords:" => {}
849
850            // Indenting Content
851            // Indents the content at the current selection.
852            "indent:" => {}
853
854            // Canceling Operations
855            // Cancels the current operation.
856            "cancelOperation:" => {}
857
858            // Supporting QuickLook
859            // Invokes QuickLook to preview the current selection.
860            "quickLookPreviewItems:" => {}
861
862            // Supporting Writing Directions
863            "makeBaseWritingDirectionLeftToRight:" => {}
864            "makeBaseWritingDirectionNatural:" => {}
865            "makeBaseWritingDirectionRightToLeft:" => {}
866            "makeTextWritingDirectionLeftToRight:" => {}
867            "makeTextWritingDirectionNatural:" => {}
868            "makeTextWritingDirectionRightToLeft:" => {}
869
870            // Changing Capitalization
871            "capitalizeWord:" => {}
872            "changeCaseOfLetter:" => {}
873            "lowercaseWord:" => {}
874            "uppercaseWord:" => {}
875
876            // Supporting Marked Selections
877            "setMark:" => {}
878            "selectToMark:" => {}
879            "deleteToMark:" => {}
880            "swapWithMark:" => {}
881
882            // Supporting Autocomplete
883            "complete:" => {}
884
885            // Instance Methods
886            "showContextMenuForSelection:" => {}
887
888            // Unknown command
889            _ => {}
890        };
891
892        None
893    }
894
895    pub(crate) fn apply_ime_event(
896        &mut self,
897        font_ctx: &mut FontContext,
898        layout_ctx: &mut LayoutContext<TextBrush>,
899        event: BlitzImeEvent,
900    ) -> Option<GeneratedTextInputEvent> {
901        let editor = &mut self.editor;
902        let mut driver = editor.driver(font_ctx, layout_ctx);
903
904        match event {
905            BlitzImeEvent::Enabled => {
906                // Do nothing
907                None
908            }
909            BlitzImeEvent::Disabled => {
910                driver.clear_compose();
911                Some(GeneratedTextInputEvent::PreEditChange)
912            }
913            BlitzImeEvent::Commit(text) => {
914                driver.insert_or_replace_selection(&text);
915                Some(GeneratedTextInputEvent::Input)
916            }
917            BlitzImeEvent::Preedit(text, cursor) => {
918                if text.is_empty() {
919                    driver.clear_compose();
920                } else {
921                    driver.set_compose(&text, cursor);
922                }
923                Some(GeneratedTextInputEvent::PreEditChange)
924            }
925            BlitzImeEvent::DeleteSurrounding {
926                before_bytes,
927                after_bytes,
928            } => {
929                let _ = before_bytes;
930                let _ = after_bytes;
931                // TODO
932                None
933            }
934        }
935    }
936}
937
938#[cfg(test)]
939mod content_widths_cache_tests {
940    use super::*;
941    use parley::{InlineBox, InlineBoxKind, TextStyle};
942
943    /// Build a [`TextLayout`] containing `text`, optionally followed by an inline box of
944    /// `inline_box_width` pixels.
945    fn build_layout(text: &str, inline_box_width: Option<f32>) -> TextLayout {
946        let mut font_ctx = FontContext::default();
947        let mut layout_ctx = LayoutContext::new();
948        let style: TextStyle<'_, '_, TextBrush> = TextStyle::default();
949        let mut builder = layout_ctx.tree_builder(&mut font_ctx, 1.0, true, &style);
950        builder.push_text(text);
951        if let Some(width) = inline_box_width {
952            builder.push_inline_box(InlineBox {
953                id: 0,
954                kind: InlineBoxKind::InFlow,
955                index: text.len(),
956                width,
957                height: 10.0,
958            });
959        }
960
961        let mut text_layout = TextLayout::new();
962        text_layout.text = builder.build_into(&mut text_layout.layout);
963        text_layout
964    }
965
966    #[test]
967    fn first_call_matches_an_uncached_computation() {
968        let mut text_layout = build_layout("the quick brown fox", None);
969        let expected = text_layout.layout.calculate_content_widths();
970
971        let cached = text_layout.content_widths();
972
973        assert_eq!(cached.min, expected.min);
974        assert_eq!(cached.max, expected.max);
975        assert!(cached.min > 0.0);
976        assert!(cached.max > cached.min);
977    }
978
979    #[test]
980    fn text_only_layout_reuses_the_cached_widths() {
981        let mut text_layout = build_layout("the quick brown fox", None);
982        text_layout.content_widths();
983
984        // Poison the stored result. A second call that recomputed would overwrite this with
985        // the real widths, so seeing the poisoned value back proves the cache was hit.
986        let poison = ContentWidths {
987            min: -1.0,
988            max: -2.0,
989        };
990        text_layout.content_widths.as_mut().unwrap().widths = poison;
991
992        let second = text_layout.content_widths();
993        assert_eq!(second.min, poison.min);
994        assert_eq!(second.max, poison.max);
995    }
996
997    #[test]
998    fn a_changed_inline_box_width_forces_a_recompute() {
999        let mut text_layout = build_layout("the quick brown fox", Some(40.0));
1000        let first = text_layout.content_widths();
1001
1002        // Same poison as above, so a stale hit would be visible.
1003        text_layout.content_widths.as_mut().unwrap().widths = ContentWidths {
1004            min: -1.0,
1005            max: -2.0,
1006        };
1007
1008        // Re-measuring the inline box under a different constraint is exactly what block
1009        // layout does between a min-content and a max-content pass.
1010        text_layout.layout.inline_boxes_mut()[0].width = 400.0;
1011
1012        let second = text_layout.content_widths();
1013        assert!(second.min > 0.0);
1014        assert!(second.max > first.max);
1015        assert_eq!(second.min, 400.0);
1016    }
1017
1018    #[test]
1019    fn an_unchanged_inline_box_width_still_hits_the_cache() {
1020        let mut text_layout = build_layout("the quick brown fox", Some(40.0));
1021        text_layout.content_widths();
1022
1023        let poison = ContentWidths {
1024            min: -1.0,
1025            max: -2.0,
1026        };
1027        text_layout.content_widths.as_mut().unwrap().widths = poison;
1028        // Write the identical width back; the key is unchanged so this must not recompute.
1029        text_layout.layout.inline_boxes_mut()[0].width = 40.0;
1030
1031        let second = text_layout.content_widths();
1032        assert_eq!(second.min, poison.min);
1033        assert_eq!(second.max, poison.max);
1034    }
1035
1036    #[test]
1037    fn rebuilding_the_layout_discards_the_cache() {
1038        let mut text_layout = build_layout("the quick brown fox", None);
1039        text_layout.content_widths();
1040        assert!(text_layout.content_widths.is_some());
1041
1042        // Stand in for `build_inline_layout_into`, which clears the cache before re-shaping.
1043        text_layout.content_widths = None;
1044        let rebuilt = build_layout("a much much much longer run of text", None);
1045        text_layout.layout = rebuilt.layout;
1046        text_layout.text = rebuilt.text;
1047
1048        let widths = text_layout.content_widths();
1049        let expected = text_layout.layout.calculate_content_widths();
1050        assert_eq!(widths.max, expected.max);
1051    }
1052}
1053
1054#[cfg(test)]
1055mod shortcut_tests {
1056    use super::*;
1057    use blitz_traits::events::{BlitzKeyEvent, KeyState};
1058    use blitz_traits::shell::DummyShellProvider;
1059    use keyboard_types::Location;
1060
1061    fn control_event(key: Key, code: Code) -> BlitzKeyEvent {
1062        BlitzKeyEvent {
1063            key,
1064            code,
1065            modifiers: Modifiers::CONTROL,
1066            location: Location::Standard,
1067            is_auto_repeating: false,
1068            is_composing: false,
1069            state: KeyState::Pressed,
1070            text: None,
1071        }
1072    }
1073
1074    #[test]
1075    fn control_character_cut_uses_the_physical_key_code() {
1076        let event = control_event(Key::Character("\u{18}".into()), Code::KeyX);
1077        assert_eq!(clipboard_command(&event), Some(ClipboardCommand::Cut));
1078    }
1079
1080    #[test]
1081    fn backspace_does_not_depend_on_an_apple_standard_keybinding() {
1082        let mut data = TextInputData::new(false);
1083        let mut font_ctx = FontContext::default();
1084        let mut layout_ctx = LayoutContext::new();
1085        data.set_text(&mut font_ctx, &mut layout_ctx, "typo");
1086        data.editor
1087            .driver(&mut font_ctx, &mut layout_ctx)
1088            .move_to_text_end();
1089        let event = BlitzKeyEvent {
1090            key: Key::Backspace,
1091            code: Code::Backspace,
1092            modifiers: Modifiers::empty(),
1093            location: Location::Standard,
1094            is_auto_repeating: false,
1095            is_composing: false,
1096            state: KeyState::Pressed,
1097            text: None,
1098        };
1099
1100        assert!(matches!(
1101            data.apply_keypress_event(&mut font_ctx, &mut layout_ctx, &DummyShellProvider, event,),
1102            Some(GeneratedTextInputEvent::Input)
1103        ));
1104        assert_eq!(data.editor.raw_text(), "typ");
1105    }
1106}