Skip to main content

blockworx_web/
status_line.rs

1//! The status line, in the diagram's bottom-right corner: plain muted text,
2//! no container (`docs/cad-ui-spec.md` §2.0.1). Ambient information is not a
3//! control and must not look like one. The corner is the one nothing else
4//! claims — the sidebar's panel opens over the left and the toolbar sits in
5//! the middle.
6//!
7//! Two lines. The first says the most relevant thing there is to say, in one
8//! fixed priority; the second is the title block — who, which rev, and when
9//! — which describes **the drawing being looked at**, so under the lens it is
10//! the earlier rev's.
11//!
12//! It is glass over the canvas, so it measures itself and tells the shell
13//! what it covers; that is the safe region a framing centres in.
14
15use core::time::Duration;
16
17use blockworx_kernel::Reading;
18use dioxus::prelude::*;
19use dioxus::web::WebEventExt as _;
20
21use crate::shell::{Band, Owed, Shell, after, measured};
22
23/// How long a confirmation holds the line before it falls back.
24const DWELL: Duration = Duration::from_secs(2);
25
26/// What the first line says, in the order it is picked. The enum's own order
27/// *is* the priority.
28#[derive(Clone, PartialEq, Eq, Debug)]
29pub enum Says {
30    /// What just landed — the only loud thing on the line.
31    Confirmed(String),
32    /// How to use the armed tool, for as long as it is armed.
33    Instruction(String),
34    /// The path to what is selected.
35    Selection(String),
36    Idle {
37        zoom: String,
38        cursor: String,
39    },
40}
41
42impl Says {
43    /// The one picker, so the line cannot show two things at once.
44    #[must_use]
45    pub fn of(confirmed: Option<&str>, reading: &Reading) -> Self {
46        if let Some(said) = confirmed {
47            return Says::Confirmed(said.to_owned());
48        }
49        if let Some(how) = &reading.tool {
50            return Says::Instruction(how.to_string());
51        }
52        if let Some(what) = &reading.selection {
53            return Says::Selection(what.clone());
54        }
55        Says::Idle {
56            zoom: format!("{:.0}%", reading.zoom.get() * 100.0),
57            cursor: reading
58                .cursor
59                .map_or_else(|| "\u{2013},\u{2013}".to_owned(), |cell| format!("{cell}")),
60        }
61    }
62}
63
64/// How much the journal still owes the origin — the one thing on this host
65/// that is committed but not yet on disk.
66///
67/// `None` is everything in step, which says nothing rather than saying so.
68#[must_use]
69pub fn writing(owed: Owed) -> Option<String> {
70    match owed {
71        Owed(0) => None,
72        Owed(depth) => Some(format!("Writing\u{2026} ({depth})")),
73    }
74}
75
76#[component]
77pub fn StatusLine(shell: Shell) -> Element {
78    let chrome = shell.chrome();
79    let owed = shell.owed();
80    let mut confirmed = use_signal(|| None::<String>);
81    let mut holding = use_signal(|| 0_u64);
82    use_effect(move || {
83        let Some(landed) = chrome.read().landed.clone() else {
84            return;
85        };
86        let taken = *holding.peek() + 1;
87        holding.set(taken);
88        confirmed.set(Some(landed));
89        after(DWELL, move || {
90            if *holding.peek() == taken {
91                confirmed.set(None);
92            }
93        });
94    });
95
96    let read = chrome.read();
97    let says = Says::of(confirmed.read().as_deref(), &read.status);
98    let block = read.status.title.clone();
99    let owes = writing(owed());
100    let written = if block.written.is_empty() {
101        String::new()
102    } else {
103        format!(" \u{00b7} {}", block.written)
104    };
105    let shell = use_hook(|| CopyValue::new(shell));
106    rsx! {
107        div {
108            class: "pointer-events-none absolute bottom-0 right-0 z-10 max-w-[30%] p-4 \
109                    text-right text-xs leading-relaxed",
110            role: "status",
111            onmounted: move |event| {
112                if let Some(element) = event.try_as_web_event() {
113                    covers(&shell.read(), &element);
114                }
115            },
116            onresize: move |event| {
117                if let Some(entry) = event.try_as_web_event() {
118                    covers(&shell.read(), &entry.target());
119                }
120            },
121            div { class: "flex items-center justify-end gap-4",
122                match says {
123                    Says::Confirmed(said) => rsx! {
124                        span { class: "truncate font-medium text-sky-600 dark:text-sky-400", "{said}" }
125                    },
126                    Says::Instruction(how) => rsx! {
127                        span { class: "truncate text-zinc-600 dark:text-zinc-300", "{how}" }
128                    },
129                    Says::Selection(what) => rsx! {
130                        span { class: "truncate text-zinc-500 dark:text-zinc-400", "{what}" }
131                    },
132                    Says::Idle { zoom, cursor } => rsx! {
133                        span { class: "font-mono text-zinc-500 dark:text-zinc-500", "{zoom}" }
134                        span { class: "font-mono text-zinc-500 dark:text-zinc-500", "{cursor}" }
135                    },
136                }
137                if let Some(owes) = owes {
138                    span { class: "text-amber-600 dark:text-amber-400", "data-owes": "true", "{owes}" }
139                }
140            }
141            div { class: "truncate text-zinc-500 dark:text-zinc-500",
142                "{block.author} \u{00b7} rev {block.rev.get()}{written}"
143            }
144        }
145    }
146}
147
148/// What this band covers of the diagram, in the canvas's own coordinates —
149/// the two share a positioned parent, so the offsets are already those.
150fn covers(shell: &Shell, element: &web_sys::Element) {
151    if let Some(at) = measured(element) {
152        shell.covers(Band::Status, at);
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use blockworx_geom::grid::GridCell;
159    use blockworx_kernel::TitleBlock;
160    use blockworx_paint::Zoom;
161
162    use super::*;
163
164    fn resting() -> Reading {
165        Reading {
166            tool: None,
167            selection: None,
168            zoom: Zoom::new(1.0),
169            cursor: None,
170            title: TitleBlock {
171                author: "tester".to_owned(),
172                rev: blockworx_doc::rev::Rev::ZERO,
173                written: String::new(),
174            },
175        }
176    }
177
178    /// The order the enum is written in is the order the line picks in, and
179    /// every step of it is reachable.
180    #[test]
181    fn the_line_says_the_most_relevant_thing_there_is() {
182        let mut reading = resting();
183        assert!(matches!(Says::of(None, &reading), Says::Idle { .. },),);
184        reading.selection = Some("Rig / Filter".to_owned());
185        assert_eq!(
186            Says::of(None, &reading),
187            Says::Selection("Rig / Filter".to_owned()),
188        );
189        reading.tool = Some("Click one corner, then the other".into());
190        assert_eq!(
191            Says::of(None, &reading),
192            Says::Instruction("Click one corner, then the other".to_owned()),
193        );
194        assert_eq!(
195            Says::of(Some("Added a block"), &reading),
196            Says::Confirmed("Added a block".to_owned()),
197        );
198    }
199
200    /// Idle names the zoom and where the pointer is, and says so with a
201    /// fixed-width placeholder rather than nothing when it is off the canvas.
202    #[test]
203    fn idle_names_the_zoom_and_the_cell_under_the_pointer() {
204        let mut reading = resting();
205        reading.zoom = Zoom::new(0.5);
206        let Says::Idle { zoom, cursor } = Says::of(None, &reading) else {
207            panic!("a resting line is idle");
208        };
209        assert_eq!(zoom, "50%");
210        assert_eq!(cursor, "\u{2013},\u{2013}");
211        reading.cursor = Some(GridCell { x: 3, y: 4 });
212        let Says::Idle { cursor, .. } = Says::of(None, &reading) else {
213            panic!("a resting line is idle");
214        };
215        assert_ne!(cursor, "\u{2013},\u{2013}");
216    }
217
218    /// The journal slot: a queue with work in it says how much, and an
219    /// empty one says nothing at all.
220    #[test]
221    fn the_slot_says_what_the_journal_still_owes() {
222        assert_eq!(writing(Owed(0)), None);
223        assert_eq!(writing(Owed(1)).as_deref(), Some("Writing\u{2026} (1)"));
224        assert_eq!(writing(Owed(3)).as_deref(), Some("Writing\u{2026} (3)"));
225    }
226}