Skip to main content

frust_core/
selection_toolbar.rs

1//! The selection-toolbar seam: what a text field asks for when it has a
2//! selection, and who draws it.
3//!
4//! # Two routes, one request
5//!
6//! A "copy / cut / paste / select all" bar over a selection is drawn by the
7//! *platform* on iOS (`UIEditMenuInteraction` — the system owns its look, its
8//! placement and its animation) and by the *framework* everywhere else. Both
9//! routes need the same facts — where the selection is, which verbs apply, and
10//! whether a menu is wanted right now — so a focused field publishes one
11//! [`SelectionToolbarRequest`] per paint and the two routes diverge downstream
12//! of it:
13//!
14//! * [`SelectionToolbarPolicy::Native`]: the request surfaces on
15//!   [`RenderRoot::selection_toolbar`](crate::app::RenderRoot::selection_toolbar)
16//!   with a generation a shell diffs, and the shell asks the platform to present
17//!   its own menu.
18//! * [`SelectionToolbarPolicy::Framework`]: the field hosts its own toolbar pod
19//!   through the overlay portal (see [`crate::overlay`]) — and still publishes
20//!   the request, which costs a pointer-sized write and keeps one code path
21//!   rather than two.
22//!
23//! # Process-global slots
24//!
25//! The policy and the builder are process-global `Mutex` slots, mirroring
26//! `frust_shell_common::theme_override`'s contract exactly: callable from any
27//! thread (documented, not enforced), read on the UI thread by whoever needs
28//! them, and last-writer-wins. They live in `frust-core` rather than beside
29//! their nearest neighbours in the shells because
30//! [`RenderRoot`](crate::app::RenderRoot) itself is a reader — a slot a shell
31//! owned would be unreachable from here without inverting the layer
32//! dependencies.
33//!
34//! The builder is what a design system installs so a text field can float
35//! *someone's* toolbar without `frust-core` knowing a single widget type: it
36//! returns an [`AnyView`] over `()` (the pod's own state; see [`crate::overlay`]
37//! for why an overlay pod is state-independent of the app), so the catalog that
38//! installs it decides the whole look.
39
40use std::cell::Cell;
41use std::sync::{Arc, Mutex};
42
43use kurbo::{Rect, Size};
44
45use crate::event::PassBracket;
46use crate::view::AnyView;
47
48thread_local! {
49    /// The selection-toolbar request published during the paint pass currently
50    /// running on this thread — written by
51    /// [`PaintCtx::publish_selection_toolbar`](crate::widget::PaintCtx::publish_selection_toolbar)
52    /// and resolved by [`RenderRoot::paint`](crate::app::RenderRoot::paint).
53    ///
54    /// Last writer wins, for
55    /// [`EventCtx::set_cursor`](crate::event::EventCtx::set_cursor)'s reason: at
56    /// most one field holds the focus a selection belongs to, so "who asked"
57    /// adds nothing, and no container between the field and the root reads the
58    /// value.
59    ///
60    /// **Pass-scoped**, so a pass in which nobody published resolves to `None`
61    /// rather than to whatever the previous pass left — which is what makes a
62    /// blurred field put the toolbar away with no widget having to retract
63    /// anything. A field that merely lost its selection keeps publishing: it is
64    /// still focused, and its verbs are still the answer the platform asks for.
65    static PUBLISHED: Cell<Option<SelectionToolbarRequest>> = const { Cell::new(None) };
66
67    /// Whether a selection-toolbar publish pass is open on this thread — owned
68    /// by [`SelectionToolbarPass`] alone (see [`PassBracket`]).
69    static PASS_OPEN: Cell<bool> = const { Cell::new(false) };
70}
71
72/// Which clipboard verbs apply to the current selection — the enabled set, not a
73/// menu layout.
74///
75/// A field computes these from its own state (an empty selection has nothing to
76/// copy; a read-only field cannot cut or paste; a field whose whole content is
77/// already selected offers no select-all), and whoever draws the menu decides
78/// how to present a disabled verb — by omitting it, greying it, or ignoring the
79/// distinction.
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
81pub struct SelectionToolbarActions {
82    /// Copy the selection to the host clipboard.
83    pub copy: bool,
84    /// Cut the selection to the host clipboard.
85    pub cut: bool,
86    /// Replace the selection with the host clipboard's contents.
87    pub paste: bool,
88    /// Select the field's whole content.
89    pub select_all: bool,
90}
91
92/// One focused field's published "here is my selection, these verbs apply to
93/// it, and this is whether a menu is wanted over it right now".
94///
95/// Published per paint pass by every **focused** field, with or without a
96/// selection; a pass without one means no field holds the focus. Two facts of
97/// different shapes travel together here: [`anchor`](Self::anchor) and
98/// [`actions`](Self::actions) are a **level** — the state of the field as it
99/// stands this frame, which a platform responder chain must be able to read at
100/// any moment — while [`present_menu`](Self::present_menu) is the **edge** that
101/// asks for a menu to go up.
102#[derive(Clone, Copy, Debug, PartialEq)]
103pub struct SelectionToolbarRequest {
104    /// The selection's bounding rect in **absolute logical window space** — the
105    /// same space an [`OverlayEntry::window_rect`](crate::overlay::OverlayEntry::window_rect)
106    /// uses, so a framework toolbar can be placed against it directly and a
107    /// native menu can be anchored to it after the shell's own scale conversion.
108    ///
109    /// Falls back to the caret rect while the selection is collapsed, which is
110    /// the right anchor for a paste-only menu.
111    pub anchor: Rect,
112    /// Which verbs apply. A **level**: computed from the field's own state, not
113    /// from whether any bar is up, so a hardware Cmd+C arriving with nothing on
114    /// screen still finds an answer here.
115    pub actions: SelectionToolbarActions,
116    /// Whether the field wants a menu **presented now** — the one edge in an
117    /// otherwise level-shaped request.
118    ///
119    /// A field raises this when a gesture asks for the bar (a long press, a
120    /// secondary press) and drops it again when the bar is dismissed, while it
121    /// keeps republishing the same anchor and verbs either way. It is what
122    /// [`RenderRoot::selection_toolbar_generation`](crate::app::RenderRoot::selection_toolbar_generation)
123    /// moves on, together with `actions` — an anchor that merely follows a
124    /// growing selection must not ask a platform to re-present its menu.
125    pub present_menu: bool,
126}
127
128/// Who draws the selection toolbar.
129#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
130pub enum SelectionToolbarPolicy {
131    /// The framework draws it: the field floats a toolbar pod through the
132    /// overlay portal ([`crate::overlay`]). The default, and the only route on a
133    /// platform with no system edit menu.
134    #[default]
135    Framework,
136    /// The platform draws it: the field publishes the request and nothing else,
137    /// and the shell presents the host's own menu.
138    Native,
139}
140
141/// The process-wide toolbar policy, plus whether a shell has locked it.
142///
143/// Value and lock share one `Mutex` rather than two separate slots, so a
144/// concurrent lock claim and set can never interleave into a state neither
145/// caller asked for.
146#[derive(Clone, Copy, Debug)]
147struct PolicyState {
148    value: SelectionToolbarPolicy,
149    /// Set once by [`lock_selection_toolbar_policy`]; while `true`,
150    /// [`set_selection_toolbar_policy`] is a refused no-op.
151    locked: bool,
152}
153
154/// The process-wide toolbar policy. A plain value rather than a generation-
155/// carrying slot like [`BUILDER`]: it is read directly at the point of decision
156/// (there is nothing to diff), and the default is the Framework route.
157static POLICY: Mutex<PolicyState> = Mutex::new(PolicyState {
158    value: SelectionToolbarPolicy::Framework,
159    locked: false,
160});
161
162/// Choose who draws the selection toolbar, process-wide.
163///
164/// Callable from any thread; the slot is a plain `Mutex`, and each reader
165/// observes it on its own thread when it next asks (see the module docs' thread
166/// contract). A shell whose platform REQUIRES a given route should call
167/// [`lock_selection_toolbar_policy`] instead — this setter is a refused no-op
168/// once a shell has done that, so an app that calls it after start-up cannot
169/// silently undo the platform's own requirement.
170///
171/// The refusal is silent to this function's own `()` return (unchanged, so no
172/// existing caller need change), but not silent to the process: a debug build
173/// logs it once, mirroring `TextInputWidget::sync_toolbar`'s wiring-gap
174/// diagnostic — a wrongly-timed app override is a wiring gap worth hearing
175/// about in development, not an error a release build can act on.
176pub fn set_selection_toolbar_policy(policy: SelectionToolbarPolicy) {
177    let mut state = POLICY.lock().unwrap_or_else(|e| e.into_inner());
178    if state.locked {
179        #[cfg(debug_assertions)]
180        eprintln!(
181            "frust-core: the selection-toolbar policy is locked to {:?} by the platform shell; \
182             ignoring an app's set_selection_toolbar_policy({policy:?}) call (see \
183             frust_core::lock_selection_toolbar_policy)",
184            state.value
185        );
186        return;
187    }
188    state.value = policy;
189}
190
191/// The process-wide toolbar policy, [`SelectionToolbarPolicy::Framework`] until
192/// something sets otherwise.
193pub fn selection_toolbar_policy() -> SelectionToolbarPolicy {
194    POLICY.lock().unwrap_or_else(|e| e.into_inner()).value
195}
196
197/// Declare the policy a shell's platform REQUIRES, locking the slot so a
198/// later [`set_selection_toolbar_policy`] call cannot silently override it.
199///
200/// Reads like the other override/cooperative pair below
201/// ([`set_selection_toolbar_builder`]/[`install_selection_toolbar_builder_if_unset`]):
202/// unconditional for the first caller, the way `set_selection_toolbar_builder`
203/// is — but unlike that pair, only the FIRST claim ever wins here, since the
204/// whole point of a lock is that one declaration sticks against every later
205/// call, an app's included. Returns whether this call actually claimed the
206/// lock: `false` means the slot was already locked (most likely a repeated
207/// shell start-up), and the value is left exactly as the earlier claim left
208/// it.
209///
210/// A platform shell calls this once, at start-up, before any app code has had
211/// a chance to run — see `frust-shell-ios`'s `frust_init` for why iOS in
212/// particular cannot leave this choice to an app.
213pub fn lock_selection_toolbar_policy(policy: SelectionToolbarPolicy) -> bool {
214    let mut state = POLICY.lock().unwrap_or_else(|e| e.into_inner());
215    if state.locked {
216        return false;
217    }
218    state.value = policy;
219    state.locked = true;
220    true
221}
222
223/// Builds the view a framework-drawn selection toolbar floats.
224///
225/// Called with the request the field published and the logical window size (so
226/// the toolbar can size or clamp itself against the window it will float in),
227/// and returns the pod's view over `()` — an overlay pod carries no application
228/// state, so a toolbar built here is usable from a field hosted under any app
229/// state at all.
230///
231/// `Arc<dyn Fn ... + Send + Sync>` because the slot is process-global: the
232/// closure is shared by every root in the process and may be installed from any
233/// thread.
234pub type SelectionToolbarBuilder =
235    Arc<dyn Fn(&SelectionToolbarRequest, Size) -> AnyView<()> + Send + Sync>;
236
237/// The process-wide builder slot; `None` until a design system installs one, in
238/// which case a field under the Framework policy floats nothing and the platform
239/// route is the only one available.
240static BUILDER: Mutex<Option<SelectionToolbarBuilder>> = Mutex::new(None);
241
242/// Install the selection-toolbar builder, **replacing** whatever was there.
243///
244/// The explicit-override half of the pair: an app that wants its own toolbar
245/// over the catalog's calls this. Callable from any thread (see the module docs).
246pub fn set_selection_toolbar_builder(builder: SelectionToolbarBuilder) {
247    *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = Some(builder);
248}
249
250/// Install the builder only if none is installed, reporting whether it took.
251///
252/// The cooperative half of the pair, and what a design system's own
253/// initialisation should call: two catalogs linked into one binary must not
254/// fight over the slot, and an app's explicit
255/// [`set_selection_toolbar_builder`] must not be undone by a catalog
256/// initialising later.
257pub fn install_selection_toolbar_builder_if_unset(builder: SelectionToolbarBuilder) -> bool {
258    let mut slot = BUILDER.lock().unwrap_or_else(|e| e.into_inner());
259    if slot.is_some() {
260        return false;
261    }
262    *slot = Some(builder);
263    true
264}
265
266/// The installed builder, or `None` when nothing has installed one.
267///
268/// Returns a clone of the `Arc` rather than lending the slot, so a caller never
269/// holds the process-global lock while building a view.
270pub fn selection_toolbar_builder() -> Option<SelectionToolbarBuilder> {
271    BUILDER
272        .lock()
273        .unwrap_or_else(|e| e.into_inner())
274        .as_ref()
275        .map(Arc::clone)
276}
277
278/// The open/close bracket around one selection-toolbar publish pass — the
279/// [`crate::overlay::OverlayPaintPass`] shape, one channel over.
280///
281/// [`RenderRoot::paint`](crate::app::RenderRoot::paint) holds one for the length
282/// of the pass, so a publish made by a widget painted with no root above it (a
283/// leaf unit test) is dropped by the next pass's clear rather than leaking into
284/// it.
285pub(crate) struct SelectionToolbarPass(PassBracket<Option<SelectionToolbarRequest>>);
286
287impl SelectionToolbarPass {
288    /// Open a publish pass, starting from "nobody has published anything".
289    pub(crate) fn enter() -> Self {
290        SelectionToolbarPass(PassBracket::enter(&PUBLISHED, &PASS_OPEN))
291    }
292
293    /// Take what *this* pass published, leaving the slot empty.
294    pub(crate) fn take(&self) -> Option<SelectionToolbarRequest> {
295        self.0.take()
296    }
297}
298
299/// Record `request` in the pass currently painting on this thread.
300///
301/// The implementation behind
302/// [`PaintCtx::publish_selection_toolbar`](crate::widget::PaintCtx::publish_selection_toolbar),
303/// which is the API a field calls; this is the module-private half so the slot
304/// stays owned here.
305pub(crate) fn publish(request: SelectionToolbarRequest) {
306    PUBLISHED.with(|slot| slot.set(Some(request)));
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use std::sync::Mutex as StdMutex;
313
314    /// Serialises the tests that mutate the process-global policy/builder slots
315    /// — mirrors `frust_shell_common::theme_override`'s `TEST_LOCK` pattern, and
316    /// is necessary for the same reason: Rust runs a crate's tests in parallel
317    /// threads that share one process, so two tests writing the same static
318    /// would see each other's writes.
319    static TEST_LOCK: StdMutex<()> = StdMutex::new(());
320
321    fn toolbar_request() -> SelectionToolbarRequest {
322        SelectionToolbarRequest {
323            anchor: Rect::new(10.0, 20.0, 110.0, 40.0),
324            actions: SelectionToolbarActions {
325                copy: true,
326                cut: true,
327                paste: false,
328                select_all: true,
329            },
330            present_menu: true,
331        }
332    }
333
334    #[test]
335    fn policy_defaults_to_framework_and_round_trips() {
336        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
337        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
338        assert_eq!(
339            selection_toolbar_policy(),
340            SelectionToolbarPolicy::Framework
341        );
342        assert_eq!(
343            SelectionToolbarPolicy::default(),
344            SelectionToolbarPolicy::Framework,
345            "the default route is the framework-drawn one"
346        );
347
348        set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
349        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);
350
351        // Leave the slot as the rest of the process expects to find it.
352        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
353    }
354
355    /// Test-only escape hatch: release a lock claimed with
356    /// [`lock_selection_toolbar_policy`] so one test proving the lock's effect
357    /// does not trap every test after it that mutates the process-global
358    /// policy the ordinary way. Not `pub`, not `#[cfg(test)]`-exported beyond
359    /// this module: unlocking a shell's declared policy from app code would
360    /// defeat the whole point of the lock, so this stays a private detail of
361    /// this crate's own test suite rather than a casual public API.
362    fn unlock_selection_toolbar_policy_for_test() {
363        POLICY.lock().unwrap_or_else(|e| e.into_inner()).locked = false;
364    }
365
366    #[test]
367    fn set_policy_is_unaffected_when_nothing_has_locked_it() {
368        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
369        // At rest, nothing has locked the slot — desktop, Android and web
370        // never call `lock_selection_toolbar_policy`, so this is their whole
371        // world: `set_selection_toolbar_policy` behaves exactly as it always
372        // has.
373        set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
374        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);
375        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
376        assert_eq!(
377            selection_toolbar_policy(),
378            SelectionToolbarPolicy::Framework
379        );
380    }
381
382    #[test]
383    fn locking_the_policy_sets_the_declared_value() {
384        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
385        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
386
387        assert!(
388            lock_selection_toolbar_policy(SelectionToolbarPolicy::Native),
389            "the first claim always takes the lock"
390        );
391        assert_eq!(
392            selection_toolbar_policy(),
393            SelectionToolbarPolicy::Native,
394            "claiming the lock sets the value it declares"
395        );
396
397        unlock_selection_toolbar_policy_for_test();
398    }
399
400    #[test]
401    fn a_locked_policy_refuses_a_later_set_and_a_later_claim() {
402        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
403        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
404        assert!(lock_selection_toolbar_policy(
405            SelectionToolbarPolicy::Native
406        ));
407
408        // An app's later call must not move a locked slot.
409        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
410        assert_eq!(
411            selection_toolbar_policy(),
412            SelectionToolbarPolicy::Native,
413            "a locked slot must not move for an app's later set_selection_toolbar_policy call"
414        );
415
416        // Nor may a second shell's claim silently displace the first.
417        assert!(
418            !lock_selection_toolbar_policy(SelectionToolbarPolicy::Framework),
419            "a second lock claim must not displace the first"
420        );
421        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);
422
423        // Leave the slot as the rest of the process expects to find it.
424        unlock_selection_toolbar_policy_for_test();
425        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
426    }
427
428    #[test]
429    fn install_if_unset_yields_to_an_installed_builder_but_set_replaces_it() {
430        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
431        // Start from a known-empty slot (another test may have installed one).
432        *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = None;
433        assert!(
434            selection_toolbar_builder().is_none(),
435            "nothing is installed at rest"
436        );
437
438        let first: SelectionToolbarBuilder = Arc::new(|_req, _size| crate::view::any(FirstView));
439        assert!(
440            install_selection_toolbar_builder_if_unset(Arc::clone(&first)),
441            "the first cooperative install takes the empty slot"
442        );
443
444        let second: SelectionToolbarBuilder = Arc::new(|_req, _size| crate::view::any(SecondView));
445        assert!(
446            !install_selection_toolbar_builder_if_unset(Arc::clone(&second)),
447            "a second cooperative install must not displace the first"
448        );
449        assert!(
450            Arc::ptr_eq(
451                &selection_toolbar_builder().expect("a builder is installed"),
452                &first
453            ),
454            "the slot still holds the first builder"
455        );
456
457        set_selection_toolbar_builder(Arc::clone(&second));
458        assert!(
459            Arc::ptr_eq(
460                &selection_toolbar_builder().expect("a builder is installed"),
461                &second
462            ),
463            "an explicit set replaces whatever stood"
464        );
465
466        *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = None;
467    }
468
469    #[test]
470    fn publish_is_pass_scoped_and_last_writer_wins() {
471        let pass = SelectionToolbarPass::enter();
472        assert_eq!(pass.take(), None, "a fresh pass starts empty");
473
474        publish(toolbar_request());
475        let mut second = toolbar_request();
476        second.actions.paste = true;
477        publish(second);
478        assert_eq!(
479            pass.take(),
480            Some(second),
481            "the last publish of the pass is the one resolved"
482        );
483        assert_eq!(pass.take(), None, "and the drain empties the slot");
484        drop(pass);
485
486        // A publish made with no pass open is dropped by the next pass's clear,
487        // never leaked into it.
488        publish(toolbar_request());
489        let next = SelectionToolbarPass::enter();
490        assert_eq!(
491            next.take(),
492            None,
493            "a stray publish does not survive into the next pass"
494        );
495    }
496
497    /// A view double for the builder-slot tests — the builder's return type is
498    /// `AnyView<()>`, and these tests only ever compare `Arc` identities, so the
499    /// view never has to build anything.
500    struct FirstView;
501    /// The second double; see [`FirstView`].
502    struct SecondView;
503
504    macro_rules! stub_view {
505        ($name:ident) => {
506            impl crate::view::View<()> for $name {
507                type Element = StubWidget;
508                fn build(&self, _ctx: &mut crate::view::BuildCtx<'_>) -> StubWidget {
509                    StubWidget
510                }
511                fn rebuild(
512                    &self,
513                    _prev: &Self,
514                    _element: &mut StubWidget,
515                    _ctx: &mut crate::view::BuildCtx<'_>,
516                ) -> crate::view::ChangeFlags {
517                    crate::view::ChangeFlags::NONE
518                }
519            }
520        };
521    }
522    stub_view!(FirstView);
523    stub_view!(SecondView);
524
525    /// The widget both doubles build: a zero-sized leaf that paints nothing.
526    struct StubWidget;
527    impl crate::widget::Widget for StubWidget {
528        fn layout(
529            &mut self,
530            _ctx: &mut crate::widget::LayoutCtx,
531            _bc: &crate::layout::BoxConstraints,
532        ) -> Size {
533            Size::ZERO
534        }
535        fn paint(
536            &mut self,
537            _ctx: &mut crate::widget::PaintCtx,
538            _scene: &mut dyn crate::widget::PaintScene,
539        ) {
540        }
541    }
542}