Skip to main content

frust_core/
selection_toolbar.rs

1//! The selection-toolbar seam: what a focused text field publishes about its
2//! selection, and who draws the copy/cut/paste/select-all bar over it.
3//!
4//! A focused field publishes one [`SelectionToolbarRequest`] per paint. Under
5//! [`SelectionToolbarPolicy::Native`] a shell reads it from `RenderRoot::selection_toolbar`
6//! and presents the platform menu; under [`SelectionToolbarPolicy::Framework`] the
7//! field floats the installed builder's view through [`crate::overlay`]. The policy
8//! and builder are process-global `Mutex` slots, callable from any thread.
9
10use std::cell::Cell;
11use std::sync::{Arc, Mutex};
12
13use kurbo::{Rect, Size};
14
15use crate::event::PassBracket;
16use crate::view::AnyView;
17
18thread_local! {
19    /// This paint pass's request, last writer wins; pass-scoped, so a pass with
20    /// no publish (a blurred field) resolves to `None`.
21    static PUBLISHED: Cell<Option<SelectionToolbarRequest>> = const { Cell::new(None) };
22
23    static PASS_OPEN: Cell<bool> = const { Cell::new(false) };
24}
25
26/// Which clipboard verbs apply to the current selection: the enabled set, not a
27/// menu layout.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
29pub struct SelectionToolbarActions {
30    pub copy: bool,
31    pub cut: bool,
32    pub paste: bool,
33    pub select_all: bool,
34}
35
36/// One focused field's selection, its applicable verbs, and whether it wants a
37/// menu now. Published every paint while focused, with or without a selection.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct SelectionToolbarRequest {
40    /// The selection's bounds (the caret rect when collapsed), in absolute
41    /// logical window space.
42    pub anchor: Rect,
43    /// A level from the field's own state, so a hardware Cmd+C finds an answer.
44    pub actions: SelectionToolbarActions,
45    /// The one edge: a gesture asked for the bar and it has not been dismissed.
46    pub present_menu: bool,
47}
48
49/// Who draws the selection toolbar.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
51pub enum SelectionToolbarPolicy {
52    /// The field floats a toolbar pod through the overlay portal. The default.
53    #[default]
54    Framework,
55    /// The field only publishes the request; the shell presents the host menu.
56    Native,
57}
58
59/// Value and lock share one `Mutex`, so a lock claim and a set never interleave.
60#[derive(Clone, Copy, Debug)]
61struct PolicyState {
62    value: SelectionToolbarPolicy,
63    locked: bool,
64}
65
66static POLICY: Mutex<PolicyState> = Mutex::new(PolicyState {
67    value: SelectionToolbarPolicy::Framework,
68    locked: false,
69});
70
71/// Choose who draws the selection toolbar, process-wide. A no-op once a shell
72/// has called [`lock_selection_toolbar_policy`]; a debug build logs each refusal.
73pub fn set_selection_toolbar_policy(policy: SelectionToolbarPolicy) {
74    let mut state = POLICY.lock().unwrap_or_else(|e| e.into_inner());
75    if state.locked {
76        #[cfg(debug_assertions)]
77        eprintln!(
78            "frust-core: the selection-toolbar policy is locked to {:?} by the platform shell; \
79             ignoring an app's set_selection_toolbar_policy({policy:?}) call (see \
80             frust_core::lock_selection_toolbar_policy)",
81            state.value
82        );
83        return;
84    }
85    state.value = policy;
86}
87
88/// The process-wide toolbar policy; [`SelectionToolbarPolicy::Framework`] until set.
89pub fn selection_toolbar_policy() -> SelectionToolbarPolicy {
90    POLICY.lock().unwrap_or_else(|e| e.into_inner()).value
91}
92
93/// Set and lock the policy a shell's platform requires; only the first claim
94/// wins. Returns `false`, leaving the value alone, when already locked.
95pub fn lock_selection_toolbar_policy(policy: SelectionToolbarPolicy) -> bool {
96    let mut state = POLICY.lock().unwrap_or_else(|e| e.into_inner());
97    if state.locked {
98        return false;
99    }
100    state.value = policy;
101    state.locked = true;
102    true
103}
104
105/// Builds the toolbar view from the request and the logical window size. The
106/// view is over `()`, so one builder serves fields under any app state.
107pub type SelectionToolbarBuilder =
108    Arc<dyn Fn(&SelectionToolbarRequest, Size) -> AnyView<()> + Send + Sync>;
109
110/// `None` until something installs a builder; until then a Framework-policy
111/// field has nothing to float.
112static BUILDER: Mutex<Option<SelectionToolbarBuilder>> = Mutex::new(None);
113
114/// Install the builder, replacing any already installed (the app override).
115pub fn set_selection_toolbar_builder(builder: SelectionToolbarBuilder) {
116    *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = Some(builder);
117}
118
119/// Install the builder only if none is installed, returning whether it took;
120/// what a design system calls, so it never undoes an app's override.
121pub fn install_selection_toolbar_builder_if_unset(builder: SelectionToolbarBuilder) -> bool {
122    let mut slot = BUILDER.lock().unwrap_or_else(|e| e.into_inner());
123    if slot.is_some() {
124        return false;
125    }
126    *slot = Some(builder);
127    true
128}
129
130/// A clone of the installed builder, so no caller builds a view under the lock.
131pub fn selection_toolbar_builder() -> Option<SelectionToolbarBuilder> {
132    BUILDER
133        .lock()
134        .unwrap_or_else(|e| e.into_inner())
135        .as_ref()
136        .map(Arc::clone)
137}
138
139/// The [`PassBracket`] around one publish pass, the `OverlayPaintPass` shape.
140pub(crate) struct SelectionToolbarPass(PassBracket<Option<SelectionToolbarRequest>>);
141
142impl SelectionToolbarPass {
143    pub(crate) fn enter() -> Self {
144        SelectionToolbarPass(PassBracket::enter(&PUBLISHED, &PASS_OPEN))
145    }
146
147    pub(crate) fn take(&self) -> Option<SelectionToolbarRequest> {
148        self.0.take()
149    }
150}
151
152/// Record `request` in this thread's open pass (behind `PaintCtx::publish_selection_toolbar`).
153pub(crate) fn publish(request: SelectionToolbarRequest) {
154    PUBLISHED.with(|slot| slot.set(Some(request)));
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::sync::Mutex as StdMutex;
161
162    /// Serialises the tests that mutate the process-global policy/builder slots
163    /// — mirrors `frust_shell_common::theme_override`'s `TEST_LOCK` pattern, and
164    /// is necessary for the same reason: Rust runs a crate's tests in parallel
165    /// threads that share one process, so two tests writing the same static
166    /// would see each other's writes.
167    static TEST_LOCK: StdMutex<()> = StdMutex::new(());
168
169    fn toolbar_request() -> SelectionToolbarRequest {
170        SelectionToolbarRequest {
171            anchor: Rect::new(10.0, 20.0, 110.0, 40.0),
172            actions: SelectionToolbarActions {
173                copy: true,
174                cut: true,
175                paste: false,
176                select_all: true,
177            },
178            present_menu: true,
179        }
180    }
181
182    #[test]
183    fn policy_defaults_to_framework_and_round_trips() {
184        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
185        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
186        assert_eq!(
187            selection_toolbar_policy(),
188            SelectionToolbarPolicy::Framework
189        );
190        assert_eq!(
191            SelectionToolbarPolicy::default(),
192            SelectionToolbarPolicy::Framework,
193            "the default route is the framework-drawn one"
194        );
195
196        set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
197        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);
198
199        // Leave the slot as the rest of the process expects to find it.
200        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
201    }
202
203    /// Test-only escape hatch: release a lock claimed with
204    /// [`lock_selection_toolbar_policy`] so one test proving the lock's effect
205    /// does not trap every test after it that mutates the process-global
206    /// policy the ordinary way. Not `pub`, not `#[cfg(test)]`-exported beyond
207    /// this module: unlocking a shell's declared policy from app code would
208    /// defeat the whole point of the lock, so this stays a private detail of
209    /// this crate's own test suite rather than a casual public API.
210    fn unlock_selection_toolbar_policy_for_test() {
211        POLICY.lock().unwrap_or_else(|e| e.into_inner()).locked = false;
212    }
213
214    #[test]
215    fn set_policy_is_unaffected_when_nothing_has_locked_it() {
216        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
217        // At rest, nothing has locked the slot — desktop, Android and web
218        // never call `lock_selection_toolbar_policy`, so this is their whole
219        // world: `set_selection_toolbar_policy` behaves exactly as it always
220        // has.
221        set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
222        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);
223        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
224        assert_eq!(
225            selection_toolbar_policy(),
226            SelectionToolbarPolicy::Framework
227        );
228    }
229
230    #[test]
231    fn locking_the_policy_sets_the_declared_value() {
232        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
233        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
234
235        assert!(
236            lock_selection_toolbar_policy(SelectionToolbarPolicy::Native),
237            "the first claim always takes the lock"
238        );
239        assert_eq!(
240            selection_toolbar_policy(),
241            SelectionToolbarPolicy::Native,
242            "claiming the lock sets the value it declares"
243        );
244
245        unlock_selection_toolbar_policy_for_test();
246    }
247
248    #[test]
249    fn a_locked_policy_refuses_a_later_set_and_a_later_claim() {
250        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
251        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
252        assert!(lock_selection_toolbar_policy(
253            SelectionToolbarPolicy::Native
254        ));
255
256        // An app's later call must not move a locked slot.
257        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
258        assert_eq!(
259            selection_toolbar_policy(),
260            SelectionToolbarPolicy::Native,
261            "a locked slot must not move for an app's later set_selection_toolbar_policy call"
262        );
263
264        // Nor may a second shell's claim silently displace the first.
265        assert!(
266            !lock_selection_toolbar_policy(SelectionToolbarPolicy::Framework),
267            "a second lock claim must not displace the first"
268        );
269        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);
270
271        // Leave the slot as the rest of the process expects to find it.
272        unlock_selection_toolbar_policy_for_test();
273        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
274    }
275
276    #[test]
277    fn install_if_unset_yields_to_an_installed_builder_but_set_replaces_it() {
278        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
279        // Start from a known-empty slot (another test may have installed one).
280        *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = None;
281        assert!(
282            selection_toolbar_builder().is_none(),
283            "nothing is installed at rest"
284        );
285
286        let first: SelectionToolbarBuilder = Arc::new(|_req, _size| crate::view::any(FirstView));
287        assert!(
288            install_selection_toolbar_builder_if_unset(Arc::clone(&first)),
289            "the first cooperative install takes the empty slot"
290        );
291
292        let second: SelectionToolbarBuilder = Arc::new(|_req, _size| crate::view::any(SecondView));
293        assert!(
294            !install_selection_toolbar_builder_if_unset(Arc::clone(&second)),
295            "a second cooperative install must not displace the first"
296        );
297        assert!(
298            Arc::ptr_eq(
299                &selection_toolbar_builder().expect("a builder is installed"),
300                &first
301            ),
302            "the slot still holds the first builder"
303        );
304
305        set_selection_toolbar_builder(Arc::clone(&second));
306        assert!(
307            Arc::ptr_eq(
308                &selection_toolbar_builder().expect("a builder is installed"),
309                &second
310            ),
311            "an explicit set replaces whatever stood"
312        );
313
314        *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = None;
315    }
316
317    #[test]
318    fn publish_is_pass_scoped_and_last_writer_wins() {
319        let pass = SelectionToolbarPass::enter();
320        assert_eq!(pass.take(), None, "a fresh pass starts empty");
321
322        publish(toolbar_request());
323        let mut second = toolbar_request();
324        second.actions.paste = true;
325        publish(second);
326        assert_eq!(
327            pass.take(),
328            Some(second),
329            "the last publish of the pass is the one resolved"
330        );
331        assert_eq!(pass.take(), None, "and the drain empties the slot");
332        drop(pass);
333
334        // A publish made with no pass open is dropped by the next pass's clear,
335        // never leaked into it.
336        publish(toolbar_request());
337        let next = SelectionToolbarPass::enter();
338        assert_eq!(
339            next.take(),
340            None,
341            "a stray publish does not survive into the next pass"
342        );
343    }
344
345    /// A view double for the builder-slot tests — the builder's return type is
346    /// `AnyView<()>`, and these tests only ever compare `Arc` identities, so the
347    /// view never has to build anything.
348    struct FirstView;
349    /// The second double; see [`FirstView`].
350    struct SecondView;
351
352    macro_rules! stub_view {
353        ($name:ident) => {
354            impl crate::view::View<()> for $name {
355                type Element = StubWidget;
356                fn build(&self, _ctx: &mut crate::view::BuildCtx<'_>) -> StubWidget {
357                    StubWidget
358                }
359                fn rebuild(
360                    &self,
361                    _prev: &Self,
362                    _element: &mut StubWidget,
363                    _ctx: &mut crate::view::BuildCtx<'_>,
364                ) -> crate::view::ChangeFlags {
365                    crate::view::ChangeFlags::NONE
366                }
367            }
368        };
369    }
370    stub_view!(FirstView);
371    stub_view!(SecondView);
372
373    /// The widget both doubles build: a zero-sized leaf that paints nothing.
374    struct StubWidget;
375    impl crate::widget::Widget for StubWidget {
376        fn layout(
377            &mut self,
378            _ctx: &mut crate::widget::LayoutCtx,
379            _bc: &crate::layout::BoxConstraints,
380        ) -> Size {
381            Size::ZERO
382        }
383        fn paint(
384            &mut self,
385            _ctx: &mut crate::widget::PaintCtx,
386            _scene: &mut dyn crate::widget::PaintScene,
387        ) {
388        }
389    }
390}