Skip to main content

blockworx_web/
notices.rs

1//! What the session has to say about its document, and what the shell has to
2//! say about itself.
3//!
4//! Two surfaces, because they are two kinds of thing. The **strip** carries
5//! the session's notices: a standing fact stands until the fact does, and a
6//! failure carries an acknowledgement and is gone for the rest of the session
7//! once it is given — an error nobody can dismiss makes the editor unusable.
8//! The **toast** carries what the shell itself could not do; it is reserved
9//! for events that need attention (`docs/cad-ui-spec.md` §2.0.2), and keeping
10//! it rare is what keeps it noticeable, so routine confirmations go to the
11//! status line instead.
12
13use core::time::Duration;
14
15use blockworx_kernel::{Event, Notice};
16use blockworx_tools::tool::Action;
17use dioxus::prelude::*;
18use dioxus::web::WebEventExt as _;
19
20use crate::shell::{Band, Shell, after, measured};
21
22/// How long a toast holds before it leaves.
23const DWELL: Duration = Duration::from_millis(2_400);
24
25/// One notice as the strip draws it: what it says, and the acknowledgement
26/// it carries if it is a failure.
27///
28/// The index a dismissal reports counts **failures only** — that is what the
29/// session's own acknowledgement is by — so it is worked out here rather than
30/// read off the row's place in the list.
31#[must_use]
32pub fn acknowledgements(notices: &[Notice]) -> Vec<(String, Option<usize>)> {
33    let mut failures = 0;
34    notices
35        .iter()
36        .map(|notice| match notice {
37            Notice::Failure(said) => {
38                let at = failures;
39                failures += 1;
40                (said.clone(), Some(at))
41            }
42            Notice::Standing(said) => (said.clone(), None),
43        })
44        .collect()
45}
46
47#[component]
48pub fn Notices(shell: Shell) -> Element {
49    let chrome = shell.chrome();
50    let rows = acknowledgements(&chrome.read().notices);
51    if rows.is_empty() {
52        return rsx! {};
53    }
54    let shell = use_hook(|| CopyValue::new(shell));
55    rsx! {
56        div {
57            class: "absolute right-4 top-4 z-20 flex max-w-[40%] flex-col gap-2 rounded-2xl \
58                    bg-white/90 p-3 text-sm shadow-lg ring-1 ring-zinc-950/5 backdrop-blur-xl \
59                    dark:bg-zinc-800/90 dark:ring-white/10",
60            role: "alert",
61            onmounted: move |event| {
62                if let Some(element) = event.try_as_web_event() {
63                    covers(&shell.read(), &element);
64                }
65            },
66            onresize: move |event| {
67                if let Some(entry) = event.try_as_web_event() {
68                    covers(&shell.read(), &entry.target());
69                }
70            },
71            for (said , failure) in rows {
72                div { key: "{said}", class: "flex items-start gap-3",
73                    span { class: "min-w-0 flex-1 text-zinc-700 dark:text-zinc-200", "{said}" }
74                    if let Some(at) = failure {
75                        button {
76                            class: "flex-none rounded-lg px-2 py-1 text-xs font-medium \
77                                    text-zinc-500 transition hover:bg-zinc-950/6 \
78                                    dark:text-zinc-400 dark:hover:bg-white/10",
79                            title: "Done with this message",
80                            "data-dismiss": "{at}",
81                            onclick: move |_| {
82                                shell.read().say(Event::Action(Action::AcknowledgeFailure(at)));
83                            },
84                            "Dismiss"
85                        }
86                    }
87                }
88            }
89        }
90    }
91}
92
93/// What the shell itself could not do. One message at a time — a newer one
94/// replaces whatever is showing — and it takes no room from the canvas: glass
95/// floating over the middle is not a wall. It stands above the toolbar, which
96/// holds the bottom centre.
97#[component]
98pub fn Toast(shell: Shell) -> Element {
99    let reported = shell.reported();
100    let mut showing = use_signal(|| None::<String>);
101    let mut holding = use_signal(|| 0_u64);
102    use_effect(move || {
103        let Some(what) = reported.read().what.clone() else {
104            return;
105        };
106        let taken = *holding.peek() + 1;
107        holding.set(taken);
108        showing.set(Some(what));
109        after(DWELL, move || {
110            if *holding.peek() == taken {
111                showing.set(None);
112            }
113        });
114    });
115    let Some(what) = showing.read().clone() else {
116        return rsx! {};
117    };
118    rsx! {
119        div {
120            class: "pointer-events-none absolute bottom-28 left-1/2 z-40 -translate-x-1/2 \
121                    rounded-2xl bg-zinc-900 px-4 py-2.5 text-sm text-zinc-50 shadow-xl \
122                    dark:bg-zinc-100 dark:text-zinc-900",
123            role: "alert",
124            "data-toast": "true",
125            "{what}"
126        }
127    }
128}
129
130fn covers(shell: &Shell, element: &web_sys::Element) {
131    if let Some(at) = measured(element) {
132        shell.covers(Band::Notices, at);
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    /// A dismissal names a failure by its place *among failures*, which is
141    /// what the session acknowledges by — a standing fact in the middle of
142    /// the list must not shift the count.
143    #[test]
144    fn a_dismissal_counts_failures_and_not_rows() {
145        let rows = acknowledgements(&[
146            Notice::Failure("Could not open it".to_owned()),
147            Notice::Standing("Read-only".to_owned()),
148            Notice::Failure("Could not write it".to_owned()),
149        ]);
150        assert_eq!(
151            rows.iter().map(|(_, at)| *at).collect::<Vec<_>>(),
152            vec![Some(0), None, Some(1)],
153        );
154    }
155
156    /// A standing fact cannot be dismissed: dismissing "read-only" would not
157    /// make the container writable.
158    #[test]
159    fn a_standing_fact_carries_no_acknowledgement() {
160        let rows = acknowledgements(&[Notice::Standing("Read-only".to_owned())]);
161        assert_eq!(rows, vec![("Read-only".to_owned(), None)]);
162    }
163}