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}