Skip to main content

blockworx/shell/
mod.rs

1//! The editor's frame: the three persistent regions of `docs/cad-ui-spec.md`
2//! §2, mapped onto blockworx by `docs/cad-shell-playbook.md`.
3//!
4//! | Piece | Module | Berth | Elevation |
5//! |---|---|---|---|
6//! | top bar | [`top_bar`] | top, spanning | docked |
7//! | tool rail | [`tool_cluster`] | left, centred | floating |
8//! | navigator | [`navigator`] | right, full height | docked |
9//! | status line | [`status_line`] | bottom left | bare |
10//!
11//! The canvas runs edge to edge under all of it, which is why [`insets`]
12//! exists: nothing reflows the drawing surface, so fit-to-view has to inset
13//! by the chrome's *measured* boxes or the model lands underneath it.
14//! [`Chrome`] is the one place both halves happen — a piece cannot be drawn
15//! without being measured.
16//!
17//! The [`toast`] is the exception, and is not in the table: it is transient,
18//! it cannot be pressed, and it takes no room from the canvas — so it draws
19//! itself rather than going through [`Chrome`], and the safe area never hears
20//! about it.
21
22pub mod glass;
23pub mod insets;
24pub mod navigator;
25#[cfg(all(test, feature = "kittest"))]
26pub(crate) mod picture;
27pub mod status_line;
28pub mod toast;
29pub mod tool_cluster;
30pub mod top_bar;
31pub mod workspace;
32
33pub use glass::Berth;
34pub use insets::SafeArea;
35
36/// One frame's chrome: where each visible piece landed, and therefore what
37/// the canvas has left.
38///
39/// Built fresh each frame and thrown away with it. A piece that did not draw
40/// contributes nothing, so the navigator opening or closing changes the region
41/// in the frame it happens and there is no cache to invalidate.
42pub struct Chrome {
43    ctx: egui::Context,
44    safe: SafeArea,
45}
46
47impl Chrome {
48    /// Begin a frame's chrome over the canvas `viewport`.
49    pub fn over(ctx: &egui::Context, viewport: egui::Rect) -> Self {
50        Chrome {
51            ctx: ctx.clone(),
52            safe: SafeArea::over(viewport),
53        }
54    }
55
56    /// Draw one piece in its berth, in the treatment its elevation calls for,
57    /// and record the room it took from the canvas.
58    pub fn piece<R>(&mut self, berth: Berth, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
59        self.shaped(berth, egui::Vec2::ZERO, glass::Tint::None, add)
60    }
61
62    /// One piece, `slide` away from where it settles and washed with `tint`.
63    ///
64    /// The canvas is inset by where a travelling piece *settles*, not by
65    /// where it is this frame: fit-to-view reads the safe region every frame,
66    /// so a region that travelled with the animation would set the drawing
67    /// chasing it for the length of the motion.
68    pub fn shaped<R>(
69        &mut self,
70        berth: Berth,
71        slide: egui::Vec2,
72        tint: glass::Tint,
73        add: impl FnOnce(&mut egui::Ui) -> R,
74    ) -> R {
75        let shape = berth.shape();
76        let elevation = berth.elevation();
77        let area = glass::floating(berth, self.clearance(berth) + slide).show(&self.ctx, |ui| {
78            glass::shell(ui, shape, elevation, tint)
79                .show(ui, |ui| {
80                    glass::type_scale(ui, shape);
81                    match shape.content_height() {
82                        // Every piece the mockup fixes a height for is one row of
83                        // controls, so the row is opened here rather than by each
84                        // piece: it stands the bar's full height from the first
85                        // widget on, which is what centres a label placed before a
86                        // taller button beside it.
87                        Some(height) => {
88                            ui.horizontal(|ui| {
89                                ui.set_min_height(height);
90                                add(ui)
91                            })
92                            .inner
93                        }
94                        None => add(ui),
95                    }
96                })
97                .inner
98        });
99        self.safe
100            .covered_by(berth.edge(), area.response.rect.translate(-slide));
101        area.inner
102    }
103
104    /// The room a berth that hangs from what the chrome already measured
105    /// takes — the navigator, whose top is the top bar's bottom. Every other
106    /// berth hangs off its own corner and needs none.
107    fn clearance(&self, berth: Berth) -> egui::Vec2 {
108        if berth.clears_the_chrome_beside_it() {
109            egui::vec2(0.0, self.safe.depth(insets::Edge::Top))
110        } else {
111            egui::Vec2::ZERO
112        }
113    }
114
115    /// How tall a piece that runs the length of its edge may be: what the
116    /// chrome already measured beside it has left. Read before drawing it, so
117    /// the piece's own box is not in the answer.
118    pub fn edge_span(&self) -> f32 {
119        self.safe.viewport().height() - self.safe.depth(insets::Edge::Top)
120    }
121
122    /// What the canvas has left, for the framing that has to land inside it
123    /// and the overlay that has to clamp into it.
124    pub fn safe(&self) -> SafeArea {
125        self.safe
126    }
127
128    /// The context the frame is drawing into — for the one piece that
129    /// animates, which has to ask egui where its motion has got to.
130    pub fn ctx(&self) -> &egui::Context {
131        &self.ctx
132    }
133}
134
135/// Where a piece landed last frame, as egui recorded it — for a test that
136/// asks whether the chrome and the drawing can collide, and for the gesture
137/// that has to know whether it ended on the glass.
138pub fn berth_rect(ctx: &egui::Context, berth: Berth) -> Option<egui::Rect> {
139    egui::AreaState::load(ctx, berth.id())
140        .map(|state| state.rect())
141        .filter(|rect| rect.is_positive())
142}
143
144/// Whether `at` lands on a piece of chrome rather than on the canvas under
145/// it — what a gesture that began on the glass must ask before the document
146/// hears about where it ended. The status line is not chrome for this
147/// purpose: it answers no pointer, so the canvas under it is reachable.
148pub fn over_the_chrome(ctx: &egui::Context, at: egui::Pos2) -> bool {
149    Berth::ALL
150        .into_iter()
151        .filter(|berth| berth.elevation() != glass::Elevation::Bare)
152        .any(|berth| berth_rect(ctx, berth).is_some_and(|rect| rect.contains(at)))
153}
154
155/// The glass drawn in the canvas's own layer, where it stood last frame —
156/// the selection bar, the notices strip. egui's hit test gives a click there
157/// to the control, but counts the canvas beneath as hovered while the button
158/// is pressed and not once it is released, so the canvas would hear the
159/// press and never the release. The canvas is told these before it takes
160/// a press.
161pub(crate) fn glass(ctx: &egui::Context) -> Vec<blockworx_geom::Rect> {
162    [
163        crate::panels::overlay::bar_rect(ctx),
164        crate::panels::notices::strip_rect(ctx),
165    ]
166    .into_iter()
167    .flatten()
168    .map(crate::canvas::convert::IntoGeom::geom)
169    .collect()
170}
171
172/// The one text style the frame sets numbers in: coordinates and zoom line up
173/// column-wise only in a fixed pitch, and a proportional readout jitters as
174/// the pointer moves.
175pub(crate) fn mono(text: impl Into<String>) -> egui::RichText {
176    egui::RichText::new(text).monospace().small()
177}
178
179#[cfg(test)]
180pub(crate) mod tests {
181    use super::*;
182
183    /// A screen the whole frame lays out on comfortably.
184    pub(crate) fn screen() -> egui::Rect {
185        egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1000.0, 700.0))
186    }
187
188    /// Spec v3's frame is three regions and only three, plus the status line
189    /// that is text rather than a region — one berth each, and one form each:
190    /// the navigator has no second way of hanging.
191    #[test]
192    fn the_frame_has_a_place_for_every_piece_and_no_piece_two_places() {
193        assert_eq!(
194            Berth::ALL.len(),
195            4,
196            "§2's three regions and §2.0.1's status line",
197        );
198        let regions = Berth::ALL
199            .into_iter()
200            .filter(|berth| berth.elevation() != glass::Elevation::Bare)
201            .count();
202        assert_eq!(regions, 3, "spec §2's table lists exactly three regions");
203    }
204}