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}