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