Skip to main content

blitz_dom/node/
text.rs

1use blitz_traits::{
2    events::{BlitzImeEvent, BlitzKeyEvent},
3    node_id::NodeId,
4    shell::ShellProvider,
5};
6use keyboard_types::{Code, Key, Modifiers};
7use parley::{ContentWidths, FontContext, LayoutContext};
8
9use crate::util::{ACTION_MOD, has_clipboard_modifier};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12enum ClipboardCommand {
13    Copy,
14    Cut,
15    Paste,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19enum HistoryCommand {
20    Undo,
21    Redo,
22}
23
24/// Ctrl/Cmd+Z undoes, and Shift+Z or Ctrl+Y redoes.
25///
26/// Ctrl+Y is the Windows redo and is accepted everywhere rather than gated on
27/// the platform: it costs one arm, and a user who reaches for it on macOS gets
28/// a redo instead of a `y`.
29fn history_command(event: &BlitzKeyEvent) -> Option<HistoryCommand> {
30    if !has_clipboard_modifier(event.modifiers) {
31        return None;
32    }
33    let shift = event.modifiers.contains(Modifiers::SHIFT);
34    let is = |code: Code, ch: &str| {
35        event.code == code || matches!(&event.key, Key::Character(c) if c.eq_ignore_ascii_case(ch))
36    };
37
38    if is(Code::KeyZ, "z") {
39        return Some(if shift {
40            HistoryCommand::Redo
41        } else {
42            HistoryCommand::Undo
43        });
44    }
45    if is(Code::KeyY, "y") {
46        return Some(HistoryCommand::Redo);
47    }
48    None
49}
50
51/// One point a text input can be returned to.
52///
53/// The whole value, not a diff. A text input holds a single line or a short
54/// message rather than a document, so the simplest thing that is always correct
55/// beats a delta encoding that has to be right about every mutation path —
56/// typing, IME preedit, paste, cut, drag, and the Apple standard keybindings
57/// all reach the buffer through parley's driver, and a snapshot cannot miss one.
58///
59/// The selection travels with the text because restoring one without the other
60/// is the wrong behaviour: undoing a paste has to put the caret back where the
61/// text was inserted, not leave it wherever the caret happened to be.
62#[derive(Clone, Debug, PartialEq, Eq)]
63struct TextEditSnapshot {
64    text: String,
65    /// Byte offsets, in the order the selection was made, so an undone
66    /// selection keeps the end the user was extending from.
67    anchor: usize,
68    focus: usize,
69}
70
71/// Undo and redo for one text input.
72///
73/// # Why this is here and not a crate
74///
75/// The obvious candidates do not fit. `undo` and `undoredo` are command-pattern
76/// or delta libraries: they want to own the mutation so they can invert it, but
77/// every mutation here already goes through `parley::PlainEditor`'s driver, so
78/// adopting one means rerouting every edit site through command objects to buy
79/// back what a snapshot gives for free. `loro`'s `UndoManager` is built for
80/// CRDT documents that have to skip *remote* peers' edits; a text field has no
81/// peers, and it costs 144 transitive crates and a second source of truth for
82/// the text. Snapshot-based crates are ruled out at the source: `PlainEditor`
83/// does not implement `Clone`.
84///
85/// So this is what a browser does, which is also what WebKit hands a normal
86/// Tauri app for free: remember the value and the selection, coalesce a run of
87/// typing into one entry, and cap the depth.
88#[derive(Debug, Default)]
89pub struct TextEditHistory {
90    /// States that can be returned to, oldest first. The last entry is the one
91    /// an undo restores; the state being left is pushed on the way out.
92    undo: Vec<TextEditSnapshot>,
93    /// States undone and not yet re-applied, most recently undone last.
94    redo: Vec<TextEditSnapshot>,
95    /// Where the editor was at the last recorded point, so the next edit can be
96    /// tested against it for continuation. Follows the editor.
97    current: Option<TextEditSnapshot>,
98    /// The state the in-flight run of typing began from, held still while the
99    /// run continues. This, not [`Self::current`], is what an undo restores —
100    /// otherwise undo walks back one character at a time.
101    burst: Option<TextEditSnapshot>,
102    /// Set while an undo or redo is applying, so restoring a snapshot cannot
103    /// record itself as a fresh edit.
104    applying: bool,
105}
106
107/// Deep enough that a session's editing is recoverable, bounded so a long-lived
108/// input cannot grow without limit. Chrome and Firefox both cap in this region.
109const MAX_UNDO_DEPTH: usize = 200;
110
111impl TextEditHistory {
112    /// Whether `next` continues the burst that produced `previous`.
113    ///
114    /// Typing is coalesced so one undo removes a word or a run, not a single
115    /// character: an undo per keystroke is technically faithful and unusable.
116    /// A run continues while text is only being appended at the caret and the
117    /// character added is not whitespace — a space or a newline ends the run,
118    /// which is what makes undo land on word and line boundaries.
119    ///
120    /// Anything else — a deletion, a paste, a caret move, a selection replaced —
121    /// starts a new entry, because those are the edits a user thinks of as one
122    /// action.
123    fn continues_burst(previous: &TextEditSnapshot, next: &TextEditSnapshot) -> bool {
124        // Only ever appending, and only at the caret.
125        if next.text.len() <= previous.text.len() {
126            return false;
127        }
128        if previous.anchor != previous.focus || next.anchor != next.focus {
129            return false;
130        }
131        // The insertion has to be at the previous caret, with everything before
132        // and after it untouched.
133        let caret = previous.focus;
134        if caret > previous.text.len() || next.focus <= caret {
135            return false;
136        }
137        let added = next.focus - caret;
138        if next.text.len() != previous.text.len() + added {
139            return false;
140        }
141        if previous.text.get(..caret) != next.text.get(..caret) {
142            return false;
143        }
144        if previous.text.get(caret..) != next.text.get(next.focus..) {
145            return false;
146        }
147
148        // A word or line boundary closes the run, so undo stops at one.
149        !next.text[caret..next.focus]
150            .chars()
151            .any(|c| c.is_whitespace())
152    }
153
154    /// Record the state the editor is in *before* an edit is applied.
155    ///
156    /// Called on the way into every mutation. The first call seeds `current`
157    /// without pushing, because there is nothing to return to yet; after that,
158    /// a state that does not continue the current burst is pushed as its own
159    /// undo entry.
160    fn record(&mut self, snapshot: TextEditSnapshot) {
161        if self.applying {
162            return;
163        }
164
165        let Some(previous) = self.current.clone() else {
166            // Nothing to return to yet: this is the state the first edit will
167            // be applied to, so it becomes the burst start.
168            self.current = Some(snapshot);
169            return;
170        };
171        if previous == snapshot {
172            return;
173        }
174
175        // Any real edit ends the redo branch, including one that merely
176        // continues a run of typing. Clearing this only when a run *ended* let
177        // a redo after "undo, then keep typing" resurrect the text that was
178        // typed over.
179        self.redo.clear();
180
181        // `burst` is the state the current run of typing began from, and it is
182        // what an undo has to restore. Advancing it per keystroke — which is
183        // what overwriting `current` here used to do — is why undo removed a
184        // single character instead of the whole word.
185        let burst = self.burst.as_ref().unwrap_or(&previous);
186        if Self::continues_burst(burst, &snapshot) {
187            // Still the same run. Hold the start, and let `current` follow the
188            // editor so the next keystroke is compared against where it is now.
189            self.burst = Some(burst.clone());
190            self.current = Some(snapshot);
191            return;
192        }
193
194        // The run ended, so the state it started from becomes an undo entry.
195        let entry = self.burst.take().unwrap_or(previous);
196        self.current = Some(snapshot);
197        self.undo.push(entry);
198        if self.undo.len() > MAX_UNDO_DEPTH {
199            self.undo.remove(0);
200        }
201    }
202
203    /// The state to restore for an undo, given where the editor is now.
204    fn undo(&mut self, now: TextEditSnapshot) -> Option<TextEditSnapshot> {
205        // A run of typing that has not been closed yet is still undoable, and
206        // the state to return to is where that run began. Without this, typing
207        // a word and pressing undo would skip over it to the entry before.
208        if let Some(burst) = self.burst.take() {
209            if burst != now {
210                self.undo.push(burst);
211            }
212        }
213        let restore = self.undo.pop()?;
214        self.redo.push(now);
215        self.current = Some(restore.clone());
216        Some(restore)
217    }
218
219    /// The state to restore for a redo, given where the editor is now.
220    fn redo(&mut self, now: TextEditSnapshot) -> Option<TextEditSnapshot> {
221        let restore = self.redo.pop()?;
222        self.undo.push(now);
223        // A redo lands on a settled state, so there is no run in flight.
224        self.burst = None;
225        self.current = Some(restore.clone());
226        Some(restore)
227    }
228}
229
230fn clipboard_command(event: &BlitzKeyEvent) -> Option<ClipboardCommand> {
231    if !has_clipboard_modifier(event.modifiers) {
232        return None;
233    }
234    match event.code {
235        Code::KeyC => Some(ClipboardCommand::Copy),
236        Code::KeyX => Some(ClipboardCommand::Cut),
237        Code::KeyV => Some(ClipboardCommand::Paste),
238        _ => match &event.key {
239            Key::Character(c) if c.eq_ignore_ascii_case("c") => Some(ClipboardCommand::Copy),
240            Key::Character(c) if c.eq_ignore_ascii_case("x") => Some(ClipboardCommand::Cut),
241            Key::Character(c) if c.eq_ignore_ascii_case("v") => Some(ClipboardCommand::Paste),
242            _ => None,
243        },
244    }
245}
246
247#[derive(Debug, Clone, Copy, Default, PartialEq)]
248/// Parley Brush type for Blitz which contains the Blitz node id
249pub struct TextBrush {
250    /// The node id for the span
251    pub id: NodeId,
252    /// The DOM text node supplying this run, separate from its styling element.
253    pub text_node: Option<NodeId>,
254}
255
256impl TextBrush {
257    pub(crate) fn from_id(id: NodeId) -> Self {
258        Self {
259            id,
260            text_node: None,
261        }
262    }
263}
264
265/// A [`ContentWidths`] result together with the inline box widths it was derived from.
266///
267/// Only ever produced by [`TextLayout::content_widths`]. Invalidation sites set
268/// [`TextLayout::content_widths`] to `None` rather than constructing this.
269#[derive(Clone, Debug)]
270pub struct CachedContentWidths {
271    /// The `width` of every inline box in the layout, as raw bit patterns, at the moment
272    /// `widths` was computed. Stored as bits so the comparison is exact rather than
273    /// approximate, and boxed so that the overwhelmingly common "no inline boxes" case does
274    /// not allocate.
275    inline_box_widths: Box<[u32]>,
276    widths: ContentWidths,
277}
278
279#[derive(Clone, Default)]
280pub struct TextLayout {
281    pub text: String,
282    pub content_widths: Option<CachedContentWidths>,
283    pub layout: parley::layout::Layout<TextBrush>,
284    /// The width the lines were last broken at *by a layout pass*, in device
285    /// pixels.
286    ///
287    /// Measuring re-breaks the same layout at trial widths and stores the
288    /// result back on the node, so the state left behind belongs to whichever
289    /// pass ran last, and that is often a max-content measurement rather than
290    /// the layout. Non-atomic inline elements read their geometry straight out
291    /// of this layout, so they then report boxes from a line that is not on
292    /// screen: measured on a live transcript as a block 713px wide and three
293    /// lines tall sitting over a single line 1,742px wide, with its `<code>`
294    /// and `<strong>` boxes up to 987px outside the pane.
295    ///
296    /// Recording it lets a measuring pass put the lines back where layout left
297    /// them.
298    pub laid_out_at: Option<f32>,
299}
300
301impl TextLayout {
302    pub fn new() -> Self {
303        Default::default()
304    }
305
306    /// The layout's min-content and max-content widths, recomputed only when the inputs to
307    /// that computation have actually changed.
308    ///
309    /// WHY this is cached: `Layout::calculate_content_widths` walks every shaped cluster in
310    /// the layout, and block layout asks for the content widths two or three times per pass
311    /// (once under a min-content constraint, once under max-content, then again for the
312    /// definite measure), so the same scan is repeated over the same data.
313    ///
314    /// WHY it is safe: the result is a pure function of exactly two things, the shaped runs
315    /// and the current width of each inline box.
316    ///
317    /// The shaped runs only change when the inline layout is rebuilt, and every rebuild goes
318    /// through `build_inline_layout_into`, which clears this cache. Damage propagation clears
319    /// it too, in the same places it clears the Taffy layout cache, so a text edit or a style
320    /// change affecting font, size, weight, letter/word spacing or white-space collapsing
321    /// always re-measures.
322    ///
323    /// The inline box widths are the reason this cannot be a plain one-shot cache: they are
324    /// re-measured on every pass, and an inline box legitimately measures differently under a
325    /// min-content constraint than under a max-content one, so the same shaped text can yield
326    /// different content widths from one call to the next. Rather than guess which constraint
327    /// a cached entry belongs to, we record the box widths the entry was computed from and
328    /// reuse it only when they are bit-for-bit identical. A layout containing no inline
329    /// boxes, which is the common case and where the scan cost is concentrated, therefore
330    /// hits the cache on every pass after the first.
331    pub fn content_widths(&mut self) -> ContentWidths {
332        // `InlineBox::kind` is fixed when the layout is built (and a change to it goes via a
333        // rebuild, which invalidates this cache), so the widths alone identify the inline box
334        // state that `calculate_content_widths` reads.
335        let inline_box_widths: Box<[u32]> = self
336            .layout
337            .inline_boxes()
338            .iter()
339            .map(|ibox| ibox.width.to_bits())
340            .collect();
341
342        if let Some(cached) = &self.content_widths
343            && cached.inline_box_widths == inline_box_widths
344        {
345            return cached.widths;
346        }
347
348        let widths = self.layout.calculate_content_widths();
349        self.content_widths = Some(CachedContentWidths {
350            inline_box_widths,
351            widths,
352        });
353        widths
354    }
355}
356
357impl std::fmt::Debug for TextLayout {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        write!(f, "TextLayout")
360    }
361}
362
363// TODO: support keypress events
364pub enum GeneratedTextInputEvent {
365    Input,
366    Select,
367    PreEditChange,
368    Submit,
369}
370
371pub struct TextInputData {
372    /// A parley TextEditor instance
373    pub editor: Box<parley::PlainEditor<TextBrush>>,
374    /// Shaped placeholder text, painted only while the editable value is empty.
375    pub placeholder_editor: Option<Box<parley::PlainEditor<TextBrush>>>,
376    /// Undo and redo for this input. Parley has no history of its own, so
377    /// without this Cmd+Z reached no handler and did nothing at all.
378    history: TextEditHistory,
379    /// Whether the input is a singleline or multiline input
380    pub is_multiline: bool,
381    /// The scroll offset of the text content within the input, in CSS (unscaled) pixels.
382    ///
383    /// For single-line inputs this is a horizontal offset; for multi-line inputs it is a
384    /// vertical offset. It is kept up to date so that the caret remains visible within the
385    /// input's content box.
386    pub scroll_offset: f32,
387    pub layout_width: Option<f32>,
388}
389
390// FIXME: Implement Clone for PlainEditor
391impl Clone for TextInputData {
392    fn clone(&self) -> Self {
393        TextInputData::new(self.is_multiline)
394    }
395}
396
397impl TextInputData {
398    pub fn new(is_multiline: bool) -> Self {
399        let editor = Box::new(parley::PlainEditor::new(16.0));
400        Self {
401            editor,
402            placeholder_editor: None,
403            history: TextEditHistory::default(),
404            is_multiline,
405            scroll_offset: 0.0,
406            layout_width: None,
407        }
408    }
409
410    /// The editor's current value and selection, as an undo entry.
411    fn snapshot(&self) -> TextEditSnapshot {
412        let selection = self.editor.raw_selection();
413        TextEditSnapshot {
414            text: self.editor.raw_text().to_string(),
415            anchor: selection.anchor().index(),
416            focus: selection.focus().index(),
417        }
418    }
419
420    /// Remember where the editor is, before an edit changes it.
421    fn record_history(&mut self) {
422        let snapshot = self.snapshot();
423        self.history.record(snapshot);
424    }
425
426    /// Put the editor back to `snapshot`, text and selection together.
427    ///
428    /// `applying` is held for the duration so the restore cannot be recorded as
429    /// a new edit, which would make undo a no-op that toggles between two
430    /// states.
431    fn restore(
432        &mut self,
433        font_ctx: &mut FontContext,
434        layout_ctx: &mut LayoutContext<TextBrush>,
435        snapshot: &TextEditSnapshot,
436    ) {
437        self.history.applying = true;
438        self.editor.set_text(&snapshot.text);
439        let mut driver = self.editor.driver(font_ctx, layout_ctx);
440        // Byte offsets from a snapshot of this same buffer, but the text has
441        // just been replaced, so clamp rather than trust them: parley ignores a
442        // non-boundary index and the caret would silently stay put.
443        let len = snapshot.text.len();
444        let anchor = snapshot.anchor.min(len);
445        let focus = snapshot.focus.min(len);
446        if anchor == focus {
447            driver.move_to_byte(focus);
448        } else {
449            driver.select_byte_range(anchor, focus);
450        }
451        self.history.applying = false;
452    }
453
454    /// Apply an undo or a redo, if there is one to apply.
455    fn apply_history_command(
456        &mut self,
457        font_ctx: &mut FontContext,
458        layout_ctx: &mut LayoutContext<TextBrush>,
459        command: HistoryCommand,
460    ) -> Option<GeneratedTextInputEvent> {
461        let now = self.snapshot();
462        let restore = match command {
463            HistoryCommand::Undo => self.history.undo(now),
464            HistoryCommand::Redo => self.history.redo(now),
465        }?;
466        self.restore(font_ctx, layout_ctx, &restore);
467        Some(GeneratedTextInputEvent::Input)
468    }
469
470    /// The height of the laid out text, in CSS (unscaled) pixels.
471    ///
472    /// Parley lays out at the editor's scale, so `Layout::height` is device
473    /// pixels. Everything outside this type speaks CSS pixels, so the division
474    /// belongs here rather than at each call site: two of them forgot it, and
475    /// the result was a textarea that measured four times too tall on a retina
476    /// display.
477    pub fn content_height(&self) -> Option<f32> {
478        self.editor
479            .try_layout()
480            .map(|layout| layout.height() / layout.scale())
481    }
482
483    /// Push [`Self::layout_width`] into the editors, converting to their space.
484    ///
485    /// The remembered width is CSS pixels, because that is what layout hands
486    /// in. Parley wraps against its own scaled layout, so a width passed
487    /// straight through wraps at `width / scale`: on a 2x display a textarea
488    /// broke its text at half the box, and an autosizing composer grew to a
489    /// second line after half a line of typing.
490    fn apply_layout_width(&mut self) {
491        let Some(width) = self.layout_width else {
492            return;
493        };
494        self.editor.set_width(Some(width * self.editor.get_scale()));
495        if let Some(placeholder) = self.placeholder_editor.as_mut() {
496            placeholder.set_width(Some(width * placeholder.get_scale()));
497        }
498    }
499
500    pub fn sync_multiline_width(
501        &mut self,
502        font_ctx: &mut FontContext,
503        layout_ctx: &mut LayoutContext<TextBrush>,
504        width: f32,
505    ) {
506        if !self.is_multiline || width <= 0.0 {
507            return;
508        }
509        if self
510            .layout_width
511            .is_some_and(|current| (current - width).abs() < 0.01)
512        {
513            return;
514        }
515        self.layout_width = Some(width);
516        self.apply_layout_width();
517        self.editor.driver(font_ctx, layout_ctx).refresh_layout();
518        if let Some(placeholder) = self.placeholder_editor.as_mut() {
519            placeholder.driver(font_ctx, layout_ctx).refresh_layout();
520        }
521    }
522
523    pub fn set_text(
524        &mut self,
525        font_ctx: &mut FontContext,
526        layout_ctx: &mut LayoutContext<TextBrush>,
527        text: &str,
528    ) {
529        if self.editor.text() != text {
530            self.editor.set_text(text);
531            // Put the wrap width back before re-laying out.
532            //
533            // `PlainEditor::set_text` rebuilds the layout without a width, so
534            // new text would otherwise be laid out on one endless line and walk
535            // out of the box. `sync_multiline_width` would normally restore it,
536            // but it returns early when the width it remembers already matches
537            // the one being asked for, and it does match: only the text
538            // changed. The remembered width is a claim about the *layout*, so
539            // it has to be re-applied whenever the layout is thrown away.
540            //
541            // Re-applying here rather than waiting for the next measure is also
542            // what lets `scrollHeight` be answered without resolving the whole
543            // document, which typing does on every keystroke.
544            self.apply_layout_width();
545            self.editor.driver(font_ctx, layout_ctx).refresh_layout();
546            // Put the caret at the end, where the value setter is specified to
547            // leave it.
548            //
549            // `PlainEditor::set_text` rebuilds the buffer and leaves the
550            // selection collapsed at offset 0. HTML says assigning `value`
551            // must "move the text entry cursor position to the end of the text
552            // control", so without this any page that writes back to an input
553            // while someone is typing throws their caret to the front of the
554            // field. An address bar that rewrites `example.com` as
555            // `https://example.com` on submit is the case that found it.
556            //
557            // After `refresh_layout`, not before: the cursor is resolved
558            // against the layout that call rebuilds.
559            self.editor.driver(font_ctx, layout_ctx).move_to_text_end();
560        }
561    }
562
563    /// Recompute [`Self::scroll_offset`] so that the caret stays visible within the input's
564    /// content box.
565    ///
566    /// `content_box_width` and `content_box_height` are the dimensions of the input's content
567    /// box in CSS (unscaled) pixels.
568    pub fn clamp_scroll_offset(&mut self, content_box_width: f32, content_box_height: f32) {
569        let Some(layout) = self.editor.try_layout() else {
570            return;
571        };
572        // Parley lays out at the editor's scale, so its geometry is in scaled (device) pixels.
573        // We convert into CSS (unscaled) pixels to match `scroll_offset` and the content box.
574        let scale = layout.scale();
575
576        // The caret geometry relative to the start of the text content.
577        let Some(caret) = self.editor.cursor_geometry(1.5) else {
578            return;
579        };
580
581        // Caret bounds and content/viewport extents along the scrolling axis (CSS pixels).
582        let (caret_start, caret_end, content, viewport) = if self.is_multiline {
583            (
584                caret.y0 as f32 / scale,
585                caret.y1 as f32 / scale,
586                layout.height() / scale,
587                content_box_height,
588            )
589        } else {
590            (
591                caret.x0 as f32 / scale,
592                caret.x1 as f32 / scale,
593                layout.full_width() / scale,
594                content_box_width,
595            )
596        };
597
598        let mut offset = self.scroll_offset;
599
600        // Scroll so that both edges of the caret are within the visible region.
601        if caret_end > offset + viewport {
602            offset = caret_end - viewport;
603        }
604        if caret_start < offset {
605            offset = caret_start;
606        }
607
608        // Never scroll past the content, and never scroll into negative space. The content
609        // extent includes the caret so that a caret at the very end remains fully visible
610        // (its rendered width extends slightly past the text).
611        let max_offset = (content.max(caret_end) - viewport).max(0.0);
612        self.scroll_offset = offset.clamp(0.0, max_offset);
613    }
614
615    /// The maximum valid value of [`Self::scroll_offset`] (in CSS pixels) given the input's
616    /// content box, i.e. the extent by which the text content overflows the content box along
617    /// the input's scroll axis.
618    ///
619    /// `content_box_width` and `content_box_height` are the dimensions of the input's content
620    /// box in CSS (unscaled) pixels.
621    pub fn max_scroll_offset(&self, content_box_width: f32, content_box_height: f32) -> f32 {
622        let Some(layout) = self.editor.try_layout() else {
623            return 0.0;
624        };
625        let scale = layout.scale();
626        let (content, viewport) = if self.is_multiline {
627            (layout.height() / scale, content_box_height)
628        } else {
629            (layout.full_width() / scale, content_box_width)
630        };
631        (content - viewport).max(0.0)
632    }
633
634    /// Scroll the input's text content by `delta` CSS pixels along its scroll axis (horizontal
635    /// for single-line inputs, vertical for multi-line inputs), clamping to the scrollable
636    /// range.
637    ///
638    /// Returns the portion of `delta` that could not be consumed (because the input was already
639    /// scrolled to its limit), so the caller can bubble it up to an ancestor scroller.
640    pub fn scroll_by(
641        &mut self,
642        delta: f32,
643        content_box_width: f32,
644        content_box_height: f32,
645    ) -> f32 {
646        let max_offset = self.max_scroll_offset(content_box_width, content_box_height);
647        if max_offset <= 0.0 {
648            return delta;
649        }
650
651        // Match the sign convention used for block scrolling: a positive delta decreases the
652        // scroll offset.
653        let new_offset = (self.scroll_offset - delta).clamp(0.0, max_offset);
654        let consumed = self.scroll_offset - new_offset;
655        self.scroll_offset = new_offset;
656        delta - consumed
657    }
658
659    pub(crate) fn apply_keypress_event(
660        &mut self,
661        font_ctx: &mut FontContext,
662        layout_ctx: &mut LayoutContext<TextBrush>,
663        shell_provider: &dyn ShellProvider,
664        event: BlitzKeyEvent,
665    ) -> Option<GeneratedTextInputEvent> {
666        // Do nothing if it is a keyup event
667        if !event.state.is_pressed() {
668            return None;
669        }
670
671        // Undo and redo first: they are the one pair that must not be recorded
672        // as edits, and `history_command` is checked before anything mutates.
673        if let Some(command) = history_command(&event) {
674            return self.apply_history_command(font_ctx, layout_ctx, command);
675        }
676
677        // Every path below this point can change the buffer, so the state being
678        // left is recorded here rather than at each of them. A keystroke that
679        // turns out to only move the caret records a snapshot equal to the last
680        // one, which `record` discards.
681        self.record_history();
682
683        let mods = event.modifiers;
684        let shift = mods.contains(Modifiers::SHIFT);
685        let action_mod = mods.contains(ACTION_MOD);
686        let word_mod = mods.contains(Modifiers::ALT);
687        let is_multiline = self.is_multiline;
688        let editor = &mut self.editor;
689        let mut driver = editor.driver(font_ctx, layout_ctx);
690        if let Some(command) = clipboard_command(&event) {
691            match command {
692                ClipboardCommand::Copy => {
693                    if let Some(text) = driver.editor.selected_text() {
694                        let _ = shell_provider.set_clipboard_text(text.to_owned());
695                    }
696                }
697                ClipboardCommand::Cut => {
698                    if let Some(text) = driver.editor.selected_text() {
699                        let _ = shell_provider.set_clipboard_text(text.to_owned());
700                        driver.delete_selection()
701                    }
702                }
703                ClipboardCommand::Paste => {
704                    let text = shell_provider.get_clipboard_text().unwrap_or_default();
705                    driver.insert_or_replace_selection(&text)
706                }
707            }
708
709            return Some(GeneratedTextInputEvent::Input);
710        }
711        match event.key {
712            Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => {
713                if shift {
714                    driver.collapse_selection()
715                } else {
716                    driver.select_all()
717                }
718                return Some(GeneratedTextInputEvent::Select);
719            }
720            Key::ArrowLeft => {
721                if action_mod {
722                    if shift {
723                        driver.select_to_line_start()
724                    } else {
725                        driver.move_to_line_start()
726                    }
727                } else if word_mod {
728                    if shift {
729                        driver.select_word_left()
730                    } else {
731                        driver.move_word_left()
732                    }
733                } else if shift {
734                    driver.select_left()
735                } else {
736                    driver.move_left()
737                }
738                return Some(GeneratedTextInputEvent::Select);
739            }
740            Key::ArrowRight => {
741                if action_mod {
742                    if shift {
743                        driver.select_to_line_end()
744                    } else {
745                        driver.move_to_line_end()
746                    }
747                } else if word_mod {
748                    if shift {
749                        driver.select_word_right()
750                    } else {
751                        driver.move_word_right()
752                    }
753                } else if shift {
754                    driver.select_right()
755                } else {
756                    driver.move_right()
757                }
758                return Some(GeneratedTextInputEvent::Select);
759            }
760            Key::ArrowUp => {
761                if action_mod && shift {
762                    driver.select_to_text_start()
763                } else if action_mod {
764                    driver.move_to_text_start()
765                } else if shift {
766                    driver.select_up()
767                } else {
768                    driver.move_up()
769                }
770                return Some(GeneratedTextInputEvent::Select);
771            }
772            Key::ArrowDown => {
773                if action_mod && shift {
774                    driver.select_to_text_end()
775                } else if action_mod {
776                    driver.move_to_text_end()
777                } else if shift {
778                    driver.select_down()
779                } else {
780                    driver.move_down()
781                }
782                return Some(GeneratedTextInputEvent::Select);
783            }
784            Key::Home => {
785                if action_mod {
786                    if shift {
787                        driver.select_to_text_start()
788                    } else {
789                        driver.move_to_text_start()
790                    }
791                } else if shift {
792                    driver.select_to_line_start()
793                } else {
794                    driver.move_to_line_start()
795                }
796                return Some(GeneratedTextInputEvent::Select);
797            }
798            Key::End => {
799                if action_mod {
800                    if shift {
801                        driver.select_to_text_end()
802                    } else {
803                        driver.move_to_text_end()
804                    }
805                } else if shift {
806                    driver.select_to_line_end()
807                } else {
808                    driver.move_to_line_end()
809                }
810                return Some(GeneratedTextInputEvent::Select);
811            }
812            Key::Delete => {
813                #[cfg(target_os = "macos")]
814                if mods.contains(Modifiers::SUPER) {
815                    if driver.editor.raw_selection().is_collapsed() {
816                        driver.select_to_line_end();
817                    }
818                    driver.delete_selection();
819                } else if mods.contains(Modifiers::ALT) {
820                    driver.delete_word();
821                } else {
822                    driver.delete();
823                }
824                #[cfg(not(target_os = "macos"))]
825                if action_mod {
826                    driver.delete_word();
827                } else {
828                    driver.delete();
829                }
830                return Some(GeneratedTextInputEvent::Input);
831            }
832            Key::Backspace => {
833                #[cfg(target_os = "macos")]
834                if mods.contains(Modifiers::SUPER) {
835                    if driver.editor.raw_selection().is_collapsed() {
836                        driver.select_to_line_start();
837                    }
838                    driver.delete_selection();
839                } else if mods.contains(Modifiers::ALT) {
840                    driver.backdelete_word();
841                } else {
842                    driver.backdelete();
843                }
844                #[cfg(not(target_os = "macos"))]
845                if action_mod {
846                    driver.backdelete_word();
847                } else {
848                    driver.backdelete();
849                }
850                return Some(GeneratedTextInputEvent::Input);
851            }
852
853            Key::Character(c) if c == "\n" => {
854                if is_multiline {
855                    driver.insert_or_replace_selection("\n");
856                    return Some(GeneratedTextInputEvent::Input);
857                } else {
858                    return Some(GeneratedTextInputEvent::Submit);
859                }
860            }
861            Key::Enter => {
862                if is_multiline {
863                    driver.insert_or_replace_selection("\n");
864                    return Some(GeneratedTextInputEvent::Input);
865                } else {
866                    return Some(GeneratedTextInputEvent::Submit);
867                }
868            }
869            Key::Character(s)
870                if !mods.contains(Modifiers::CONTROL) && !mods.contains(Modifiers::SUPER) =>
871            {
872                driver.insert_or_replace_selection(&s);
873                return Some(GeneratedTextInputEvent::Input);
874            }
875            _ => {}
876        };
877
878        None
879    }
880
881    pub(crate) fn apply_apple_standard_keybinding(
882        &mut self,
883        font_ctx: &mut FontContext,
884        layout_ctx: &mut LayoutContext<TextBrush>,
885        shell_provider: &dyn ShellProvider,
886        command: &str,
887    ) -> Option<GeneratedTextInputEvent> {
888        // AppKit routes a large part of macOS text editing here rather than
889        // through `apply_keypress_event` — every delete, transpose and kill —
890        // so an undo stack fed only by keypresses would miss them.
891        self.record_history();
892
893        let editor = &mut self.editor;
894        let mut driver = editor.driver(font_ctx, layout_ctx);
895        let is_multiline = self.is_multiline;
896
897        match command {
898            // Inserting Content
899
900            // Inserts a backtab character.
901            "insertBacktab:" => {}
902            // Inserts a container break, such as a new page break.
903            "insertContainerBreak:" => {}
904            // Inserts a double quotation mark without substituting a curly quotation mark.
905            "insertDoubleQuoteIgnoringSubstitution:" => {
906                driver.insert_or_replace_selection("\"");
907                return Some(GeneratedTextInputEvent::Input);
908            }
909            // Inserts a line break character.
910            "insertLineBreak:" => {
911                driver.insert_or_replace_selection("\n");
912                return Some(GeneratedTextInputEvent::Input);
913            }
914            // Inserts a newline character.
915            "insertNewline:" => {
916                if is_multiline {
917                    driver.insert_or_replace_selection("\n");
918                    return Some(GeneratedTextInputEvent::Input);
919                } else {
920                    return Some(GeneratedTextInputEvent::Submit);
921                }
922            }
923            // Inserts a newline character without invoking the field editor’s normal handling to end editing.
924            "insertNewlineIgnoringFieldEditor:" => {
925                driver.insert_or_replace_selection("\n");
926                return Some(GeneratedTextInputEvent::Input);
927            }
928            // Inserts a paragraph separator.
929            "insertParagraphSeparator:" => {
930                driver.insert_or_replace_selection("\n");
931                return Some(GeneratedTextInputEvent::Input);
932            }
933            "insertSingleQuoteIgnoringSubstitution:" => {
934                driver.insert_or_replace_selection("'");
935                return Some(GeneratedTextInputEvent::Input);
936            }
937            // Inserts a tab character.
938            "insertTab:" | "insertTabIgnoringFieldEditor:" => {
939                // Ignore for now seeing as parley has poor support for laying out tabs
940            }
941            // Inserts the text you specify.
942            "insertText:" => {}
943
944            // Deleting Content
945
946            // Deletes content moving backward from the current insertion point.
947            // Physical Backspace/Delete events are handled directly above. AppKit may
948            // deliver these selectors as well, but applying both would delete twice.
949            "deleteBackward:" | "deleteBackwardByDecomposingPreviousCharacter:" => {}
950            "deleteForward:" => {}
951            // Deletes content from the insertion point to the beginning of the current line.
952            "deleteToBeginningOfLine:" => {
953                if driver.editor.raw_selection().is_collapsed() {
954                    driver.select_to_line_start();
955                }
956                driver.delete_selection();
957                return Some(GeneratedTextInputEvent::Input);
958            }
959            // Deletes content from the insertion point to the beginning of the current paragraph.
960            "deleteToEndOfLine:" => {
961                if driver.editor.raw_selection().is_collapsed() {
962                    driver.select_to_line_end();
963                }
964                driver.delete_selection();
965                return Some(GeneratedTextInputEvent::Input);
966            }
967            "deleteToBeginningOfParagraph:" => {
968                if driver.editor.raw_selection().is_collapsed() {
969                    driver.select_to_hard_line_start();
970                }
971                driver.delete_selection();
972                return Some(GeneratedTextInputEvent::Input);
973            }
974
975            // Deletes content from the insertion point to the end of the current line.
976            "deleteToEndOfParagraph:" => {
977                if driver.editor.raw_selection().is_collapsed() {
978                    driver.select_to_hard_line_end();
979                }
980                driver.delete_selection();
981                return Some(GeneratedTextInputEvent::Input);
982            }
983            // Deletes content from the insertion point to the end of the current paragraph.
984            "deleteWordBackward:" => {}
985            // Deletes the word preceding the current insertion point.
986            "deleteWordForward:" => {}
987            // Deletes the current selection, placing it in a temporary buffer, such as the Clipboard.
988            "yank:" => {
989                if let Some(text) = driver.editor.selected_text() {
990                    let _ = shell_provider.set_clipboard_text(text.to_owned());
991                    driver.delete_selection();
992                    return Some(GeneratedTextInputEvent::Input);
993                }
994            }
995
996            // Moving the Insertion Pointer
997
998            // Moves the insertion pointer backward in the current content.
999            "moveBackward:" => {
1000                driver.move_left(); // TODO: Bidi-aware
1001                return Some(GeneratedTextInputEvent::Select);
1002            }
1003
1004            // Moves the insertion pointer down in the current content.
1005            "moveDown:" => {
1006                driver.move_down();
1007                return Some(GeneratedTextInputEvent::Select);
1008            }
1009            // Moves the insertion pointer forward in the current content.
1010            "moveForward:" => {
1011                driver.move_right();
1012                return Some(GeneratedTextInputEvent::Select);
1013            } // TODO: Bidi-aware
1014
1015            // Moves the insertion pointer left in the current content.
1016            "moveLeft:" => {
1017                driver.move_left();
1018                return Some(GeneratedTextInputEvent::Select);
1019            }
1020            // Moves the insertion pointer right in the current content.
1021            "moveRight:" => {
1022                driver.move_right();
1023                return Some(GeneratedTextInputEvent::Select);
1024            }
1025            // Moves the insertion pointer up in the current content.
1026            "moveUp:" => {
1027                driver.move_up();
1028                return Some(GeneratedTextInputEvent::Select);
1029            }
1030
1031            // Modifying the Selection
1032
1033            // Extends the selection to include the content before the current selection.
1034            "moveBackwardAndModifySelection:" => {
1035                driver.select_left(); // TODO: Bidi-aware
1036                return Some(GeneratedTextInputEvent::Select);
1037            }
1038            // Extends the selection to include the content below the current selection.
1039            "moveDownAndModifySelection:" => {
1040                driver.select_down();
1041                return Some(GeneratedTextInputEvent::Select);
1042            }
1043            // Extends the selection to include the content after the current selection.
1044            "moveForwardAndModifySelection:" => {
1045                driver.select_right(); // TODO: Bidi-aware
1046                return Some(GeneratedTextInputEvent::Select);
1047            }
1048            // Extends the selection to include the content to the left of the current selection.
1049            "moveLeftAndModifySelection:" => {
1050                driver.select_left();
1051                return Some(GeneratedTextInputEvent::Select);
1052            }
1053            // Extends the selection to include the content to the right of the current selection.
1054            "moveRightAndModifySelection:" => {
1055                driver.select_right();
1056                return Some(GeneratedTextInputEvent::Select);
1057            }
1058            // Extends the selection to include the content above the current selection.
1059            "moveUpAndModifySelection:" => {
1060                driver.select_up();
1061                return Some(GeneratedTextInputEvent::Select);
1062            }
1063
1064            // Changing the Selection
1065            "selectAll:" => {
1066                driver.select_all();
1067                return Some(GeneratedTextInputEvent::Select);
1068            }
1069            "selectLine:" => {
1070                driver.move_to_line_start();
1071                driver.select_to_line_end();
1072                return Some(GeneratedTextInputEvent::Select);
1073            }
1074            "selectParagraph:" => {
1075                driver.move_to_hard_line_start();
1076                driver.select_to_hard_line_end();
1077                return Some(GeneratedTextInputEvent::Select);
1078            }
1079            "selectWord:" => {
1080                // TODO
1081            }
1082
1083            // Moving the Selection in Documents
1084            "moveToBeginningOfDocument:" => {
1085                driver.move_to_text_start();
1086                return Some(GeneratedTextInputEvent::Select);
1087            }
1088            "moveToBeginningOfDocumentAndModifySelection:" => {
1089                driver.select_to_text_start();
1090                return Some(GeneratedTextInputEvent::Select);
1091            }
1092            "moveToEndOfDocument:" => {
1093                driver.move_to_text_end();
1094                return Some(GeneratedTextInputEvent::Select);
1095            }
1096            "moveToEndOfDocumentAndModifySelection:" => {
1097                driver.move_to_text_end();
1098                return Some(GeneratedTextInputEvent::Select);
1099            }
1100
1101            // Moving the Selection in Paragraphs
1102            "moveParagraphBackwardAndModifySelection:" => {}
1103            "moveParagraphForwardAndModifySelection:" => {}
1104            "moveToBeginningOfParagraph:" => {
1105                driver.move_to_hard_line_start();
1106                return Some(GeneratedTextInputEvent::Select);
1107            }
1108            "moveToBeginningOfParagraphAndModifySelection:" => {
1109                driver.select_to_hard_line_start();
1110                return Some(GeneratedTextInputEvent::Select);
1111            }
1112            "moveToEndOfParagraph:" => {
1113                driver.move_to_hard_line_end();
1114                return Some(GeneratedTextInputEvent::Select);
1115            }
1116            "moveToEndOfParagraphAndModifySelection:" => {
1117                driver.select_to_hard_line_end();
1118                return Some(GeneratedTextInputEvent::Select);
1119            }
1120
1121            // Moving the Selection in Lines of Text
1122            "moveToBeginningOfLine:" => {
1123                driver.move_to_line_start();
1124                return Some(GeneratedTextInputEvent::Select);
1125            }
1126            "moveToBeginningOfLineAndModifySelection:" => {
1127                driver.select_to_line_start();
1128                return Some(GeneratedTextInputEvent::Select);
1129            }
1130            "moveToEndOfLine:" => {
1131                driver.move_to_line_end();
1132                return Some(GeneratedTextInputEvent::Select);
1133            }
1134            "moveToEndOfLineAndModifySelection:" => {
1135                driver.select_to_line_end();
1136                return Some(GeneratedTextInputEvent::Select);
1137            }
1138            "moveToLeftEndOfLine:" => {
1139                driver.move_to_text_start();
1140                return Some(GeneratedTextInputEvent::Select);
1141            }
1142            "moveToLeftEndOfLineAndModifySelection:" => {
1143                driver.select_to_line_start();
1144                return Some(GeneratedTextInputEvent::Select);
1145            }
1146            "moveToRightEndOfLine:" => {
1147                driver.move_to_line_end();
1148                return Some(GeneratedTextInputEvent::Select);
1149            }
1150            "moveToRightEndOfLineAndModifySelection:" => {
1151                driver.select_to_line_end();
1152                return Some(GeneratedTextInputEvent::Select);
1153            }
1154
1155            // Moving the Selection by Word Boundaries
1156            "moveWordBackward:" => {
1157                driver.move_word_left();
1158                return Some(GeneratedTextInputEvent::Select);
1159            }
1160            "moveWordBackwardAndModifySelection:" => {
1161                driver.select_word_left();
1162                return Some(GeneratedTextInputEvent::Select);
1163            }
1164            "moveWordForward:" => {
1165                driver.move_word_right();
1166                return Some(GeneratedTextInputEvent::Select);
1167            }
1168            "moveWordForwardAndModifySelection:" => {
1169                driver.select_word_right();
1170                return Some(GeneratedTextInputEvent::Select);
1171            }
1172            "moveWordLeft:" => {
1173                driver.move_word_left();
1174                return Some(GeneratedTextInputEvent::Select);
1175            }
1176            "moveWordLeftAndModifySelection:" => {
1177                driver.select_word_left();
1178                return Some(GeneratedTextInputEvent::Select);
1179            }
1180            "moveWordRight:" => {
1181                driver.move_word_right();
1182                return Some(GeneratedTextInputEvent::Select);
1183            }
1184            "moveWordRightAndModifySelection:" => {
1185                driver.select_word_right();
1186                return Some(GeneratedTextInputEvent::Select);
1187            }
1188
1189            // Scrolling Content
1190
1191            // Scrolls the content down by a page.
1192            "scrollPageDown:" => {}
1193            // Scrolls the content up by a page.
1194            "scrollPageUp:" => {}
1195            // Scrolls the content down by a line.
1196            "scrollLineDown:" => {}
1197            // Scrolls the content up by a line.
1198            "scrollLineUp:" => {}
1199            // Scrolls the content to the beginning of the document.
1200            "scrollToBeginningOfDocument:" => {}
1201            // Scrolls the content to the end of the document.
1202            "scrollToEndOfDocument:" => {}
1203            // Moves the visible content region down by a page.
1204            "pageDown:" => {}
1205            // Moves the visible content region up by a page.
1206            "pageUp:" => {}
1207            // Moves the visible content region down by a page, and extends the current selection.
1208            "pageDownAndModifySelection:" => {}
1209            // Moves the visible content region up by a page, and extends the current selection.
1210            "pageUpAndModifySelection:" => {}
1211            // Moves the visible content region so the current selection is visually centered.
1212            "centerSelectionInVisibleArea:" => {}
1213
1214            // Transposing Elements
1215
1216            // Transposes the content around the current selection.
1217            "transpose:" => {}
1218            // Transposes the words around the current selection.
1219            "transposeWords:" => {}
1220
1221            // Indenting Content
1222            // Indents the content at the current selection.
1223            "indent:" => {}
1224
1225            // Canceling Operations
1226            // Cancels the current operation.
1227            "cancelOperation:" => {}
1228
1229            // Supporting QuickLook
1230            // Invokes QuickLook to preview the current selection.
1231            "quickLookPreviewItems:" => {}
1232
1233            // Supporting Writing Directions
1234            "makeBaseWritingDirectionLeftToRight:" => {}
1235            "makeBaseWritingDirectionNatural:" => {}
1236            "makeBaseWritingDirectionRightToLeft:" => {}
1237            "makeTextWritingDirectionLeftToRight:" => {}
1238            "makeTextWritingDirectionNatural:" => {}
1239            "makeTextWritingDirectionRightToLeft:" => {}
1240
1241            // Changing Capitalization
1242            "capitalizeWord:" => {}
1243            "changeCaseOfLetter:" => {}
1244            "lowercaseWord:" => {}
1245            "uppercaseWord:" => {}
1246
1247            // Supporting Marked Selections
1248            "setMark:" => {}
1249            "selectToMark:" => {}
1250            "deleteToMark:" => {}
1251            "swapWithMark:" => {}
1252
1253            // Supporting Autocomplete
1254            "complete:" => {}
1255
1256            // Instance Methods
1257            "showContextMenuForSelection:" => {}
1258
1259            // Unknown command
1260            _ => {}
1261        };
1262
1263        None
1264    }
1265
1266    pub(crate) fn apply_ime_event(
1267        &mut self,
1268        font_ctx: &mut FontContext,
1269        layout_ctx: &mut LayoutContext<TextBrush>,
1270        event: BlitzImeEvent,
1271    ) -> Option<GeneratedTextInputEvent> {
1272        // Only a commit, deliberately.
1273        //
1274        // A composition session emits a preedit per keystroke, and recording
1275        // those would fill the stack with half-composed text: undoing after
1276        // typing a Japanese word would walk back through its romaji rather than
1277        // removing the word. The commit is the edit the user made, so it is the
1278        // only point that becomes undoable.
1279        if matches!(event, BlitzImeEvent::Commit(_)) {
1280            self.record_history();
1281        }
1282
1283        let editor = &mut self.editor;
1284        let mut driver = editor.driver(font_ctx, layout_ctx);
1285
1286        match event {
1287            BlitzImeEvent::Enabled => {
1288                // Do nothing
1289                None
1290            }
1291            BlitzImeEvent::Disabled => {
1292                driver.clear_compose();
1293                Some(GeneratedTextInputEvent::PreEditChange)
1294            }
1295            BlitzImeEvent::Commit(text) => {
1296                driver.insert_or_replace_selection(&text);
1297                Some(GeneratedTextInputEvent::Input)
1298            }
1299            BlitzImeEvent::Preedit(text, cursor) => {
1300                if text.is_empty() {
1301                    driver.clear_compose();
1302                } else {
1303                    driver.set_compose(&text, cursor);
1304                }
1305                Some(GeneratedTextInputEvent::PreEditChange)
1306            }
1307            BlitzImeEvent::DeleteSurrounding {
1308                before_bytes,
1309                after_bytes,
1310            } => {
1311                let _ = before_bytes;
1312                let _ = after_bytes;
1313                // TODO
1314                None
1315            }
1316        }
1317    }
1318}
1319
1320#[cfg(test)]
1321mod content_widths_cache_tests {
1322    use super::*;
1323    use parley::{InlineBox, InlineBoxKind, TextStyle};
1324
1325    /// Build a [`TextLayout`] containing `text`, optionally followed by an inline box of
1326    /// `inline_box_width` pixels.
1327    fn build_layout(text: &str, inline_box_width: Option<f32>) -> TextLayout {
1328        let mut font_ctx = FontContext::default();
1329        let mut layout_ctx = LayoutContext::new();
1330        let style: TextStyle<'_, '_, TextBrush> = TextStyle::default();
1331        let mut builder = layout_ctx.tree_builder(&mut font_ctx, 1.0, true, &style);
1332        builder.push_text(text);
1333        if let Some(width) = inline_box_width {
1334            builder.push_inline_box(InlineBox {
1335                id: 0,
1336                kind: InlineBoxKind::InFlow,
1337                index: text.len(),
1338                width,
1339                height: 10.0,
1340            });
1341        }
1342
1343        let mut text_layout = TextLayout::new();
1344        text_layout.text = builder.build_into(&mut text_layout.layout);
1345        text_layout
1346    }
1347
1348    /// macOS only: it compares a cached content width against an uncached one,
1349    /// and both are zero with no font registered, so the comparison holds
1350    /// without the cache having done anything. See the target-scoped `parley`
1351    /// dev-dependency in this crate's manifest.
1352    #[cfg(target_os = "macos")]
1353    #[test]
1354    fn first_call_matches_an_uncached_computation() {
1355        let mut text_layout = build_layout("the quick brown fox", None);
1356        let expected = text_layout.layout.calculate_content_widths();
1357
1358        let cached = text_layout.content_widths();
1359
1360        assert_eq!(cached.min, expected.min);
1361        assert_eq!(cached.max, expected.max);
1362        assert!(cached.min > 0.0);
1363        assert!(cached.max > cached.min);
1364    }
1365
1366    #[test]
1367    fn text_only_layout_reuses_the_cached_widths() {
1368        let mut text_layout = build_layout("the quick brown fox", None);
1369        text_layout.content_widths();
1370
1371        // Poison the stored result. A second call that recomputed would overwrite this with
1372        // the real widths, so seeing the poisoned value back proves the cache was hit.
1373        let poison = ContentWidths {
1374            min: -1.0,
1375            max: -2.0,
1376        };
1377        text_layout.content_widths.as_mut().unwrap().widths = poison;
1378
1379        let second = text_layout.content_widths();
1380        assert_eq!(second.min, poison.min);
1381        assert_eq!(second.max, poison.max);
1382    }
1383
1384    #[test]
1385    fn a_changed_inline_box_width_forces_a_recompute() {
1386        let mut text_layout = build_layout("the quick brown fox", Some(40.0));
1387        let first = text_layout.content_widths();
1388
1389        // Same poison as above, so a stale hit would be visible.
1390        text_layout.content_widths.as_mut().unwrap().widths = ContentWidths {
1391            min: -1.0,
1392            max: -2.0,
1393        };
1394
1395        // Re-measuring the inline box under a different constraint is exactly what block
1396        // layout does between a min-content and a max-content pass.
1397        text_layout.layout.inline_boxes_mut()[0].width = 400.0;
1398
1399        let second = text_layout.content_widths();
1400        assert!(second.min > 0.0);
1401        assert!(second.max > first.max);
1402        assert_eq!(second.min, 400.0);
1403    }
1404
1405    #[test]
1406    fn an_unchanged_inline_box_width_still_hits_the_cache() {
1407        let mut text_layout = build_layout("the quick brown fox", Some(40.0));
1408        text_layout.content_widths();
1409
1410        let poison = ContentWidths {
1411            min: -1.0,
1412            max: -2.0,
1413        };
1414        text_layout.content_widths.as_mut().unwrap().widths = poison;
1415        // Write the identical width back; the key is unchanged so this must not recompute.
1416        text_layout.layout.inline_boxes_mut()[0].width = 40.0;
1417
1418        let second = text_layout.content_widths();
1419        assert_eq!(second.min, poison.min);
1420        assert_eq!(second.max, poison.max);
1421    }
1422
1423    #[test]
1424    fn rebuilding_the_layout_discards_the_cache() {
1425        let mut text_layout = build_layout("the quick brown fox", None);
1426        text_layout.content_widths();
1427        assert!(text_layout.content_widths.is_some());
1428
1429        // Stand in for `build_inline_layout_into`, which clears the cache before re-shaping.
1430        text_layout.content_widths = None;
1431        let rebuilt = build_layout("a much much much longer run of text", None);
1432        text_layout.layout = rebuilt.layout;
1433        text_layout.text = rebuilt.text;
1434
1435        let widths = text_layout.content_widths();
1436        let expected = text_layout.layout.calculate_content_widths();
1437        assert_eq!(widths.max, expected.max);
1438    }
1439}
1440
1441#[cfg(test)]
1442mod shortcut_tests {
1443    use super::*;
1444    use blitz_traits::events::{BlitzKeyEvent, KeyState};
1445    use blitz_traits::shell::DummyShellProvider;
1446    use keyboard_types::Location;
1447
1448    fn control_event(key: Key, code: Code) -> BlitzKeyEvent {
1449        BlitzKeyEvent {
1450            key,
1451            code,
1452            modifiers: Modifiers::CONTROL,
1453            location: Location::Standard,
1454            is_auto_repeating: false,
1455            is_composing: false,
1456            state: KeyState::Pressed,
1457            text: None,
1458        }
1459    }
1460
1461    #[test]
1462    fn control_character_cut_uses_the_physical_key_code() {
1463        let event = control_event(Key::Character("\u{18}".into()), Code::KeyX);
1464        assert_eq!(clipboard_command(&event), Some(ClipboardCommand::Cut));
1465    }
1466
1467    /// macOS only: it drives a real edit and reads the text back, which needs
1468    /// the editor to have shaped something to delete from. See the
1469    /// target-scoped `parley` dev-dependency in this crate's manifest.
1470    #[cfg(target_os = "macos")]
1471    #[test]
1472    fn backspace_does_not_depend_on_an_apple_standard_keybinding() {
1473        let mut data = TextInputData::new(false);
1474        let mut font_ctx = FontContext::default();
1475        let mut layout_ctx = LayoutContext::new();
1476        data.set_text(&mut font_ctx, &mut layout_ctx, "typo");
1477        data.editor
1478            .driver(&mut font_ctx, &mut layout_ctx)
1479            .move_to_text_end();
1480        let event = BlitzKeyEvent {
1481            key: Key::Backspace,
1482            code: Code::Backspace,
1483            modifiers: Modifiers::empty(),
1484            location: Location::Standard,
1485            is_auto_repeating: false,
1486            is_composing: false,
1487            state: KeyState::Pressed,
1488            text: None,
1489        };
1490
1491        assert!(matches!(
1492            data.apply_keypress_event(&mut font_ctx, &mut layout_ctx, &DummyShellProvider, event,),
1493            Some(GeneratedTextInputEvent::Input)
1494        ));
1495        assert_eq!(data.editor.raw_text(), "typ");
1496    }
1497}
1498
1499/// Undo and redo, driven through the same entry point a keystroke takes.
1500///
1501/// Asserted end to end rather than against [`TextEditHistory`] directly: the
1502/// part that was missing was not a stack, it was a stack wired to the editor,
1503/// and a unit test of the stack alone would pass with nothing connected.
1504#[cfg(test)]
1505mod history_tests {
1506    use super::*;
1507    use blitz_traits::events::{BlitzKeyEvent, KeyState};
1508    use blitz_traits::shell::DummyShellProvider;
1509    use keyboard_types::Location;
1510
1511    struct Input {
1512        data: TextInputData,
1513        font_ctx: FontContext,
1514        layout_ctx: LayoutContext<TextBrush>,
1515    }
1516
1517    impl Input {
1518        fn new() -> Self {
1519            Self {
1520                data: TextInputData::new(true),
1521                font_ctx: FontContext::default(),
1522                layout_ctx: LayoutContext::new(),
1523            }
1524        }
1525
1526        fn press(&mut self, key: Key, code: Code, modifiers: Modifiers) {
1527            let event = BlitzKeyEvent {
1528                key,
1529                code,
1530                modifiers,
1531                location: Location::Standard,
1532                is_auto_repeating: false,
1533                is_composing: false,
1534                state: KeyState::Pressed,
1535                text: None,
1536            };
1537            self.data.apply_keypress_event(
1538                &mut self.font_ctx,
1539                &mut self.layout_ctx,
1540                &DummyShellProvider,
1541                event,
1542            );
1543        }
1544
1545        /// Type `text` one character at a time, as a keyboard would.
1546        fn type_text(&mut self, text: &str) {
1547            for ch in text.chars() {
1548                self.press(
1549                    Key::Character(ch.to_string()),
1550                    Code::Unidentified,
1551                    Modifiers::empty(),
1552                );
1553            }
1554        }
1555
1556        fn undo(&mut self) {
1557            self.press(Key::Character("z".into()), Code::KeyZ, Modifiers::CONTROL);
1558        }
1559
1560        fn redo(&mut self) {
1561            self.press(
1562                Key::Character("z".into()),
1563                Code::KeyZ,
1564                Modifiers::CONTROL | Modifiers::SHIFT,
1565            );
1566        }
1567
1568        fn text(&self) -> &str {
1569            self.data.editor.raw_text()
1570        }
1571    }
1572
1573    /// The bug itself: Cmd+Z reached no handler, so it did nothing.
1574    #[test]
1575    fn undo_restores_the_text_from_before_the_edit() {
1576        let mut input = Input::new();
1577        input.type_text("first");
1578        input.type_text(" second");
1579
1580        input.undo();
1581
1582        // "first " and not "first": the space closed the run, so the state the
1583        // next run began from is the one with the separator already typed. That
1584        // is where Chrome and Firefox land too — the boundary belongs to the
1585        // text that preceded it, not to the word being started.
1586        assert_eq!(
1587            input.text(),
1588            "first ",
1589            "undo should remove the most recent word",
1590        );
1591    }
1592
1593    #[test]
1594    fn redo_reapplies_what_undo_removed() {
1595        let mut input = Input::new();
1596        input.type_text("first");
1597        input.type_text(" second");
1598        let full = input.text().to_string();
1599
1600        input.undo();
1601        input.redo();
1602
1603        assert_eq!(input.text(), full, "redo should restore the undone text");
1604    }
1605
1606    /// One undo removes a word, not a keystroke.
1607    ///
1608    /// An undo per character is faithful to what happened and unusable, so a
1609    /// run of typing coalesces and the whitespace closes it.
1610    #[test]
1611    fn a_run_of_typing_undoes_as_one_word_rather_than_per_character() {
1612        let mut input = Input::new();
1613        input.type_text("hello world");
1614
1615        input.undo();
1616
1617        assert_eq!(
1618            input.text(),
1619            "hello ",
1620            "the burst should end at the space, not at the previous character",
1621        );
1622    }
1623
1624    /// Undo has to be reachable more than once.
1625    #[test]
1626    fn repeated_undo_walks_back_through_the_history() {
1627        let mut input = Input::new();
1628        input.type_text("one two three");
1629
1630        input.undo();
1631        assert_eq!(input.text(), "one two ");
1632        input.undo();
1633        assert_eq!(input.text(), "one ");
1634        input.undo();
1635        assert_eq!(input.text(), "");
1636    }
1637
1638    /// Undo on an untouched input must not panic or invent a state.
1639    #[test]
1640    fn undo_with_nothing_to_undo_leaves_the_text_alone() {
1641        let mut input = Input::new();
1642        input.type_text("only");
1643
1644        input.undo();
1645        input.undo();
1646        input.undo();
1647
1648        assert_eq!(input.text(), "");
1649    }
1650
1651    /// Typing after an undo drops the redo branch, as every editor does.
1652    #[test]
1653    fn a_fresh_edit_after_an_undo_clears_the_redo_stack() {
1654        let mut input = Input::new();
1655        input.type_text("first");
1656        input.type_text(" second");
1657
1658        input.undo();
1659        assert_eq!(input.text(), "first ");
1660        input.type_text("third");
1661        input.redo();
1662
1663        assert_eq!(
1664            input.text(),
1665            "first third",
1666            "redo must not resurrect a branch that was typed over",
1667        );
1668    }
1669
1670    /// Ctrl+Y is the Windows redo and is accepted on every platform.
1671    #[test]
1672    fn control_y_also_redoes() {
1673        let mut input = Input::new();
1674        input.type_text("first");
1675        input.type_text(" second");
1676        let full = input.text().to_string();
1677
1678        input.undo();
1679        input.press(Key::Character("y".into()), Code::KeyY, Modifiers::CONTROL);
1680
1681        assert_eq!(input.text(), full);
1682    }
1683
1684    /// The chord must not reach the buffer as text.
1685    ///
1686    /// `history_command` returns before any mutation, so undo cannot also
1687    /// insert a `z` — which is what an unhandled chord would have done.
1688    #[test]
1689    fn the_undo_chord_does_not_type_its_own_character() {
1690        let mut input = Input::new();
1691        input.type_text("text");
1692
1693        input.undo();
1694        input.redo();
1695
1696        assert!(
1697            !input.text().contains('z'),
1698            "the undo chord leaked into the buffer: {:?}",
1699            input.text(),
1700        );
1701    }
1702
1703    /// Undo restores the caret, not just the string.
1704    #[test]
1705    fn undo_restores_the_selection_along_with_the_text() {
1706        let mut input = Input::new();
1707        input.type_text("alpha");
1708        input.type_text(" beta");
1709
1710        input.undo();
1711
1712        let selection = input.data.editor.raw_selection();
1713        assert_eq!(
1714            selection.focus().index(),
1715            input.text().len(),
1716            "the caret should return to the end of the restored text",
1717        );
1718    }
1719
1720    /// The stack is bounded, so a long-lived input cannot grow without limit.
1721    #[test]
1722    fn the_history_is_capped_at_the_maximum_depth() {
1723        let mut history = TextEditHistory::default();
1724        for i in 0..(MAX_UNDO_DEPTH + 50) {
1725            history.record(TextEditSnapshot {
1726                text: format!("state {i}"),
1727                anchor: 0,
1728                focus: 0,
1729            });
1730        }
1731
1732        assert!(
1733            history.undo.len() <= MAX_UNDO_DEPTH,
1734            "history grew to {} entries, past the {MAX_UNDO_DEPTH} cap",
1735            history.undo.len(),
1736        );
1737    }
1738}
1739
1740/// Undo and redo under either action modifier.
1741///
1742/// macOS users can rebind the standard editing commands system-wide through
1743/// `NSUserKeyEquivalents`, and a machine that maps Copy to Ctrl+C rather than
1744/// Cmd+C is not exotic. Both modifiers are accepted for the same reason the
1745/// clipboard accepts both: dropping one means the chord silently does nothing.
1746#[cfg(test)]
1747mod history_chord_tests {
1748    use super::*;
1749    use blitz_traits::events::{BlitzKeyEvent, KeyState};
1750    use keyboard_types::Location;
1751
1752    fn event(key: Key, code: Code, modifiers: Modifiers) -> BlitzKeyEvent {
1753        BlitzKeyEvent {
1754            key,
1755            code,
1756            modifiers,
1757            location: Location::Standard,
1758            is_auto_repeating: false,
1759            is_composing: false,
1760            state: KeyState::Pressed,
1761            text: None,
1762        }
1763    }
1764
1765    #[test]
1766    fn undo_is_recognised_under_control_and_under_the_platform_modifier() {
1767        for modifiers in [Modifiers::CONTROL, ACTION_MOD] {
1768            assert_eq!(
1769                history_command(&event(Key::Character("z".into()), Code::KeyZ, modifiers)),
1770                Some(HistoryCommand::Undo),
1771            );
1772        }
1773    }
1774
1775    #[test]
1776    fn shift_z_redoes_under_either_modifier() {
1777        for modifiers in [Modifiers::CONTROL, ACTION_MOD] {
1778            assert_eq!(
1779                history_command(&event(
1780                    Key::Character("z".into()),
1781                    Code::KeyZ,
1782                    modifiers | Modifiers::SHIFT,
1783                )),
1784                Some(HistoryCommand::Redo),
1785            );
1786        }
1787    }
1788
1789    /// A remapped layout still undoes, because the physical key is checked.
1790    #[test]
1791    fn a_remapped_character_still_undoes_by_its_physical_key() {
1792        assert_eq!(
1793            history_command(&event(
1794                Key::Character("w".into()),
1795                Code::KeyZ,
1796                Modifiers::CONTROL,
1797            )),
1798            Some(HistoryCommand::Undo),
1799        );
1800    }
1801
1802    #[test]
1803    fn the_chord_needs_a_modifier() {
1804        assert_eq!(
1805            history_command(&event(
1806                Key::Character("z".into()),
1807                Code::KeyZ,
1808                Modifiers::empty(),
1809            )),
1810            None,
1811        );
1812    }
1813}