Skip to main content

teksilo_core/text_touch/
affordance_layer.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The widgets that fill the text-affordance overlay band: the selection
5//! handles and the magnifier.
6//!
7//! # Why they are overlay nodes
8//!
9//! Both hang *outside* the text they belong to — a handle under the last line,
10//! a lens above the first — and every real editor sits inside something with
11//! `clips_children`. Painted by the host they would be cut off, and a 44 dp hit
12//! rectangle that reaches past the editor's own bounds would never be offered
13//! the press. In the
14//! [`TextAffordance`](crate::overlay::OverlayBand::TextAffordance) band they are
15//! ordinary widgets with ordinary bounds, above the content, below every menu,
16//! and exempt from the outside-press dismissal that every caret-moving tap
17//! would otherwise trigger.
18//!
19//! # Focus
20//!
21//! Handles are **not** focusable. The band is anchor-independent, so the
22//! framework never moves focus into it, and a Tab stop that appears in the
23//! middle of a sentence the moment a finger touched it would be worse than
24//! useless to a keyboard user — who has arrow keys and Shift for the same job.
25//! Assistive technology reaches a handle through
26//! [`Action::SetValue`](accesskit::Action) on its
27//! [`Role::Slider`](accesskit::Role) node instead, which is a route a pointer
28//! and a screen reader can both take.
29//!
30//! # Mounting
31//!
32//! One [`TextAffordanceLayer`] per text surface, built in the host's `build()`
33//! and raised as the content of a
34//! [`FullViewport`](crate::overlay::OverlayPlacement::FullViewport) overlay in
35//! the affordance band. The layer passes events through, so the window beneath
36//! it behaves normally everywhere its children are not.
37
38use std::rc::Rc;
39
40use teksilo_canvas::{Canvas, Point, Rect, SizeProposal};
41
42use crate::accessibility::AccessNodeBuilder;
43use crate::binding::BindingLevel;
44use crate::build_context::BuildContext;
45use crate::event::{EventResponse, WidgetEvent};
46use crate::overlay::SelectionHandleKind;
47use crate::styles::{TextMagnifierRecipe, TextSelectionHandleRecipe};
48use crate::widget::{
49    EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
50};
51use crate::widget_builder::WidgetBuilder;
52use crate::widget_id::WidgetId;
53
54use super::{HandleDragPhase, SelectionHandleGeometry, TextAffordances};
55
56/// How a host services the affordance widgets.
57///
58/// The widgets know where they are; only the host knows what text is under
59/// them, so every act that changes the selection comes back through here. A
60/// typical implementation is three lines forwarding to
61/// [`TouchSelection`](super::TouchSelection) with the host's own
62/// [`TextHitSource`](super::TextHitSource).
63pub trait TextAffordanceDelegate {
64    /// One sample of a handle drag.
65    fn handle_drag(
66        &self,
67        kind: SelectionHandleKind,
68        phase: HandleDragPhase,
69        point: Point,
70        ctx: &mut EventContext<'_>,
71    );
72
73    /// Move a handle to a text offset — the assistive-technology route, from
74    /// `Action::SetValue` on the handle's slider node.
75    fn set_handle_offset(
76        &self,
77        kind: SelectionHandleKind,
78        offset: usize,
79        ctx: &mut EventContext<'_>,
80    );
81}
82
83/// One selection handle: a disc on a stem, with a square hit rectangle.
84pub struct SelectionHandle {
85    kind: SelectionHandleKind,
86    affordances: TextAffordances,
87    recipe: TextSelectionHandleRecipe,
88    delegate: Rc<dyn TextAffordanceDelegate>,
89}
90
91impl std::fmt::Debug for SelectionHandle {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("SelectionHandle")
94            .field("kind", &self.kind)
95            .finish()
96    }
97}
98
99impl SelectionHandle {
100    pub fn new(
101        kind: SelectionHandleKind,
102        affordances: TextAffordances,
103        recipe: TextSelectionHandleRecipe,
104        delegate: Rc<dyn TextAffordanceDelegate>,
105    ) -> Self {
106        Self {
107            kind,
108            affordances,
109            recipe,
110            delegate,
111        }
112    }
113
114    fn geometry(&self) -> Option<SelectionHandleGeometry> {
115        self.affordances.handle(self.kind)
116    }
117
118    /// The name a screen reader reads. Deliberately not localised here: the
119    /// affordance layer lives in `teksilo-core`, which has no message bundle,
120    /// so a host that ships translations overrides it with `.access_label`.
121    fn default_label(&self) -> &'static str {
122        match self.kind {
123            SelectionHandleKind::Caret => "Text cursor",
124            SelectionHandleKind::Start => "Selection start",
125            SelectionHandleKind::End => "Selection end",
126        }
127    }
128}
129
130impl Widget for SelectionHandle {
131    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
132        let kind = self.kind;
133        let drag_delegate = Rc::clone(&self.delegate);
134        let action_delegate = Rc::clone(&self.delegate);
135        let handlers = crate::widget_builder::HandlerSet::new()
136            .on_pointer_event(move |event, ctx| {
137                // A mouse's click is not this node's. Handles are only ever
138                // raised by a direct pointer, but on a hybrid machine a mouse can
139                // arrive afterwards and click one — and swallowing that press
140                // would cost the caret placement it was asking for. The
141                // controller would refuse the drag anyway; without this the press
142                // is consumed before it gets there.
143                //
144                // `Ignored` sends it up this node's own bubble path, not to the
145                // editor: a handle is a node in the overlay's content, and the
146                // editor is a different root. So a host that wants a cursor's
147                // click on a handle answered puts that arm on the content root
148                // above these nodes — see the checklist in
149                // `docs/text-touch-editing.md`.
150                if !ctx.pointer_kind().is_direct() {
151                    return EventResponse::Ignored;
152                }
153                match event {
154                    WidgetEvent::PointerDown { position, .. } => {
155                        ctx.capture_pointer();
156                        drag_delegate.handle_drag(kind, HandleDragPhase::Begin, *position, ctx);
157                        EventResponse::Handled
158                    }
159                    WidgetEvent::PointerMove { position, .. } => {
160                        drag_delegate.handle_drag(kind, HandleDragPhase::Move, *position, ctx);
161                        EventResponse::Handled
162                    }
163                    WidgetEvent::PointerUp { position, .. } => {
164                        drag_delegate.handle_drag(kind, HandleDragPhase::End, *position, ctx);
165                        EventResponse::Handled
166                    }
167                    WidgetEvent::PointerCancel { .. } => {
168                        // Deliberately **not** `window_position`. The three arms
169                        // above hand `handle_drag` points in this handle's own
170                        // space — the router localises them — while a cancel's
171                        // position is window-space by contract, so forwarding it
172                        // would credit the same sink a point from a different
173                        // frame. `HandleDragPhase::Cancel` discards the point
174                        // (`TouchSelection::drag_handle` routes it to
175                        // `cancel_drag`, which takes none), so the honest value
176                        // is the origin: nothing in the wrong frame flows, and
177                        // no reader is deprived of one it could have used.
178                        drag_delegate.handle_drag(
179                            kind,
180                            HandleDragPhase::Cancel,
181                            Point::new(0.0, 0.0),
182                            ctx,
183                        );
184                        EventResponse::Handled
185                    }
186                    _ => EventResponse::Ignored,
187                }
188            })
189            .on_access_action_request(move |action, _node, data, ctx| {
190                if action != accesskit::Action::SetValue {
191                    return EventResponse::Ignored;
192                }
193                let offset = match data {
194                    Some(accesskit::ActionData::NumericValue(v)) => v.max(0.0) as usize,
195                    Some(accesskit::ActionData::Value(v)) => match v.parse::<usize>() {
196                        Ok(parsed) => parsed,
197                        Err(_) => return EventResponse::Ignored,
198                    },
199                    _ => return EventResponse::Ignored,
200                };
201                action_delegate.set_handle_offset(kind, offset, ctx);
202                EventResponse::Handled
203            });
204        ctx.apply_self_handlers(handlers);
205        vec![]
206    }
207
208    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
209        let extent = self.recipe.hit_size;
210        proposal.resolve(extent, extent).into()
211    }
212
213    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
214        let Some(geometry) = self.geometry() else {
215            return;
216        };
217        let fill = self.recipe.fill.resolve(ctx.theme);
218        let radius = self.recipe.diameter / 2.0;
219        // The disc is centred in the node, and the node was placed centred on
220        // the anchor — but the hit square may have been nudged to stay on
221        // screen, so the disc follows the *anchor*, not the node's middle.
222        let centre = Point::new(
223            geometry
224                .anchor
225                .x
226                .clamp(bounds.x + radius, bounds.right() - radius),
227            geometry
228                .anchor
229                .y
230                .clamp(bounds.y + radius, bounds.bottom() - radius),
231        );
232        if self.recipe.stem_width > 0.0 {
233            let stem_x = centre.x - self.recipe.stem_width / 2.0;
234            let caret = geometry.caret;
235            let (top, bottom) = if centre.y < caret.y {
236                (centre.y, caret.y)
237            } else {
238                (caret.bottom(), centre.y)
239            };
240            if bottom > top {
241                canvas.fill_rect(
242                    Rect::new(stem_x, top, self.recipe.stem_width, bottom - top),
243                    fill,
244                );
245            }
246        }
247        if self.recipe.outline_width > 0.0 {
248            canvas.stroke_circle(
249                centre,
250                radius,
251                self.recipe.outline.resolve(ctx.theme),
252                self.recipe.outline_width,
253            );
254        }
255        canvas.fill_circle(centre, radius, fill);
256    }
257
258    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
259        builder.set_role(accesskit::Role::Slider);
260        builder.set_name(self.default_label());
261        if let Some(geometry) = self.geometry() {
262            builder.set_numeric_value(geometry.offset as f64);
263            builder.set_min_numeric_value(0.0);
264            builder.set_max_numeric_value(geometry.document_len as f64);
265            builder.set_numeric_value_step(1.0);
266        }
267        // Not `Focus`: the handle is deliberately outside the Tab ring (see the
268        // module docs), so advertising a focus action would promise a stop that
269        // does not exist. `SetValue` is the whole AT contract — move this end
270        // of the selection to a character offset.
271        builder.add_action(accesskit::Action::SetValue);
272    }
273}
274
275/// The magnifier lens: a framed window onto a magnified replay of the host's
276/// own text layer.
277pub struct TextMagnifier {
278    affordances: TextAffordances,
279    recipe: TextMagnifierRecipe,
280    painter: Rc<dyn Fn(&mut Canvas, &PaintContext<'_>)>,
281}
282
283impl std::fmt::Debug for TextMagnifier {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.debug_struct("TextMagnifier").finish()
286    }
287}
288
289impl TextMagnifier {
290    /// `painter` re-emits the host's **text layer only**, in window
291    /// coordinates. It is re-entered during the same frame; the contract it
292    /// must meet, and what happens when it does not, are in
293    /// [`super::magnifier`].
294    pub fn new(
295        affordances: TextAffordances,
296        recipe: TextMagnifierRecipe,
297        painter: Rc<dyn Fn(&mut Canvas, &PaintContext<'_>)>,
298    ) -> Self {
299        Self {
300            affordances,
301            recipe,
302            painter,
303        }
304    }
305}
306
307impl Widget for TextMagnifier {
308    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
309        proposal
310            .resolve(self.recipe.radius * 2.0, self.recipe.half_height * 2.0)
311            .into()
312    }
313
314    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
315        let Some(request) = self.affordances.magnifier() else {
316            return;
317        };
318        let corner = self.recipe.corner_radius;
319        canvas.fill_rounded_rect(
320            bounds,
321            teksilo_tokens::CornerRadius::uniform(corner),
322            self.recipe.background.resolve(ctx.theme),
323        );
324        // Content first, frame second: the frame's ink is what covers the
325        // corners a rectangular clip cannot remove.
326        //
327        // The transform comes from the request rather than being recomposed
328        // here, so there is one copy of the rule to be wrong about. The node is
329        // placed *on* `request.lens`, so taking the centre from the request
330        // rather than from `bounds` cannot disagree with where the lens sits.
331        ctx.replay(canvas, &*self.painter, request.transform(), bounds);
332        if self.recipe.border_width > 0.0 {
333            canvas.stroke_rounded_rect(
334                bounds,
335                teksilo_tokens::CornerRadius::uniform(corner),
336                self.recipe.border.resolve(ctx.theme),
337                self.recipe.border_width,
338            );
339        }
340    }
341
342    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
343        // A lens shows what is already in the tree. Announcing it would make a
344        // screen reader read the same sentence twice.
345        builder.set_hidden();
346    }
347}
348
349/// The overlay content root: every affordance for one text surface.
350///
351/// Built once, in the host's `build()`. Each child is gated by its own
352/// `visible_when`, so a selection that gains or loses a handle is a dormancy
353/// flip and a re-place — never a rebuild, which would tear down the node under
354/// the finger mid-drag.
355pub struct TextAffordanceLayer {
356    affordances: TextAffordances,
357    handle_recipe: TextSelectionHandleRecipe,
358    magnifier_recipe: TextMagnifierRecipe,
359    delegate: Rc<dyn TextAffordanceDelegate>,
360    painter: Option<Rc<dyn Fn(&mut Canvas, &PaintContext<'_>)>>,
361    handles: Vec<(SelectionHandleKind, WidgetId)>,
362    magnifier: Option<WidgetId>,
363}
364
365impl std::fmt::Debug for TextAffordanceLayer {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        f.debug_struct("TextAffordanceLayer")
368            .field("handles", &self.handles.len())
369            .field("magnifier", &self.magnifier.is_some())
370            .finish()
371    }
372}
373
374impl TextAffordanceLayer {
375    pub fn new(
376        affordances: TextAffordances,
377        handle_recipe: TextSelectionHandleRecipe,
378        magnifier_recipe: TextMagnifierRecipe,
379        delegate: Rc<dyn TextAffordanceDelegate>,
380    ) -> Self {
381        Self {
382            affordances,
383            handle_recipe,
384            magnifier_recipe,
385            delegate,
386            painter: None,
387            handles: Vec::new(),
388            magnifier: None,
389        }
390    }
391
392    /// Supply the text-layer painter that fills the lens. Without one there is
393    /// no magnifier node at all — the per-surface opt-out, for a host whose
394    /// paint cannot be re-entered.
395    pub fn magnifier_painter(
396        mut self,
397        painter: Rc<dyn Fn(&mut Canvas, &PaintContext<'_>)>,
398    ) -> Self {
399        self.painter = Some(painter);
400        self
401    }
402}
403
404impl Widget for TextAffordanceLayer {
405    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
406        // A handle that moved has to be re-placed, not merely repainted, so the
407        // geometry version binds at `Relayout`. It is bumped once per published
408        // change, which during a drag is once per pointer sample.
409        self.affordances.version_signal().bind_to(
410            ctx.self_id(),
411            ctx.binding_registry(),
412            BindingLevel::Relayout,
413        );
414
415        self.handles.clear();
416        for kind in [
417            SelectionHandleKind::Caret,
418            SelectionHandleKind::Start,
419            SelectionHandleKind::End,
420        ] {
421            let id = ctx.add(
422                SelectionHandle::new(
423                    kind,
424                    self.affordances.clone(),
425                    self.handle_recipe,
426                    Rc::clone(&self.delegate),
427                )
428                .visible_when(self.affordances.handle_visible_signal(kind)),
429            );
430            self.handles.push((kind, id));
431        }
432        self.magnifier = self.painter.as_ref().map(|painter| {
433            ctx.add(
434                TextMagnifier::new(
435                    self.affordances.clone(),
436                    self.magnifier_recipe,
437                    Rc::clone(painter),
438                )
439                .visible_when(self.affordances.magnifier_visible_signal()),
440            )
441        });
442
443        let handlers = crate::widget_builder::HandlerSet::new().event_pass_through(true);
444        ctx.apply_self_handlers(handlers);
445
446        self.children()
447    }
448
449    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
450        // The layer covers whatever the overlay gives it — a full viewport —
451        // and positions its children in window coordinates inside that.
452        proposal.resolve(0.0, 0.0).into()
453    }
454
455    fn place_children(
456        &self,
457        _bounds: Rect,
458        _proposal: SizeProposal,
459        children: &mut [WidgetPlacement],
460        _ctx: &LayoutContext,
461    ) {
462        for placement in children.iter_mut() {
463            if let Some((kind, _)) = self.handles.iter().find(|(_, id)| *id == placement.id) {
464                if let Some(geometry) = self.affordances.handle(*kind) {
465                    placement.origin = geometry.hit.origin();
466                    placement.size = geometry.hit.size();
467                }
468            } else if Some(placement.id) == self.magnifier
469                && let Some(request) = self.affordances.magnifier()
470            {
471                placement.origin = request.lens.origin();
472                placement.size = request.lens.size();
473            }
474        }
475    }
476
477    fn children(&self) -> Vec<WidgetId> {
478        self.handles
479            .iter()
480            .map(|(_, id)| *id)
481            .chain(self.magnifier)
482            .collect()
483    }
484
485    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
486        // A bare container. Its children carry the semantics; giving it a role
487        // would add a traversal stop between the editor and its handles.
488        builder.set_role(accesskit::Role::GenericContainer);
489    }
490}