Skip to main content

frus_widgets/
barrier.rs

1//! Widgets that **withhold** part of the frame: [`IgnorePointer`] and
2//! [`AbsorbPointer`] take a subtree out of the input path, [`Visibility`] and
3//! [`Offstage`] take it out of the picture, [`ExcludeSemantics`] takes it out of the
4//! accessibility tree.
5//!
6//! They all express the same idea — *this subtree is built, but part of what it
7//! produced does not count* — and they share one mechanism, [`ModalBarrier`], applied by the
8//! walk in `ui.rs`: the subtree is walked normally, then whatever it added to the
9//! selected registries is dropped again.
10//!
11//! Dropping afterwards rather than skipping beforehand is what makes them **exact**.
12//! A widget deep inside can register a click target, a focus stop, a scrollable area,
13//! a drag handle or an accessibility node, and it does so without knowing that
14//! something above it is holding the whole subtree out of the frame. Removing at the
15//! barrier catches every one of those, including the ones added by widgets written
16//! later.
17//!
18//! ## Which to reach for
19//!
20//! | | laid out | painted | takes input | blocks what is behind |
21//! |---|---|---|---|---|
22//! | [`IgnorePointer`] | yes | yes | no | no — input falls through |
23//! | [`AbsorbPointer`] | yes | yes | no | yes — input stops here |
24//! | [`Visibility`] hidden, keeping its size | yes | no | no | no |
25//! | [`Visibility`] hidden | no | no | no | no |
26//! | [`Offstage`] | no | no | no | no |
27//! | [`ExcludeSemantics`] | yes | yes | yes | n/a |
28//!
29//! The difference between the first two is the one that matters and is easy to get
30//! backwards: a disabled control that should let a click reach the card underneath is
31//! an `IgnorePointer`; a shield over a screen that is busy, which must swallow every
32//! stray tap, is an `AbsorbPointer`.
33
34use frus_core::{Rect, Scene};
35use frus_layout::{Dimension, Style};
36
37use crate::interaction::Status;
38use crate::theme::Theme;
39use crate::widget::Widget;
40
41/// What a subtree is not allowed to contribute to the frame.
42///
43/// Returned by [`Widget::barrier`]; the walk applies it once the subtree has been
44/// visited, by discarding what that subtree added.
45#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
46pub struct ModalBarrier {
47    /// Drops the subtree's input targets: clicks, long presses, focus stops,
48    /// scrollable areas, scrollbars, drag handles, reorder handles and interactive
49    /// viewports.
50    pub pointer: bool,
51    /// Makes the barrier itself an input target that carries no message, so input
52    /// landing on it stops there instead of reaching whatever is painted behind. Only
53    /// meaningful together with `pointer`.
54    pub absorb: bool,
55    /// Drops the subtree's primitives — laid out and measured, but not drawn.
56    pub paint: bool,
57    /// Drops the subtree's accessibility nodes.
58    pub semantics: bool,
59}
60
61impl ModalBarrier {
62    /// A barrier that lets everything through — what a widget with no opinion returns.
63    pub const NONE: Self = Self {
64        pointer: false,
65        absorb: false,
66        paint: false,
67        semantics: false,
68    };
69
70    /// `true` when this barrier would change nothing, so the walk can skip it.
71    pub fn is_none(&self) -> bool {
72        *self == Self::NONE
73    }
74}
75
76/// The style of a box that takes no room at all.
77fn collapsed() -> Style {
78    Style {
79        width: Dimension::Length(0.0),
80        height: Dimension::Length(0.0),
81        ..Default::default()
82    }
83}
84
85/// Makes its child **invisible to input** while leaving it laid out and painted.
86///
87/// Input passes straight through to whatever is behind — which is the whole difference
88/// from [`AbsorbPointer`].
89///
90/// ```ignore
91/// IgnorePointer::new(row).ignoring(saving)   // inert while a save is in flight
92/// ```
93///
94/// The subtree keeps its accessibility nodes: a screen reader can still read a form
95/// that is momentarily inert, which is more useful than having it vanish. Wrap it in
96/// [`ExcludeSemantics`] as well if it really should be unreachable.
97pub struct IgnorePointer<Msg = crate::callback::Callback> {
98    ignoring: bool,
99    children: Vec<Box<dyn Widget<Msg>>>,
100}
101
102impl<Msg> IgnorePointer<Msg> {
103    /// Takes `child` out of the input path.
104    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
105        Self {
106            ignoring: true,
107            children: vec![Box::new(child)],
108        }
109    }
110
111    /// Turns the barrier on or off from a condition — `false` leaves the child fully
112    /// interactive, so a caller does not have to build two different trees.
113    pub fn ignoring(mut self, ignoring: bool) -> Self {
114        self.ignoring = ignoring;
115        self
116    }
117}
118
119impl<Msg: Clone> Widget<Msg> for IgnorePointer<Msg> {
120    fn style(&self) -> Style {
121        Style::default()
122    }
123
124    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
125        &self.children
126    }
127
128    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
129
130    fn on_click(&self) -> Option<Msg> {
131        None
132    }
133
134    fn barrier(&self) -> Option<ModalBarrier> {
135        self.ignoring.then_some(ModalBarrier {
136            pointer: true,
137            ..ModalBarrier::NONE
138        })
139    }
140}
141
142/// Makes its child invisible to input **and stops there**: the barrier is itself an
143/// input target, so anything landing on it is swallowed rather than reaching what is
144/// painted behind.
145///
146/// This is the shield over a screen that is busy, and the backdrop of a modal.
147///
148/// ```ignore
149/// AbsorbPointer::new(screen).absorbing(loading)
150/// ```
151pub struct AbsorbPointer<Msg = crate::callback::Callback> {
152    absorbing: bool,
153    children: Vec<Box<dyn Widget<Msg>>>,
154}
155
156impl<Msg> AbsorbPointer<Msg> {
157    /// Takes `child` out of the input path and swallows what lands on it.
158    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
159        Self {
160            absorbing: true,
161            children: vec![Box::new(child)],
162        }
163    }
164
165    /// Turns the barrier on or off from a condition.
166    pub fn absorbing(mut self, absorbing: bool) -> Self {
167        self.absorbing = absorbing;
168        self
169    }
170}
171
172impl<Msg: Clone> Widget<Msg> for AbsorbPointer<Msg> {
173    fn style(&self) -> Style {
174        Style::default()
175    }
176
177    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
178        &self.children
179    }
180
181    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
182
183    fn on_click(&self) -> Option<Msg> {
184        None
185    }
186
187    fn barrier(&self) -> Option<ModalBarrier> {
188        self.absorbing.then_some(ModalBarrier {
189            pointer: true,
190            absorb: true,
191            ..ModalBarrier::NONE
192        })
193    }
194}
195
196/// Hides its child from assistive technologies while leaving it visible and
197/// interactive — for decoration that would only add noise to a screen reader, or for a
198/// label the control beside it already announces.
199///
200/// ```ignore
201/// ExcludeSemantics::new(Icon::new(icons::CHEVRON))   // the button already says "next"
202/// ```
203pub struct ExcludeSemantics<Msg = crate::callback::Callback> {
204    excluding: bool,
205    children: Vec<Box<dyn Widget<Msg>>>,
206}
207
208impl<Msg> ExcludeSemantics<Msg> {
209    /// Removes `child`'s subtree from the accessibility tree.
210    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
211        Self {
212            excluding: true,
213            children: vec![Box::new(child)],
214        }
215    }
216
217    /// Turns the exclusion on or off from a condition.
218    pub fn excluding(mut self, excluding: bool) -> Self {
219        self.excluding = excluding;
220        self
221    }
222}
223
224impl<Msg: Clone> Widget<Msg> for ExcludeSemantics<Msg> {
225    fn style(&self) -> Style {
226        Style::default()
227    }
228
229    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
230        &self.children
231    }
232
233    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
234
235    fn on_click(&self) -> Option<Msg> {
236        None
237    }
238
239    fn barrier(&self) -> Option<ModalBarrier> {
240        self.excluding.then_some(ModalBarrier {
241            semantics: true,
242            ..ModalBarrier::NONE
243        })
244    }
245}
246
247/// Takes its child **out of the layout entirely**: no box, no paint, no input, no
248/// accessibility. The surrounding row or column closes up as if the child were not
249/// written at all.
250///
251/// ```ignore
252/// Offstage::new(panel).offstage(!expanded)
253/// ```
254///
255/// The child is not built into the tree while offstage, so it holds no retained state
256/// — a caret position, a scroll offset — and starts fresh when it comes back. That is
257/// the right trade for a branch that is genuinely gone; [`Visibility::maintain_size`]
258/// is the one to reach for when the box has to stay.
259pub struct Offstage<Msg = crate::callback::Callback> {
260    offstage: bool,
261    children: Vec<Box<dyn Widget<Msg>>>,
262}
263
264impl<Msg> Offstage<Msg> {
265    /// Removes `child` from the layout.
266    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
267        Self {
268            offstage: true,
269            children: vec![Box::new(child)],
270        }
271    }
272
273    /// Turns it on or off from a condition; `false` lays the child out normally.
274    pub fn offstage(mut self, offstage: bool) -> Self {
275        self.offstage = offstage;
276        self
277    }
278}
279
280impl<Msg: Clone> Widget<Msg> for Offstage<Msg> {
281    fn style(&self) -> Style {
282        if self.offstage {
283            collapsed()
284        } else {
285            Style::default()
286        }
287    }
288
289    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
290        if self.offstage {
291            &[]
292        } else {
293            &self.children
294        }
295    }
296
297    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
298
299    fn on_click(&self) -> Option<Msg> {
300        None
301    }
302}
303
304/// Shows or hides its child, with a say in **how much of it survives** being hidden.
305///
306/// The plain form collapses the child away entirely, as [`Offstage`] does:
307///
308/// ```ignore
309/// Visibility::new(banner).visible(has_error)
310/// ```
311///
312/// `maintain_size` keeps the box — the child still measures and still takes its space,
313/// it is simply not drawn. That is what stops a row from twitching as a spinner comes
314/// and goes, and what lets two alternating labels of different widths hold a column
315/// steady:
316///
317/// ```ignore
318/// Visibility::new(spinner).visible(loading).maintain_size()
319/// ```
320///
321/// `replacement` puts something else in the collapsed child's place instead — a
322/// spacer, a placeholder, a shorter message.
323pub struct Visibility<Msg = crate::callback::Callback> {
324    visible: bool,
325    maintain_size: bool,
326    maintain_interactivity: bool,
327    maintain_semantics: bool,
328    /// At most one entry. A `Vec` rather than an `Option` so `children` can hand out a
329    /// slice without building one.
330    child: Vec<Box<dyn Widget<Msg>>>,
331    replacement: Vec<Box<dyn Widget<Msg>>>,
332}
333
334impl<Msg> Visibility<Msg> {
335    /// Shows `child`. Combine with [`visible`](Self::visible) to make it conditional.
336    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
337        Self {
338            visible: true,
339            maintain_size: false,
340            maintain_interactivity: false,
341            maintain_semantics: false,
342            child: vec![Box::new(child)],
343            replacement: Vec::new(),
344        }
345    }
346
347    /// Whether the child is shown.
348    pub fn visible(mut self, visible: bool) -> Self {
349        self.visible = visible;
350        self
351    }
352
353    /// Keeps the child's **box** while hidden: it is laid out and measured as usual,
354    /// but not drawn, not clickable and not read out.
355    pub fn maintain_size(mut self) -> Self {
356        self.maintain_size = true;
357        self
358    }
359
360    /// Keeps the child **taking input** while hidden. Only meaningful together with
361    /// [`maintain_size`](Self::maintain_size), and rarely what you want: an invisible
362    /// control that still reacts surprises everyone but its author.
363    pub fn maintain_interactivity(mut self) -> Self {
364        self.maintain_interactivity = true;
365        self
366    }
367
368    /// Keeps the child in the **accessibility tree** while hidden — for content that
369    /// is visually redundant but should still be announced.
370    pub fn maintain_semantics(mut self) -> Self {
371        self.maintain_semantics = true;
372        self
373    }
374
375    /// What to show in the child's place when it is hidden **and** its box is not
376    /// maintained.
377    pub fn replacement(mut self, replacement: impl Widget<Msg> + 'static) -> Self {
378        self.replacement = vec![Box::new(replacement)];
379        self
380    }
381
382    /// `true` when the child is in the tree this frame (shown, or hidden but still
383    /// occupying its box).
384    fn keeps_child(&self) -> bool {
385        self.visible || self.maintain_size
386    }
387}
388
389impl<Msg: Clone> Widget<Msg> for Visibility<Msg> {
390    fn style(&self) -> Style {
391        if self.keeps_child() || !self.replacement.is_empty() {
392            Style::default()
393        } else {
394            collapsed()
395        }
396    }
397
398    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
399        if self.keeps_child() {
400            &self.child
401        } else {
402            &self.replacement
403        }
404    }
405
406    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
407
408    fn on_click(&self) -> Option<Msg> {
409        None
410    }
411
412    fn barrier(&self) -> Option<ModalBarrier> {
413        // Only the "hidden but still occupying its box" case needs a barrier: the
414        // collapsed case has no child in the tree to withhold anything.
415        if self.visible || !self.maintain_size {
416            return None;
417        }
418        Some(ModalBarrier {
419            pointer: !self.maintain_interactivity,
420            absorb: false,
421            paint: true,
422            semantics: !self.maintain_semantics,
423        })
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::{build_ui, Button, Container, Runtime, SingleChildScrollView, Text};
431    use frus_core::{Color, Point, Primitive, Size};
432
433    fn ui_of(root: &dyn Widget<i32>) -> crate::Ui<i32> {
434        build_ui(
435            root,
436            Size::new(100.0, 100.0),
437            &Runtime::default(),
438            &crate::Theme::dark(),
439        )
440    }
441
442    /// A 100 × 100 red box emitting `msg` when clicked.
443    fn button(msg: i32) -> Container<i32> {
444        Container::new()
445            .width(100.0)
446            .height(100.0)
447            .color(Color::rgb(1.0, 0.0, 0.0))
448            .on_click(msg)
449    }
450
451    /// The centre of the surface — inside every box these tests build.
452    fn centre() -> Point {
453        Point::new(50.0, 50.0)
454    }
455
456    #[test]
457    fn ignoring_takes_the_subtree_out_of_the_input_path() {
458        let plain =
459            crate::Flex::<i32>::column().child(IgnorePointer::new(button(1)).ignoring(false));
460        let ui = ui_of(&plain);
461        assert_eq!(
462            ui.hit(centre()).and_then(|id| ui.msg_for(id)),
463            Some(1),
464            "ignoring(false) leaves the child fully interactive"
465        );
466
467        let ignored = crate::Flex::<i32>::column().child(IgnorePointer::new(button(1)));
468        let ui = ui_of(&ignored);
469        assert!(ui.hit(centre()).is_none());
470    }
471
472    #[test]
473    fn ignoring_lets_input_reach_what_is_behind() {
474        // Two overlapping layers: the ignored one is painted on top.
475        let root = crate::Stack::<i32>::new()
476            .width(100.0)
477            .height(100.0)
478            .layer(button(1))
479            .layer(IgnorePointer::new(button(2)));
480        let ui = ui_of(&root);
481        assert_eq!(
482            ui.hit(centre()).and_then(|id| ui.msg_for(id)),
483            Some(1),
484            "the click falls through the ignored layer to the one underneath"
485        );
486    }
487
488    #[test]
489    fn absorbing_stops_input_at_the_barrier() {
490        let root = crate::Stack::<i32>::new()
491            .width(100.0)
492            .height(100.0)
493            .layer(button(1))
494            .layer(AbsorbPointer::new(button(2)));
495        let ui = ui_of(&root);
496        let id = ui.hit(centre()).expect("the barrier is itself a target");
497        assert_eq!(
498            ui.msg_for(id),
499            None,
500            "it swallows the click rather than emitting anything"
501        );
502    }
503
504    #[test]
505    fn absorbing_can_be_turned_off_from_a_condition() {
506        let root = crate::Stack::<i32>::new()
507            .width(100.0)
508            .height(100.0)
509            .layer(button(1))
510            .layer(AbsorbPointer::new(button(2)).absorbing(false));
511        let ui = ui_of(&root);
512        assert_eq!(ui.hit(centre()).and_then(|id| ui.msg_for(id)), Some(2));
513    }
514
515    #[test]
516    fn a_barrier_still_paints_its_child() {
517        let root = crate::Flex::<i32>::column().child(IgnorePointer::new(button(1)));
518        let ui = ui_of(&root);
519        assert!(
520            ui.scene()
521                .primitives()
522                .iter()
523                .any(|p| matches!(p, Primitive::Rect { color, .. } if color.r > 0.5)),
524            "an ignored subtree is invisible to input, not to the eye"
525        );
526    }
527
528    /// The barrier acts on what the subtree registered, not on what the barrier widget
529    /// itself declares — so a target several levels down is caught too.
530    #[test]
531    fn a_target_deep_inside_is_caught() {
532        let deep = crate::Flex::<i32>::column().child(
533            crate::Flex::<i32>::column().child(crate::Flex::<i32>::column().child(button(1))),
534        );
535        let root = crate::Flex::<i32>::column().child(IgnorePointer::new(deep));
536        let ui = ui_of(&root);
537        assert!(ui.hit(centre()).is_none());
538    }
539
540    #[test]
541    fn a_scrollable_inside_a_barrier_stops_scrolling() {
542        let long = SingleChildScrollView::<i32>::new()
543            .width(100.0)
544            .height(100.0)
545            .child(Container::new().width(100.0).height(1000.0));
546        let root = crate::Flex::<i32>::column().child(IgnorePointer::new(long));
547        let ui = ui_of(&root);
548        assert!(
549            ui.scroll_hit(centre()).is_none(),
550            "the wheel has nothing to scroll through a barrier"
551        );
552    }
553
554    /// A widget that annotates itself for assistive technologies — the only kind
555    /// [`ExcludeSemantics`] has anything to remove.
556    fn announced() -> Button<i32> {
557        Button::new("Send").on_press(1)
558    }
559
560    #[test]
561    fn excluding_semantics_leaves_input_and_paint_alone() {
562        let visible = crate::Flex::<i32>::column().child(announced());
563        let plain = ui_of(&visible);
564        assert!(
565            !plain.semantics().is_empty(),
566            "the fixture has something to exclude"
567        );
568        // A point inside the button, which sits at the top of the column.
569        let on_the_button = Point::new(20.0, 12.0);
570        assert!(plain.hit(on_the_button).is_some(), "and it is clickable");
571
572        let root = crate::Flex::<i32>::column().child(ExcludeSemantics::new(announced()));
573        let ui = ui_of(&root);
574        assert!(
575            ui.semantics().is_empty(),
576            "gone from the accessibility tree"
577        );
578        assert!(ui.hit(on_the_button).is_some(), "and still clickable");
579    }
580
581    #[test]
582    fn an_ignored_subtree_keeps_its_semantics() {
583        let root = crate::Flex::<i32>::column().child(IgnorePointer::new(announced()));
584        let ui = ui_of(&root);
585        assert!(
586            !ui.semantics().is_empty(),
587            "a momentarily inert form is still worth reading out"
588        );
589        assert!(
590            ui.hit(Point::new(20.0, 12.0)).is_none(),
591            "but it takes no input"
592        );
593    }
594
595    #[test]
596    fn offstage_takes_no_room() {
597        let shown = crate::Flex::<i32>::column()
598            .child(Offstage::new(button(1)).offstage(false))
599            .child(Text::new("after"));
600        let ui = ui_of(&shown);
601        assert!(ui.hit(centre()).is_some());
602
603        let hidden = crate::Flex::<i32>::column()
604            .child(Offstage::new(button(1)))
605            .child(Text::new("after"));
606        let ui = ui_of(&hidden);
607        assert!(ui.hit(centre()).is_none());
608        assert!(
609            ui.scene()
610                .primitives()
611                .iter()
612                .all(|p| !matches!(p, Primitive::Rect { color, .. } if color.r > 0.5)),
613            "an offstage child is not painted either"
614        );
615    }
616
617    #[test]
618    fn a_hidden_visibility_collapses_by_default() {
619        let root = crate::Flex::<i32>::column().child(Visibility::new(button(1)).visible(false));
620        let ui = ui_of(&root);
621        assert!(ui.hit(centre()).is_none());
622        assert_eq!(
623            Widget::<i32>::children(&Visibility::new(button(1)).visible(false)).len(),
624            0,
625            "no child in the tree at all"
626        );
627    }
628
629    #[test]
630    fn maintaining_the_size_keeps_the_box_but_not_the_paint() {
631        let hidden = || Visibility::new(button(1)).visible(false).maintain_size();
632        let root = crate::Flex::<i32>::column().child(hidden());
633        let ui = ui_of(&root);
634        assert!(
635            ui.scene()
636                .primitives()
637                .iter()
638                .all(|p| !matches!(p, Primitive::Rect { color, .. } if color.r > 0.5)),
639            "not drawn"
640        );
641        assert!(ui.hit(centre()).is_none(), "and not clickable");
642        assert_eq!(
643            Widget::<i32>::children(&hidden()).len(),
644            1,
645            "but still laid out, so the row does not twitch"
646        );
647    }
648
649    #[test]
650    fn maintained_interactivity_survives_the_barrier() {
651        let root = crate::Flex::<i32>::column().child(
652            Visibility::new(button(1))
653                .visible(false)
654                .maintain_size()
655                .maintain_interactivity(),
656        );
657        let ui = ui_of(&root);
658        assert_eq!(ui.hit(centre()).and_then(|id| ui.msg_for(id)), Some(1));
659    }
660
661    #[test]
662    fn a_replacement_takes_the_hidden_childs_place() {
663        let root = crate::Flex::<i32>::column().child(
664            Visibility::new(button(1))
665                .visible(false)
666                .replacement(button(2)),
667        );
668        let ui = ui_of(&root);
669        assert_eq!(
670            ui.hit(centre()).and_then(|id| ui.msg_for(id)),
671            Some(2),
672            "the replacement is an ordinary child, barrier-free"
673        );
674    }
675
676    #[test]
677    fn a_widget_that_withholds_nothing_declares_no_barrier() {
678        assert_eq!(Widget::<i32>::barrier(&Visibility::new(button(1))), None);
679        assert_eq!(
680            Widget::<i32>::barrier(&IgnorePointer::new(button(1)).ignoring(false)),
681            None
682        );
683    }
684}