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}