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;