Skip to main content

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;