Skip to main content

teksilo_widgets/common/
editor_runtime.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-frame runtime mechanics shared by every text-editing surface.
5//!
6//! [`RichTextEditor`](crate::rich_text::RichTextEditor),
7//! [`TextInputField`](crate::primitives::TextInputField) and
8//! [`CodeEditor`](crate::code_editor::CodeEditor) are three different widgets
9//! — different documents, different layout strategies, different event
10//! vocabularies — but they share the same *clock*: a caret that blinks
11//! against wall-clock time, a debounce window that coalesces edit bursts, and
12//! a scroll-metric publish step that turns engine content metrics into the
13//! signals their scroll bars bind to.
14//!
15//! Those three mechanisms are what lives here. They were duplicated
16//! byte-for-byte between the first two surfaces before this module existed
17//! (`text_input_field.rs` even carried a `// same as RichTextEditor` comment
18//! on its blink constant), and a third copy for the code editor is what
19//! prompted the extraction: three hand-maintained copies of a timing rule
20//! drift, and drift in *this* rule is invisible in tests and obvious to
21//! users — a caret that blinks at a different rate in one widget than the
22//! next.
23//!
24//! Deliberately **not** here: anything that reads the document, the engine,
25//! or the cursor. Each surface's state struct stays its own. These types own
26//! a timer and nothing else, which is why they can be shared without coupling
27//! three widgets to one another.
28
29use std::cell::Cell;
30use std::rc::Rc;
31use std::time::{Duration, Instant};
32
33use teksilo_core::signal::Signal;
34
35/// Caret blink half-period — the time between on/off toggles, so a full
36/// on→off→on cycle takes twice this. 500 ms is the common desktop default
37/// (Qt's default `QApplication::cursorFlashTime` is 1000 ms *per cycle*).
38const CARET_BLINK_INTERVAL: f32 = 0.5;
39
40/// Debounce window for coalesced signal emission (`text_changed`,
41/// `format_changed`, `undo_redo_changed`). Rapid typing must not hammer
42/// every toolbar observer once per keystroke.
43const DEBOUNCE_WINDOW_SECS: f32 = 0.150;
44
45/// How the caret is presented on a text surface.
46///
47/// Lives here rather than beside one widget's policy bundle because the shared
48/// blink state machine is what interprets it, and all three text surfaces feed
49/// that machine. Re-exported as `rich_text::CaretPolicy` — that is its public
50/// name.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CaretPolicy {
53    /// Caret blinks while the widget has focus (editor preset).
54    Blinking,
55    /// Caret visible but not blinking. Use for focusable surfaces that
56    /// need a visible insertion point without distracting animation —
57    /// e.g. a custom read-only editor the user can navigate and copy
58    /// but that must not suggest editability. Neither built-in preset
59    /// uses this value; construct a custom `PolicyBundle` to opt in.
60    StaticVisible,
61    /// Caret not rendered at all (read-only preset).
62    Hidden,
63}
64
65/// A command a text surface's keyboard layer may emit.
66///
67/// Each surface defines its own vocabulary — the rich text editor's
68/// `EditCommandKind` knows about tables, lists and blockquotes; the code
69/// editor's `CodeCommand` knows about indent levels and line comments and
70/// would be nonsense in prose. What they share is the single question a
71/// read-only preset needs answered, which is this trait.
72pub trait EditorCommand: Copy {
73    /// Whether this command modifies the document. Navigation, selection, and
74    /// copy never do.
75    fn mutates_document(&self) -> bool;
76
77    /// Whether this command can take away text the document already holds —
78    /// removing it, replacing it, or reverting it to an earlier state.
79    ///
80    /// The question [`CommandFilter::ForwardOnly`] needs answered, and a
81    /// strictly narrower set than [`mutates_document`](Self::mutates_document):
82    /// typing a character mutates without ever being regressive, while a
83    /// delete, a cut and an undo are all regressive. Structure commands that
84    /// re-shape a block without dropping any of its characters (leaving a list,
85    /// popping out of a blockquote, changing indent) are **not** regressive —
86    /// blocking them would trap a writer inside a list with no way out.
87    ///
88    /// Implemented as an exhaustive `matches!` over the surface's own command
89    /// enum rather than defaulted here, so adding a command is a compile-time
90    /// prompt to classify it. A forgotten command silently defaulting to
91    /// "harmless" is exactly how a forward-only mode grows a hole.
92    fn is_regressive(&self) -> bool;
93}
94
95/// Command filter consulted before any cursor call in a surface's keyboard
96/// layer.
97///
98/// Generic over the command vocabulary rather than duplicated per surface: the
99/// *rule* ("a read-only surface accepts everything that doesn't mutate") is the
100/// same for prose and for code, only the list of commands differs.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CommandFilter {
103    /// Every command accepted (editor preset).
104    All,
105    /// Mutating commands rejected; navigation and copy/select-all accepted
106    /// (read-only preset).
107    ReadOnly,
108    /// Additive editing only: the document may grow anywhere, but nothing the
109    /// writer already committed can be taken away from the keyboard.
110    ///
111    /// Deletes, word-deletes, cut and undo/redo are rejected; typing, pasting,
112    /// splitting blocks, formatting and every navigation command are accepted.
113    /// Unlike [`ReadOnly`](Self::ReadOnly) the surface is still a real editor —
114    /// caret, accessibility role and clipboard stay editable — so a host
115    /// swapping this in at runtime changes what the keyboard may do and nothing
116    /// else.
117    ///
118    /// ⚠ Rejecting commands is only half of the guarantee. Inserting over a
119    /// selection deletes it one layer below any command vocabulary (in
120    /// `TextCursor::insert_text`), so a surface honouring this filter must also
121    /// collapse the selection before inserting —
122    /// [`collapses_selection_before_insert`](Self::collapses_selection_before_insert)
123    /// is that question, and `RichTextEditor` answers it at every insert site.
124    ForwardOnly,
125}
126
127impl CommandFilter {
128    pub fn accepts<C: EditorCommand>(&self, cmd: C) -> bool {
129        match self {
130            Self::All => true,
131            // Everything that doesn't touch the document is fair game: a
132            // read-only surface still navigates, selects, and copies.
133            Self::ReadOnly => !cmd.mutates_document(),
134            // Everything that doesn't take text away: the draft only grows.
135            Self::ForwardOnly => !cmd.is_regressive(),
136        }
137    }
138
139    /// Whether an insertion must first collapse the selection instead of
140    /// replacing it.
141    ///
142    /// True only for [`ForwardOnly`](Self::ForwardOnly). Type-over is a
143    /// *delete* that never passes through a command filter, so the insert sites
144    /// themselves ask this question and move the caret to the end of the
145    /// selection first — the typed text lands after the selected passage rather
146    /// than in place of it, and the keystroke is neither lost nor destructive.
147    pub fn collapses_selection_before_insert(&self) -> bool {
148        matches!(self, Self::ForwardOnly)
149    }
150
151    /// Whether a command may replace document content wholesale (an
152    /// assistive-technology `SetValue`, which swaps the entire document for a
153    /// new string).
154    ///
155    /// Only [`All`](Self::All) permits it: under `ReadOnly` nothing may be
156    /// written, and under `ForwardOnly` a whole-document replacement is the
157    /// single most regressive edit there is, however additive the text arriving
158    /// with it looks.
159    pub fn allows_wholesale_replacement(&self) -> bool {
160        matches!(self, Self::All)
161    }
162}
163
164/// Drives the AccessKit role a text surface reports.
165///
166/// Deliberately only two values. Both map to roles that
167/// `accesskit_consumer::Node::supports_text_ranges()` accepts, which is a hard
168/// requirement rather than a preference: a role outside that set (`Role::Code`,
169/// `Role::Log`) silently disables caret and selection reporting through the
170/// platform accessibility layer, so a screen reader could read the text once on
171/// focus but never track the cursor through it. A surface wanting log-style
172/// announcements pairs `Document` with an explicit `Live` — live-region
173/// behaviour is an independent property, not a role.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum AccessibilityRole {
176    /// `Role::MultilineTextInput` — editable.
177    Editor,
178    /// `Role::Document` — read-only body of text.
179    Document,
180}
181
182/// Clipboard surface exposed by a text widget.
183///
184/// The command filter already rejects cut/paste for a read-only preset; this
185/// drives UI affordances such as disabled menu items.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum ClipboardPolicy {
188    Full,
189    CopyAndSelectAllOnly,
190}
191
192impl ClipboardPolicy {
193    pub fn allows_cut(&self) -> bool {
194        matches!(self, Self::Full)
195    }
196    pub fn allows_paste(&self) -> bool {
197        matches!(self, Self::Full)
198    }
199    /// `PasteUnformatted` mirrors `Paste` today: both are gated by the
200    /// same policy bit. Kept as a separate accessor so a future preset
201    /// that admits plain-only paste while rejecting rich paste can
202    /// diverge without changing call sites.
203    pub fn allows_paste_unformatted(&self) -> bool {
204        matches!(self, Self::Full)
205    }
206    /// Always `true` — copying is allowed under every policy, including
207    /// `CopyAndSelectAllOnly`. Provided as a method (rather than a
208    /// hardcoded literal at call sites) so a future preset can diverge
209    /// without changing callers.
210    pub fn allows_copy(&self) -> bool {
211        true
212    }
213}
214
215/// One bundle per construction preset: the single source of truth for the four
216/// independent decisions that separate an editable surface from a viewer.
217///
218/// Shared by every text surface. The bundle is what lets a widget never consult
219/// a `read_only: bool` flag — each dimension is decided once, at construction,
220/// and read where it matters.
221#[derive(Debug, Clone, Copy)]
222pub struct PolicyBundle {
223    pub command_filter: CommandFilter,
224    pub caret_policy: CaretPolicy,
225    pub access_role: AccessibilityRole,
226    pub clipboard_policy: ClipboardPolicy,
227}
228
229impl PolicyBundle {
230    pub const fn is_read_only(&self) -> bool {
231        matches!(self.access_role, AccessibilityRole::Document)
232    }
233
234    /// This bundle with a different command filter, every other dimension
235    /// untouched.
236    ///
237    /// The only supported way to build a restricted-but-still-editable preset:
238    /// caret, accessibility role and clipboard describe *what kind of surface
239    /// this is*, and a mode that merely narrows what the keyboard may do must
240    /// not quietly turn an editor into a document.
241    pub const fn with_command_filter(self, command_filter: CommandFilter) -> Self {
242        Self {
243            command_filter,
244            ..self
245        }
246    }
247}
248
249/// The full editor preset: every command accepted, caret blinks,
250/// `MultilineTextInput` role, full clipboard support.
251pub const EDITOR_PRESET: PolicyBundle = PolicyBundle {
252    command_filter: CommandFilter::All,
253    caret_policy: CaretPolicy::Blinking,
254    access_role: AccessibilityRole::Editor,
255    clipboard_policy: ClipboardPolicy::Full,
256};
257
258/// The read-only preset: only navigation + copy/select-all, `Document` role,
259/// no cut/paste. The caret is hidden entirely — view-only widgets ship without
260/// any caret affordance. Applications that need a focusable read-only surface
261/// with a visible caret can construct a custom preset via [`PolicyBundle`].
262pub const READ_ONLY_PRESET: PolicyBundle = PolicyBundle {
263    command_filter: CommandFilter::ReadOnly,
264    caret_policy: CaretPolicy::Hidden,
265    access_role: AccessibilityRole::Document,
266    clipboard_policy: ClipboardPolicy::CopyAndSelectAllOnly,
267};
268
269/// Wall-clock caret blink state machine.
270///
271/// Blinks against `Instant::now()` rather than accumulating `delta`, so the
272/// visible cadence stays locked to real seconds no matter how the frame
273/// scheduler behaves: if ticks are skipped, delayed, or clamped, the next
274/// tick catches up instead of the blink slowing down with the frame rate.
275#[derive(Debug, Default)]
276pub(crate) struct CaretBlink {
277    last_toggle: Option<Instant>,
278}
279
280impl CaretBlink {
281    pub(crate) fn new() -> Self {
282        Self { last_toggle: None }
283    }
284
285    /// Restart the blink phase, so the next toggle is a full interval away.
286    ///
287    /// Call on focus gain and after every caret move: a caret that happens to
288    /// be mid-off when the user moves it reads as a dropped keystroke, so every
289    /// editor restarts the phase on motion rather than letting the toggle land
290    /// wherever it falls.
291    ///
292    /// **Does not itself show the caret** — the caller must set
293    /// `caret_visible` alongside this. That split is deliberate rather than an
294    /// oversight: `sync_cursor_signals` has to publish the signal *after*
295    /// dropping its `RefCell` borrow of the editor state (a `Signal::set` fans
296    /// out to observers synchronously, and an observer that reaches back into
297    /// the widget would panic on the live borrow), so this type cannot own the
298    /// write. Restarting without also setting `caret_visible` leaves the caret
299    /// dark for up to one full interval after a cursor move — the exact
300    /// symptom this method exists to prevent.
301    pub(crate) fn restart(&mut self) {
302        self.last_toggle = Some(Instant::now());
303    }
304
305    /// Forget the phase entirely (next `tick` starts a fresh interval).
306    pub(crate) fn reset(&mut self) {
307        self.last_toggle = None;
308    }
309
310    /// Drive one frame of blinking.
311    ///
312    /// `active` is `has_focus && window_active` — a caret in an unfocused
313    /// widget or an inactive window is hidden, which is the universal desktop
314    /// convention (Qt / Cocoa / Win32 / GTK all do this).
315    ///
316    /// `wake_at` is the tree's one-shot wake-up slot. The blink schedules its
317    /// next toggle there so the event loop can idle in `WaitUntil` between
318    /// toggles. Without it a blinking caret would have to keep the frame loop
319    /// pumping at the OS's maximum rate (~90 fps was observed) to catch a
320    /// transition that happens twice a second.
321    pub(crate) fn tick(
322        &mut self,
323        policy: CaretPolicy,
324        active: bool,
325        caret_visible: &Signal<bool>,
326        wake_at: Option<&Rc<Cell<Option<Instant>>>>,
327    ) {
328        let blinking = active && policy == CaretPolicy::Blinking;
329        if blinking {
330            let now = Instant::now();
331            let interval = Duration::from_secs_f32(CARET_BLINK_INTERVAL);
332            match self.last_toggle {
333                None => self.last_toggle = Some(now),
334                Some(last) if now.saturating_duration_since(last) >= interval => {
335                    self.last_toggle = Some(now);
336                    let was = caret_visible.get();
337                    caret_visible.set(!was);
338                }
339                _ => {}
340            }
341            if let (Some(last), Some(wake)) = (self.last_toggle, wake_at) {
342                let next = last + interval;
343                // Never push an earlier pending wake-up later — another
344                // subsystem may need the loop awake before our next toggle.
345                let merged = match wake.get() {
346                    Some(existing) if existing <= next => existing,
347                    _ => next,
348                };
349                wake.set(Some(merged));
350            }
351            return;
352        }
353
354        self.last_toggle = None;
355        match policy {
356            CaretPolicy::Blinking => {
357                // Not active: caret off.
358                if caret_visible.get() {
359                    caret_visible.set(false);
360                }
361            }
362            CaretPolicy::StaticVisible => {
363                // A static caret still hides in an inactive window; it just
364                // doesn't animate while shown.
365                if caret_visible.get() != active {
366                    caret_visible.set(active);
367                }
368            }
369            // Hidden never renders a caret — the signal is seeded false and
370            // nothing here should flip it on.
371            CaretPolicy::Hidden => {}
372        }
373    }
374}
375
376/// Fixed-window coalescing timer.
377///
378/// Owns *only* the timer. Which flags to drain and what to publish differ per
379/// surface (the rich text editor debounces text + format + undo/redo, the
380/// plain input only text + undo/redo), so the caller keeps its own flags and
381/// asks this type one question: has the window elapsed?
382#[derive(Debug)]
383pub(crate) struct Debounce {
384    timer: f32,
385}
386
387impl Default for Debounce {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393impl Debounce {
394    /// Start already-expired, so the first frame after construction publishes
395    /// initial state (`can_undo` / `can_redo`) immediately instead of making
396    /// a freshly-built toolbar wait 150 ms to render correctly.
397    pub(crate) fn new() -> Self {
398        Self { timer: 1.0 }
399    }
400
401    /// Advance by `delta` seconds. Returns `true` exactly on the frames where
402    /// the window has elapsed, resetting itself for the next window.
403    pub(crate) fn tick(&mut self, delta: f32) -> bool {
404        self.timer += delta;
405        if self.timer >= DEBOUNCE_WINDOW_SECS {
406            self.timer = 0.0;
407            return true;
408        }
409        false
410    }
411}
412
413/// The scroll numbers a text surface publishes each frame, derived from the
414/// engine's content metrics and the current viewport.
415#[derive(Debug, Clone, Copy, PartialEq)]
416pub(crate) struct ScrollMetrics {
417    pub max_x: f32,
418    pub max_y: f32,
419    pub ratio_x: f32,
420    pub ratio_y: f32,
421}
422
423impl ScrollMetrics {
424    /// Derive the metrics from the engine's content size and the viewport.
425    ///
426    /// A ratio is the visible fraction of the content on that axis, which is
427    /// what a scroll bar sizes its thumb from. It is `1.0` (full thumb, i.e.
428    /// "everything is visible") when the axis has no content or no viewport —
429    /// a zero-height thumb on an empty document would read as a bug.
430    pub(crate) fn compute(
431        content_height: f32,
432        max_content_width: f32,
433        viewport_width: f32,
434        viewport_height: f32,
435    ) -> Self {
436        Self {
437            max_x: (max_content_width - viewport_width).max(0.0),
438            max_y: (content_height - viewport_height).max(0.0),
439            ratio_x: if max_content_width > 0.0 && viewport_width > 0.0 {
440                (viewport_width / max_content_width).clamp(0.0, 1.0)
441            } else {
442                1.0
443            },
444            ratio_y: if content_height > 0.0 && viewport_height > 0.0 {
445                (viewport_height / content_height).clamp(0.0, 1.0)
446            } else {
447                1.0
448            },
449        }
450    }
451
452    /// Publish into the surface's signals, and clamp the live scroll offsets
453    /// to the fresh maxima.
454    ///
455    /// Every write is guarded by a change-check because `Signal::set` clones
456    /// and fans out to every observer unconditionally — it has no internal
457    /// `PartialEq` skip — so re-setting an unchanged value still walks every
458    /// scroll bar and layout listener. This ran to ~5 % of frame CPU in
459    /// `set<f32>` on a flamegraph before the guards were added.
460    ///
461    /// The clamp is why this is one method rather than four setters: deleting
462    /// text shrinks `max_y`, and a scroll offset left beyond the new maximum
463    /// would leave the view parked past the end of the document.
464    pub(crate) fn publish(
465        &self,
466        scroll_x: &Signal<f32>,
467        scroll_y: &Signal<f32>,
468        max_scroll_x: &Signal<f32>,
469        max_scroll_y: &Signal<f32>,
470        viewport_ratio_x: &Signal<f32>,
471        viewport_ratio_y: &Signal<f32>,
472    ) {
473        max_scroll_x.set_if_changed(self.max_x);
474        max_scroll_y.set_if_changed(self.max_y);
475        viewport_ratio_x.set_if_changed(self.ratio_x);
476        viewport_ratio_y.set_if_changed(self.ratio_y);
477        scroll_x.set_if_changed(scroll_x.get().clamp(0.0, self.max_x));
478        scroll_y.set_if_changed(scroll_y.get().clamp(0.0, self.max_y));
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn wake_slot() -> Rc<Cell<Option<Instant>>> {
487        Rc::new(Cell::new(None))
488    }
489
490    #[test]
491    fn blink_hides_caret_when_not_active() {
492        let mut blink = CaretBlink::new();
493        let visible = Signal::new(true);
494        blink.tick(CaretPolicy::Blinking, false, &visible, None);
495        assert!(!visible.get(), "an unfocused caret must not be drawn");
496    }
497
498    #[test]
499    fn blink_does_not_toggle_before_the_interval() {
500        let mut blink = CaretBlink::new();
501        let visible = Signal::new(true);
502        // First tick only seeds the phase.
503        blink.tick(CaretPolicy::Blinking, true, &visible, None);
504        // Second tick, immediately after: far short of 500 ms.
505        blink.tick(CaretPolicy::Blinking, true, &visible, None);
506        assert!(visible.get(), "caret must not toggle within the interval");
507    }
508
509    #[test]
510    fn blink_toggles_once_the_interval_has_elapsed() {
511        let mut blink = CaretBlink::new();
512        let visible = Signal::new(true);
513        // Backdate the phase past the interval instead of sleeping.
514        blink.last_toggle =
515            Some(Instant::now() - Duration::from_secs_f32(CARET_BLINK_INTERVAL + 0.01));
516        blink.tick(CaretPolicy::Blinking, true, &visible, None);
517        assert!(!visible.get(), "caret must toggle after the interval");
518    }
519
520    #[test]
521    fn blink_schedules_a_wake_up_so_the_loop_can_idle() {
522        let mut blink = CaretBlink::new();
523        let visible = Signal::new(true);
524        let wake = wake_slot();
525        blink.tick(CaretPolicy::Blinking, true, &visible, Some(&wake));
526        assert!(
527            wake.get().is_some(),
528            "a blinking caret must schedule its next toggle, else the event \
529             loop has to poll at max rate to catch it"
530        );
531    }
532
533    #[test]
534    fn blink_never_delays_an_earlier_pending_wake_up() {
535        let mut blink = CaretBlink::new();
536        let visible = Signal::new(true);
537        let wake = wake_slot();
538        let sooner = Instant::now() + Duration::from_millis(10);
539        wake.set(Some(sooner));
540        blink.tick(CaretPolicy::Blinking, true, &visible, Some(&wake));
541        assert_eq!(
542            wake.get(),
543            Some(sooner),
544            "another subsystem's earlier wake-up must survive — pushing it \
545             out to our toggle would stall whatever needed it"
546        );
547    }
548
549    #[test]
550    fn hidden_policy_never_shows_the_caret() {
551        let mut blink = CaretBlink::new();
552        let visible = Signal::new(false);
553        blink.tick(CaretPolicy::Hidden, true, &visible, None);
554        assert!(
555            !visible.get(),
556            "a hidden caret must stay hidden when focused"
557        );
558    }
559
560    #[test]
561    fn static_visible_tracks_activity_without_blinking() {
562        let mut blink = CaretBlink::new();
563        let visible = Signal::new(false);
564        blink.tick(CaretPolicy::StaticVisible, true, &visible, None);
565        assert!(visible.get(), "a static caret shows while active");
566        blink.tick(CaretPolicy::StaticVisible, false, &visible, None);
567        assert!(!visible.get(), "a static caret hides when inactive");
568    }
569
570    /// `restart` buys the caret a full interval of stillness. This is what
571    /// keeps it lit while the user holds an arrow key: every move restarts the
572    /// phase, so the toggle never lands mid-motion.
573    #[test]
574    fn restart_delays_the_next_toggle_by_a_full_interval() {
575        let mut blink = CaretBlink::new();
576        let visible = Signal::new(true);
577        // Phase is already one interval old: the next tick would toggle.
578        blink.last_toggle =
579            Some(Instant::now() - Duration::from_secs_f32(CARET_BLINK_INTERVAL + 0.01));
580        blink.restart();
581        blink.tick(CaretPolicy::Blinking, true, &visible, None);
582        assert!(
583            visible.get(),
584            "restart must push the pending toggle out by a full interval, else \
585             the caret blinks off mid-keystroke"
586        );
587    }
588
589    /// The counterpart to the doc contract: `restart` deliberately does not
590    /// write `caret_visible` (the caller must, outside its state borrow). A
591    /// caller that forgets leaves the caret dark for an interval, so pin the
592    /// split here rather than let a reader assume either way.
593    #[test]
594    fn restart_does_not_itself_show_the_caret() {
595        let mut blink = CaretBlink::new();
596        let visible = Signal::new(false);
597        blink.restart();
598        blink.tick(CaretPolicy::Blinking, true, &visible, None);
599        assert!(
600            !visible.get(),
601            "restart seeds the phase only — showing the caret is the caller's"
602        );
603    }
604
605    #[test]
606    fn debounce_starts_expired_so_initial_state_publishes_at_once() {
607        let mut d = Debounce::new();
608        assert!(
609            d.tick(0.0),
610            "a freshly built toolbar must not wait a window to show correct \
611             undo/redo state"
612        );
613    }
614
615    #[test]
616    fn debounce_coalesces_within_the_window() {
617        let mut d = Debounce::new();
618        assert!(d.tick(0.0));
619        assert!(!d.tick(0.05));
620        assert!(!d.tick(0.05));
621        assert!(d.tick(0.05), "0.15s total must close the window");
622    }
623
624    #[test]
625    fn scroll_metrics_report_no_overflow_when_content_fits() {
626        let m = ScrollMetrics::compute(50.0, 80.0, 100.0, 100.0);
627        assert_eq!(m.max_x, 0.0);
628        assert_eq!(m.max_y, 0.0);
629        assert_eq!(m.ratio_x, 1.0);
630        assert_eq!(m.ratio_y, 1.0);
631    }
632
633    #[test]
634    fn scroll_metrics_report_overflow_when_content_exceeds_viewport() {
635        // 200pt of content against a 100px viewport → max 100, ratio 0.5.
636        let m = ScrollMetrics::compute(200.0, 200.0, 100.0, 100.0);
637        assert_eq!(m.max_y, 100.0);
638        assert!((m.ratio_y - 0.5).abs() < 1e-6);
639    }
640
641    #[test]
642    fn scroll_metrics_ratio_is_full_on_an_empty_document() {
643        let m = ScrollMetrics::compute(0.0, 0.0, 100.0, 100.0);
644        assert_eq!(
645            m.ratio_y, 1.0,
646            "an empty document must show a full thumb, not a zero-height one"
647        );
648    }
649
650    /// The limits and ratios are what every scroll bar binds to. Without this,
651    /// dropping a publish line leaves all 14 tests here green and surfaces
652    /// only as an unrelated rich-text affinity test failing — which sends the
653    /// next maintainer debugging the wrong subsystem.
654    #[test]
655    fn publish_writes_every_limit_and_ratio_signal() {
656        let (sx, sy) = (Signal::new(0.0), Signal::new(0.0));
657        let (mx, my) = (Signal::new(0.0), Signal::new(0.0));
658        let (rx, ry) = (Signal::new(1.0), Signal::new(1.0));
659        // 400x200 of content in a 100x100 viewport: overflows on both axes.
660        let m = ScrollMetrics::compute(200.0, 400.0, 100.0, 100.0);
661        m.publish(&sx, &sy, &mx, &my, &rx, &ry);
662
663        assert_eq!(
664            mx.get(),
665            300.0,
666            "horizontal limit must reach the scroll bar"
667        );
668        assert_eq!(my.get(), 100.0, "vertical limit must reach the scroll bar");
669        assert!(
670            (rx.get() - 0.25).abs() < 1e-6,
671            "horizontal thumb ratio must reach the scroll bar, got {}",
672            rx.get()
673        );
674        assert!(
675            (ry.get() - 0.5).abs() < 1e-6,
676            "vertical thumb ratio must reach the scroll bar, got {}",
677            ry.get()
678        );
679    }
680
681    #[test]
682    fn publish_clamps_a_scroll_offset_left_past_the_new_end() {
683        let (sx, sy) = (Signal::new(0.0), Signal::new(500.0));
684        let (mx, my) = (Signal::new(0.0), Signal::new(500.0));
685        let (rx, ry) = (Signal::new(1.0), Signal::new(1.0));
686        // The document shrank: content now fits entirely.
687        let m = ScrollMetrics::compute(50.0, 50.0, 100.0, 100.0);
688        m.publish(&sx, &sy, &mx, &my, &rx, &ry);
689        assert_eq!(
690            sy.get(),
691            0.0,
692            "deleting text must not leave the view parked past the end"
693        );
694    }
695}