Skip to main content

teksilo_core/
text_touch.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Touch text editing: the contract a text surface implements, and the
5//! controller that turns a finger into a selection.
6//!
7//! # Why this lives in `teksilo-core`
8//!
9//! Several independent surfaces select text: the rich-text editor, the
10//! single-line field, the code editor — and the terminal, whose grid selection
11//! is read-only. The terminal deliberately does not depend on
12//! `teksilo-widgets`, so a controller that lived there could not serve it. (An
13//! embedded `WebView` is not on this list: its engine owns the page's selection
14//! and paints its own handles.)
15//! Everything a surface needs therefore ships here: the [`TextHitSource`]
16//! contract, the [`TouchSelection`] controller, the published affordance
17//! geometry, and the affordance widgets. What does *not* ship here is which
18//! colours they use — that is a Tier-3 recipe
19//! ([`TextSelectionStyle`](crate::styles::TextSelectionStyle)) whose shipped
20//! default lives in `teksilo-widgets` with every other `Recipe*Style`.
21//!
22//! # The mouse is untouched
23//!
24//! Every **pointer** entry point begins by asking
25//! [`PointerKind::is_direct`](teksilo_tokens::PointerKind::is_direct) and
26//! returns [`EventResponse::Ignored`](crate::event::EventResponse)
27//! without touching any state when the answer is no. A mouse — and a legacy
28//! event, which the router reports as the mouse at the tree epoch — therefore
29//! reaches none of this code, and an editor that installs the controller edits
30//! byte for byte as it did before. A stylus **is** direct and gets the full
31//! affordance set: a pen selects text the way a finger does, and its own
32//! precision is expressed through its gesture profile rather than by hiding the
33//! handles.
34//!
35//! # Coordinates
36//!
37//! Every point and rectangle crossing this contract is in **window** logical
38//! coordinates — the space `WidgetEvent::PointerDown::position` arrives in and
39//! the space an overlay is positioned in. A host whose engine works in
40//! document coordinates converts on the way in and on the way out; doing it
41//! anywhere else means the handles and the caret disagree the moment the
42//! editor is scrolled.
43//!
44//! # What a host owes
45//!
46//! See `docs/text-touch-editing.md` for the checklist. In outline: implement
47//! [`TextHitSource`], own a [`TouchSelection`], forward pointer events and the
48//! long press to it, mount a [`TextAffordanceLayer`] in the
49//! [`TextAffordance`](crate::overlay::OverlayBand::TextAffordance) band, and
50//! dismiss the controller when focus, content or read-only status changes.
51
52pub mod affordance_layer;
53pub mod magnifier;
54
55use std::cell::RefCell;
56use std::ops::Range;
57use std::rc::Rc;
58
59use teksilo_canvas::{Point, Rect};
60use teksilo_tokens::{InputTokens, TargetRole};
61
62use crate::environment::LayoutDirection;
63use crate::event::{EventResponse, WidgetEvent};
64use crate::overlay::direction::HorizontalSide;
65use crate::overlay::{OverlayPlacement, SelectionHandleKind};
66use crate::signal::Signal;
67use crate::styles::TextSelectionHandleRecipe;
68use crate::styles::density::dp;
69use crate::widget::EventContext;
70
71pub use affordance_layer::{
72    SelectionHandle, TextAffordanceDelegate, TextAffordanceLayer, TextMagnifier,
73};
74pub use magnifier::MagnifierRequest;
75
76/// Diameter of a selection handle's painted disc, in dp.
77///
78/// A `Decoration` dimension: the same at every density. Conformance is carried
79/// by [`HANDLE_HIT_SIZE`], which is what the pointer actually meets.
80pub const HANDLE_DIAMETER: f32 = 24.0;
81
82/// Extent of a selection handle's square hit rectangle, in dp.
83///
84/// A `Target` dimension, so the density floor can only raise it — and it is
85/// specified at the size the *tallest* rung asks for, so no shipped ladder
86/// raises it and a handle is the same size to a fingertip at every density.
87pub const HANDLE_HIT_SIZE: f32 = 44.0;
88
89/// Width of the stem drawn from a handle's disc to the caret it marks, in dp.
90pub const HANDLE_STEM_WIDTH: f32 = 2.0;
91
92/// One command a selection toolbar may offer.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
94pub enum TextAction {
95    Cut,
96    Copy,
97    Paste,
98    SelectAll,
99    /// A host-defined command, named by a stable key the host resolves to a
100    /// label and a handler — "Look Up", "Translate", "Add to dictionary".
101    Custom(&'static str),
102}
103
104/// Which clipboard commands a surface will honour **right now**.
105///
106/// Derived rather than declared: the default
107/// [`TextHitSource::clipboard_actions`] computes every field from
108/// `is_editable`, `allows_copy` and the current selection, so a surface cannot
109/// offer a Cut it would refuse. Override it only to add or remove a command for
110/// a reason those three do not capture.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112pub struct ClipboardActions {
113    pub cut: bool,
114    pub copy: bool,
115    pub paste: bool,
116    pub select_all: bool,
117}
118
119impl ClipboardActions {
120    /// The commands as a toolbar orders them: destructive first, then the two
121    /// that add, then the one that widens.
122    pub fn to_actions(self) -> Vec<TextAction> {
123        let mut actions = Vec::new();
124        if self.cut {
125            actions.push(TextAction::Cut);
126        }
127        if self.copy {
128            actions.push(TextAction::Copy);
129        }
130        if self.paste {
131            actions.push(TextAction::Paste);
132        }
133        if self.select_all {
134            actions.push(TextAction::SelectAll);
135        }
136        actions
137    }
138}
139
140/// The geometry and selection state of one text surface, as the touch
141/// controller needs to see it.
142///
143/// # Relationship to [`TextSurface`](crate::text_surface::TextSurface)
144///
145/// The two do not overlap and neither subsumes the other. `TextSurface` answers
146/// *"perform this command"* — undo, cut, paste — for a host that routes a
147/// chord or a menu row into whatever has focus. This trait answers *"where is
148/// the text"*, which is what a finger needs. The one place they meet is
149/// [`clipboard_actions`](Self::clipboard_actions), which says which commands to
150/// **offer**; invoking them stays with `TextSurface`, so there is exactly one
151/// implementation of each command.
152pub trait TextHitSource {
153    /// The text offset nearest `point`.
154    fn offset_at(&self, point: Point) -> usize;
155
156    /// The caret rectangle at `offset` — a thin, line-height-tall rectangle.
157    /// Handles hang off it, so a host that returns a zero-height rectangle gets
158    /// handles that sit on the baseline.
159    fn caret_rect(&self, offset: usize) -> Rect;
160
161    /// The word containing `offset`, for the long-press selection.
162    fn word_range_at(&self, offset: usize) -> Range<usize>;
163
164    /// The line containing `offset`.
165    fn line_range_at(&self, offset: usize) -> Range<usize>;
166
167    /// The current selection, as a half-open offset range. An empty range is a
168    /// collapsed caret.
169    fn selection(&self) -> Range<usize>;
170
171    /// Move the selection. Called on every sample of a handle drag, so an
172    /// implementation that rebuilds the document here will be felt.
173    fn set_selection(&mut self, range: Range<usize>);
174
175    /// The selection's bounding rectangle, or `None` when nothing is selected.
176    ///
177    /// Deliberately one rectangle and not a list of per-line rectangles: the
178    /// only consumer is the toolbar's
179    /// [`AboveSelection`](crate::overlay::OverlayPlacement::AboveSelection)
180    /// placement, which wants the block to hang above, and the engines behind
181    /// the shipped editors expose a union box rather than per-line geometry. A
182    /// surface that has per-line rectangles should still return their union.
183    fn selection_bounds(&self) -> Option<Rect>;
184
185    /// The visible rectangle of this surface. Affordances are clamped into it,
186    /// and a caret outside it has no handle.
187    fn viewport(&self) -> Rect;
188
189    /// The largest offset a caret may take — the length of the text.
190    ///
191    /// Not geometry, and not in the original contract: a handle is exposed to
192    /// assistive technology as a slider, and a slider without a maximum
193    /// announces a position out of nothing. Every engine behind the shipped
194    /// editors knows this number.
195    fn document_len(&self) -> usize;
196
197    /// Does this surface accept edits? A read-only surface shows no caret
198    /// handle and offers only what it can honour.
199    fn is_editable(&self) -> bool;
200
201    /// May its contents leave the process at all? A password field says no, and
202    /// then neither Cut nor Copy is offered.
203    fn allows_copy(&self) -> bool {
204        true
205    }
206
207    /// Which clipboard commands to offer for the current state. Derived from
208    /// the three questions above; see [`ClipboardActions`].
209    fn clipboard_actions(&self) -> ClipboardActions {
210        let editable = self.is_editable();
211        let has_selection = !self.selection().is_empty();
212        ClipboardActions {
213            cut: editable && has_selection && self.allows_copy(),
214            copy: has_selection && self.allows_copy(),
215            paste: editable,
216            // Widening a selection is only an offer while there is something
217            // left to widen to; with a range already up, the toolbar's other
218            // commands are what the user came for.
219            select_all: !has_selection,
220        }
221    }
222}
223
224/// The painted and hittable geometry of one selection handle.
225#[derive(Debug, Clone, Copy, PartialEq)]
226pub struct SelectionHandleGeometry {
227    /// Which handle this is, in **logical** order.
228    pub kind: SelectionHandleKind,
229    /// The text offset this handle marks — the slider value assistive
230    /// technology reads and writes.
231    pub offset: usize,
232    /// The largest offset the surface has, so the slider node has a maximum.
233    pub document_len: usize,
234    /// Which physical side of the selection it is on, resolved once through
235    /// [`SelectionHandleKind::side`] so a mirror can never be applied twice.
236    /// `None` for the caret handle, which has no side.
237    pub side: Option<HorizontalSide>,
238    /// The caret rectangle this handle marks.
239    pub caret: Rect,
240    /// Centre of the painted disc.
241    pub anchor: Point,
242    /// The painted disc's bounding square.
243    pub visual: Rect,
244    /// The square that accepts the press. Never smaller than the disc, and
245    /// nudged so as much of it as possible lies inside the viewport.
246    pub hit: Rect,
247}
248
249/// The two dimensions a handle is built from. Taken from the active
250/// [`TextSelectionStyle`](crate::styles::TextSelectionStyle) so the controller
251/// that hit-tests a handle and the widget that paints it cannot disagree.
252#[derive(Debug, Clone, Copy, PartialEq)]
253pub struct HandleMetrics {
254    /// Diameter of the painted disc.
255    pub diameter: f32,
256    /// Extent of the square hit rectangle.
257    pub hit: f32,
258}
259
260impl HandleMetrics {
261    /// The shipped dimensions projected onto a density ladder.
262    pub fn for_tokens(tokens: &InputTokens) -> Self {
263        Self {
264            diameter: dp(HANDLE_DIAMETER, TargetRole::Decoration, tokens),
265            hit: dp(HANDLE_HIT_SIZE, TargetRole::Target, tokens),
266        }
267    }
268}
269
270impl Default for HandleMetrics {
271    fn default() -> Self {
272        Self::for_tokens(&InputTokens::default())
273    }
274}
275
276impl From<&TextSelectionHandleRecipe> for HandleMetrics {
277    fn from(recipe: &TextSelectionHandleRecipe) -> Self {
278        Self {
279            diameter: recipe.diameter,
280            hit: recipe.hit_size,
281        }
282    }
283}
284
285/// The selection toolbar the controller wants raised.
286#[derive(Debug, Clone, PartialEq)]
287pub struct SelectionToolbarRequest {
288    /// The commands to offer, in toolbar order.
289    pub actions: Vec<TextAction>,
290    /// The rectangle to hang above — the selection's bounds, or the caret's
291    /// when nothing is selected.
292    pub anchor: Rect,
293}
294
295impl SelectionToolbarRequest {
296    /// The placement to raise this toolbar with.
297    ///
298    /// [`AboveSelection`](OverlayPlacement::AboveSelection) already flips below
299    /// when the selection is against the top of the usable area, so a host
300    /// needs no fallback of its own.
301    pub fn placement(&self) -> OverlayPlacement {
302        OverlayPlacement::AboveSelection {
303            selection: self.anchor,
304        }
305    }
306}
307
308// ---------------------------------------------------------------------------
309// Published affordance state
310// ---------------------------------------------------------------------------
311
312#[derive(Debug, Default, Clone, PartialEq)]
313struct AffordanceState {
314    handles: Vec<SelectionHandleGeometry>,
315    magnifier: Option<MagnifierRequest>,
316    toolbar: Option<SelectionToolbarRequest>,
317}
318
319/// A cloneable view of what a [`TouchSelection`] currently wants shown.
320///
321/// The handle and magnifier widgets read this rather than the controller, so
322/// the affordance layer can be mounted once, in an overlay, while the
323/// controller stays inside the editor that owns the text. Clone it to share;
324/// every clone sees the same state.
325#[derive(Clone)]
326pub struct TextAffordances {
327    inner: Rc<RefCell<AffordanceState>>,
328    version: Signal<u64>,
329}
330
331impl Default for TextAffordances {
332    fn default() -> Self {
333        Self {
334            inner: Rc::new(RefCell::new(AffordanceState::default())),
335            version: Signal::new(0),
336        }
337    }
338}
339
340impl std::fmt::Debug for TextAffordances {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        let state = self.inner.borrow();
343        f.debug_struct("TextAffordances")
344            .field("handles", &state.handles.len())
345            .field("magnifier", &state.magnifier.is_some())
346            .field("toolbar", &state.toolbar.is_some())
347            .finish()
348    }
349}
350
351impl TextAffordances {
352    /// A view with nothing shown.
353    pub fn new() -> Self {
354        Self::default()
355    }
356
357    /// Bumped whenever anything below changes. Bind it at
358    /// [`Relayout`](crate::binding::BindingLevel::Relayout) — a handle that
359    /// moved has to be re-placed, not merely repainted.
360    pub fn version_signal(&self) -> Signal<u64> {
361        self.version.clone()
362    }
363
364    /// Every handle currently wanted, in `Caret` / `Start` / `End` order.
365    pub fn handles(&self) -> Vec<SelectionHandleGeometry> {
366        self.inner.borrow().handles.clone()
367    }
368
369    /// The handle of a given kind, if it is wanted.
370    pub fn handle(&self, kind: SelectionHandleKind) -> Option<SelectionHandleGeometry> {
371        self.inner
372            .borrow()
373            .handles
374            .iter()
375            .find(|h| h.kind == kind)
376            .copied()
377    }
378
379    /// Whether a handle of `kind` is wanted — for a `visible_when` gate.
380    pub fn handle_visible_signal(&self, kind: SelectionHandleKind) -> Signal<bool> {
381        let inner = Rc::clone(&self.inner);
382        self.version
383            .map(move |_| inner.borrow().handles.iter().any(|h| h.kind == kind))
384    }
385
386    /// The magnifier, while one is raised.
387    pub fn magnifier(&self) -> Option<MagnifierRequest> {
388        self.inner.borrow().magnifier
389    }
390
391    /// Whether a magnifier is raised — for a `visible_when` gate.
392    pub fn magnifier_visible_signal(&self) -> Signal<bool> {
393        let inner = Rc::clone(&self.inner);
394        self.version
395            .map(move |_| inner.borrow().magnifier.is_some())
396    }
397
398    /// The toolbar the controller wants raised, if any.
399    pub fn toolbar(&self) -> Option<SelectionToolbarRequest> {
400        self.inner.borrow().toolbar.clone()
401    }
402
403    /// Whether anything at all is shown.
404    pub fn is_empty(&self) -> bool {
405        let state = self.inner.borrow();
406        state.handles.is_empty() && state.magnifier.is_none() && state.toolbar.is_none()
407    }
408
409    /// Replace the published state and notify, dropping the mutable borrow
410    /// **before** the notification so an observer may read it back.
411    fn publish(&self, next: AffordanceState) {
412        {
413            let mut state = self.inner.borrow_mut();
414            if *state == next {
415                return;
416            }
417            *state = next;
418        }
419        let version = self.version.get();
420        self.version.set(version.wrapping_add(1));
421    }
422}
423
424// ---------------------------------------------------------------------------
425// The controller
426// ---------------------------------------------------------------------------
427
428/// Which end of a handle drag a sample is.
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum HandleDragPhase {
431    Begin,
432    Move,
433    End,
434    Cancel,
435}
436
437/// Whether a handle-drag sample of this `kind` and `phase` moved the **caret**
438/// — the question a host answers by reporting the IME cursor area.
439///
440/// The controller moves the caret itself, through
441/// [`TextHitSource::set_selection`], so nothing downstream of it knows the
442/// insertion point changed. Without a report the OS candidate window stays
443/// wherever the caret last was *typed* to, and a finger that drags the caret
444/// handle across a document and then types Japanese, Chinese or Korean gets its
445/// candidate list at the old position.
446///
447/// The two discriminations this makes, and why:
448///
449/// * **`Caret` only.** A `Start` / `End` drag is choosing a *range*, not an
450///   insertion point — the controller publishes no caret handle while a
451///   selection stands. It also could not be reported coherently: the shipped
452///   reporters read the editor's own cursor, and
453///   [`TextHitSource::set_selection`] leaves that on the range's upper end, so
454///   an `End` drag would move the candidate window and a `Start` drag would
455///   not. The mouse agrees — a drag-extend reports nothing either, and the
456///   shipped rule across every stack is "report where a caret is *placed*".
457/// * **Not `Cancel`.** Every other phase writes a selection —
458///   `Begin` runs one update immediately and `End` runs a last one — while
459///   `Cancel` drops the drag and only recomputes geometry. Nothing moved, so
460///   there is nothing to report.
461///
462/// The **report itself** is deliberately not offered here. Each editing stack
463/// has its own reporter, holding that stack's focus / read-only / layout guard
464/// and the dedup that keeps an input method from feeding an unchanged rectangle
465/// back as a fresh empty preedit; a reporter on the controller would skip all
466/// of it and leave the stack's cache stale besides. So this answers *when*, and
467/// the host answers *what* — which is also why the controller's own
468/// `report_ime_area` is gone rather than wired.
469pub fn drag_moves_the_caret(kind: SelectionHandleKind, phase: HandleDragPhase) -> bool {
470    kind == SelectionHandleKind::Caret
471        && matches!(
472            phase,
473            HandleDragPhase::Begin | HandleDragPhase::Move | HandleDragPhase::End
474        )
475}
476
477#[derive(Debug, Clone, Copy)]
478struct HandleDrag {
479    /// Which handle the finger grabbed. Read only to tell a caret drag from a
480    /// range drag — the two branches of [`TouchSelection::update_drag`] — so it
481    /// is *not* re-pointed when the drag crosses the other end: which end the
482    /// finger then holds is derived from the resulting range, not recorded here.
483    kind: SelectionHandleKind,
484    /// The offset that stays put — the *other* end of the selection.
485    fixed: usize,
486}
487
488/// Turns a direct pointer into a text selection: long press to select a word,
489/// handles to adjust it, a magnifier while adjusting, a toolbar when done.
490///
491/// A host owns one of these per text surface, forwards pointer events to it,
492/// and mounts a [`TextAffordanceLayer`] fed by [`affordances`](Self::affordances).
493pub struct TouchSelection {
494    metrics: HandleMetrics,
495    magnifier_radius: f32,
496    magnifier_half_height: f32,
497    magnifier_rise: f32,
498    magnifier_scale: f32,
499    magnifier_enabled: bool,
500    reduced_motion: bool,
501    affordances: TextAffordances,
502    drag: Option<HandleDrag>,
503    /// Whether affordances have been raised at all. A surface that has never
504    /// been touched shows nothing, even though it has a caret.
505    raised: bool,
506}
507
508impl std::fmt::Debug for TouchSelection {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        f.debug_struct("TouchSelection")
511            .field("metrics", &self.metrics)
512            .field("magnifier_enabled", &self.magnifier_enabled)
513            .field("reduced_motion", &self.reduced_motion)
514            .field("dragging", &self.drag.is_some())
515            .field("raised", &self.raised)
516            .finish()
517    }
518}
519
520impl Default for TouchSelection {
521    fn default() -> Self {
522        Self::new()
523    }
524}
525
526impl TouchSelection {
527    /// A controller with the shipped Compact metrics, the magnifier on, and
528    /// motion unrestricted.
529    ///
530    /// Build it in `build()` and refine it there:
531    /// [`reduced_motion`](Self::reduced_motion) has no accessor on
532    /// [`EventContext`], so the value must be captured while a
533    /// [`BuildContext`](crate::build_context::BuildContext) is in hand.
534    pub fn new() -> Self {
535        Self {
536            metrics: HandleMetrics::default(),
537            magnifier_radius: magnifier::MAGNIFIER_RADIUS,
538            magnifier_half_height: magnifier::MAGNIFIER_HALF_HEIGHT,
539            magnifier_rise: magnifier::MAGNIFIER_RISE,
540            magnifier_scale: magnifier::MAGNIFIER_SCALE,
541            magnifier_enabled: true,
542            reduced_motion: false,
543            affordances: TextAffordances::new(),
544            drag: None,
545            raised: false,
546        }
547    }
548
549    /// Use `metrics` for handle geometry. Pass the ones derived from the same
550    /// [`TextSelectionHandleRecipe`] the affordance layer paints with.
551    pub fn metrics(mut self, metrics: HandleMetrics) -> Self {
552        self.metrics = metrics;
553        self
554    }
555
556    /// Lens geometry, from the active style's
557    /// [`TextMagnifierRecipe`](crate::styles::TextMagnifierRecipe).
558    pub fn magnifier_metrics(
559        mut self,
560        radius: f32,
561        half_height: f32,
562        rise: f32,
563        scale: f32,
564    ) -> Self {
565        self.magnifier_radius = radius;
566        self.magnifier_half_height = half_height;
567        self.magnifier_rise = rise;
568        self.magnifier_scale = scale;
569        self
570    }
571
572    /// Turn the magnifier off for this surface — the per-widget opt-out.
573    ///
574    /// A surface whose text layer cannot be replayed purely (see
575    /// [`magnifier`]) must set this, because there is no way for the framework
576    /// to detect that it could not.
577    pub fn magnifier(mut self, enabled: bool) -> Self {
578        self.magnifier_enabled = enabled;
579        self
580    }
581
582    /// Whether the user asked for reduced motion. Gates the magnifier
583    /// entirely: a lens that appears and then chases the finger is exactly the
584    /// unbidden movement the preference is about, and the selection works
585    /// without it.
586    pub fn reduced_motion(mut self, reduced: bool) -> Self {
587        self.reduced_motion = reduced;
588        self
589    }
590
591    /// The published geometry. Clone it into the affordance layer.
592    pub fn affordances(&self) -> TextAffordances {
593        self.affordances.clone()
594    }
595
596    /// Every handle currently wanted.
597    pub fn handles(&self) -> Vec<SelectionHandleGeometry> {
598        self.affordances.handles()
599    }
600
601    /// The magnifier, while one is raised.
602    pub fn magnifier_request(&self) -> Option<MagnifierRequest> {
603        self.affordances.magnifier()
604    }
605
606    /// The toolbar the controller wants raised, if any.
607    pub fn toolbar(&self) -> Option<SelectionToolbarRequest> {
608        self.affordances.toolbar()
609    }
610
611    /// Whether a handle drag is in progress.
612    pub fn is_dragging(&self) -> bool {
613        self.drag.is_some()
614    }
615
616    /// Retract every affordance. The controller owns its own lifetime: the
617    /// text-affordance band is exempt from outside-press dismissal, so nothing
618    /// else will do this. Call it when focus leaves, the content changes, the
619    /// surface becomes read-only, or the window deactivates.
620    pub fn dismiss(&mut self) {
621        self.drag = None;
622        self.raised = false;
623        self.affordances.publish(AffordanceState::default());
624    }
625
626    /// Recompute every affordance from `source`.
627    ///
628    /// Call it after any change the controller did not make — an arrow key, an
629    /// undo, a scroll that moved the caret — or the handles keep the position
630    /// the text used to be at.
631    pub fn refresh(&mut self, direction: LayoutDirection, source: &dyn TextHitSource) {
632        if !self.raised {
633            return;
634        }
635        self.affordances.publish(AffordanceState {
636            handles: self.compute_handles(direction, source),
637            toolbar: self.compute_toolbar(source),
638            // A lens exists only while a finger is on a handle, and every path
639            // into `refresh` is a path out of that.
640            magnifier: None,
641        });
642    }
643
644    /// Raise the affordances for the current selection.
645    ///
646    /// The host calls this when a direct pointer finishes placing a caret: the
647    /// caret placement itself stays with the host's own code, which is what
648    /// keeps a mouse's path unchanged.
649    pub fn raise(&mut self, direction: LayoutDirection, source: &dyn TextHitSource) {
650        self.raised = true;
651        self.refresh(direction, source);
652    }
653
654    /// Select the word under `point` and raise the affordances — the long-press
655    /// gesture.
656    ///
657    /// Returns [`Ignored`](EventResponse::Ignored) untouched for an indirect
658    /// pointer. That branch is load-bearing: the gesture arena installs a
659    /// long-press recognizer on the presence of the handler alone, with no
660    /// pointer-kind condition, so without it a half-second mouse hold inside an
661    /// editor would select a word.
662    ///
663    /// # Why the device is a parameter here and not read off `ctx`
664    ///
665    /// [`handle_pointer`](Self::handle_pointer) and
666    /// [`drag_handle`](Self::drag_handle) ask `ctx.pointer_kind()`, and they are
667    /// right to: both serve a **sample**, and the tree installs that sample's
668    /// pointer for the length of the dispatch. A hold serves no sample — it is a
669    /// deadline coming due — and the answer a context can give for it is only as
670    /// good as the tree's bookkeeping at tick time. It was wrong for the whole
671    /// of this method's first life: `current_input` is saved-and-restored around
672    /// every dispatch, so the timer path read back `InputSnapshot::default()`
673    /// and this guard refused **every** genuine touch hold, which made the entry
674    /// point dead code and cost its first host a duplicate guard of its own.
675    /// The tree now installs the holding contact
676    /// (`InputSnapshot::for_recognized_gesture`), so `ctx` answers correctly
677    /// too — but the gesture already carries the truth on
678    /// [`TapEvent::pointer`](crate::gesture::TapEvent::pointer), and a host that
679    /// drives this from anywhere else — an assistive-technology action, its own
680    /// hold timer — has no snapshot behind it at all. So the caller names the
681    /// device.
682    ///
683    /// `point` is in **window** coordinates, like every other point this type
684    /// takes — deliberately *not* `TapEvent::position`, which the router has
685    /// already rewritten into the target's local space.
686    pub fn on_long_press(
687        &mut self,
688        pointer: crate::pointer::PointerInfo,
689        point: Point,
690        ctx: &mut EventContext<'_>,
691        source: &mut dyn TextHitSource,
692    ) -> EventResponse {
693        if !pointer.kind.is_direct() {
694            return EventResponse::Ignored;
695        }
696        let offset = source.offset_at(point);
697        let word = source.word_range_at(offset);
698        source.set_selection(word);
699        self.raised = true;
700        self.refresh(ctx.layout_direction(), source);
701        EventResponse::Handled
702    }
703
704    /// Route one pointer event, for a host that paints its own handles.
705    ///
706    /// **A host that mounts the [`TextAffordanceLayer`] does not call this.**
707    /// The layer's handles are widgets in their own right, sitting above the
708    /// editor and offered the press first, so such a host wires the pieces
709    /// directly instead: [`on_long_press`](Self::on_long_press) from its own
710    /// long-press handler, [`raise`](Self::raise) from its own release arm, and
711    /// [`drag_handle`](Self::drag_handle) from the layer's node. All three
712    /// stock hosts are of that shape.
713    ///
714    /// What this offers a host that draws the handles itself is the routing
715    /// those hosts get from the layer: it claims only what belongs to the
716    /// affordances — a press that lands on a handle, and the samples of a drag
717    /// it started. Everything else, including every event from an indirect
718    /// pointer, is [`Ignored`](EventResponse::Ignored), so the host's own caret
719    /// placement runs exactly as it did.
720    pub fn handle_pointer(
721        &mut self,
722        event: &WidgetEvent,
723        ctx: &mut EventContext<'_>,
724        source: &mut dyn TextHitSource,
725    ) -> EventResponse {
726        if !ctx.pointer_kind().is_direct() {
727            return EventResponse::Ignored;
728        }
729        let direction = ctx.layout_direction();
730        match event {
731            WidgetEvent::PointerDown { position, .. } => {
732                match self.handle_at(*position) {
733                    Some(kind) => {
734                        self.begin_drag(kind, *position, direction, source);
735                        ctx.capture_pointer();
736                        EventResponse::Handled
737                    }
738                    // Not on a handle: the host places the caret, and the
739                    // affordances go away until the press resolves.
740                    None => EventResponse::Ignored,
741                }
742            }
743            WidgetEvent::PointerMove { position, .. } if self.drag.is_some() => {
744                self.update_drag(*position, direction, source);
745                EventResponse::Handled
746            }
747            WidgetEvent::PointerUp { position, .. } => {
748                if self.drag.is_some() {
749                    self.end_drag(*position, direction, source);
750                    EventResponse::Handled
751                } else {
752                    self.raise(direction, source);
753                    EventResponse::Ignored
754                }
755            }
756            WidgetEvent::PointerCancel { .. } if self.drag.is_some() => {
757                self.cancel_drag(direction, source);
758                EventResponse::Handled
759            }
760            _ => EventResponse::Ignored,
761        }
762    }
763
764    /// Drive a handle drag from the affordance layer's own node.
765    ///
766    /// The layer knows which handle was pressed — it is a widget in its own
767    /// right — so it names the kind instead of making the controller hit-test
768    /// for it.
769    ///
770    /// An indirect pointer is refused here as well as at the layer's node and in
771    /// [`handle_pointer`](Self::handle_pointer). The three guards cover three
772    /// different ways in, and this is the one a caller of the public API reaches
773    /// without passing either of the others.
774    pub fn drag_handle(
775        &mut self,
776        kind: SelectionHandleKind,
777        phase: HandleDragPhase,
778        point: Point,
779        ctx: &mut EventContext<'_>,
780        source: &mut dyn TextHitSource,
781    ) -> EventResponse {
782        if !ctx.pointer_kind().is_direct() {
783            return EventResponse::Ignored;
784        }
785        let direction = ctx.layout_direction();
786        match phase {
787            HandleDragPhase::Begin => self.begin_drag(kind, point, direction, source),
788            HandleDragPhase::Move => self.update_drag(point, direction, source),
789            HandleDragPhase::End => self.end_drag(point, direction, source),
790            HandleDragPhase::Cancel => self.cancel_drag(direction, source),
791        }
792        EventResponse::Handled
793    }
794
795    /// Which handle, if any, accepts a press at `point`.
796    ///
797    /// Handles overlap when a selection is short, so this answers with the one
798    /// whose centre is nearest rather than the first in the list — the same
799    /// tie-break the hit-test slop pass uses between adjacent grips.
800    pub fn handle_at(&self, point: Point) -> Option<SelectionHandleKind> {
801        self.affordances
802            .handles()
803            .into_iter()
804            .filter(|h| h.hit.contains(point))
805            .min_by(|a, b| {
806                distance_squared(a.anchor, point).total_cmp(&distance_squared(b.anchor, point))
807            })
808            .map(|h| h.kind)
809    }
810
811    // -- drag -------------------------------------------------------------
812
813    fn begin_drag(
814        &mut self,
815        kind: SelectionHandleKind,
816        point: Point,
817        direction: LayoutDirection,
818        source: &mut dyn TextHitSource,
819    ) {
820        let selection = source.selection();
821        let fixed = match kind {
822            SelectionHandleKind::Start => selection.end,
823            SelectionHandleKind::End => selection.start,
824            SelectionHandleKind::Caret => selection.start,
825        };
826        self.drag = Some(HandleDrag { kind, fixed });
827        self.raised = true;
828        self.update_drag(point, direction, source);
829    }
830
831    fn update_drag(
832        &mut self,
833        point: Point,
834        direction: LayoutDirection,
835        source: &mut dyn TextHitSource,
836    ) {
837        let Some(drag) = self.drag else {
838            return;
839        };
840        let moving = source.offset_at(point);
841        let dragging_caret = drag.kind == SelectionHandleKind::Caret;
842        if dragging_caret {
843            source.set_selection(moving..moving);
844        } else {
845            // The selection is whatever lies between the end that stays put and
846            // the finger, so dragging one end past the other grows the range on
847            // the far side instead of collapsing it. Which *kind* of handle is
848            // then under the finger is symmetric in the crossing direction and
849            // follows from the range alone — `compute_handles` reads the offsets
850            // back out, so the finger holds the start once its offset is below
851            // the fixed end's and the end once it is above. Nothing records the
852            // crossing: `drag.kind` is read only to tell these two branches
853            // apart, and crossing over cannot turn a range drag into a caret
854            // drag.
855            source.set_selection(drag.fixed.min(moving)..drag.fixed.max(moving));
856        }
857        let mut handles = self.compute_handles(direction, source);
858        if !dragging_caret {
859            // Dragging one end exactly onto the other empties the selection,
860            // and an empty selection would otherwise be published as a caret
861            // handle — a third target appearing under the finger mid-gesture.
862            // The two ends stay up until the finger lifts.
863            handles.retain(|h| h.kind != SelectionHandleKind::Caret);
864        }
865        self.affordances.publish(AffordanceState {
866            handles,
867            magnifier: self.compute_magnifier(point, source),
868            // A toolbar over the text being adjusted is in the way, and its
869            // commands would be aimed at a selection that is still moving.
870            toolbar: None,
871        });
872    }
873
874    fn end_drag(
875        &mut self,
876        point: Point,
877        direction: LayoutDirection,
878        source: &mut dyn TextHitSource,
879    ) {
880        if self.drag.is_some() {
881            self.update_drag(point, direction, source);
882        }
883        self.drag = None;
884        self.refresh(direction, source);
885    }
886
887    fn cancel_drag(&mut self, direction: LayoutDirection, source: &mut dyn TextHitSource) {
888        self.drag = None;
889        self.refresh(direction, source);
890    }
891
892    // -- geometry ---------------------------------------------------------
893
894    fn compute_handles(
895        &self,
896        direction: LayoutDirection,
897        source: &dyn TextHitSource,
898    ) -> Vec<SelectionHandleGeometry> {
899        let viewport = source.viewport();
900        let document_len = source.document_len();
901        let selection = source.selection();
902        let kinds: &[(SelectionHandleKind, usize)] = &if selection.is_empty() {
903            // A caret handle exists to drag a caret about, so it is offered
904            // only where the user may move one. A surface that takes no edits
905            // either has no caret at all (a terminal) or has one it does not
906            // let the user place (a log view); either way there is nothing for
907            // the handle to do. Selecting is not editing, so the two selection
908            // handles below are offered whether the surface is editable or not.
909            if source.is_editable() {
910                vec![(SelectionHandleKind::Caret, selection.start)]
911            } else {
912                vec![]
913            }
914        } else {
915            vec![
916                (SelectionHandleKind::Start, selection.start),
917                (SelectionHandleKind::End, selection.end),
918            ]
919        };
920        kinds
921            .iter()
922            .filter_map(|&(kind, offset)| {
923                handle_geometry(
924                    kind,
925                    offset,
926                    document_len,
927                    source.caret_rect(offset),
928                    direction,
929                    viewport,
930                    self.metrics,
931                )
932            })
933            .collect()
934    }
935
936    fn compute_toolbar(&self, source: &dyn TextHitSource) -> Option<SelectionToolbarRequest> {
937        let actions = source.clipboard_actions().to_actions();
938        if actions.is_empty() {
939            return None;
940        }
941        let anchor = source
942            .selection_bounds()
943            .unwrap_or_else(|| source.caret_rect(source.selection().end));
944        Some(SelectionToolbarRequest { actions, anchor })
945    }
946
947    fn compute_magnifier(
948        &self,
949        point: Point,
950        source: &dyn TextHitSource,
951    ) -> Option<MagnifierRequest> {
952        if !self.magnifier_enabled || self.reduced_motion {
953            return None;
954        }
955        Some(MagnifierRequest::new(
956            point,
957            source.viewport(),
958            self.magnifier_radius,
959            self.magnifier_half_height,
960            self.magnifier_rise,
961            self.magnifier_scale,
962        ))
963    }
964}
965
966fn distance_squared(a: Point, b: Point) -> f32 {
967    let dx = a.x - b.x;
968    let dy = a.y - b.y;
969    dx * dx + dy * dy
970}
971
972/// The geometry of one handle hanging off `caret`, or `None` when the caret is
973/// not visible in `viewport` at all.
974///
975/// The disc hangs **above** the line for a `Start` handle and **below** it for
976/// `End` and `Caret`, so the two ends of a one-line selection do not sit on top
977/// of each other. When the preferred side does not fit in the viewport the
978/// handle takes the other one — a handle under the last line of a surface whose
979/// bottom is the window's bottom would otherwise be off-screen, which is the
980/// case this rule exists for.
981pub fn handle_geometry(
982    kind: SelectionHandleKind,
983    offset: usize,
984    document_len: usize,
985    caret: Rect,
986    direction: LayoutDirection,
987    viewport: Rect,
988    metrics: HandleMetrics,
989) -> Option<SelectionHandleGeometry> {
990    if !rects_intersect(caret, viewport) {
991        return None;
992    }
993    let radius = metrics.diameter / 2.0;
994    let cx = caret.x + caret.width / 2.0;
995    let above = Point::new(cx, caret.y - radius);
996    let below = Point::new(cx, caret.bottom() + radius);
997    let (preferred, alternate) = match kind {
998        SelectionHandleKind::Start => (above, below),
999        SelectionHandleKind::End | SelectionHandleKind::Caret => (below, above),
1000    };
1001    let fits = |p: Point| p.y - radius >= viewport.y && p.y + radius <= viewport.bottom();
1002    let anchor = if fits(preferred) {
1003        preferred
1004    } else if fits(alternate) {
1005        alternate
1006    } else {
1007        preferred
1008    };
1009    let visual = square_centred_on(anchor, metrics.diameter);
1010    // The hit square may slide, but never so far that the disc leaves it: a
1011    // press on the ink the user aimed at must always land.
1012    let slack = ((metrics.hit - metrics.diameter) / 2.0).max(0.0);
1013    let hit = nudge_into(square_centred_on(anchor, metrics.hit), viewport, slack);
1014    Some(SelectionHandleGeometry {
1015        kind,
1016        offset,
1017        document_len,
1018        side: kind.side(direction),
1019        caret,
1020        anchor,
1021        visual,
1022        hit,
1023    })
1024}
1025
1026fn square_centred_on(centre: Point, extent: f32) -> Rect {
1027    Rect::new(
1028        centre.x - extent / 2.0,
1029        centre.y - extent / 2.0,
1030        extent,
1031        extent,
1032    )
1033}
1034
1035/// `rect` moved toward `bounds` by at most `slack` on each axis.
1036fn nudge_into(rect: Rect, bounds: Rect, slack: f32) -> Rect {
1037    let dx = if rect.x < bounds.x {
1038        (bounds.x - rect.x).min(slack)
1039    } else if rect.right() > bounds.right() {
1040        -((rect.right() - bounds.right()).min(slack))
1041    } else {
1042        0.0
1043    };
1044    let dy = if rect.y < bounds.y {
1045        (bounds.y - rect.y).min(slack)
1046    } else if rect.bottom() > bounds.bottom() {
1047        -((rect.bottom() - bounds.bottom()).min(slack))
1048    } else {
1049        0.0
1050    };
1051    Rect::new(rect.x + dx, rect.y + dy, rect.width, rect.height)
1052}
1053
1054fn rects_intersect(a: Rect, b: Rect) -> bool {
1055    a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
1056}
1057
1058#[cfg(test)]
1059mod tests;