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}