Skip to main content

blockworx/shell/
status_line.rs

1//! The status line: plain muted text at the bottom left, in no container and
2//! answering no pointer.
3//!
4//! *Things about mode come from the top; things about feedback come from the
5//! bottom.* The first line is the state, four of them, always showing the
6//! most relevant — and the priority between them is written down exactly
7//! once, in [`Says::of`], so no caller can decide it differently.
8//!
9//! The second line is the drawing's own title block, which is what the user
10//! called this corner: the author, the rev on the canvas, and when that rev
11//! was written. It does not change with what the first line is saying.
12//!
13//! This is where the per-edit confirmations live. The toast keeps only what
14//! needs attention; a rev landing is routine, and routine belongs here.
15
16use crate::{
17    grid::GridCell,
18    kernel::{Reading, TitleBlock},
19    shell::{glass, mono},
20};
21use blockworx_paint::Zoom;
22
23/// What the line is saying this frame, most relevant first. The order of the
24/// variants *is* the priority — [`Says::of`] takes the first that applies.
25#[derive(Clone, PartialEq, Debug)]
26pub enum Says {
27    /// An action just landed: what it did and the rev it wrote, until the
28    /// dwell is up.
29    Confirmed(String),
30    /// A tool is armed: how to use it, for as long as it stays armed.
31    Instruction(std::borrow::Cow<'static, str>),
32    /// Something is selected: the path to it. Ephemeral, which is why it
33    /// cannot share the bar's own breadcrumb slot.
34    Selection(String),
35    /// Nothing else to say. A generic "units · snap" has nothing to add
36    /// here: there is no unit word, and blockworx snaps everything, always.
37    Idle {
38        zoom: Zoom,
39        cursor: Option<GridCell>,
40    },
41}
42
43/// The title block as one line, in the order the user asked for it. A
44/// session with no clock stops after the rev rather than trailing a
45/// separator.
46fn title_line(title: &TitleBlock) -> String {
47    let head = format!("{} {SEPARATOR} rev {}", title.author, title.rev.get());
48    match title.written.trim() {
49        "" => head,
50        when => format!("{head} {SEPARATOR} {when}"),
51    }
52}
53
54/// What the title block's fields are told apart by — the mockup's own
55/// interpunct, which the breadcrumb and the viewing mode already use.
56const SEPARATOR: char = '\u{00b7}';
57
58impl Says {
59    /// Always showing the most relevant thing, resolved once.
60    ///
61    /// A confirmation outranks everything for its two seconds: it is the
62    /// answer to what the user just did, and the states under it will still
63    /// be true when it goes.
64    fn of(confirmed: Option<String>, reading: Reading) -> Says {
65        let Reading {
66            tool,
67            selection,
68            zoom,
69            cursor,
70            // The title block is not a state: it is drawn under whichever
71            // one of these wins.
72            title: _,
73        } = reading;
74        if let Some(said) = confirmed {
75            return Says::Confirmed(said);
76        }
77        if let Some(instruction) = tool {
78            return Says::Instruction(instruction);
79        }
80        if let Some(path) = selection {
81            return Says::Selection(path);
82        }
83        Says::Idle { zoom, cursor }
84    }
85}
86
87/// Say that an action landed. It shows for [`DWELL`] and then the line goes
88/// back to whatever was true underneath it.
89pub fn say(ctx: &egui::Context, said: impl Into<String>) {
90    let raised = ctx.input(|i| i.time);
91    ctx.data_mut(|data| {
92        data.insert_temp(
93            id(),
94            Confirmation {
95                said: said.into(),
96                raised,
97            },
98        );
99    });
100    ctx.request_repaint();
101}
102
103/// Draw the line in its berth.
104pub fn status_line(chrome: &mut super::Chrome, reading: Reading) {
105    let title = reading.title.clone();
106    let says = Says::of(confirmed(chrome.ctx()), reading);
107    chrome.piece(glass::Berth::StatusLine, |ui| {
108        ui.vertical(|ui| {
109            ui.horizontal(|ui| {
110                ui.spacing_mut().item_spacing.x = GAP;
111                match &says {
112                    // A confirmation is the one thing here the eye is meant to
113                    // find, so it reads at full strength where the rest is muted.
114                    Says::Confirmed(said) => {
115                        let ink = glass::full_ink(ui.visuals());
116                        ui.label(egui::RichText::new(said).color(ink));
117                    }
118                    Says::Instruction(instruction) => {
119                        ui.label(instruction.as_ref());
120                    }
121                    Says::Selection(path) => {
122                        ui.label(egui::RichText::new(path).weak());
123                    }
124                    Says::Idle { zoom, cursor } => {
125                        let percent = (zoom.get() * 100.0).round() as i32;
126                        ui.label(mono(format!("{percent}%")).weak());
127                        // A steady width whether or not the pointer is over the
128                        // canvas, so the line's right end does not shuffle as the
129                        // mouse leaves. The unit is not named: the editor measures
130                        // in nothing else (item 13).
131                        ui.label(
132                            mono(match cursor {
133                                Some(cell) => format!("{cell}"),
134                                None => "\u{2013},\u{2013}".to_owned(),
135                            })
136                            .weak(),
137                        );
138                    }
139                }
140            });
141            // Always, and always the same words in every state: the title block
142            // is what the drawing *is*, where the line above it is what just
143            // happened to it.
144            ui.label(egui::RichText::new(title_line(&title)).weak());
145        });
146    });
147}
148
149/// The confirmation still standing, if its dwell has not run out — and the
150/// one repaint it is owed, at the moment it ends.
151///
152/// Asking for that frame rather than a stream of them is what lets an idle
153/// frame with a confirmation on it still settle.
154fn confirmed(ctx: &egui::Context) -> Option<String> {
155    let showing = ctx.data(|data| data.get_temp::<Confirmation>(id()))?;
156    let elapsed =
157        core::time::Duration::from_secs_f64((ctx.input(|i| i.time) - showing.raised).max(0.0));
158    let Some(left) = DWELL.checked_sub(elapsed) else {
159        ctx.data_mut(|data| data.remove::<Confirmation>(id()));
160        return None;
161    };
162    ctx.request_repaint_after(left);
163    Some(showing.said)
164}
165
166/// What was said, and when. Kept in egui's own memory rather than in the
167/// editor: it is chrome that dies with the session, and the paths that raise
168/// one — a worker thread finishing a write — have a context and no editor.
169#[derive(Clone)]
170struct Confirmation {
171    said: String,
172    raised: f64,
173}
174
175/// What the line is confirming — for a test that drives a real file path and
176/// asks what the user was told about it.
177#[cfg(test)]
178pub(crate) fn showing(ctx: &egui::Context) -> Option<String> {
179    ctx.data(|data| data.get_temp::<Confirmation>(id()))
180        .map(|showing| showing.said)
181}
182
183fn id() -> egui::Id {
184    egui::Id::new("shell_status_confirmation")
185}
186
187/// How long a confirmation holds before the line reverts.
188const DWELL: core::time::Duration = core::time::Duration::from_secs(2);
189
190/// The mockup's `.status{gap:16px}`.
191const GAP: f32 = 16.0;
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::canvas::convert::IntoGeom as _;
197    use crate::panels::painted::Chrome;
198    use crate::shell::tests::screen;
199
200    fn reading() -> Reading {
201        Reading {
202            tool: None,
203            selection: None,
204            zoom: Zoom::unity(),
205            cursor: Some(GridCell { x: 12, y: -4 }),
206            title: title(),
207        }
208    }
209
210    fn title() -> TitleBlock {
211        TitleBlock {
212            author: AUTHOR.to_owned(),
213            rev: blockworx_doc::fixtures::rev(4),
214            written: WRITTEN.to_owned(),
215        }
216    }
217
218    const AUTHOR: &str = "Ada Lovelace";
219    const WRITTEN: &str = "2026-09-01 14:32";
220
221    /// Items 9 and 13: *"The text at the lower left should list the author's
222    /// name"* and *"on a second line, the author, current rev, and date/time
223    /// of that rev."* Both lines draw, and the second says all three.
224    #[test]
225    fn the_line_carries_a_title_block_under_whatever_it_is_saying() {
226        let mut chrome = Chrome::new(screen().geom());
227        let show = |ui: &mut egui::Ui| {
228            let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
229            status_line(&mut floating, reading());
230        };
231        chrome.settle(show);
232        let block = format!("{AUTHOR} \u{00b7} rev 4 \u{00b7} {WRITTEN}");
233        assert!(
234            chrome.shows(&block),
235            "the title block never drew: {:?}",
236            chrome.texts(),
237        );
238        let state = chrome
239            .rect("100%")
240            .expect("the state line lost its zoom readout");
241        let title = chrome.rect(&block).expect("the title block never drew");
242        assert!(
243            title.top() >= state.bottom(),
244            "the title block is not the second line: {title:?} against {state:?}",
245        );
246
247        // And it says the same thing whatever the line above it is saying.
248        chrome.settle(|ui| {
249            say(ui.ctx(), CONFIRMED);
250            let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
251            status_line(&mut floating, reading());
252        });
253        assert!(
254            chrome.shows(CONFIRMED) && chrome.shows(&block),
255            "a confirmation covered the title block: {:?}",
256            chrome.texts(),
257        );
258    }
259
260    /// The same trap as the bar's document name, one line down: a
261    /// confirmation is the one thing here the eye is meant to find, and
262    /// `RichText::strong` would have painted it in `widgets.active.fg_stroke`
263    /// — this palette's darkest base — on the theme's own surface.
264    #[test]
265    fn a_confirmation_is_painted_in_ink_the_canvas_can_be_read_against() {
266        let visuals = crate::canvas::convert::visuals(
267            &blockworx_paint::Scheme::default().palette(blockworx_paint::Luminance::Dark),
268        );
269        let mut chrome = Chrome::new(screen().geom());
270        chrome.ctx().set_visuals(visuals.clone());
271        chrome.settle(|ui| {
272            say(ui.ctx(), CONFIRMED);
273            let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
274            status_line(&mut floating, reading());
275        });
276        let ink = chrome
277            .format_of(CONFIRMED)
278            .expect("the confirmation never drew")
279            .color;
280        assert_eq!(ink, glass::full_ink(&visuals));
281        assert_ne!(
282            ink,
283            visuals.strong_text_color(),
284            "the confirmation went back to egui's strong text colour",
285        );
286    }
287
288    /// A session with no clock keeps the author and the rev, and stops there
289    /// rather than trailing a separator with nothing after it.
290    #[test]
291    fn a_session_with_no_clock_says_what_it_knows_and_no_more() {
292        assert_eq!(
293            title_line(&TitleBlock {
294                written: String::new(),
295                ..title()
296            }),
297            format!("{AUTHOR} \u{00b7} rev 4"),
298        );
299        assert_eq!(
300            title_line(&title()),
301            format!("{AUTHOR} \u{00b7} rev 4 \u{00b7} {WRITTEN}"),
302        );
303    }
304
305    /// The safe area is measured, so two lines take the room two lines need
306    /// — nothing the model draws may land on either of them.
307    #[test]
308    fn the_room_the_line_keeps_clears_both_of_its_lines() {
309        let mut chrome = Chrome::new(screen().geom());
310        let mut region = egui::Rect::NOTHING;
311        chrome.settle(|ui| {
312            let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
313            status_line(&mut floating, reading());
314            region = floating.safe().region();
315        });
316        let state = chrome
317            .rect("100%")
318            .expect("the state line lost its zoom readout");
319        let block = format!("{AUTHOR} \u{00b7} rev 4 \u{00b7} {WRITTEN}");
320        let title = chrome.rect(&block).expect("the title block never drew");
321        assert!(
322            title.top() >= state.bottom(),
323            "precondition: the title block is the second line",
324        );
325        assert!(
326            region.bottom() <= state.top(),
327            "the canvas may land on the status line: {region:?} against {state:?}",
328        );
329    }
330
331    /// The line is text, not a control: it draws no container and takes no
332    /// pointer, so the canvas under it is still reachable.
333    #[test]
334    fn the_line_draws_no_container_and_answers_no_pointer() {
335        let mut chrome = Chrome::new(screen().geom());
336        chrome.settle(|ui| {
337            let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
338            status_line(&mut floating, reading());
339        });
340        let at = crate::shell::berth_rect(chrome.ctx(), glass::Berth::StatusLine)
341            .expect("the status line never laid out");
342        assert!(
343            !crate::shell::over_the_chrome(chrome.ctx(), at.center()),
344            "the status line took the canvas's pointer at {at:?}",
345        );
346        assert!(
347            chrome.shows("100%"),
348            "the idle line lost its zoom: {:?}",
349            chrome.texts(),
350        );
351    }
352
353    /// A confirmation holds for its dwell, then the line goes back to what
354    /// was true underneath it — and the frames after it ask for nothing, so
355    /// an idle editor still settles.
356    #[test]
357    fn a_confirmation_reverts_after_its_dwell_and_settles() {
358        let mut said = false;
359        let settle = crate::canvas::settle::probe(200, |ui| {
360            if !said {
361                say(ui.ctx(), CONFIRMED);
362                said = true;
363            }
364            let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
365            status_line(&mut floating, reading());
366        });
367        crate::canvas::settle::assert_settles(&settle, 190);
368    }
369
370    /// The same, read through the words the line actually paints: the
371    /// confirmation is there at first and gone once the dwell is up.
372    #[test]
373    fn the_confirmation_is_painted_and_then_is_not() {
374        let mut chrome = Chrome::new(screen().geom());
375        let show = |ui: &mut egui::Ui| {
376            let mut floating = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
377            status_line(&mut floating, reading());
378        };
379        chrome.settle(|ui| {
380            say(ui.ctx(), CONFIRMED);
381            show(ui);
382        });
383        assert!(
384            chrome.shows(CONFIRMED),
385            "the confirmation never drew: {:?}",
386            chrome.texts(),
387        );
388        chrome.wait(core::time::Duration::from_millis(2_500), show);
389        assert!(
390            !chrome.shows(CONFIRMED),
391            "the confirmation outstayed its dwell: {:?}",
392            chrome.texts(),
393        );
394        assert!(chrome.shows("100%"), "the line never came back");
395    }
396
397    const CONFIRMED: &str = "Add block Filter \u{2014} rev 4";
398}