logicaffeine_web/ui/pages/studio_layout.rs
1//! The Studio's resizable layout: the desktop columns — file sidebar, then up to three
2//! panels — and the height of the bottom-docked debugger drawer.
3//!
4//! Widths are CSS pixels. The sidebar, the left panel and the right panel each hold their
5//! width; the center panel is the flexible one and takes whatever the others leave. A
6//! resizer moves exactly one boundary, so a drag changes only the two columns beside it —
7//! every other column keeps its width to the pixel. Every column has a minimum, and the
8//! minimums fit side by side at the narrowest desktop width, so the row never overflows.
9//!
10//! The layout the user shapes by dragging is a preference; what the Studio draws is that
11//! preference [`Columns::fit`] to the current width. Narrowing the window squeezes the
12//! drawn layout, and widening it again brings the preference back unchanged.
13//!
14//! A phone shows one pane at a time ([`MobileTab`], opened on [`first_pane`]), and keeps the
15//! caret's line above its keyboard ([`caret_in_view`]).
16
17use crate::ui::state::StudioMode;
18
19/// The narrowest window that gets the side-by-side layout; below it the Studio stacks.
20pub const DESKTOP_MIN_WIDTH: f64 = (crate::ui::responsive::breakpoints::MOBILE_MAX + 1) as f64;
21
22pub const SIDEBAR_MIN: f64 = 140.0;
23pub const LEFT_MIN: f64 = 200.0;
24pub const CENTER_MIN: f64 = 200.0;
25pub const RIGHT_MIN: f64 = 200.0;
26
27/// Width of a resizer bar.
28pub const RESIZER: f64 = 6.0;
29/// The hairline between neighbours in the panel row.
30pub const GAP: f64 = 1.0;
31/// Differences smaller than this are floating-point noise, not layout.
32const SUBPIXEL: f64 = 1e-6;
33
34/// The pane a phone shows, one at a time: the editor, the output, or the third panel (Console,
35/// Context or the picture). Every Run and Execute shows the output.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
37pub enum MobileTab {
38 #[default]
39 Panel1,
40 Panel2,
41 Panel3,
42}
43
44/// The pane a phone opens a file of `mode` on: the output for a file that runs or previews as it
45/// opens, the editor for Math, which waits for Execute. Decided by the mode alone, so the server's
46/// render is the phone's first frame.
47pub fn first_pane(mode: StudioMode) -> MobileTab {
48 match mode {
49 StudioMode::Math => MobileTab::Panel1,
50 StudioMode::Logic | StudioMode::Code | StudioMode::Hardware | StudioMode::Web => MobileTab::Panel2,
51 }
52}
53
54/// A stretch of the page from top to bottom, in CSS pixels.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct Extent {
57 pub top: f64,
58 pub bottom: f64,
59}
60
61/// How far the editor scrolls so the caret's line shows in `view`, the part of its pane the reader
62/// can see (above an on-screen keyboard): nothing when it shows already, up to its top when it is
63/// above, down to its bottom when it is below. A line taller than the view shows from its top.
64pub fn caret_in_view(caret: Extent, view: Extent) -> f64 {
65 if caret.top < view.top || caret.bottom - caret.top > view.bottom - view.top {
66 caret.top - view.top
67 } else if caret.bottom > view.bottom {
68 caret.bottom - view.bottom
69 } else {
70 0.0
71 }
72}
73
74/// Which optional columns are on screen. The left and center panels always are.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct Shown {
77 pub sidebar: bool,
78 pub right: bool,
79}
80
81/// A resizer, named by the boundary it moves.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Resizer {
84 SidebarLeft,
85 LeftCenter,
86 CenterRight,
87}
88
89/// The fixed-width columns. The center is derived: see [`Columns::center`].
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub struct Columns {
92 pub sidebar: f64,
93 pub left: f64,
94 pub right: f64,
95}
96
97impl Columns {
98 /// Pixels taken by the resizer bars and hairlines for the columns shown.
99 pub fn chrome(shown: Shown) -> f64 {
100 let sidebar_bar = if shown.sidebar { RESIZER } else { 0.0 };
101 // left | bar | center, and with a right panel: | bar | right — a hairline between each pair.
102 let panel_row = if shown.right { 2.0 * RESIZER + 4.0 * GAP } else { RESIZER + 2.0 * GAP };
103 sidebar_bar + panel_row
104 }
105
106 /// The center panel's width in a row `width` pixels wide.
107 pub fn center(&self, width: f64, shown: Shown) -> f64 {
108 width
109 - Self::chrome(shown)
110 - if shown.sidebar { self.sidebar } else { 0.0 }
111 - self.left
112 - if shown.right { self.right } else { 0.0 }
113 }
114
115 /// The layout a first visit starts from: a 300px sidebar beside panels split 35% / 40% /
116 /// 25%, the proportions the Studio has always opened with.
117 pub fn initial(width: f64, shown: Shown) -> Self {
118 // 300px: the longest shipped example name, three folders deep, reads whole (DAT-11).
119 let sidebar = 300.0;
120 let panels = width - Self::chrome(Shown { sidebar: true, right: true }) - sidebar;
121 Columns { sidebar, left: 0.35 * panels, right: 0.25 * panels }.fit(width, shown)
122 }
123
124 /// The layout after dragging `resizer` by `dx` pixels, where `self` is the layout on
125 /// screen when the drag began. Only the two columns beside the resizer change, and
126 /// neither passes its minimum.
127 pub fn dragged(self, resizer: Resizer, dx: f64, width: f64, shown: Shown) -> Self {
128 let center = self.center(width, shown);
129 let mut next = self;
130 match resizer {
131 Resizer::SidebarLeft if shown.sidebar => {
132 let dx = bounded(dx, SIDEBAR_MIN - self.sidebar, self.left - LEFT_MIN);
133 next.sidebar += dx;
134 next.left -= dx;
135 }
136 Resizer::LeftCenter => {
137 next.left += bounded(dx, LEFT_MIN - self.left, center - CENTER_MIN);
138 }
139 Resizer::CenterRight if shown.right => {
140 next.right -= bounded(dx, CENTER_MIN - center, self.right - RIGHT_MIN);
141 }
142 Resizer::SidebarLeft | Resizer::CenterRight => {}
143 }
144 next
145 }
146
147 /// This layout drawn in a row `width` pixels wide. Each column is held to its bounds;
148 /// any change in width lands on the center first, and when the center would drop below
149 /// its minimum the right panel gives way, then the left, then the sidebar, each down to
150 /// its own minimum. A column that is not shown keeps its width for when it returns.
151 pub fn fit(self, width: f64, shown: Shown) -> Self {
152 let mut next = Columns {
153 sidebar: self.sidebar.max(SIDEBAR_MIN),
154 left: self.left.max(LEFT_MIN),
155 right: self.right.max(RIGHT_MIN),
156 };
157 let mut short = CENTER_MIN - next.center(width, shown);
158 if short < SUBPIXEL {
159 return next;
160 }
161 let mut give = |column: &mut f64, min: f64| {
162 let taken = (*column - min).clamp(0.0, short.max(0.0));
163 *column -= taken;
164 short -= taken;
165 };
166 if shown.right {
167 give(&mut next.right, RIGHT_MIN);
168 }
169 give(&mut next.left, LEFT_MIN);
170 if shown.sidebar {
171 give(&mut next.sidebar, SIDEBAR_MIN);
172 }
173 next
174 }
175}
176
177/// `dx` limited to `[lo, hi]`. A range left empty by a row too narrow for every minimum
178/// allows no movement past its tighter end.
179fn bounded(dx: f64, lo: f64, hi: f64) -> f64 {
180 dx.max(lo).min(hi)
181}
182
183/// The drawer can shrink to its control bar and no further.
184pub const DRAWER_MIN: f64 = 44.0;
185/// However tall the drawer grows, the panel row above it keeps at least this much.
186pub const PANELS_MIN_HEIGHT: f64 = 100.0;
187/// The drawer's height when it first opens: room for its controls and its tabs.
188pub const DRAWER_INITIAL: f64 = 340.0;
189
190/// The drawer's height after dragging its grip by `dy` pixels (down is positive), where
191/// `start` is its height when the drag began and `room` is the height the panel row and the
192/// drawer share.
193pub fn drawer_dragged(start: f64, dy: f64, room: f64) -> f64 {
194 drawer_fit(start - dy, room)
195}
196
197/// A drawer height drawn in `room` pixels shared with the panel row: never below the
198/// control bar, and never so tall the panels lose their minimum.
199pub fn drawer_fit(height: f64, room: f64) -> f64 {
200 docked_fit(height, room, DRAWER_MIN, PANELS_MIN_HEIGHT)
201}
202
203/// A pane docked below another in `room` pixels: never below its own `min`, never so tall
204/// the pane above loses `above`. When the room cannot hold both, the docked pane keeps `min`.
205fn docked_fit(height: f64, room: f64, min: f64, above: f64) -> f64 {
206 height.min(room - above).max(min)
207}
208
209/// Logic's proof drawer shrinks to its bar and no further.
210pub const PROOF_MIN: f64 = 44.0;
211/// The proof drawer's height when it first opens: room for its bar and the three steps.
212pub const PROOF_DRAWER_INITIAL: f64 = 200.0;
213
214/// The proof drawer's height after dragging its grip by `dy` pixels (down is positive), where
215/// `start` is its height when the drag began and `room` is the height the panel row and the
216/// drawer share: never below its bar, and never so tall the panels lose their minimum.
217pub fn proof_dragged(start: f64, dy: f64, room: f64) -> f64 {
218 docked_fit(start - dy, room, PROOF_MIN, PANELS_MIN_HEIGHT)
219}
220
221impl Resizer {
222 /// The `data-resizer` attribute that names this resizer's bar in the page.
223 pub fn attr(self) -> &'static str {
224 match self {
225 Resizer::SidebarLeft => "sidebar-left",
226 Resizer::LeftCenter => "left-center",
227 Resizer::CenterRight => "center-right",
228 }
229 }
230}
231
232/// On a phone the panels stack; the upper one's share of the stack, in percent, stays
233/// between these bounds so neither panel can be dragged out of sight.
234pub const SPLIT_MIN: f64 = 15.0;
235pub const SPLIT_MAX: f64 = 85.0;
236
237/// The upper panel's share of a stack `height` pixels tall whose top edge is at `top`, when
238/// the split bar is at `y`.
239pub fn split_percent(y: f64, top: f64, height: f64) -> f64 {
240 if height <= 0.0 {
241 return 50.0;
242 }
243 ((y - top) / height * 100.0).clamp(SPLIT_MIN, SPLIT_MAX)
244}
245
246#[cfg(test)]
247#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/studio_layout/tests.rs"]
248mod tests;
249
250#[cfg(test)]
251#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/studio_layout/bands_tests.rs"]
252mod bands_tests;