frust-core 0.5.2

Frust's declarative View API, retained Widget tree, box-constraint layout and rebuild/layout/paint pass.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
//! The selection-toolbar seam: what a text field asks for when it has a
//! selection, and who draws it.
//!
//! # Two routes, one request
//!
//! A "copy / cut / paste / select all" bar over a selection is drawn by the
//! *platform* on iOS (`UIEditMenuInteraction` — the system owns its look, its
//! placement and its animation) and by the *framework* everywhere else. Both
//! routes need the same facts — where the selection is, which verbs apply, and
//! whether a menu is wanted right now — so a focused field publishes one
//! [`SelectionToolbarRequest`] per paint and the two routes diverge downstream
//! of it:
//!
//! * [`SelectionToolbarPolicy::Native`]: the request surfaces on
//!   [`RenderRoot::selection_toolbar`](crate::app::RenderRoot::selection_toolbar)
//!   with a generation a shell diffs, and the shell asks the platform to present
//!   its own menu.
//! * [`SelectionToolbarPolicy::Framework`]: the field hosts its own toolbar pod
//!   through the overlay portal (see [`crate::overlay`]) — and still publishes
//!   the request, which costs a pointer-sized write and keeps one code path
//!   rather than two.
//!
//! # Process-global slots
//!
//! The policy and the builder are process-global `Mutex` slots, mirroring
//! `frust_shell_common::theme_override`'s contract exactly: callable from any
//! thread (documented, not enforced), read on the UI thread by whoever needs
//! them, and last-writer-wins. They live in `frust-core` rather than beside
//! their nearest neighbours in the shells because
//! [`RenderRoot`](crate::app::RenderRoot) itself is a reader — a slot a shell
//! owned would be unreachable from here without inverting the layer
//! dependencies.
//!
//! The builder is what a design system installs so a text field can float
//! *someone's* toolbar without `frust-core` knowing a single widget type: it
//! returns an [`AnyView`] over `()` (the pod's own state; see [`crate::overlay`]
//! for why an overlay pod is state-independent of the app), so the catalog that
//! installs it decides the whole look.

use std::cell::Cell;
use std::sync::{Arc, Mutex};

use kurbo::{Rect, Size};

use crate::event::PassBracket;
use crate::view::AnyView;

thread_local! {
    /// The selection-toolbar request published during the paint pass currently
    /// running on this thread — written by
    /// [`PaintCtx::publish_selection_toolbar`](crate::widget::PaintCtx::publish_selection_toolbar)
    /// and resolved by [`RenderRoot::paint`](crate::app::RenderRoot::paint).
    ///
    /// Last writer wins, for
    /// [`EventCtx::set_cursor`](crate::event::EventCtx::set_cursor)'s reason: at
    /// most one field holds the focus a selection belongs to, so "who asked"
    /// adds nothing, and no container between the field and the root reads the
    /// value.
    ///
    /// **Pass-scoped**, so a pass in which nobody published resolves to `None`
    /// rather than to whatever the previous pass left — which is what makes a
    /// blurred field put the toolbar away with no widget having to retract
    /// anything. A field that merely lost its selection keeps publishing: it is
    /// still focused, and its verbs are still the answer the platform asks for.
    static PUBLISHED: Cell<Option<SelectionToolbarRequest>> = const { Cell::new(None) };

    /// Whether a selection-toolbar publish pass is open on this thread — owned
    /// by [`SelectionToolbarPass`] alone (see [`PassBracket`]).
    static PASS_OPEN: Cell<bool> = const { Cell::new(false) };
}

/// Which clipboard verbs apply to the current selection — the enabled set, not a
/// menu layout.
///
/// A field computes these from its own state (an empty selection has nothing to
/// copy; a read-only field cannot cut or paste; a field whose whole content is
/// already selected offers no select-all), and whoever draws the menu decides
/// how to present a disabled verb — by omitting it, greying it, or ignoring the
/// distinction.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct SelectionToolbarActions {
    /// Copy the selection to the host clipboard.
    pub copy: bool,
    /// Cut the selection to the host clipboard.
    pub cut: bool,
    /// Replace the selection with the host clipboard's contents.
    pub paste: bool,
    /// Select the field's whole content.
    pub select_all: bool,
}

/// One focused field's published "here is my selection, these verbs apply to
/// it, and this is whether a menu is wanted over it right now".
///
/// Published per paint pass by every **focused** field, with or without a
/// selection; a pass without one means no field holds the focus. Two facts of
/// different shapes travel together here: [`anchor`](Self::anchor) and
/// [`actions`](Self::actions) are a **level** — the state of the field as it
/// stands this frame, which a platform responder chain must be able to read at
/// any moment — while [`present_menu`](Self::present_menu) is the **edge** that
/// asks for a menu to go up.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SelectionToolbarRequest {
    /// The selection's bounding rect in **absolute logical window space** — the
    /// same space an [`OverlayEntry::window_rect`](crate::overlay::OverlayEntry::window_rect)
    /// uses, so a framework toolbar can be placed against it directly and a
    /// native menu can be anchored to it after the shell's own scale conversion.
    ///
    /// Falls back to the caret rect while the selection is collapsed, which is
    /// the right anchor for a paste-only menu.
    pub anchor: Rect,
    /// Which verbs apply. A **level**: computed from the field's own state, not
    /// from whether any bar is up, so a hardware Cmd+C arriving with nothing on
    /// screen still finds an answer here.
    pub actions: SelectionToolbarActions,
    /// Whether the field wants a menu **presented now** — the one edge in an
    /// otherwise level-shaped request.
    ///
    /// A field raises this when a gesture asks for the bar (a long press, a
    /// secondary press) and drops it again when the bar is dismissed, while it
    /// keeps republishing the same anchor and verbs either way. It is what
    /// [`RenderRoot::selection_toolbar_generation`](crate::app::RenderRoot::selection_toolbar_generation)
    /// moves on, together with `actions` — an anchor that merely follows a
    /// growing selection must not ask a platform to re-present its menu.
    pub present_menu: bool,
}

/// Who draws the selection toolbar.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum SelectionToolbarPolicy {
    /// The framework draws it: the field floats a toolbar pod through the
    /// overlay portal ([`crate::overlay`]). The default, and the only route on a
    /// platform with no system edit menu.
    #[default]
    Framework,
    /// The platform draws it: the field publishes the request and nothing else,
    /// and the shell presents the host's own menu.
    Native,
}

/// The process-wide toolbar policy, plus whether a shell has locked it.
///
/// Value and lock share one `Mutex` rather than two separate slots, so a
/// concurrent lock claim and set can never interleave into a state neither
/// caller asked for.
#[derive(Clone, Copy, Debug)]
struct PolicyState {
    value: SelectionToolbarPolicy,
    /// Set once by [`lock_selection_toolbar_policy`]; while `true`,
    /// [`set_selection_toolbar_policy`] is a refused no-op.
    locked: bool,
}

/// The process-wide toolbar policy. A plain value rather than a generation-
/// carrying slot like [`BUILDER`]: it is read directly at the point of decision
/// (there is nothing to diff), and the default is the Framework route.
static POLICY: Mutex<PolicyState> = Mutex::new(PolicyState {
    value: SelectionToolbarPolicy::Framework,
    locked: false,
});

/// Choose who draws the selection toolbar, process-wide.
///
/// Callable from any thread; the slot is a plain `Mutex`, and each reader
/// observes it on its own thread when it next asks (see the module docs' thread
/// contract). A shell whose platform REQUIRES a given route should call
/// [`lock_selection_toolbar_policy`] instead — this setter is a refused no-op
/// once a shell has done that, so an app that calls it after start-up cannot
/// silently undo the platform's own requirement.
///
/// The refusal is silent to this function's own `()` return (unchanged, so no
/// existing caller need change), but not silent to the process: a debug build
/// logs it once, mirroring `TextInputWidget::sync_toolbar`'s wiring-gap
/// diagnostic — a wrongly-timed app override is a wiring gap worth hearing
/// about in development, not an error a release build can act on.
pub fn set_selection_toolbar_policy(policy: SelectionToolbarPolicy) {
    let mut state = POLICY.lock().unwrap_or_else(|e| e.into_inner());
    if state.locked {
        #[cfg(debug_assertions)]
        eprintln!(
            "frust-core: the selection-toolbar policy is locked to {:?} by the platform shell; \
             ignoring an app's set_selection_toolbar_policy({policy:?}) call (see \
             frust_core::lock_selection_toolbar_policy)",
            state.value
        );
        return;
    }
    state.value = policy;
}

/// The process-wide toolbar policy, [`SelectionToolbarPolicy::Framework`] until
/// something sets otherwise.
pub fn selection_toolbar_policy() -> SelectionToolbarPolicy {
    POLICY.lock().unwrap_or_else(|e| e.into_inner()).value
}

/// Declare the policy a shell's platform REQUIRES, locking the slot so a
/// later [`set_selection_toolbar_policy`] call cannot silently override it.
///
/// Reads like the other override/cooperative pair below
/// ([`set_selection_toolbar_builder`]/[`install_selection_toolbar_builder_if_unset`]):
/// unconditional for the first caller, the way `set_selection_toolbar_builder`
/// is — but unlike that pair, only the FIRST claim ever wins here, since the
/// whole point of a lock is that one declaration sticks against every later
/// call, an app's included. Returns whether this call actually claimed the
/// lock: `false` means the slot was already locked (most likely a repeated
/// shell start-up), and the value is left exactly as the earlier claim left
/// it.
///
/// A platform shell calls this once, at start-up, before any app code has had
/// a chance to run — see `frust-shell-ios`'s `frust_init` for why iOS in
/// particular cannot leave this choice to an app.
pub fn lock_selection_toolbar_policy(policy: SelectionToolbarPolicy) -> bool {
    let mut state = POLICY.lock().unwrap_or_else(|e| e.into_inner());
    if state.locked {
        return false;
    }
    state.value = policy;
    state.locked = true;
    true
}

/// Builds the view a framework-drawn selection toolbar floats.
///
/// Called with the request the field published and the logical window size (so
/// the toolbar can size or clamp itself against the window it will float in),
/// and returns the pod's view over `()` — an overlay pod carries no application
/// state, so a toolbar built here is usable from a field hosted under any app
/// state at all.
///
/// `Arc<dyn Fn ... + Send + Sync>` because the slot is process-global: the
/// closure is shared by every root in the process and may be installed from any
/// thread.
pub type SelectionToolbarBuilder =
    Arc<dyn Fn(&SelectionToolbarRequest, Size) -> AnyView<()> + Send + Sync>;

/// The process-wide builder slot; `None` until a design system installs one, in
/// which case a field under the Framework policy floats nothing and the platform
/// route is the only one available.
static BUILDER: Mutex<Option<SelectionToolbarBuilder>> = Mutex::new(None);

/// Install the selection-toolbar builder, **replacing** whatever was there.
///
/// The explicit-override half of the pair: an app that wants its own toolbar
/// over the catalog's calls this. Callable from any thread (see the module docs).
pub fn set_selection_toolbar_builder(builder: SelectionToolbarBuilder) {
    *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = Some(builder);
}

/// Install the builder only if none is installed, reporting whether it took.
///
/// The cooperative half of the pair, and what a design system's own
/// initialisation should call: two catalogs linked into one binary must not
/// fight over the slot, and an app's explicit
/// [`set_selection_toolbar_builder`] must not be undone by a catalog
/// initialising later.
pub fn install_selection_toolbar_builder_if_unset(builder: SelectionToolbarBuilder) -> bool {
    let mut slot = BUILDER.lock().unwrap_or_else(|e| e.into_inner());
    if slot.is_some() {
        return false;
    }
    *slot = Some(builder);
    true
}

/// The installed builder, or `None` when nothing has installed one.
///
/// Returns a clone of the `Arc` rather than lending the slot, so a caller never
/// holds the process-global lock while building a view.
pub fn selection_toolbar_builder() -> Option<SelectionToolbarBuilder> {
    BUILDER
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .as_ref()
        .map(Arc::clone)
}

/// The open/close bracket around one selection-toolbar publish pass — the
/// [`crate::overlay::OverlayPaintPass`] shape, one channel over.
///
/// [`RenderRoot::paint`](crate::app::RenderRoot::paint) holds one for the length
/// of the pass, so a publish made by a widget painted with no root above it (a
/// leaf unit test) is dropped by the next pass's clear rather than leaking into
/// it.
pub(crate) struct SelectionToolbarPass(PassBracket<Option<SelectionToolbarRequest>>);

impl SelectionToolbarPass {
    /// Open a publish pass, starting from "nobody has published anything".
    pub(crate) fn enter() -> Self {
        SelectionToolbarPass(PassBracket::enter(&PUBLISHED, &PASS_OPEN))
    }

    /// Take what *this* pass published, leaving the slot empty.
    pub(crate) fn take(&self) -> Option<SelectionToolbarRequest> {
        self.0.take()
    }
}

/// Record `request` in the pass currently painting on this thread.
///
/// The implementation behind
/// [`PaintCtx::publish_selection_toolbar`](crate::widget::PaintCtx::publish_selection_toolbar),
/// which is the API a field calls; this is the module-private half so the slot
/// stays owned here.
pub(crate) fn publish(request: SelectionToolbarRequest) {
    PUBLISHED.with(|slot| slot.set(Some(request)));
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex as StdMutex;

    /// Serialises the tests that mutate the process-global policy/builder slots
    /// — mirrors `frust_shell_common::theme_override`'s `TEST_LOCK` pattern, and
    /// is necessary for the same reason: Rust runs a crate's tests in parallel
    /// threads that share one process, so two tests writing the same static
    /// would see each other's writes.
    static TEST_LOCK: StdMutex<()> = StdMutex::new(());

    fn toolbar_request() -> SelectionToolbarRequest {
        SelectionToolbarRequest {
            anchor: Rect::new(10.0, 20.0, 110.0, 40.0),
            actions: SelectionToolbarActions {
                copy: true,
                cut: true,
                paste: false,
                select_all: true,
            },
            present_menu: true,
        }
    }

    #[test]
    fn policy_defaults_to_framework_and_round_trips() {
        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
        assert_eq!(
            selection_toolbar_policy(),
            SelectionToolbarPolicy::Framework
        );
        assert_eq!(
            SelectionToolbarPolicy::default(),
            SelectionToolbarPolicy::Framework,
            "the default route is the framework-drawn one"
        );

        set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);

        // Leave the slot as the rest of the process expects to find it.
        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
    }

    /// Test-only escape hatch: release a lock claimed with
    /// [`lock_selection_toolbar_policy`] so one test proving the lock's effect
    /// does not trap every test after it that mutates the process-global
    /// policy the ordinary way. Not `pub`, not `#[cfg(test)]`-exported beyond
    /// this module: unlocking a shell's declared policy from app code would
    /// defeat the whole point of the lock, so this stays a private detail of
    /// this crate's own test suite rather than a casual public API.
    fn unlock_selection_toolbar_policy_for_test() {
        POLICY.lock().unwrap_or_else(|e| e.into_inner()).locked = false;
    }

    #[test]
    fn set_policy_is_unaffected_when_nothing_has_locked_it() {
        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        // At rest, nothing has locked the slot — desktop, Android and web
        // never call `lock_selection_toolbar_policy`, so this is their whole
        // world: `set_selection_toolbar_policy` behaves exactly as it always
        // has.
        set_selection_toolbar_policy(SelectionToolbarPolicy::Native);
        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);
        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
        assert_eq!(
            selection_toolbar_policy(),
            SelectionToolbarPolicy::Framework
        );
    }

    #[test]
    fn locking_the_policy_sets_the_declared_value() {
        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);

        assert!(
            lock_selection_toolbar_policy(SelectionToolbarPolicy::Native),
            "the first claim always takes the lock"
        );
        assert_eq!(
            selection_toolbar_policy(),
            SelectionToolbarPolicy::Native,
            "claiming the lock sets the value it declares"
        );

        unlock_selection_toolbar_policy_for_test();
    }

    #[test]
    fn a_locked_policy_refuses_a_later_set_and_a_later_claim() {
        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
        assert!(lock_selection_toolbar_policy(
            SelectionToolbarPolicy::Native
        ));

        // An app's later call must not move a locked slot.
        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
        assert_eq!(
            selection_toolbar_policy(),
            SelectionToolbarPolicy::Native,
            "a locked slot must not move for an app's later set_selection_toolbar_policy call"
        );

        // Nor may a second shell's claim silently displace the first.
        assert!(
            !lock_selection_toolbar_policy(SelectionToolbarPolicy::Framework),
            "a second lock claim must not displace the first"
        );
        assert_eq!(selection_toolbar_policy(), SelectionToolbarPolicy::Native);

        // Leave the slot as the rest of the process expects to find it.
        unlock_selection_toolbar_policy_for_test();
        set_selection_toolbar_policy(SelectionToolbarPolicy::Framework);
    }

    #[test]
    fn install_if_unset_yields_to_an_installed_builder_but_set_replaces_it() {
        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        // Start from a known-empty slot (another test may have installed one).
        *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = None;
        assert!(
            selection_toolbar_builder().is_none(),
            "nothing is installed at rest"
        );

        let first: SelectionToolbarBuilder = Arc::new(|_req, _size| crate::view::any(FirstView));
        assert!(
            install_selection_toolbar_builder_if_unset(Arc::clone(&first)),
            "the first cooperative install takes the empty slot"
        );

        let second: SelectionToolbarBuilder = Arc::new(|_req, _size| crate::view::any(SecondView));
        assert!(
            !install_selection_toolbar_builder_if_unset(Arc::clone(&second)),
            "a second cooperative install must not displace the first"
        );
        assert!(
            Arc::ptr_eq(
                &selection_toolbar_builder().expect("a builder is installed"),
                &first
            ),
            "the slot still holds the first builder"
        );

        set_selection_toolbar_builder(Arc::clone(&second));
        assert!(
            Arc::ptr_eq(
                &selection_toolbar_builder().expect("a builder is installed"),
                &second
            ),
            "an explicit set replaces whatever stood"
        );

        *BUILDER.lock().unwrap_or_else(|e| e.into_inner()) = None;
    }

    #[test]
    fn publish_is_pass_scoped_and_last_writer_wins() {
        let pass = SelectionToolbarPass::enter();
        assert_eq!(pass.take(), None, "a fresh pass starts empty");

        publish(toolbar_request());
        let mut second = toolbar_request();
        second.actions.paste = true;
        publish(second);
        assert_eq!(
            pass.take(),
            Some(second),
            "the last publish of the pass is the one resolved"
        );
        assert_eq!(pass.take(), None, "and the drain empties the slot");
        drop(pass);

        // A publish made with no pass open is dropped by the next pass's clear,
        // never leaked into it.
        publish(toolbar_request());
        let next = SelectionToolbarPass::enter();
        assert_eq!(
            next.take(),
            None,
            "a stray publish does not survive into the next pass"
        );
    }

    /// A view double for the builder-slot tests — the builder's return type is
    /// `AnyView<()>`, and these tests only ever compare `Arc` identities, so the
    /// view never has to build anything.
    struct FirstView;
    /// The second double; see [`FirstView`].
    struct SecondView;

    macro_rules! stub_view {
        ($name:ident) => {
            impl crate::view::View<()> for $name {
                type Element = StubWidget;
                fn build(&self, _ctx: &mut crate::view::BuildCtx<'_>) -> StubWidget {
                    StubWidget
                }
                fn rebuild(
                    &self,
                    _prev: &Self,
                    _element: &mut StubWidget,
                    _ctx: &mut crate::view::BuildCtx<'_>,
                ) -> crate::view::ChangeFlags {
                    crate::view::ChangeFlags::NONE
                }
            }
        };
    }
    stub_view!(FirstView);
    stub_view!(SecondView);

    /// The widget both doubles build: a zero-sized leaf that paints nothing.
    struct StubWidget;
    impl crate::widget::Widget for StubWidget {
        fn layout(
            &mut self,
            _ctx: &mut crate::widget::LayoutCtx,
            _bc: &crate::layout::BoxConstraints,
        ) -> Size {
            Size::ZERO
        }
        fn paint(
            &mut self,
            _ctx: &mut crate::widget::PaintCtx,
            _scene: &mut dyn crate::widget::PaintScene,
        ) {
        }
    }
}