Skip to main content

pdfrum_form/edit/
ops.rs

1//! The editing operations, and the postlude they share.
2//!
3//! **Text is the source of truth; the layout is derived.** Every mutation
4//! edits a `String` and re-lays it out wholesale, so the two cannot disagree.
5//!
6//! A [`Place`] is meaningless without the layout it indexes, so every
7//! operation converts to a **flat character index** first, edits there and
8//! converts back — which is why undo can replay against a layout that
9//! intervening edits have reshaped.
10//!
11//! Every mutation ends the same way: re-lay out, collapse the selection onto
12//! the caret, re-seed the sticky column — except a vertical move.
13//!
14//! A control needs a [`vt::Config`] for the field's shape and a
15//! [`Metrics`] for its face; neither needs a PDF or a font file.
16//!
17//! ```
18//! use pdfrum_doc::vt::{Config, Metrics};
19//! use pdfrum_form::edit::ops::{self, TextEdit};
20//!
21//! // A fixed-width face: one unit per character.
22//! let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
23//! let config = Config {
24//!     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
25//!     font_size: 1.0,
26//!     ..Config::default()
27//! };
28//!
29//! let mut edit = TextEdit::new("Hello", &config, &metrics, true);
30//! // The caret starts at the line header, so type at the end deliberately.
31//! edit.set_caret_index(edit.len_chars());
32//! ops::insert_char(&mut edit, &config, &metrics, '!', None);
33//! assert_eq!(edit.text, "Hello!");
34//!
35//! ops::undo(&mut edit, &config, &metrics);
36//! assert_eq!(edit.text, "Hello");
37//! ```
38
39use pdfrum_doc::ap::field_body::Highlight;
40use pdfrum_doc::vt::{self, Layout, Metrics};
41
42use super::place::{Place, Range};
43use super::select::Selection;
44use super::undo::{UndoItem, UndoStack};
45
46/// A text field's editing state.
47///
48/// The invariant is one sentence: `caret` and both ends of `selection` are
49/// places in `layout`, and `layout` is what laying `text` out again would
50/// produce.
51///
52/// ```
53/// # use pdfrum_doc::vt::{Config, Metrics};
54/// # use pdfrum_form::edit::ops::{self, TextEdit};
55/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
56/// # let config = Config {
57/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
58/// #     font_size: 1.0,
59/// #     ..Config::default()
60/// # };
61///
62/// let mut edit = TextEdit::new("Hello", &config, &metrics, true);
63/// assert_eq!(edit.text, "Hello");
64/// // A fresh control's caret sits at the line header, before the text.
65/// assert_eq!(edit.caret_index(), 0);
66/// assert!(!edit.has_selection());
67///
68/// edit.set_selection(0, 4);
69/// assert_eq!(edit.selected_text(), "Hell");
70/// ```
71#[derive(Debug, Clone, PartialEq)]
72pub struct TextEdit {
73    /// The text as the user has it, which may differ from the field's stored
74    /// value until the edit commits.
75    pub text: String,
76    /// The laid-out form of that text.
77    pub layout: Layout,
78    /// Where the caret is.
79    pub caret: Place,
80    /// The caret's position before the last movement, which a shift-move
81    /// anchors on when no anchor exists yet.
82    pub previous_caret: Place,
83    /// The selection: directional, empty when its ends agree.
84    pub selection: Selection,
85    /// The column a vertical move aims for, in layout space.
86    ///
87    /// Re-seeded by every horizontal move and every mutation, and
88    /// deliberately **not** by a vertical one — which is what lets a run of
89    /// up-arrows through a ragged paragraph keep returning to the column the
90    /// caret started in.
91    pub sticky_x: f32,
92    /// How far the view is scrolled, in layout space.
93    ///
94    /// A **distance**, not a position: `LiveState::shift` negates it to move
95    /// the drawn text. Upstream's `scroll_pos_point_` is the same quantity
96    /// seeded at `rcPlate.left`, so ours is upstream's minus that — see
97    /// [`scroll_to_caret`].
98    pub scroll: (f32, f32),
99    /// Whether the view follows the caret out of the plate.
100    ///
101    /// True for any text field **without** the `DoNotScroll` flag —
102    /// single-line and multi-line alike. It gates every writer of the scroll
103    /// position, so a field that declines it never moves its view at all,
104    /// however far past the plate the caret goes.
105    pub auto_scroll: bool,
106    /// The vertical alignment offset the text is *drawn* with.
107    ///
108    /// A single-line field is drawn vertically centred in its plate, so its
109    /// glyphs sit below where the layout placed them. Every geometric query —
110    /// turning a click into a place, a place into a caret rectangle — has to
111    /// be told about that shift or it works against the wrong box: a click at
112    /// a real field's mid-height falls *below* the content and clamps to the
113    /// end of the text, silently, putting the caret at the end of the field
114    /// instead of where it was clicked.
115    ///
116    /// It is recomputed on every relayout because it depends on the content's
117    /// height, which an edit changes.
118    pub offset: (f32, f32),
119    /// Whether the field draws its text vertically centred.
120    pub centred: bool,
121    /// The undo stack.
122    pub undo: UndoStack,
123}
124
125/// The text as the layout will hold it, given whether the field is
126/// multiline.
127///
128/// A **single-line** field has no way to represent a line break, so every one
129/// is **removed** — not replaced with a space, and not treated as a
130/// terminator that truncates the rest. `"Foo\nBar"` is `"FooBar"`, and
131/// `"Foo\n"` is `"Foo"`. A `\r\n` or `\n\r` pair is one break rather than
132/// two, which is why the pairs are consumed together.
133///
134/// A multiline field keeps its breaks, normalized to `'\n'` so that the four
135/// spellings of one break compare equal.
136///
137/// This is not a convenience: the layout engine already applies exactly this
138/// rule when it lays the text out, so a control that stored the raw string
139/// would disagree with its own layout about how many characters it has.
140///
141/// ```
142/// use pdfrum_form::edit::ops::normalize_breaks;
143///
144/// // A single-line field REMOVES the break; it does not become a space and
145/// // it does not terminate the text.
146/// assert_eq!(normalize_breaks("Foo\nBar", false), "FooBar");
147/// assert_eq!(normalize_breaks("Foo\n", false), "Foo");
148///
149/// // A multiline field keeps it, normalized to `'\n'`, and a `\r\n` pair is
150/// // one break rather than two.
151/// assert_eq!(normalize_breaks("Foo\r\nBar", true), "Foo\nBar");
152///
153/// // A tab is set as a space either way, which is what the layout does.
154/// assert_eq!(normalize_breaks("a\tb", false), "a b");
155/// ```
156#[must_use]
157pub fn normalize_breaks(text: &str, multi_line: bool) -> String {
158    let chars: Vec<char> = text.chars().collect();
159    let mut out = String::with_capacity(text.len());
160    let mut index = 0;
161    while index < chars.len() {
162        let ch = chars.get(index).copied().unwrap_or('\0');
163        match ch {
164            '\r' | '\n' => {
165                // The partner of a pair is consumed with it, so a `\r\n` is
166                // one break and not two empty lines.
167                let partner = if ch == '\r' { '\n' } else { '\r' };
168                if chars.get(index + 1) == Some(&partner) {
169                    index += 1;
170                }
171                if multi_line {
172                    out.push('\n');
173                }
174            }
175            // A tab is set as a space, which the layout also does.
176            '\t' => out.push(' '),
177            _ => out.push(ch),
178        }
179        index += 1;
180    }
181    out
182}
183
184/// The offset a field's text is drawn with.
185///
186/// Top alignment shifts nothing; centred alignment shifts by half the slack
187/// between the content and the plate. The same rule the appearance path uses,
188/// because the two must agree — a caret computed against a different offset
189/// from the one the glyphs were drawn with lands in the wrong place.
190///
191/// ```
192/// # use pdfrum_doc::vt::{Config, Metrics};
193/// # use pdfrum_form::edit::ops::{self, TextEdit};
194/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
195/// # let config = Config {
196/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
197/// #     font_size: 1.0,
198/// #     ..Config::default()
199/// # };
200/// use pdfrum_doc::vt;
201///
202/// let layout = vt::layout("Hi", &config, &metrics);
203/// // Top alignment shifts nothing at all.
204/// assert_eq!(ops::vertical_offset(false, &config, &layout), (0.0, 0.0));
205///
206/// // Centred alignment never shifts horizontally.
207/// let (x, _y) = ops::vertical_offset(true, &config, &layout);
208/// assert_eq!(x, 0.0);
209/// ```
210#[must_use]
211pub fn vertical_offset(centred: bool, config: &vt::Config, layout: &Layout) -> (f32, f32) {
212    if !centred {
213        return (0.0, 0.0);
214    }
215    let content = layout.content_rect_pdf(config.plate);
216    (
217        0.0,
218        (pdfrum_doc::geom::height(content) - pdfrum_doc::geom::height(config.plate)) * 0.5,
219    )
220}
221
222impl TextEdit {
223    /// An edit control over `text`, laid out with `config`.
224    ///
225    /// `centred` says whether the field draws its text vertically centred,
226    /// which a single-line field does and a multiline one does not.
227    ///
228    /// The text is **normalized to what the layout will hold** before it is
229    /// stored — see [`normalize_breaks`]. Storing the caller's string
230    /// unchanged would break the invariant this type exists to keep: a
231    /// single-line field's layout silently drops line breaks, so a raw
232    /// `"Foo\nBar"` would report seven characters while the layout held six,
233    /// and every index derived from one would miss in the other.
234    ///
235    /// ```
236    /// # use pdfrum_doc::vt::{Config, Metrics};
237    /// # use pdfrum_form::edit::ops::{self, TextEdit};
238    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
239    /// # let config = Config {
240    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
241    /// #     font_size: 1.0,
242    /// #     ..Config::default()
243    /// # };
244    ///
245    /// // The text arrives normalized, so the control and its layout agree.
246    /// let edit = TextEdit::new("Foo\nBar", &config, &metrics, true);
247    /// assert_eq!(edit.text, "FooBar");
248    /// assert_eq!(edit.len_chars(), 6);
249    /// ```
250    #[must_use]
251    pub fn new(
252        text: impl Into<String>,
253        config: &vt::Config,
254        metrics: &Metrics<'_>,
255        centred: bool,
256    ) -> TextEdit {
257        let text = normalize_breaks(&text.into(), config.multi_line);
258        let layout = vt::layout(&text, config, metrics);
259        let caret = vt::hit::begin_place(&layout);
260        let offset = vertical_offset(centred, config, &layout);
261        TextEdit {
262            text,
263            layout,
264            caret,
265            previous_caret: caret,
266            selection: Selection::collapsed_at(caret),
267            sticky_x: 0.0,
268            scroll: (0.0, 0.0),
269            // The permissive default, because it is what every caller in this
270            // crate wants: the one field flag that clears it is read where the
271            // field's config is, and `route.rs` sets it from there.
272            auto_scroll: true,
273            centred,
274            offset,
275            undo: UndoStack::default(),
276        }
277    }
278
279    /// The caret's flat character index.
280    ///
281    /// ```
282    /// # use pdfrum_doc::vt::{Config, Metrics};
283    /// # use pdfrum_form::edit::ops::{self, TextEdit};
284    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
285    /// # let config = Config {
286    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
287    /// #     font_size: 1.0,
288    /// #     ..Config::default()
289    /// # };
290    ///
291    /// let mut edit = TextEdit::new("Hello", &config, &metrics, true);
292    /// // A fresh control starts at the line header, index zero.
293    /// assert_eq!(edit.caret_index(), 0);
294    /// edit.set_caret_index(2);
295    /// assert_eq!(edit.caret_index(), 2);
296    /// ```
297    #[must_use]
298    pub fn caret_index(&self) -> usize {
299        vt::hit::word_index_of_place(&self.layout, self.caret)
300    }
301
302    /// The selection's flat character range, ordered.
303    ///
304    /// Ordered whichever way the selection runs, so a backwards selection
305    /// answers the same pair as the forwards one over the same run.
306    ///
307    /// ```
308    /// # use pdfrum_doc::vt::{Config, Metrics};
309    /// # use pdfrum_form::edit::ops::{self, TextEdit};
310    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
311    /// # let config = Config {
312    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
313    /// #     font_size: 1.0,
314    /// #     ..Config::default()
315    /// # };
316    ///
317    /// let mut edit = TextEdit::new("ABCDEF", &config, &metrics, true);
318    /// edit.set_selection(4, 1);
319    /// assert_eq!(edit.selection_indices(), (1, 4));
320    /// ```
321    #[must_use]
322    pub fn selection_indices(&self) -> (usize, usize) {
323        let range = self.selection.range();
324        (
325            vt::hit::word_index_of_place(&self.layout, range.begin()),
326            vt::hit::word_index_of_place(&self.layout, range.end()),
327        )
328    }
329
330    /// The selected text, empty when nothing is selected.
331    ///
332    /// ```
333    /// # use pdfrum_doc::vt::{Config, Metrics};
334    /// # use pdfrum_form::edit::ops::{self, TextEdit};
335    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
336    /// # let config = Config {
337    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
338    /// #     font_size: 1.0,
339    /// #     ..Config::default()
340    /// # };
341    ///
342    /// let mut edit = TextEdit::new("ABCDEF", &config, &metrics, true);
343    /// assert_eq!(edit.selected_text(), "");
344    /// edit.set_selection(1, 4);
345    /// assert_eq!(edit.selected_text(), "BCD");
346    /// ```
347    #[must_use]
348    pub fn selected_text(&self) -> String {
349        let (from, to) = self.selection_indices();
350        slice_chars(&self.text, from, to)
351    }
352
353    /// Whether anything is selected.
354    ///
355    /// ```
356    /// # use pdfrum_doc::vt::{Config, Metrics};
357    /// # use pdfrum_form::edit::ops::{self, TextEdit};
358    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
359    /// # let config = Config {
360    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
361    /// #     font_size: 1.0,
362    /// #     ..Config::default()
363    /// # };
364    ///
365    /// let mut edit = TextEdit::new("ABCDEF", &config, &metrics, true);
366    /// assert!(!edit.has_selection());
367    /// edit.select_all();
368    /// assert!(edit.has_selection());
369    /// ```
370    #[must_use]
371    pub fn has_selection(&self) -> bool {
372        !self.selection.is_empty()
373    }
374
375    /// How many characters the text holds.
376    ///
377    /// Characters, not bytes: a field of Hebrew letters is as long as it
378    /// looks.
379    ///
380    /// ```
381    /// # use pdfrum_doc::vt::{Config, Metrics};
382    /// # use pdfrum_form::edit::ops::{self, TextEdit};
383    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
384    /// # let config = Config {
385    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
386    /// #     font_size: 1.0,
387    /// #     ..Config::default()
388    /// # };
389    ///
390    /// let edit = TextEdit::new("\u{05D1}\u{05D2}\u{05EA}", &config, &metrics, true);
391    /// assert_eq!(edit.len_chars(), 3);
392    /// ```
393    #[must_use]
394    pub fn len_chars(&self) -> usize {
395        self.text.chars().count()
396    }
397
398    /// Moves the caret to a flat character index, collapsing the selection.
399    ///
400    /// The selection is left collapsed *at the new caret*, which is a live
401    /// anchor rather than no anchor.
402    ///
403    /// ```
404    /// # use pdfrum_doc::vt::{Config, Metrics};
405    /// # use pdfrum_form::edit::ops::{self, TextEdit};
406    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
407    /// # let config = Config {
408    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
409    /// #     font_size: 1.0,
410    /// #     ..Config::default()
411    /// # };
412    ///
413    /// let mut edit = TextEdit::new("ABCDEF", &config, &metrics, true);
414    /// edit.select_all();
415    /// edit.set_caret_index(2);
416    /// assert_eq!(edit.caret_index(), 2);
417    /// assert!(!edit.has_selection());
418    /// ```
419    pub fn set_caret_index(&mut self, index: usize) {
420        self.previous_caret = self.caret;
421        self.caret = vt::hit::place_of_word_index(&self.layout, index);
422        self.selection = Selection::collapsed_at(self.caret);
423    }
424
425    /// Moves the caret without touching the selection — what a shift-move
426    /// needs, since it must extend from an anchor the caret is leaving.
427    ///
428    /// ```
429    /// # use pdfrum_doc::vt::{Config, Metrics};
430    /// # use pdfrum_form::edit::ops::{self, TextEdit};
431    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
432    /// # let config = Config {
433    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
434    /// #     font_size: 1.0,
435    /// #     ..Config::default()
436    /// # };
437    ///
438    /// let mut edit = TextEdit::new("ABCDEF", &config, &metrics, true);
439    /// edit.set_selection(1, 4);
440    /// edit.move_caret_keeping_selection(0);
441    /// assert_eq!(edit.caret_index(), 0);
442    /// // The selection is untouched, unlike after `set_caret_index`.
443    /// assert_eq!(edit.selected_text(), "BCD");
444    /// ```
445    pub fn move_caret_keeping_selection(&mut self, index: usize) {
446        self.previous_caret = self.caret;
447        self.caret = vt::hit::place_of_word_index(&self.layout, index);
448    }
449
450    /// Selects everything. Records no undo item — selecting is not an edit.
451    ///
452    /// ```
453    /// # use pdfrum_doc::vt::{Config, Metrics};
454    /// # use pdfrum_form::edit::ops::{self, TextEdit};
455    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
456    /// # let config = Config {
457    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
458    /// #     font_size: 1.0,
459    /// #     ..Config::default()
460    /// # };
461    ///
462    /// let mut edit = TextEdit::new("Hello", &config, &metrics, true);
463    /// edit.select_all();
464    /// assert_eq!(edit.selected_text(), "Hello");
465    /// assert!(!edit.undo.can_undo(), "selecting records nothing");
466    ///
467    /// // An empty field has nothing to select.
468    /// let mut blank = TextEdit::new("", &config, &metrics, true);
469    /// blank.select_all();
470    /// assert_eq!(blank.selected_text(), "");
471    /// ```
472    pub fn select_all(&mut self) {
473        let begin = vt::hit::begin_place(&self.layout);
474        let end = vt::hit::end_place(&self.layout);
475        self.selection = Selection::new(begin, end);
476        self.previous_caret = self.caret;
477        self.caret = end;
478    }
479
480    /// Drops the selection, leaving a live anchor at the caret.
481    ///
482    /// ```
483    /// # use pdfrum_doc::vt::{Config, Metrics};
484    /// # use pdfrum_form::edit::ops::{self, TextEdit};
485    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
486    /// # let config = Config {
487    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
488    /// #     font_size: 1.0,
489    /// #     ..Config::default()
490    /// # };
491    ///
492    /// let mut edit = TextEdit::new("Hello", &config, &metrics, true);
493    /// edit.select_all();
494    /// edit.select_none();
495    /// assert!(!edit.has_selection());
496    /// // Collapsed, not reset: the next shift-move extends from here.
497    /// assert!(!edit.selection.is_reset());
498    /// ```
499    pub fn select_none(&mut self) {
500        self.selection = Selection::collapsed_at(self.caret);
501    }
502
503    /// Selects a **signed** character range, the way an embedder asks for one.
504    ///
505    /// The signs are not an accident of the C API and are not clamping: they
506    /// are three distinct instructions sharing one signature, and the order
507    /// they are tested in is what makes them unambiguous.
508    ///
509    /// - `(0, negative)` selects **everything**. This is the documented
510    ///   spelling of "to the end", and it is tested first, so it wins over
511    ///   the rule below even though its end is also negative.
512    /// - `(negative, anything)` selects **nothing**. A negative *start* is
513    ///   not clamped to zero — it clears the selection outright, so
514    ///   `(-8, -1)` is empty rather than the whole field.
515    /// - otherwise the two are ordered and used as they are, so `(23, 12)`
516    ///   and `(12, 23)` select the same run. An end past the text clamps to
517    ///   its end, which is ordinary index saturation rather than a fourth
518    ///   rule.
519    ///
520    /// ```
521    /// # use pdfrum_doc::vt::{Config, Metrics};
522    /// # use pdfrum_form::edit::ops::{self, TextEdit};
523    /// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
524    /// # let config = Config {
525    /// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
526    /// #     font_size: 1.0,
527    /// #     ..Config::default()
528    /// # };
529    ///
530    /// let mut edit = TextEdit::new("ABCDEFGHIJ", &config, &metrics, true);
531    ///
532    /// // (0, negative) is "to the end".
533    /// edit.set_selection(0, -1);
534    /// assert_eq!(edit.selected_text(), "ABCDEFGHIJ");
535    ///
536    /// // A negative start selects nothing; it is not clamped to zero.
537    /// edit.set_selection(-8, -1);
538    /// assert_eq!(edit.selected_text(), "");
539    ///
540    /// // Otherwise the two are ordered, so either way round is the same run.
541    /// edit.set_selection(5, 2);
542    /// assert_eq!(edit.selected_text(), "CDE");
543    /// edit.set_selection(2, 5);
544    /// assert_eq!(edit.selected_text(), "CDE");
545    ///
546    /// // An end past the text clamps, which is ordinary saturation.
547    /// edit.set_selection(9, 99);
548    /// assert_eq!(edit.selected_text(), "J");
549    /// ```
550    pub fn set_selection(&mut self, start: i32, end: i32) {
551        if start == 0 && end < 0 {
552            self.select_all();
553            return;
554        }
555        if start < 0 {
556            self.select_none();
557            return;
558        }
559        let len = self.len_chars();
560        let clamp = |index: i32| usize::try_from(index).unwrap_or(0).min(len);
561        let (from, to) = if start < end {
562            (clamp(start), clamp(end))
563        } else {
564            (clamp(end), clamp(start))
565        };
566        let begin = vt::hit::place_of_word_index(&self.layout, from);
567        let finish = vt::hit::place_of_word_index(&self.layout, to);
568        self.selection = Selection::new(begin, finish);
569        self.previous_caret = self.caret;
570        self.caret = finish;
571    }
572}
573
574/// How many characters a field will still accept.
575///
576/// `None` means unlimited. A limit already reached answers zero rather than
577/// refusing, because the rule is **truncation, not rejection**: an insert
578/// that does not fit is trimmed to what does, and only an insert with no room
579/// at all does nothing.
580///
581/// ```
582/// # use pdfrum_doc::vt::{Config, Metrics};
583/// # use pdfrum_form::edit::ops::{self, TextEdit};
584/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
585/// # let config = Config {
586/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
587/// #     font_size: 1.0,
588/// #     ..Config::default()
589/// # };
590///
591/// let edit = TextEdit::new("ABCDEFGH", &config, &metrics, true);
592///
593/// // No limit at all.
594/// assert_eq!(ops::room_for(&edit, None, 0), None);
595///
596/// // Eight of ten used, so two are free.
597/// assert_eq!(ops::room_for(&edit, Some(10), 0), Some(2));
598///
599/// // Replacing three of them first frees those three too.
600/// assert_eq!(ops::room_for(&edit, Some(10), 3), Some(5));
601///
602/// // A limit already reached answers zero rather than refusing.
603/// assert_eq!(ops::room_for(&edit, Some(8), 0), Some(0));
604/// ```
605#[must_use]
606pub fn room_for(edit: &TextEdit, max_len: Option<u32>, replacing: usize) -> Option<usize> {
607    let max = max_len? as usize;
608    let after_removal = edit.len_chars().saturating_sub(replacing);
609    Some(max.saturating_sub(after_removal))
610}
611
612/// Whether an insertion that first removes `[from, to)` is refused because
613/// the field is full.
614///
615/// The removal happens **first**, and the overflow test is asked of the text
616/// with the selection already gone. Typing over a full field's entire
617/// contents therefore works, which is the behaviour a user relies on to
618/// correct an overfull field; asking before the removal would break it.
619///
620/// The removal is measured rather than performed: a trial layout of the text
621/// as it would stand answers the same question without an undo item or a
622/// caret move to roll back.
623fn insertion_is_refused(
624    edit: &TextEdit,
625    config: &vt::Config,
626    metrics: &Metrics<'_>,
627    from: usize,
628    to: usize,
629) -> bool {
630    if edit.auto_scroll || config.char_array > 0 {
631        return false;
632    }
633    if from == to {
634        return is_text_overflow(edit, config);
635    }
636    let mut trial = String::with_capacity(edit.text.len());
637    trial.extend(edit.text.chars().take(from));
638    trial.extend(edit.text.chars().skip(to));
639    let layout = vt::layout(
640        &normalize_breaks(&trial, config.multi_line),
641        config,
642        metrics,
643    );
644    layout_overflows(&layout, config)
645}
646
647/// Whether the plate is already full, so that no further text is accepted.
648///
649/// True when the field can neither scroll nor overflow **and** its content is
650/// bigger than its plate; every insertion returns without mutating when it
651/// is. ISO 32000-1 Table 228 says the same about `DoNotScroll`: once the field
652/// is full, no further text is accepted. Without this gate a `DoNotScroll`
653/// field keeps taking characters, the caret walks off the plate, and the extra
654/// text sits invisibly in the value.
655///
656/// **The check is made *before* the character**, so the one that first makes
657/// the content exceed the plate is accepted and the **next** is refused.
658///
659/// Overflow is a **comb field's** property and only a comb field's, so a comb
660/// never refuses — which is why the `char_array` test comes first here.
661///
662/// ```
663/// use pdfrum_doc::vt::{Config, Metrics};
664/// use pdfrum_form::edit::ops::{self, TextEdit};
665///
666/// let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
667/// // A plate ten characters wide that may not scroll.
668/// let config = Config {
669///     plate: kurbo::Rect::new(0.0, 0.0, 10.0, 20.0),
670///     font_size: 1.0,
671///     ..Config::default()
672/// };
673///
674/// let mut edit = TextEdit::new("ABCDEFGHIJKL", &config, &metrics, true);
675/// // A scrolling field never overflows, however long its text.
676/// assert!(!ops::is_text_overflow(&edit, &config));
677///
678/// edit.auto_scroll = false;
679/// assert!(ops::is_text_overflow(&edit, &config));
680///
681/// // A short text still fits.
682/// let mut short = TextEdit::new("AB", &config, &metrics, true);
683/// short.auto_scroll = false;
684/// assert!(!ops::is_text_overflow(&short, &config));
685/// ```
686#[must_use]
687pub fn is_text_overflow(edit: &TextEdit, config: &vt::Config) -> bool {
688    if edit.auto_scroll || config.char_array > 0 {
689        return false;
690    }
691    layout_overflows(&edit.layout, config)
692}
693
694/// The two size comparisons `IsTextOverflow` makes once its two flags have
695/// let it through, against a layout that need not be the control's own.
696///
697/// Split out so [`insertion_is_refused`] can ask the question of the text as
698/// it *would* stand after a selection is removed, which is the order upstream
699/// runs the two operations in.
700fn layout_overflows(layout: &vt::Layout, config: &vt::Config) -> bool {
701    let plate = config.plate;
702    let content = layout.content_rect_pdf(plate);
703    // The multi-line branch needs more than one line before a taller content
704    // counts: a single line taller than its plate is the ordinary case for a
705    // field whose font does not quite fit, and upstream declines to lock
706    // those out.
707    let lines: usize = layout
708        .sections
709        .iter()
710        .map(|section| section.lines.len())
711        .sum();
712    if config.multi_line
713        && lines > 1
714        && is_float_bigger(
715            pdfrum_doc::geom::height(content),
716            pdfrum_doc::geom::height(plate),
717        )
718    {
719        return true;
720    }
721    is_float_bigger(
722        pdfrum_doc::geom::width(content),
723        pdfrum_doc::geom::width(plate),
724    )
725}
726
727/// Replaces a flat character range with `insert`, recording one undo item.
728///
729/// The single mutation every other one is written in terms of. `max_len`
730/// truncates the insertion — never the field — so inserting a long string
731/// into a nearly-full field puts in as much as fits and leaves the rest of
732/// the text alone.
733///
734/// ```
735/// # use pdfrum_doc::vt::{Config, Metrics};
736/// # use pdfrum_form::edit::ops::{self, TextEdit};
737/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
738/// # let config = Config {
739/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
740/// #     font_size: 1.0,
741/// #     ..Config::default()
742/// # };
743///
744/// let mut edit = TextEdit::new("ABCDEF", &config, &metrics, true);
745/// // Replace [1, 4) with "xy", recording one undo item.
746/// assert!(ops::replace_range(&mut edit, &config, &metrics, 1, 4, "xy", None, true));
747/// assert_eq!(edit.text, "AxyEF");
748/// assert_eq!(edit.caret_index(), 3, "the caret lands after the insertion");
749///
750/// // Nothing removed and nothing inserted is not an edit.
751/// assert!(!ops::replace_range(&mut edit, &config, &metrics, 2, 2, "", None, true));
752///
753/// // `max_len` truncates the INSERTION, never the field.
754/// let mut limited = TextEdit::new("AB", &config, &metrics, true);
755/// ops::replace_range(&mut limited, &config, &metrics, 2, 2, "xyz", Some(4), true);
756/// assert_eq!(limited.text, "ABxy");
757/// ```
758#[allow(clippy::too_many_arguments)] // The one primitive; every other
759// mutation is a short call into it, so the arguments live here rather than
760// being spread across five near-identical bodies.
761pub fn replace_range(
762    edit: &mut TextEdit,
763    config: &vt::Config,
764    metrics: &Metrics<'_>,
765    from: usize,
766    to: usize,
767    insert: &str,
768    max_len: Option<u32>,
769    record: bool,
770) -> bool {
771    let (from, to) = (from.min(to), from.max(to));
772    let removed = slice_chars(&edit.text, from, to);
773
774    let inserted: String = match room_for(edit, max_len, to.saturating_sub(from)) {
775        Some(room) => insert.chars().take(room).collect(),
776        None => insert.to_string(),
777    };
778
779    if removed.is_empty() && inserted.is_empty() {
780        return false;
781    }
782
783    let before_selection = edit.selection;
784    let old_place = vt::hit::place_of_word_index(&edit.layout, from);
785
786    let mut next = String::with_capacity(edit.text.len());
787    next.extend(edit.text.chars().take(from));
788    next.push_str(&inserted);
789    next.extend(edit.text.chars().skip(to));
790    edit.text = next;
791
792    relayout(edit, config, metrics);
793
794    let caret_at = from.saturating_add(inserted.chars().count());
795    edit.previous_caret = edit.caret;
796    edit.caret = vt::hit::place_of_word_index(&edit.layout, caret_at);
797    edit.selection = Selection::collapsed_at(edit.caret);
798
799    if record {
800        push_edit(edit, &removed, &inserted, old_place, before_selection);
801    }
802    settle(edit, config, metrics);
803    true
804}
805
806/// Records the one item a replacement is worth.
807///
808/// A pure insertion and a pure deletion each record a single item; a genuine
809/// replacement — text removed *and* text put in — records the pair bracketed
810/// by boundaries, because undoing it has to do both and a caller must not be
811/// able to stop between them.
812fn push_edit(
813    edit: &mut TextEdit,
814    removed: &str,
815    inserted: &str,
816    old_place: Place,
817    before: Selection,
818) {
819    let new_place = edit.caret;
820    match (removed.is_empty(), inserted.is_empty()) {
821        (true, false) => edit
822            .undo
823            .push(insertion_item(old_place, new_place, inserted, before)),
824        (false, true) => edit.undo.push(UndoItem::Clear {
825            range: Range::new(old_place, new_place),
826            text: removed.to_string(),
827            before,
828        }),
829        (false, false) => {
830            edit.undo.push(UndoItem::GroupBoundary);
831            edit.undo.push(UndoItem::Clear {
832                range: Range::new(old_place, old_place),
833                text: removed.to_string(),
834                before,
835            });
836            edit.undo
837                .push(insertion_item(old_place, new_place, inserted, before));
838            edit.undo.push(UndoItem::GroupBoundary);
839        }
840        (true, true) => {}
841    }
842}
843
844/// The item a pure insertion records: one character is its own variant, so
845/// that typing produces exactly one item per keystroke.
846fn insertion_item(old: Place, new: Place, inserted: &str, before: Selection) -> UndoItem {
847    let mut chars = inserted.chars();
848    match (chars.next(), chars.next()) {
849        (Some(ch), None) => UndoItem::InsertWord {
850            old,
851            new,
852            ch,
853            before,
854        },
855        _ => UndoItem::InsertText {
856            old,
857            new,
858            text: inserted.to_string(),
859            before,
860        },
861    }
862}
863
864/// Types one character at the caret, replacing any selection.
865///
866/// Exactly one undo item, whether or not a selection was replaced — which is
867/// what makes a run of typing undo one keystroke at a time.
868///
869/// A field that has filled its plate and may not scroll refuses outright —
870/// see [`is_text_overflow`], which is `InsertWord`'s and `InsertReturn`'s
871/// first statement upstream.
872///
873/// ```
874/// # use pdfrum_doc::vt::{Config, Metrics};
875/// # use pdfrum_form::edit::ops::{self, TextEdit};
876/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
877/// # let config = Config {
878/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
879/// #     font_size: 1.0,
880/// #     ..Config::default()
881/// # };
882///
883/// let mut edit = TextEdit::new("", &config, &metrics, true);
884/// for ch in "ABC".chars() {
885///     ops::insert_char(&mut edit, &config, &metrics, ch, None);
886/// }
887/// assert_eq!(edit.text, "ABC");
888///
889/// // One undo item per keystroke, so a run undoes one character at a time.
890/// ops::undo(&mut edit, &config, &metrics);
891/// assert_eq!(edit.text, "AB");
892///
893/// // Typing over a selection replaces it, still in one item.
894/// edit.set_selection(0, 2);
895/// ops::insert_char(&mut edit, &config, &metrics, 'Z', None);
896/// assert_eq!(edit.text, "Z");
897/// ```
898pub fn insert_char(
899    edit: &mut TextEdit,
900    config: &vt::Config,
901    metrics: &Metrics<'_>,
902    ch: char,
903    max_len: Option<u32>,
904) -> bool {
905    let (from, to) = if edit.has_selection() {
906        edit.selection_indices()
907    } else {
908        let at = edit.caret_index();
909        (at, at)
910    };
911    if insertion_is_refused(edit, config, metrics, from, to) {
912        return false;
913    }
914    let mut buffer = [0u8; 4];
915    replace_range(
916        edit,
917        config,
918        metrics,
919        from,
920        to,
921        ch.encode_utf8(&mut buffer),
922        max_len,
923        true,
924    )
925}
926
927/// Deletes the selection, or the character **before** the caret.
928///
929/// Answers whether anything was removed; a backspace at the very start of
930/// the text does nothing.
931///
932/// ```
933/// # use pdfrum_doc::vt::{Config, Metrics};
934/// # use pdfrum_form::edit::ops::{self, TextEdit};
935/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
936/// # let config = Config {
937/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
938/// #     font_size: 1.0,
939/// #     ..Config::default()
940/// # };
941///
942/// let mut edit = TextEdit::new("ABCDE", &config, &metrics, true);
943/// edit.set_caret_index(3);
944/// assert!(ops::backspace(&mut edit, &config, &metrics));
945/// assert_eq!(edit.text, "ABDE");
946///
947/// edit.set_caret_index(0);
948/// assert!(!ops::backspace(&mut edit, &config, &metrics));
949/// assert_eq!(edit.text, "ABDE");
950/// ```
951pub fn backspace(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>) -> bool {
952    let (from, to) = if edit.has_selection() {
953        edit.selection_indices()
954    } else {
955        let at = edit.caret_index();
956        let Some(before) = at.checked_sub(1) else {
957            return false;
958        };
959        (before, at)
960    };
961    replace_range(edit, config, metrics, from, to, "", None, true)
962}
963
964/// Deletes the selection, or the character **after** the caret.
965///
966/// The other side of the caret from [`backspace`]; a delete at the end of
967/// the text does nothing.
968///
969/// ```
970/// # use pdfrum_doc::vt::{Config, Metrics};
971/// # use pdfrum_form::edit::ops::{self, TextEdit};
972/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
973/// # let config = Config {
974/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
975/// #     font_size: 1.0,
976/// #     ..Config::default()
977/// # };
978///
979/// let mut edit = TextEdit::new("ABCDE", &config, &metrics, true);
980/// edit.set_caret_index(3);
981/// assert!(ops::delete(&mut edit, &config, &metrics));
982/// assert_eq!(edit.text, "ABCE");
983///
984/// edit.set_caret_index(edit.len_chars());
985/// assert!(!ops::delete(&mut edit, &config, &metrics));
986/// assert_eq!(edit.text, "ABCE");
987/// ```
988pub fn delete(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>) -> bool {
989    let (from, to) = if edit.has_selection() {
990        edit.selection_indices()
991    } else {
992        let at = edit.caret_index();
993        if at >= edit.len_chars() {
994            return false;
995        }
996        (at, at.saturating_add(1))
997    };
998    replace_range(edit, config, metrics, from, to, "", None, true)
999}
1000
1001/// Replaces the selection with `text`, leaving the caret after it.
1002///
1003/// One undo item however long the text, which is the difference from typing
1004/// the same characters one at a time.
1005///
1006/// A `DoNotScroll` field already at its plate's edge refuses the insertion
1007/// half — see [`is_text_overflow`] — but the *removal* half still runs
1008/// upstream, because `ReplaceSelection` clears before it inserts. Deleting a
1009/// selection therefore always works, however full the field is.
1010///
1011/// ```
1012/// # use pdfrum_doc::vt::{Config, Metrics};
1013/// # use pdfrum_form::edit::ops::{self, TextEdit};
1014/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1015/// # let config = Config {
1016/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1017/// #     font_size: 1.0,
1018/// #     ..Config::default()
1019/// # };
1020///
1021/// let mut edit = TextEdit::new("AB", &config, &metrics, true);
1022/// edit.set_selection(0, 1);
1023/// assert!(ops::replace_selection(&mut edit, &config, &metrics, "XYZ", None));
1024/// assert_eq!(edit.text, "XYZB");
1025/// // A caret is left after the insertion, not a selection over it.
1026/// assert_eq!(edit.selected_text(), "");
1027/// assert_eq!(edit.caret_index(), 3);
1028///
1029/// // One undo item however long the text, unlike typing it out.
1030/// ops::undo(&mut edit, &config, &metrics);
1031/// assert_eq!(edit.text, "AB");
1032/// ```
1033pub fn replace_selection(
1034    edit: &mut TextEdit,
1035    config: &vt::Config,
1036    metrics: &Metrics<'_>,
1037    text: &str,
1038    max_len: Option<u32>,
1039) -> bool {
1040    let (from, to) = if edit.has_selection() {
1041        edit.selection_indices()
1042    } else {
1043        let at = edit.caret_index();
1044        (at, at)
1045    };
1046    let text = if insertion_is_refused(edit, config, metrics, from, to) {
1047        ""
1048    } else {
1049        text
1050    };
1051    replace_range(edit, config, metrics, from, to, text, max_len, true)
1052}
1053
1054/// Replaces the selection with `text` and **keeps the inserted text
1055/// selected**.
1056///
1057/// The whole difference from [`replace_selection`], and the reason both
1058/// exist: one leaves a caret, the other leaves a selection over what it just
1059/// put in.
1060///
1061/// ```
1062/// # use pdfrum_doc::vt::{Config, Metrics};
1063/// # use pdfrum_form::edit::ops::{self, TextEdit};
1064/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1065/// # let config = Config {
1066/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1067/// #     font_size: 1.0,
1068/// #     ..Config::default()
1069/// # };
1070///
1071/// let mut edit = TextEdit::new("AB", &config, &metrics, true);
1072/// edit.set_selection(0, 1);
1073/// assert!(ops::replace_and_keep_selection(&mut edit, &config, &metrics, "XYZ", None));
1074/// assert_eq!(edit.text, "XYZB");
1075/// // The difference from `replace_selection`: the insertion stays selected.
1076/// assert_eq!(edit.selected_text(), "XYZ");
1077///
1078/// // A reversed input selection still comes back selected forwards.
1079/// edit.set_selection(3, 0);
1080/// ops::replace_and_keep_selection(&mut edit, &config, &metrics, "12", None);
1081/// assert_eq!(edit.text, "12B");
1082/// assert_eq!(edit.selection_indices(), (0, 2));
1083/// ```
1084pub fn replace_and_keep_selection(
1085    edit: &mut TextEdit,
1086    config: &vt::Config,
1087    metrics: &Metrics<'_>,
1088    text: &str,
1089    max_len: Option<u32>,
1090) -> bool {
1091    let (from, to) = if edit.has_selection() {
1092        edit.selection_indices()
1093    } else {
1094        let at = edit.caret_index();
1095        (at, at)
1096    };
1097    let text = if insertion_is_refused(edit, config, metrics, from, to) {
1098        ""
1099    } else {
1100        text
1101    };
1102    let before = from;
1103    if !replace_range(edit, config, metrics, from, to, text, max_len, true) {
1104        return false;
1105    }
1106    let after = edit.caret_index();
1107    let begin = vt::hit::place_of_word_index(&edit.layout, before);
1108    let end = vt::hit::place_of_word_index(&edit.layout, after);
1109    edit.selection = Selection::new(begin, end);
1110    true
1111}
1112
1113/// Re-lays the text out and puts the caret back where its index says.
1114fn relayout(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>) {
1115    edit.layout = vt::layout(&edit.text, config, metrics);
1116    // The offset depends on the content's height, which the edit just moved.
1117    edit.offset = vertical_offset(edit.centred, config, &edit.layout);
1118}
1119
1120/// The shared tail of every mutation: re-seed the column a vertical move
1121/// aims for, then bring the caret back into view.
1122fn settle(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>) {
1123    edit.sticky_x = caret_x(edit, config, metrics);
1124    scroll_to_caret(edit, config, metrics);
1125}
1126
1127/// Scrolls a multiline field's view vertically by a wheel notch, answering
1128/// whether it moved.
1129///
1130/// **A `DoNotScroll` field does not move.** [`TextEdit::auto_scroll`] gates
1131/// every writer of the vertical scroll position, the wheel included, and such
1132/// a field is given no scrollbar to drag either — it simply does not pan.
1133///
1134/// The step is a quarter of the plate per notch, and the position is clamped
1135/// to the slack between content and plate, so a field with nothing to scroll
1136/// answers `false` rather than accumulating an offset it cannot use.
1137///
1138/// ```
1139/// use pdfrum_doc::vt::{Config, Metrics};
1140/// use pdfrum_form::edit::ops::{self, TextEdit};
1141///
1142/// let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1143/// // A short multiline plate, so the text has somewhere to scroll to.
1144/// let config = Config {
1145///     plate: kurbo::Rect::new(0.0, 0.0, 10.0, 4.0),
1146///     font_size: 1.0,
1147///     multi_line: true,
1148///     auto_return: true,
1149///     ..Config::default()
1150/// };
1151///
1152/// let mut edit = TextEdit::new("one\ntwo\nthree\nfour\nfive", &config, &metrics, false);
1153/// assert!(ops::scroll_by(&mut edit, &config, -1));
1154/// assert!(edit.scroll.1 > 0.0);
1155///
1156/// // A `DoNotScroll` field does not move, wheel or not.
1157/// edit.auto_scroll = false;
1158/// assert!(!ops::scroll_by(&mut edit, &config, -1));
1159/// ```
1160pub fn scroll_by(edit: &mut TextEdit, config: &vt::Config, delta_y: i32) -> bool {
1161    if !edit.auto_scroll || delta_y == 0 {
1162        return false;
1163    }
1164    let content = edit.layout.content_rect_pdf(config.plate);
1165    let slack = pdfrum_doc::geom::height(content) - pdfrum_doc::geom::height(config.plate);
1166    if slack <= 0.0 {
1167        return false;
1168    }
1169    let step = pdfrum_doc::geom::height(config.plate) * 0.25;
1170    let was = edit.scroll.1;
1171    #[expect(
1172        clippy::cast_precision_loss,
1173        reason = "a wheel delta is a small notch count"
1174    )]
1175    let by = -(delta_y as f32) * step;
1176    edit.scroll.1 = (edit.scroll.1 + by).clamp(0.0, slack);
1177    (edit.scroll.1 - was).abs() > f32::EPSILON
1178}
1179
1180/// Scrolls the view so the caret is inside the plate.
1181///
1182/// Run after every mutation and every caret move; without it a field whose
1183/// text outruns its plate keeps drawing from the first character and hides
1184/// the caret entirely.
1185///
1186/// The three comparisons carry a `0.0001` tolerance rather than a raw `<`: a
1187/// caret landing exactly on the plate edge must count as *inside*, or a field
1188/// scrolls by a whole advance on a rounding error. This is the opposite of
1189/// the hit test's tie-break, which is raw.
1190///
1191/// Only the **horizontal** half moves the view here; the wheel
1192/// ([`scroll_by`]) is the only thing that moves the vertical offset.
1193///
1194/// ```
1195/// use pdfrum_doc::vt::{Config, Metrics};
1196/// use pdfrum_form::edit::ops::{self, TextEdit};
1197///
1198/// let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1199/// // A plate ten characters wide, so a longer value must scroll.
1200/// let config = Config {
1201///     plate: kurbo::Rect::new(0.0, 0.0, 10.0, 20.0),
1202///     font_size: 1.0,
1203///     ..Config::default()
1204/// };
1205///
1206/// let mut edit = TextEdit::new("", &config, &metrics, true);
1207/// for ch in "ABCDEFGHIJKLMNO".chars() {
1208///     ops::insert_char(&mut edit, &config, &metrics, ch, None);
1209/// }
1210/// // Every mutation ends here, so the caret is already in view.
1211/// assert!(edit.scroll.0 > 0.0);
1212///
1213/// // Back to the start, and the view follows it home.
1214/// edit.set_caret_index(0);
1215/// ops::scroll_to_caret(&mut edit, &config, &metrics);
1216/// assert_eq!(edit.scroll.0, 0.0);
1217/// ```
1218pub fn scroll_to_caret(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>) {
1219    if !edit.auto_scroll {
1220        return;
1221    }
1222    let plate = config.plate;
1223    let (left, width) = (
1224        pdfrum_doc::geom::left(plate),
1225        pdfrum_doc::geom::width(plate),
1226    );
1227    if is_float_equal(left, pdfrum_doc::geom::right(plate)) {
1228        return;
1229    }
1230    // `SetScrollLimit` (`cpwl_edit_impl.cpp:1207-1234`) runs first, and a
1231    // plate wider than its content pins the view at the origin — which is
1232    // what keeps a short value left-aligned rather than drifting.
1233    let content = edit.layout.content_rect_pdf(plate);
1234    let (content_left, content_right) = (
1235        pdfrum_doc::geom::left(content),
1236        pdfrum_doc::geom::right(content),
1237    );
1238    if width > pdfrum_doc::geom::width(content) {
1239        edit.scroll.0 = 0.0;
1240    } else {
1241        // Not `f32::clamp`: `SetScrollLimit` (`cpwl_edit_impl.cpp:1215-1220`)
1242        // tests each bound with `FXSYS_IsFloatSmaller`/`IsFloatBigger`, so a
1243        // value within 0.0001 of one is left where it is rather than being
1244        // snapped onto it. Raw `<=`/`>=` would pull a rounding-edge caret a
1245        // hair further than upstream does.
1246        let (low, high) = (content_left - left, content_right - left - width);
1247        if is_float_smaller(edit.scroll.0, low) {
1248            edit.scroll.0 = low;
1249        } else if is_float_bigger(edit.scroll.0, high) {
1250            edit.scroll.0 = high;
1251        }
1252    }
1253
1254    let head = caret_x(edit, config, metrics);
1255    let head_edit = head - edit.scroll.0;
1256    if is_float_smaller(head_edit, left) || is_float_equal(head_edit, left) {
1257        edit.scroll.0 = head - left;
1258    } else if is_float_smaller(left + width, head_edit) {
1259        edit.scroll.0 = head - left - width;
1260    }
1261}
1262
1263/// Equal within the oracle's `0.0001` float tolerance.
1264fn is_float_equal(a: f32, b: f32) -> bool {
1265    (a - b).abs() < 0.0001
1266}
1267
1268/// Strictly smaller by more than the oracle's `0.0001` float tolerance.
1269fn is_float_smaller(a: f32, b: f32) -> bool {
1270    a < b && !is_float_equal(a, b)
1271}
1272
1273/// Strictly bigger by more than the oracle's `0.0001` float tolerance.
1274fn is_float_bigger(a: f32, b: f32) -> bool {
1275    a > b && !is_float_equal(a, b)
1276}
1277
1278/// The caret's horizontal position in layout space.
1279///
1280/// ```
1281/// # use pdfrum_doc::vt::{Config, Metrics};
1282/// # use pdfrum_form::edit::ops::{self, TextEdit};
1283/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1284/// # let config = Config {
1285/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1286/// #     font_size: 1.0,
1287/// #     ..Config::default()
1288/// # };
1289///
1290/// let mut edit = TextEdit::new("ABCDE", &config, &metrics, true);
1291/// edit.set_caret_index(0);
1292/// let at_start = ops::caret_x(&edit, &config, &metrics);
1293/// edit.set_caret_index(5);
1294/// let at_end = ops::caret_x(&edit, &config, &metrics);
1295/// assert!(at_end > at_start);
1296/// ```
1297#[must_use]
1298pub fn caret_x(edit: &TextEdit, config: &vt::Config, metrics: &Metrics<'_>) -> f32 {
1299    let point = vt::hit::point_at_place(
1300        &edit.layout,
1301        config.plate,
1302        config,
1303        metrics,
1304        edit.offset,
1305        edit.caret,
1306    );
1307    #[allow(clippy::cast_possible_truncation)]
1308    let x = point.x as f32;
1309    x
1310}
1311
1312/// The characters of `text` from `from` up to `to`, by character index.
1313fn slice_chars(text: &str, from: usize, to: usize) -> String {
1314    text.chars()
1315        .skip(from)
1316        .take(to.saturating_sub(from))
1317        .collect()
1318}
1319
1320/// Undoes one step, replaying each item's inverse against the live layout.
1321///
1322/// Returns whether anything was undone. The selection each item carries from
1323/// *before* its edit is restored; a redo deliberately restores none.
1324///
1325/// ```
1326/// # use pdfrum_doc::vt::{Config, Metrics};
1327/// # use pdfrum_form::edit::ops::{self, TextEdit};
1328/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1329/// # let config = Config {
1330/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1331/// #     font_size: 1.0,
1332/// #     ..Config::default()
1333/// # };
1334///
1335/// let mut edit = TextEdit::new("", &config, &metrics, true);
1336/// for ch in "ABC".chars() {
1337///     ops::insert_char(&mut edit, &config, &metrics, ch, None);
1338/// }
1339///
1340/// // One item per keystroke, so each undo takes one character.
1341/// assert!(ops::undo(&mut edit, &config, &metrics));
1342/// assert_eq!(edit.text, "AB");
1343/// assert!(ops::undo(&mut edit, &config, &metrics));
1344/// assert_eq!(edit.text, "A");
1345///
1346/// // An exhausted stack answers `false` rather than doing nothing quietly.
1347/// assert!(ops::undo(&mut edit, &config, &metrics));
1348/// assert!(!ops::undo(&mut edit, &config, &metrics));
1349/// assert_eq!(edit.text, "");
1350/// ```
1351pub fn undo(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>) -> bool {
1352    let items = edit.undo.undo();
1353    if items.is_empty() {
1354        return false;
1355    }
1356    let mut restored = None;
1357    for item in &items {
1358        restored = item.before().or(restored);
1359        invert(edit, config, metrics, item);
1360    }
1361    if let Some(selection) = restored {
1362        edit.selection = selection;
1363        edit.caret = selection.end;
1364    }
1365    true
1366}
1367
1368/// Redoes one step. Restores text only, leaving the caret collapsed — the
1369/// asymmetry with [`undo`], and the one four ported assertions observe.
1370///
1371/// ```
1372/// # use pdfrum_doc::vt::{Config, Metrics};
1373/// # use pdfrum_form::edit::ops::{self, TextEdit};
1374/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1375/// # let config = Config {
1376/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1377/// #     font_size: 1.0,
1378/// #     ..Config::default()
1379/// # };
1380///
1381/// let mut edit = TextEdit::new("AB", &config, &metrics, true);
1382/// edit.set_selection(0, 1);
1383/// ops::replace_and_keep_selection(&mut edit, &config, &metrics, "XYZ", None);
1384/// assert_eq!(edit.text, "XYZB");
1385///
1386/// // Undo restores the selection the edit was made over.
1387/// assert!(ops::undo(&mut edit, &config, &metrics));
1388/// assert_eq!(edit.text, "AB");
1389/// assert_eq!(edit.selected_text(), "A");
1390///
1391/// // Redo restores the text only, leaving the caret collapsed.
1392/// assert!(ops::redo(&mut edit, &config, &metrics));
1393/// assert_eq!(edit.text, "XYZB");
1394/// assert_eq!(edit.selected_text(), "");
1395/// ```
1396pub fn redo(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>) -> bool {
1397    let items = edit.undo.redo();
1398    if items.is_empty() {
1399        return false;
1400    }
1401    for item in &items {
1402        apply(edit, config, metrics, item);
1403    }
1404    edit.selection = Selection::collapsed_at(edit.caret);
1405    true
1406}
1407
1408/// Replays one item's inverse. Never records, which is what keeps an undo
1409/// from pushing the item it is undoing.
1410fn invert(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>, item: &UndoItem) {
1411    match item {
1412        UndoItem::InsertWord { old, ch, .. } => {
1413            let at = vt::hit::word_index_of_place(&edit.layout, *old);
1414            let mut buffer = [0u8; 4];
1415            let len = ch.encode_utf8(&mut buffer).chars().count();
1416            replace_range(edit, config, metrics, at, at + len, "", None, false);
1417        }
1418        UndoItem::InsertText { old, text, .. } => {
1419            let at = vt::hit::word_index_of_place(&edit.layout, *old);
1420            let len = text.chars().count();
1421            replace_range(edit, config, metrics, at, at + len, "", None, false);
1422        }
1423        UndoItem::InsertReturn { old, .. } => {
1424            let at = vt::hit::word_index_of_place(&edit.layout, *old);
1425            replace_range(edit, config, metrics, at, at + 1, "", None, false);
1426        }
1427        UndoItem::Clear { range, text, .. } => {
1428            let at = vt::hit::word_index_of_place(&edit.layout, range.begin());
1429            replace_range(edit, config, metrics, at, at, text, None, false);
1430        }
1431        UndoItem::Backspace { old, ch, .. } | UndoItem::Delete { old, ch, .. } => {
1432            let at = vt::hit::word_index_of_place(&edit.layout, *old);
1433            let mut buffer = [0u8; 4];
1434            replace_range(
1435                edit,
1436                config,
1437                metrics,
1438                at,
1439                at,
1440                ch.encode_utf8(&mut buffer),
1441                None,
1442                false,
1443            );
1444        }
1445        UndoItem::GroupBoundary => {}
1446    }
1447}
1448
1449/// Replays one item forwards, for a redo.
1450fn apply(edit: &mut TextEdit, config: &vt::Config, metrics: &Metrics<'_>, item: &UndoItem) {
1451    match item {
1452        UndoItem::InsertWord { old, ch, .. } => {
1453            let at = vt::hit::word_index_of_place(&edit.layout, *old);
1454            let mut buffer = [0u8; 4];
1455            replace_range(
1456                edit,
1457                config,
1458                metrics,
1459                at,
1460                at,
1461                ch.encode_utf8(&mut buffer),
1462                None,
1463                false,
1464            );
1465        }
1466        UndoItem::InsertText { old, text, .. } => {
1467            let at = vt::hit::word_index_of_place(&edit.layout, *old);
1468            replace_range(edit, config, metrics, at, at, text, None, false);
1469        }
1470        UndoItem::InsertReturn { old, .. } => {
1471            let at = vt::hit::word_index_of_place(&edit.layout, *old);
1472            replace_range(edit, config, metrics, at, at, "\r", None, false);
1473        }
1474        UndoItem::Clear { range, text, .. } => {
1475            let at = vt::hit::word_index_of_place(&edit.layout, range.begin());
1476            let len = text.chars().count();
1477            replace_range(edit, config, metrics, at, at + len, "", None, false);
1478        }
1479        UndoItem::Backspace { old, .. } | UndoItem::Delete { old, .. } => {
1480            let at = vt::hit::word_index_of_place(&edit.layout, *old);
1481            replace_range(edit, config, metrics, at, at + 1, "", None, false);
1482        }
1483        UndoItem::GroupBoundary => {}
1484    }
1485}
1486
1487/// The place a click at `point` selects.
1488///
1489/// `point` is in PDF user space. The field's own drawing offset is applied,
1490/// which is what makes a click at a field's visible mid-height land on the
1491/// character under the pointer rather than clamping to the end of the text.
1492///
1493/// ```
1494/// # use pdfrum_doc::vt::{Config, Metrics};
1495/// # use pdfrum_form::edit::ops::{self, TextEdit};
1496/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1497/// # let config = Config {
1498/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1499/// #     font_size: 1.0,
1500/// #     ..Config::default()
1501/// # };
1502/// use pdfrum_form::edit::PlaceExt;
1503///
1504/// let edit = TextEdit::new("ABCDE", &config, &metrics, true);
1505/// // Left of the first character is the line header, not character zero.
1506/// let place = ops::place_at_point(&edit, &config, &metrics, kurbo::Point::new(-5.0, 10.0));
1507/// assert!(place.at_line_start());
1508/// ```
1509#[must_use]
1510pub fn place_at_point(
1511    edit: &TextEdit,
1512    config: &vt::Config,
1513    metrics: &Metrics<'_>,
1514    point: kurbo::Point,
1515) -> Place {
1516    vt::hit::place_at_point(
1517        &edit.layout,
1518        config.plate,
1519        config,
1520        metrics,
1521        edit.offset,
1522        point,
1523    )
1524}
1525
1526/// Moves the caret to a click, collapsing the selection and dropping a fresh
1527/// anchor there.
1528///
1529/// ```
1530/// # use pdfrum_doc::vt::{Config, Metrics};
1531/// # use pdfrum_form::edit::ops::{self, TextEdit};
1532/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1533/// # let config = Config {
1534/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1535/// #     font_size: 1.0,
1536/// #     ..Config::default()
1537/// # };
1538///
1539/// let mut edit = TextEdit::new("ABCDE", &config, &metrics, true);
1540/// edit.select_all();
1541/// ops::click_at(&mut edit, &config, &metrics, kurbo::Point::new(-5.0, 10.0));
1542/// assert_eq!(edit.caret_index(), 0);
1543/// assert!(!edit.has_selection());
1544/// // A live anchor, so a following shift-move extends from the click.
1545/// assert!(!edit.selection.is_reset());
1546/// ```
1547pub fn click_at(
1548    edit: &mut TextEdit,
1549    config: &vt::Config,
1550    metrics: &Metrics<'_>,
1551    point: kurbo::Point,
1552) {
1553    let place = place_at_point(edit, config, metrics, point);
1554    edit.previous_caret = edit.caret;
1555    edit.caret = place;
1556    edit.selection = Selection::collapsed_at(place);
1557    edit.sticky_x = caret_x(edit, config, metrics);
1558    scroll_to_caret(edit, config, metrics);
1559}
1560
1561/// Extends the selection to a point, keeping the anchor — a mouse drag.
1562///
1563/// ```
1564/// # use pdfrum_doc::vt::{Config, Metrics};
1565/// # use pdfrum_form::edit::ops::{self, TextEdit};
1566/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1567/// # let config = Config {
1568/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1569/// #     font_size: 1.0,
1570/// #     ..Config::default()
1571/// # };
1572///
1573/// let mut edit = TextEdit::new("ABCDE", &config, &metrics, true);
1574/// // Press at the far left, then drag to the far right.
1575/// ops::click_at(&mut edit, &config, &metrics, kurbo::Point::new(-5.0, 10.0));
1576/// ops::drag_to(&mut edit, &config, &metrics, kurbo::Point::new(900.0, 10.0));
1577/// assert_eq!(edit.selected_text(), "ABCDE");
1578/// ```
1579pub fn drag_to(
1580    edit: &mut TextEdit,
1581    config: &vt::Config,
1582    metrics: &Metrics<'_>,
1583    point: kurbo::Point,
1584) {
1585    let place = place_at_point(edit, config, metrics, point);
1586    edit.previous_caret = edit.caret;
1587    edit.caret = place;
1588    edit.selection.set_active(place);
1589    scroll_to_caret(edit, config, metrics);
1590}
1591
1592/// Selects the whole line under a point — what a double click does.
1593///
1594/// The whole line, deliberately, and not the word under the pointer: a double
1595/// click in a field holding `"Hello World"` selects all of it.
1596///
1597/// ```
1598/// # use pdfrum_doc::vt::{Config, Metrics};
1599/// # use pdfrum_form::edit::ops::{self, TextEdit};
1600/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1601/// # let config = Config {
1602/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1603/// #     font_size: 1.0,
1604/// #     ..Config::default()
1605/// # };
1606///
1607/// let mut edit = TextEdit::new("Hello World", &config, &metrics, true);
1608/// // A double click anywhere on the line takes the whole line, not the word.
1609/// ops::select_line_at(&mut edit, &config, &metrics, kurbo::Point::new(2000.0, 10.0));
1610/// assert_eq!(edit.selected_text(), "Hello World");
1611/// ```
1612pub fn select_line_at(
1613    edit: &mut TextEdit,
1614    config: &vt::Config,
1615    metrics: &Metrics<'_>,
1616    point: kurbo::Point,
1617) {
1618    let place = place_at_point(edit, config, metrics, point);
1619    let (begin, end) = line_bounds(edit, place);
1620    edit.selection = Selection::new(begin, end);
1621    edit.previous_caret = edit.caret;
1622    edit.caret = end;
1623    scroll_to_caret(edit, config, metrics);
1624}
1625
1626/// The first and last places of the line a place sits on.
1627fn line_bounds(edit: &TextEdit, place: Place) -> (Place, Place) {
1628    let begin = Place::new(place.section, place.line, None);
1629    let end = edit
1630        .layout
1631        .sections
1632        .get(place.section as usize)
1633        .and_then(|section| section.lines.get(place.line as usize))
1634        .map_or(begin, |line| {
1635            Place::new(place.section, place.line, line.last_word())
1636        });
1637    (begin, end)
1638}
1639
1640/// The overlay a focused field draws: its caret, or its selection bands.
1641///
1642/// A field showing a selection shows **no** caret — the two are alternatives,
1643/// not additions, which is what the two `form_textfield_selected_*` goldens
1644/// pin against the two `form_textfield_focused_*` ones.
1645///
1646/// ```
1647/// # use pdfrum_doc::vt::{Config, Metrics};
1648/// # use pdfrum_form::edit::ops::{self, TextEdit};
1649/// # let metrics = Metrics { width: &|_| 1000, ascent: 800, descent: -200 };
1650/// # let config = Config {
1651/// #     plate: kurbo::Rect::new(0.0, 0.0, 1000.0, 20.0),
1652/// #     font_size: 1.0,
1653/// #     ..Config::default()
1654/// # };
1655///
1656/// let mut edit = TextEdit::new("Hello", &config, &metrics, true);
1657///
1658/// // Nothing selected: a caret and no bands.
1659/// let overlay = ops::highlight(&edit, &config, &metrics, 1.0);
1660/// assert!(overlay.caret.is_some());
1661/// assert!(overlay.selection.is_empty());
1662///
1663/// // Something selected: bands and NO caret, never both.
1664/// edit.select_all();
1665/// let overlay = ops::highlight(&edit, &config, &metrics, 1.0);
1666/// assert!(overlay.caret.is_none());
1667/// assert!(!overlay.selection.is_empty());
1668/// ```
1669#[must_use]
1670pub fn highlight(
1671    edit: &TextEdit,
1672    config: &vt::Config,
1673    metrics: &Metrics<'_>,
1674    caret_width: f32,
1675) -> Highlight {
1676    if edit.has_selection() {
1677        return Highlight {
1678            caret: None,
1679            selection: selection_bands(edit, config, metrics),
1680        };
1681    }
1682    Highlight {
1683        caret: Some(vt::hit::caret_rect(
1684            &edit.layout,
1685            config.plate,
1686            config,
1687            metrics,
1688            edit.offset,
1689            edit.caret,
1690            caret_width,
1691        )),
1692        selection: Vec::new(),
1693    }
1694}
1695
1696/// The rectangles a selection paints behind its text: **one per selected
1697/// word**, merged where they touch.
1698///
1699/// # Why per word, and not per line
1700///
1701/// The obvious implementation spans one rectangle from the caret at the
1702/// selection's start to the caret at its end, per line. That is right for a
1703/// left-to-right line and **wrong for every other kind**, because it assumes
1704/// the carets advance monotonically with the word index. They do not: on a
1705/// right-to-left line the layout places word 1 at the line's right edge and
1706/// the last word at its left, so the two endpoint carets are the *interior*
1707/// of the run rather than its extremes, and the band collapses to roughly one
1708/// character. Measured on `form_textfield_selected_rtl`, whose ten Hebrew
1709/// characters produced a six-unit band where the oracle paints fifty.
1710///
1711/// Filling one rectangle per **word** — its own extent at the line's ascent
1712/// and descent — is direction-agnostic by construction, which is why it needs
1713/// no right-to-left case. Built from the carets that bound each word, whose
1714/// min and max are that word's extent whichever way the line runs. Touching
1715/// rectangles are merged so a contiguous run is one fill.
1716///
1717/// A **section break** is skipped rather than filled. It occupies an index in
1718/// the flat numbering — the tokenizer counted it, so undo and the caret both
1719/// need it to — but it is not a word, and filling it would produce a rectangle
1720/// spanning from the end of one line to the start of the next.
1721fn selection_bands(
1722    edit: &TextEdit,
1723    config: &vt::Config,
1724    metrics: &Metrics<'_>,
1725) -> Vec<kurbo::Rect> {
1726    let range = edit.selection.range();
1727    let (from, to) = (
1728        vt::hit::word_index_of_place(&edit.layout, range.begin()),
1729        vt::hit::word_index_of_place(&edit.layout, range.end()),
1730    );
1731    if from >= to {
1732        return Vec::new();
1733    }
1734
1735    let caret_at = |index: usize| {
1736        vt::hit::caret_rect(
1737            &edit.layout,
1738            config.plate,
1739            config,
1740            metrics,
1741            edit.offset,
1742            vt::hit::place_of_word_index(&edit.layout, index),
1743            0.0,
1744        )
1745    };
1746
1747    let mut bands: Vec<kurbo::Rect> = Vec::new();
1748    for index in from..to {
1749        if is_section_break(edit, index) {
1750            continue;
1751        }
1752        let word = word_band(index, &caret_at);
1753        match bands.last_mut() {
1754            // Merge into the run being built when the two rectangles share an
1755            // edge and a line. `union` would also swallow a gap; touching is
1756            // the condition, so a band never covers a word outside the
1757            // selection.
1758            Some(last) if touches(*last, word) => *last = last.union(word),
1759            _ => bands.push(word),
1760        }
1761    }
1762    bands
1763}
1764
1765/// Whether a flat index names a **section break** rather than a character.
1766///
1767/// The break between two paragraphs counts as one index, so the place at
1768/// `index` and the place at `index + 1` land on different lines. That is the
1769/// signal, and it needs no access to the tokenizer's own bookkeeping.
1770fn is_section_break(edit: &TextEdit, index: usize) -> bool {
1771    let here = vt::hit::place_of_word_index(&edit.layout, index);
1772    let next = vt::hit::place_of_word_index(&edit.layout, index + 1);
1773    here.section != next.section || here.line != next.line
1774}
1775
1776/// One selected word's rectangle: `[x, x + width]` at the line's ascent and
1777/// descent.
1778///
1779/// Built from the two carets that bound the word, whose min and max are its
1780/// extent whichever way the line runs. That is the whole rule, and it needs no
1781/// right-to-left case of its own — which is the point of taking it per word
1782/// rather than per line.
1783fn word_band(index: usize, caret_at: &impl Fn(usize) -> kurbo::Rect) -> kurbo::Rect {
1784    let (before, after) = (caret_at(index), caret_at(index + 1));
1785    kurbo::Rect::new(
1786        before.x0.min(after.x0),
1787        before.y0.min(after.y0),
1788        before.x1.max(after.x1),
1789        before.y1.max(after.y1),
1790    )
1791}
1792
1793/// Whether two word rectangles sit on one line and share a vertical edge.
1794///
1795/// Either order counts, because a right-to-left run's next word is to the
1796/// *left* of the one before it. The tolerance is a thousandth of a unit:
1797/// consecutive words' carets come from the same accumulated advance, so they
1798/// agree exactly in principle and to within rounding in practice.
1799fn touches(a: kurbo::Rect, b: kurbo::Rect) -> bool {
1800    const EPSILON: f64 = 1e-3;
1801    let same_line = (a.y0 - b.y0).abs() < EPSILON && (a.y1 - b.y1).abs() < EPSILON;
1802    let adjacent = (a.x1 - b.x0).abs() < EPSILON || (b.x1 - a.x0).abs() < EPSILON;
1803    same_line && adjacent
1804}