Skip to main content

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