Skip to main content

blockworx/tools/
notices.rs

1//! What the session has to tell the user about its document: why the canvas is
2//! read-only, what will be overwritten by the next save, and the load that did
3//! not work.
4//!
5//! Two kinds, because they end differently. A standing fact stands until the
6//! fact does — dismissing "read-only" would not make the container writable. A
7//! failure happened once, so it carries an acknowledgement and is gone for the
8//! rest of the session once it is given.
9//!
10//! The strip hangs below the chrome along the top of the canvas rather than
11//! over it: a message that buries the toolbar takes the editor with it
12//! (docs/ui-issues.md).
13
14use crate::grid::GRID_SIZE;
15use crate::tools::chrome::Panel;
16
17pub enum Notice {
18    /// A failure that happened once — a container that would not open, a file
19    /// that would not be written.
20    Failure(String),
21    /// A standing fact about the document, true until it stops being true.
22    Standing(String),
23}
24
25/// The failure the user acknowledged, by its position among the failures.
26pub struct Acknowledged(pub usize);
27
28/// The widest a notice runs before it wraps, as a share of the canvas.
29const MAX_WIDTH: f32 = 0.4;
30
31/// Draw `notices` down the canvas's left edge, starting below `above` — the
32/// chrome band along the top (the main menu and the toolbar), which nothing
33/// here may cover.
34pub fn draw(
35    ui: &mut egui::Ui,
36    viewport: egui::Rect,
37    above: egui::Rect,
38    notices: &[Notice],
39) -> Option<Acknowledged> {
40    if notices.is_empty() {
41        return None;
42    }
43    let top = if above.is_positive() {
44        above.bottom() + GRID_SIZE
45    } else {
46        viewport.top() + GRID_SIZE
47    };
48    let strip = egui::Rect::from_min_max(
49        egui::pos2(viewport.left() + GRID_SIZE, top),
50        viewport.right_bottom(),
51    );
52    let mut child = ui.new_child(
53        Panel::DocumentNotices
54            .ui_builder()
55            .max_rect(strip)
56            .layout(egui::Layout::top_down(egui::Align::Min)),
57    );
58    let mut acknowledged = None;
59    egui::Frame::popup(child.style()).show(&mut child, |ui| {
60        ui.set_max_width(viewport.width() * MAX_WIDTH);
61        let mut failures = 0;
62        for notice in notices {
63            match notice {
64                Notice::Standing(text) => {
65                    ui.label(egui::RichText::new(text).small());
66                }
67                Notice::Failure(text) => {
68                    let failure = failures;
69                    failures += 1;
70                    ui.horizontal(|ui| {
71                        // A row lays its children out unwrapped, and a failure
72                        // names a path: without this the line runs off the
73                        // canvas, taking its acknowledgement with it.
74                        ui.add(egui::Label::new(egui::RichText::new(text).small()).wrap());
75                        if ui
76                            .small_button(DISMISS)
77                            .on_hover_text("Done with this message")
78                            .clicked()
79                        {
80                            acknowledged = Some(Acknowledged(failure));
81                        }
82                    });
83                }
84            }
85        }
86    });
87    acknowledged
88}
89
90/// The acknowledgement button's label. A word, not a glyph: the canvas fonts
91/// are chosen for diagrams and carry no ✕, which draws as an empty box.
92pub(crate) const DISMISS: &str = "Dismiss";
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::canvas::convert::{IntoEgui as _, IntoGeom as _};
98    use crate::tools::painted::Chrome;
99    use egui::{Rect, pos2, vec2};
100
101    fn viewport() -> Rect {
102        Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0))
103    }
104
105    /// A toolbar-shaped band across the top of the canvas, as the real one
106    /// stashes each frame.
107    fn toolbar() -> Rect {
108        Rect::from_min_size(pos2(380.0, 8.0), vec2(240.0, 40.0))
109    }
110
111    /// The bug this strip was rewritten for: a notice over the toolbar leaves
112    /// the editor unusable. Long enough to have run across it before.
113    #[test]
114    fn the_strip_never_covers_the_chrome_above_it() {
115        let long = "Read-only \u{2014} the log does not verify at record 41 of 128, whose \
116                    stamp names a fold this build does not produce";
117        let mut chrome = Chrome::new(viewport().geom());
118        chrome.settle(|ui| {
119            let notices = [
120                Notice::Standing(long.to_owned()),
121                Notice::Failure(long.to_owned()),
122            ];
123            let _ = draw(ui, viewport(), toolbar(), &notices);
124        });
125        assert!(
126            chrome.shows(DISMISS),
127            "the failure carries no acknowledgement: {:?}",
128            chrome.texts(),
129        );
130        let drawn = chrome
131            .rect(long)
132            .expect("the notices drew their text")
133            .union(chrome.rect(DISMISS).expect("and the dismiss button"));
134        assert!(
135            drawn.top() > toolbar().bottom(),
136            "the notices at {drawn:?} sit over the toolbar at {:?}",
137            toolbar(),
138        );
139        assert!(
140            viewport().contains_rect(drawn.egui()),
141            "the notices at {drawn:?} left the canvas",
142        );
143    }
144
145    /// Acknowledging a failure names *that* failure, so the session can drop
146    /// the one the user was done with and keep the rest.
147    #[test]
148    fn dismissing_names_the_failure_it_dismissed() {
149        let mut chrome = Chrome::new(viewport().geom());
150        let mut acknowledged = None;
151        let notices = || {
152            [
153                Notice::Failure("first".to_owned()),
154                Notice::Standing("Read-only \u{2014} another blockworx has it".to_owned()),
155                Notice::Failure("second".to_owned()),
156            ]
157        };
158        chrome.settle(|ui| {
159            let _ = draw(ui, viewport(), toolbar(), &notices());
160        });
161        // The second failure's own button: the two dismiss buttons are
162        // identical, so they are told apart by the line they sit on.
163        let second = chrome.rect("second").expect("the second failure drew");
164        let dismiss = chrome
165            .rects(DISMISS)
166            .into_iter()
167            .find(|rect| (rect.center().y - second.center().y).abs() < 4.0)
168            .expect("the second failure carries a dismiss button");
169        assert_eq!(chrome.rects(DISMISS).len(), 2, "a standing notice grew one");
170        chrome.click_at(dismiss.center(), |ui| {
171            if let Some(hit) = draw(ui, viewport(), toolbar(), &notices()) {
172                acknowledged = Some(hit.0);
173            }
174        });
175        assert_eq!(acknowledged, Some(1));
176    }
177}