Skip to main content

blockworx/shell/
glass.rs

1//! The one look every piece of chrome is drawn in, and the berths they
2//! hang from.
3//!
4//! Stated once so no two pieces can disagree about what the chrome is made
5//! of: the fill, the elevation, the radii, the tap target, and — in the same
6//! table — where each piece sits and which edge it takes room from. A
7//! component that placed itself would be free to place itself somewhere the
8//! [safe area](super::insets) does not know about.
9//!
10//! The **one elevation rule** — docked chrome is flat, square and unshadowed;
11//! floating chrome is rounded and lifted — is [`Elevation`], read off the
12//! berth. Both treatments come out of that one answer, which is what lets the
13//! floating rail sit beside the docked bar without looking arbitrary.
14//!
15//! The mockup reaches its glass with `backdrop-filter: blur(24px)`, which has
16//! no egui equivalent short of an offscreen pass. A see-through surface with
17//! nothing blurring what is under it puts the grid through the words, so the
18//! chrome is the theme's own solid surface, lifted and rounded by
19//! [`Elevation`] alone.
20
21use super::insets::Edge;
22
23/// The smallest interactive target, on every platform.
24pub const TAP: f32 = 44.0;
25
26/// The tool cluster's cell — the mockup's `.tool`, 52 square, which with
27/// [`PAD`] on either side is the 64px column the user measured off the
28/// mockup. The icon carries the meaning alone and the tooltip carries
29/// the words.
30pub const TOOL: egui::Vec2 = egui::vec2(52.0, 52.0);
31
32/// The icon in a tool cell.
33pub const TOOL_ICON: f32 = 22.0;
34
35/// A tool cell's corner, the one radius that is neither of the two above:
36/// the allowed range is 13–20, and a cell this size reads square at 13.
37pub const TOOL_RADIUS: u8 = 15;
38
39/// How far every floating piece stands off the viewport's edge.
40pub const MARGIN: f32 = 16.0;
41
42/// The icon inside a tap target, 21px.
43const TAP_ICON: f32 = 21.0;
44
45/// The only two radii: the shell of a cluster, and the controls inside it.
46const RADIUS_OUTER: u8 = 20;
47const RADIUS_INNER: u8 = 13;
48
49/// The air inside a piece, between its shell and its controls.
50pub const PAD: i8 = 6;
51
52/// The one motion the shell moves at. egui exposes no `prefers-reduced-motion`
53/// signal on any of the five platforms, so honouring it is one setting the
54/// toolkit does not have: what the shell can do is keep its whole vocabulary
55/// to a single curve — [`spring`] — and the mockup's own durations: this one
56/// for a control answering a press, and [`TRAVEL`] for a piece that comes in
57/// from an edge.
58pub const MOTION: std::time::Duration = std::time::Duration::from_millis(140);
59
60/// How long a piece takes to come in from the edge it hangs off — the
61/// mockup's `transition: transform .3s var(--spring)`, which it writes on the
62/// navigator. Longer than [`MOTION`] because the distance is: a spring that
63/// settles in 140ms over 340px reads as a jump.
64pub const TRAVEL: std::time::Duration = std::time::Duration::from_millis(300);
65
66/// How long the toast takes to rise and to go — the mockup's
67/// `.toast{transition:all .25s var(--spring)}`. Between the other two,
68/// because the distance is: it travels twenty pixels, not three hundred.
69pub const TOAST_MOTION: std::time::Duration = std::time::Duration::from_millis(250);
70
71/// How long the bar takes to change state — the mockup's
72/// `.topbar{transition:background .25s ease}`, which is what carries the
73/// amber in and out.
74pub const TINT_MOTION: std::time::Duration = std::time::Duration::from_millis(250);
75
76/// How far past its own edge a piece waits while it is away — the mockup's
77/// `translateX(100%)` plus enough that its shadow is off screen with it.
78const OFF_STAGE: f32 = 24.0;
79
80/// How far a piece `extent` long has to travel to be wholly off the edge it
81/// hangs from: its own size, the gap it stands off by, and enough more to
82/// take its shadow too.
83pub fn off_stage(extent: f32) -> f32 {
84    extent + MARGIN + OFF_STAGE
85}
86
87/// Whether a piece is part of the frame or stands over it.
88///
89/// The one elevation rule: *docked chrome is flat with no shadow and square
90/// corners; floating chrome has a shadow and large radii.* Written down once,
91/// here, and read off the berth — so a piece cannot be given a place without
92/// being given the treatment that place implies.
93#[derive(Clone, Copy, PartialEq, Eq, Debug)]
94pub enum Elevation {
95    /// The top bar and the navigator: flush with the edge they take, so they
96    /// read as the frame rather than as objects on it.
97    Docked,
98    /// The tool rail, the selection bar, the toast: lifted off the canvas.
99    Floating,
100    /// Not a surface at all — the status line, which is ambient text and must
101    /// not look like a control. It answers no pointer either.
102    Bare,
103}
104
105/// What one piece of chrome is shaped like. The height is part of the shape:
106/// a bar whose height came from what it happened to hold is a bar whose
107/// height drifts.
108#[derive(Clone, Copy, PartialEq, Eq, Debug)]
109pub enum Shape {
110    /// The top bar: 54 tall, the whole width of the window.
111    TopBar,
112    /// The selection overlay — a tap target's height over the mockup's card
113    /// corner, since it is a bar over the drawing rather than part of the
114    /// frame.
115    Bar,
116    /// As tall as what it holds: the tool rail and the navigator.
117    Panel,
118    /// The toast: as tall as the one line in it, capped, and tighter than
119    /// anything else the shell draws — the mockup's `.toast`, whose 10px of
120    /// air is half a bar's, because nothing on it can be pressed.
121    Toast,
122    /// The status line: one line of plain text with no container at all.
123    StatusLine,
124}
125
126/// The mockup's `--bar-h`, the one number the docked bar and everything
127/// measured against it read.
128pub const TOP_BAR_HEIGHT: f32 = 54.0;
129
130/// The floating bar tier's height, the mockup's `.overlay` row of 44px
131/// targets in 5px of air.
132pub const BAR_HEIGHT: f32 = 52.0;
133
134impl Shape {
135    /// How tall the piece stands, where the mockup fixes it. A `Panel` is as
136    /// tall as its contents and so answers `None`.
137    fn height(self) -> Option<f32> {
138        match self {
139            Shape::TopBar => Some(TOP_BAR_HEIGHT),
140            Shape::Bar => Some(BAR_HEIGHT),
141            Shape::Panel | Shape::Toast | Shape::StatusLine => None,
142        }
143    }
144
145    /// The shell's corner, for the pieces that have one. A docked piece is
146    /// square whatever this says, because that is the elevation rule.
147    fn radius(self) -> u8 {
148        match self {
149            Shape::TopBar | Shape::StatusLine => 0,
150            Shape::Bar => RADIUS_CARD,
151            Shape::Panel => RADIUS_OUTER,
152            // The mockup's `border-radius:22px` over a line of 13px text:
153            // more than half the height it comes out at, so it caps.
154            Shape::Toast => TOAST_RADIUS,
155        }
156    }
157
158    /// The air between the shell and what it holds. For the tiers that hold
159    /// targets the vertical half is not written down — it is whatever centres
160    /// a tap target in the height above, so those shells can never be shorter
161    /// than the buttons inside them.
162    pub fn margin(self) -> egui::Margin {
163        let (sides, vertical) = match self {
164            // The mockup's `.topbar{padding:0 10px 0 12px}`, evened out: the
165            // bar's two ends carry the same runs of tap targets.
166            Shape::TopBar => (BAR_SIDES, (TOP_BAR_HEIGHT - TAP) * 0.5),
167            Shape::Bar => (BAR_SIDES, (BAR_HEIGHT - TAP) * 0.5),
168            Shape::Panel => (f32::from(PAD), f32::from(PAD)),
169            // The mockup's `padding:10px 18px`.
170            Shape::Toast => (TOAST_SIDES, TOAST_AIR),
171            Shape::StatusLine => (0.0, 0.0),
172        };
173        egui::Margin {
174            left: sides as i8,
175            right: sides as i8,
176            top: vertical as i8,
177            bottom: vertical as i8,
178        }
179    }
180
181    /// How tall the shell's contents must stand for the piece to reach its
182    /// own height.
183    pub fn content_height(self) -> Option<f32> {
184        self.height()
185            .map(|height| height - 2.0 * f32::from(self.margin().top))
186    }
187
188    /// How big this tier sets its words, in the mockup's own points.
189    ///
190    /// One body size, 14px, is named for the whole shell. The mockup refines
191    /// it per tier and the user read the refinement off it — *"I see 14px on
192    /// the document title and 12.5 on the bottom pills. That seems like a
193    /// better split than we currently have in the app."* — which it is:
194    /// egui's stock scale sets everything at 12.5 with 9pt asides.
195    fn type_scale(self) -> [(egui::TextStyle, f32); 4] {
196        let (body, button, small, mono) = match self {
197            // `.crumb` 14.5, `.mode` 13.5, `.tip` 12.5.
198            Shape::TopBar => (14.5, 13.5, 12.5, 12.5),
199            // `.overlay .none` 13, `.overlay .selcount` 12.5.
200            Shape::Bar => (13.0, 13.5, 12.5, 12.5),
201            // `.row .t1` 14, `.row .t2`/`.t3`/`.daylbl` 12, `.seg button` 13.5.
202            Shape::Panel => (14.0, 13.5, 12.0, 12.0),
203            // `.toast{font-size:13px}`, one line and nothing else.
204            Shape::Toast => (13.0, 13.0, 13.0, 13.0),
205            // `.status{font-size:12.5px}`, likewise.
206            Shape::StatusLine => (12.5, 12.5, 12.5, 12.5),
207        };
208        [
209            (egui::TextStyle::Body, body),
210            (egui::TextStyle::Button, button),
211            (egui::TextStyle::Small, small),
212            (egui::TextStyle::Monospace, mono),
213        ]
214    }
215}
216
217/// Set the type scale a shell's words are drawn in. One call per piece rather
218/// than a size on every `RichText`, so the tier is the thing that carries the
219/// size and no widget can wander off it.
220pub fn type_scale(ui: &mut egui::Ui, shape: Shape) {
221    let style = ui.style_mut();
222    for (text_style, points) in shape.type_scale() {
223        if let Some(font) = style.text_styles.get_mut(&text_style) {
224            font.size = points;
225        }
226    }
227}
228
229/// The mockup's `--r-md`, the corner on a card that stands over the drawing.
230const RADIUS_CARD: u8 = 14;
231/// A bar's ends, the mockup's `.overlay{padding:5px}`.
232const BAR_SIDES: f32 = 5.0;
233/// The toast's, the mockup's `.toast{padding:10px 18px;border-radius:22px}`.
234const TOAST_SIDES: f32 = 18.0;
235const TOAST_AIR: f32 = 10.0;
236const TOAST_RADIUS: u8 = 22;
237
238/// Where one piece of chrome hangs, the edge it takes room from, and how it
239/// is lifted.
240///
241/// One table, because the answers must agree: a piece placed by an anchor the
242/// safe area does not know about would land the model underneath it. The edge
243/// is not derivable from the anchor — the top bar spans the window and the
244/// navigator hangs off the same corner it starts from — so all three are
245/// written down.
246///
247/// The three persistent regions, plus the status line, which is text rather
248/// than a region.
249#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
250pub enum Berth {
251    TopBar,
252    ToolCluster,
253    Navigator,
254    StatusLine,
255}
256
257impl Berth {
258    /// Every berth, for a test that ranges over the frame.
259    pub const ALL: [Berth; 4] = [
260        Berth::TopBar,
261        Berth::ToolCluster,
262        Berth::Navigator,
263        Berth::StatusLine,
264    ];
265
266    fn align(self) -> egui::Align2 {
267        match self {
268            Berth::TopBar => egui::Align2::LEFT_TOP,
269            Berth::ToolCluster => egui::Align2::LEFT_CENTER,
270            Berth::Navigator => egui::Align2::RIGHT_TOP,
271            Berth::StatusLine => egui::Align2::LEFT_BOTTOM,
272        }
273    }
274
275    /// What this piece is shaped like — in the same table as where it hangs,
276    /// so a berth cannot be given a place without being given a height.
277    pub fn shape(self) -> Shape {
278        match self {
279            Berth::TopBar => Shape::TopBar,
280            Berth::ToolCluster | Berth::Navigator => Shape::Panel,
281            Berth::StatusLine => Shape::StatusLine,
282        }
283    }
284
285    /// How this piece is lifted off the canvas, by the elevation rule.
286    pub fn elevation(self) -> Elevation {
287        match self {
288            Berth::TopBar | Berth::Navigator => Elevation::Docked,
289            Berth::ToolCluster => Elevation::Floating,
290            Berth::StatusLine => Elevation::Bare,
291        }
292    }
293
294    /// Which edge of the canvas this piece takes room from.
295    pub fn edge(self) -> Edge {
296        match self {
297            Berth::TopBar => Edge::Top,
298            Berth::ToolCluster => Edge::Left,
299            Berth::Navigator => Edge::Right,
300            Berth::StatusLine => Edge::Bottom,
301        }
302    }
303
304    /// The egui id the piece's [`egui::Area`] is registered under, so a test
305    /// can ask where it landed.
306    pub fn id(self) -> egui::Id {
307        egui::Id::new(("shell_berth", self))
308    }
309
310    /// Whether this piece hangs from the room the chrome measured before it
311    /// left, rather than from the raw edge — the navigator, which starts
312    /// under the top bar and runs to the bottom of the window.
313    pub fn clears_the_chrome_beside_it(self) -> bool {
314        matches!(self, Berth::Navigator)
315    }
316
317    /// The gap between the piece and the edge it hangs off. A docked piece
318    /// keeps none: it is not standing over the canvas, it is the end of it.
319    fn offset(self) -> egui::Vec2 {
320        match self {
321            Berth::TopBar | Berth::Navigator => egui::Vec2::ZERO,
322            // The mockup centres the rail on the canvas rather than on the
323            // window: `top: calc(50% + var(--bar-h)/2)`.
324            Berth::ToolCluster => egui::vec2(MARGIN, TOP_BAR_HEIGHT * 0.5),
325            Berth::StatusLine => egui::vec2(STATUS_INDENT, -STATUS_STANDOFF),
326        }
327    }
328}
329
330/// The mockup's `.status{left:88px}` — clear of the tool rail's column, which
331/// is [`MARGIN`] plus the cell and its air.
332const STATUS_INDENT: f32 = MARGIN + TOOL.x + 2.0 * PAD as f32 + 8.0;
333/// The mockup's `.status{bottom:18px}`.
334const STATUS_STANDOFF: f32 = 18.0;
335
336/// The [`egui::Area`] a piece is drawn in: anchored to its berth, held still,
337/// and above the canvas without being a window. `clear_of` is the extra room
338/// a berth that hangs from what the chrome left takes; every other berth
339/// passes zero.
340pub fn floating(berth: Berth, clear_of: egui::Vec2) -> egui::Area {
341    egui::Area::new(berth.id())
342        .anchor(berth.align(), berth.offset() + clear_of)
343        // Chrome the user can drag out of its berth is chrome the safe area
344        // cannot reason about, and one layout on five platforms is not one
345        // the user rearranges.
346        .movable(false)
347        // The fade egui gives a new area is motion the chrome has no reason
348        // for, and it repaints until it finishes — which no idle frame should.
349        .fade_in(false)
350        // egui holds an area inside the screen by default, which for a piece
351        // that travels off its own edge means it cannot leave: the clamp puts
352        // it back the moment the slide pushes it out. Every berth is anchored
353        // and none is movable, so the constraint has nothing else to do here.
354        .constrain(false)
355        // Ambient information is not a control, so it takes no pointer — and
356        // the readout under the cursor is not given up for it.
357        .interactable(berth.elevation() != Elevation::Bare)
358}
359
360/// A wash of colour over a piece's own fill — the amber the top bar takes on
361/// while the lens is open, rather than raising a second object.
362#[derive(Clone, Copy, PartialEq, Debug)]
363pub enum Tint {
364    None,
365    Over(egui::Color32),
366}
367
368impl Tint {
369    fn over(self, fill: egui::Color32) -> egui::Color32 {
370        match self {
371            Tint::None => fill,
372            Tint::Over(wash) => fill.blend(wash),
373        }
374    }
375}
376
377/// The shell of one piece, in the treatment its elevation calls for: docked
378/// chrome flat and square, floating chrome rounded and lifted, and ambient
379/// text neither. No hairlines anywhere, so nothing is outlined.
380pub fn shell(ui: &egui::Ui, shape: Shape, elevation: Elevation, tint: Tint) -> egui::Frame {
381    let visuals = ui.visuals();
382    let frame = egui::Frame::new().inner_margin(shape.margin());
383    let glass = tint.over(visuals.window_fill);
384    match elevation {
385        Elevation::Bare => frame,
386        Elevation::Docked => frame.fill(glass),
387        Elevation::Floating => frame
388            .fill(glass)
389            .corner_radius(shape.radius())
390            .shadow(lift(visuals)),
391    }
392}
393
394/// How far a floating piece stands off the canvas, and how softly.
395///
396/// The user, on the frame egui's own window shadow gave it: *"The drop
397/// shadows behind the floating pills/bars are too big and too far away from
398/// the objects."* egui's default falls sideways and lands hard; the mockup's
399/// `--shadow` falls straight down and diffuses, which is what reads as glass
400/// lifted rather than as a second object underneath. The ink is the theme's,
401/// so a scheme change reaches it; only the geometry and the weight are the
402/// mockup's.
403fn lift(visuals: &egui::Visuals) -> egui::Shadow {
404    let ink = visuals.window_shadow.color;
405    let carried = f32::from(ink.a()) / 255.0;
406    egui::Shadow {
407        offset: SHADOW_OFFSET,
408        blur: SHADOW_BLUR,
409        spread: 0,
410        color: ink.gamma_multiply(SHADOW_ALPHA / carried.max(f32::EPSILON)),
411    }
412}
413
414/// The mockup's `--shadow: 0 10px 34px rgba(15,25,40,.15)` — its second,
415/// tighter layer is not reproduced, because one `egui::Shadow` is one layer
416/// and the wide one is the one that carries the lift.
417const SHADOW_OFFSET: [i8; 2] = [0, 10];
418const SHADOW_BLUR: u8 = 34;
419const SHADOW_ALPHA: f32 = 0.15;
420
421/// A rule between two runs of controls in one piece — the mockup's
422/// `.divider` across a row, `.toolsep` down a column.
423///
424/// These are the shell's only hairlines. Hairlines are forbidden
425/// categorically, but the user asked for both of these by name —
426/// *"There is a separator between the undo/redo and the two view controls."*
427/// and *"Put a horizontal separator between the selection tool and the
428/// modelling tools in the main toolbar."* — so the ban is read as *no
429/// incidental hairlines*: a rule the user asked for is structure, and the
430/// two places they asked for one are the only two.
431///
432/// A row's rule is short and unindented; a column's spans the cluster inset
433/// from both shoulders. That is the mockup's own difference between the two,
434/// stated here so the two clusters cannot part company over tone.
435pub fn separator(ui: &mut egui::Ui, across: Run) {
436    let color = ui.visuals().widgets.noninteractive.bg_stroke.color;
437    ui.add_space(DIVIDER_AIR);
438    // A column's rule *takes* the whole width its cluster has claimed and
439    // *draws* the inset one, centred in it. Allocating the short rect instead
440    // would hang the rule off the cluster's leading edge, since a column lays
441    // its contents out from there — the user: *"The dividing line under the
442    // select tool is not centered."*
443    //
444    // The width the column has already claimed, not the width it could claim:
445    // an `egui::Area` is sized by its contents, so `available_width` inside
446    // one is the rest of the window.
447    let size = match across {
448        Run::Row => egui::vec2(1.0, DIVIDER_LENGTH),
449        Run::Column => egui::vec2(ui.min_rect().width(), 1.0),
450    };
451    let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
452    let rule = match across {
453        Run::Row => rect,
454        Run::Column => egui::Rect::from_center_size(
455            rect.center(),
456            egui::vec2((rect.width() - 2.0 * DIVIDER_INSET).max(0.0), rect.height()),
457        ),
458    };
459    ui.painter().rect_filled(rule, 0, color);
460    ui.add_space(DIVIDER_AIR);
461}
462
463/// The air a rule keeps on each side of itself, for a test that has only the
464/// gap between two cells to measure it by.
465#[cfg_attr(
466    not(test),
467    allow(dead_code, reason = "the rule's own audit is a test of it")
468)]
469pub fn separator_air() -> f32 {
470    DIVIDER_AIR
471}
472
473/// Which way the controls a rule separates are running.
474#[derive(Clone, Copy, PartialEq, Eq)]
475pub enum Run {
476    Row,
477    Column,
478}
479
480/// The mockup's `.divider{height:22px}` and both rules' `margin: 5px`.
481const DIVIDER_LENGTH: f32 = 24.0;
482const DIVIDER_AIR: f32 = 5.0;
483/// The mockup's `.toolsep{margin:5px 12px}` — a column's rule stops short
484/// of both shoulders where a row's runs beside its neighbours at full height.
485const DIVIDER_INSET: f32 = 12.0;
486
487/// Whether a control can be pressed this frame.
488///
489/// Disabled, never hidden, is the house rule for the whole editor, so every
490/// control the shell draws goes through this rather than through an `if`
491/// around the widget.
492#[derive(Clone, Copy, PartialEq, Eq)]
493pub enum Live {
494    Yes,
495    No,
496}
497
498impl From<bool> for Live {
499    fn from(live: bool) -> Self {
500        if live { Live::Yes } else { Live::No }
501    }
502}
503
504impl Live {
505    fn enabled(self) -> bool {
506        self == Live::Yes
507    }
508}
509
510/// Whether the surface a control opens — a picker, a menu, the navigator — is
511/// showing, which is what draws the control pressed. Stated once so the
512/// control and the thing it opened cannot disagree about which of them is up.
513#[derive(Clone, Copy, PartialEq, Eq)]
514pub enum Opened {
515    Yes,
516    No,
517}
518
519impl From<bool> for Opened {
520    fn from(opened: bool) -> Self {
521        if opened { Opened::Yes } else { Opened::No }
522    }
523}
524
525impl Opened {
526    fn showing(self) -> bool {
527        self == Opened::Yes
528    }
529}
530
531/// A tap-sized icon button, framed only under the pointer — the mockup's
532/// `.tapbtn`. `hover` is shown whether or not the button is live, because a
533/// dead control still has to say what it would do.
534pub fn tap_button(
535    ui: &mut egui::Ui,
536    icon: egui::ImageSource<'static>,
537    live: Live,
538    hover: impl Into<egui::WidgetText>,
539) -> egui::Response {
540    tap_toggle(ui, icon, live, Opened::No, hover)
541}
542
543/// The same, drawn pressed while the surface it opens is up — the mockup's
544/// `.tapbtn[aria-pressed="true"]`.
545pub fn tap_toggle(
546    ui: &mut egui::Ui,
547    icon: egui::ImageSource<'static>,
548    live: Live,
549    opened: Opened,
550    hover: impl Into<egui::WidgetText>,
551) -> egui::Response {
552    let hover = hover.into();
553    ui.add_enabled_ui(live.enabled(), |ui| {
554        let button = egui::Button::image(image(ui, icon, TAP_ICON))
555            .min_size(egui::Vec2::splat(TAP))
556            .corner_radius(RADIUS_INNER)
557            .frame_when_inactive(opened.showing())
558            .selected(opened.showing());
559        ui.add(button)
560    })
561    .inner
562    .on_hover_text(hover.clone())
563    .on_disabled_hover_text(hover)
564}
565
566/// The one control the shell draws *filled*: a call to action inside a piece
567/// that is itself already a fill — the viewing mode's Return, the mockup's
568/// `.mode .ret`. Wider than its word, because a button the user is meant to
569/// reach for should not be the same size as the word in it.
570pub fn tap_filled(
571    ui: &mut egui::Ui,
572    text: &str,
573    fill: egui::Color32,
574    ink: egui::Color32,
575    hover: impl Into<egui::WidgetText>,
576) -> egui::Response {
577    ui.add(
578        egui::Button::new(egui::RichText::new(text).color(ink).strong())
579            .fill(fill)
580            .min_size(egui::vec2(FILLED_WIDTH, TAP))
581            .corner_radius(RADIUS_INNER),
582    )
583    .on_hover_text(hover)
584}
585
586/// The mockup's `.ret` is its word with 15px of shoulder either side; this
587/// is that width for the longest word the shell puts on one.
588const FILLED_WIDTH: f32 = 88.0;
589
590/// The brightest ink the shell writes in — and the one place that knows not
591/// to ask egui for it.
592///
593/// [`egui::Visuals::strong_text_color`] reads `widgets.active.fg_stroke`,
594/// which this app's palette sets to its *darkest* base: the ink for a word
595/// sitting on an accent-filled control, not a louder version of body text.
596/// Anything drawn with it on one of the shell's own surfaces disappears into
597/// them, as the top bar's document name found. The theme's plain text colour is full strength here, and
598/// `weak_text_color` is the muted one under it.
599pub fn full_ink(visuals: &egui::Visuals) -> egui::Color32 {
600    visuals.text_color()
601}
602
603/// An icon at `size`, tinted to whatever the enclosing `ui` is currently
604/// drawing text in — so a disabled scope fades the glyph with its label.
605pub fn image(ui: &egui::Ui, source: egui::ImageSource<'static>, size: f32) -> egui::Image<'static> {
606    egui::Image::new(source)
607        .fit_to_exact_size(egui::Vec2::splat(size))
608        .tint(ui.visuals().widgets.inactive.fg_stroke.color)
609}
610
611/// How far a control shrinks under the finger — the mockup's
612/// `.tool:active{transform:scale(.9)}`.
613const PRESS_SCALE: f32 = 0.9;
614
615/// Whether the pointer is down on a control this frame.
616#[derive(Clone, Copy, PartialEq, Eq)]
617pub enum Pressed {
618    Yes,
619    No,
620}
621
622impl From<bool> for Pressed {
623    fn from(held: bool) -> Self {
624        if held { Pressed::Yes } else { Pressed::No }
625    }
626}
627
628/// How big a control's plate draws itself this frame, `id` being the control's
629/// own and `held` whether the pointer is down on it.
630///
631/// The user, on the mockup: *"I like the bouncier tool button behavior of the
632/// mockup. It looks like on press, the highlight shrinks slightly and then
633/// 'pops up' with a blue highlight to indicate the tool has been selected."*
634/// The mockup's `transition: transform .14s var(--spring)` overshoots in
635/// whichever direction it is travelling, so [`spring`] is read forwards while
636/// the finger is down and forwards *along the return* once it lets go — a
637/// curve simply run backwards would ease into rest, and the bounce out of rest
638/// is the half the user was pointing at.
639///
640/// It reaches an exact fixed point at 1.0, so an untouched cluster asks for no
641/// repaints and an idle frame still settles.
642pub fn press_scale(ctx: &egui::Context, id: egui::Id, held: Pressed) -> f32 {
643    let down = held == Pressed::Yes;
644    let travel =
645        Progress::new(ctx.animate_bool_with_time(id.with("press"), down, MOTION.as_secs_f32()));
646    if down {
647        1.0 + (PRESS_SCALE - 1.0) * spring(travel)
648    } else {
649        PRESS_SCALE + (1.0 - PRESS_SCALE) * spring(travel.reversed())
650    }
651}
652
653/// The space that separates two groups inside one piece — hairline rules are
654/// forbidden, so a run of controls is broken by air.
655pub fn group_gap(ui: &mut egui::Ui) {
656    ui.add_space(GROUP_GAP);
657}
658
659const GROUP_GAP: f32 = 10.0;
660
661/// How far through a motion the shell is. Clamped, and never NaN: a curve
662/// evaluated outside its own domain is not motion, and egui hands out a raw
663/// `f32` that a stalled frame can put out of range.
664#[derive(Clone, Copy, PartialEq, PartialOrd, Debug)]
665pub struct Progress(f32);
666
667impl Progress {
668    pub const START: Progress = Progress(0.0);
669    pub const DONE: Progress = Progress(1.0);
670
671    pub fn new(fraction: f32) -> Self {
672        if fraction.is_nan() {
673            Progress::DONE
674        } else {
675            Progress(fraction.clamp(0.0, 1.0))
676        }
677    }
678
679    /// The same motion running the other way — what a piece leaving does.
680    pub fn reversed(self) -> Self {
681        Progress(1.0 - self.0)
682    }
683}
684
685impl From<Progress> for f32 {
686    fn from(progress: Progress) -> f32 {
687        progress.0
688    }
689}
690
691/// The shell's one motion curve, the mockup's `cubic-bezier(.32,1.5,.5,1)`:
692/// the second control point sits above the track, which is where the overshoot
693/// the user asked for comes from — *"animating in from the right edge with a
694/// little overshoot and springy action."*
695///
696/// egui interpolates linearly, so the curve is evaluated here rather than
697/// asked for: Newton on the horizontal polynomial recovers the Bézier
698/// parameter at `at`, and the vertical polynomial at that parameter is the
699/// eased fraction. It leaves the unit interval on purpose — an eased value
700/// over 1 *is* the overshoot — so it answers a bare fraction rather than
701/// another [`Progress`].
702pub fn spring(at: Progress) -> f32 {
703    /// The curve's own control points, x then y.
704    const P1: (f32, f32) = (0.32, 1.5);
705    const P2: (f32, f32) = (0.5, 1.0);
706    /// Enough for a curve this shallow; the residual is under a tenth of a
707    /// pixel over any distance the shell animates.
708    const REFINEMENTS: usize = 6;
709
710    let bezier = |a: f32, b: f32, s: f32| {
711        let r = 1.0 - s;
712        3.0 * r * r * s * a + 3.0 * r * s * s * b + s * s * s
713    };
714    let slope = |a: f32, b: f32, s: f32| {
715        let r = 1.0 - s;
716        3.0 * r * r * a + 6.0 * r * s * (b - a) + 3.0 * s * s * (1.0 - b)
717    };
718
719    let target = f32::from(at);
720    let mut s = target;
721    for _ in 0..REFINEMENTS {
722        let d = slope(P1.0, P2.0, s);
723        if d.abs() < f32::EPSILON {
724            break;
725        }
726        s = (s - (bezier(P1.0, P2.0, s) - target) / d).clamp(0.0, 1.0);
727    }
728    bezier(P1.1, P2.1, s)
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    /// The berths are distinct places: two pieces sharing an id would share
736    /// egui's area state and land on top of each other.
737    #[test]
738    fn every_berth_is_its_own_place() {
739        let ids: std::collections::HashSet<egui::Id> =
740            Berth::ALL.iter().map(|berth| berth.id()).collect();
741        assert_eq!(ids.len(), Berth::ALL.len());
742    }
743
744    /// The elevation rule, read off the table both ways: a docked piece is
745    /// flush with the edge it takes, square and unshadowed; a floating one
746    /// stands off, rounds and lifts. Nothing is half of each.
747    #[test]
748    fn docked_chrome_is_flat_and_flush_where_floating_chrome_is_lifted() {
749        let visuals = egui::Visuals::light();
750        let ctx = egui::Context::default();
751        let mut frames = Vec::new();
752        ctx.run_ui(egui::RawInput::default(), |ui| {
753            for berth in Berth::ALL {
754                frames.push((
755                    berth,
756                    shell(ui, berth.shape(), berth.elevation(), Tint::None),
757                ));
758            }
759        })
760        .drop_without_applying_deltas();
761        for (berth, frame) in frames {
762            match berth.elevation() {
763                Elevation::Docked => {
764                    assert_eq!(
765                        berth.offset(),
766                        egui::Vec2::ZERO,
767                        "{berth:?} is docked but stands off its edge",
768                    );
769                    assert_eq!(frame.corner_radius, egui::CornerRadius::ZERO);
770                    assert_eq!(frame.shadow, egui::Shadow::NONE, "{berth:?} casts a shadow");
771                    assert_ne!(frame.fill, egui::Color32::TRANSPARENT);
772                }
773                Elevation::Floating => {
774                    assert_ne!(berth.offset(), egui::Vec2::ZERO);
775                    assert!(frame.corner_radius.nw > 0, "{berth:?} floats but is square");
776                    assert_eq!(frame.shadow.offset, SHADOW_OFFSET);
777                }
778                Elevation::Bare => {
779                    assert_eq!(
780                        frame.fill,
781                        egui::Color32::TRANSPARENT,
782                        "{berth:?} is ambient text but draws a container",
783                    );
784                    assert_eq!(frame.shadow, egui::Shadow::NONE);
785                }
786            }
787        }
788        let _ = visuals;
789    }
790
791    /// Nothing the chrome draws is see-through, in either state. The mockup's
792    /// translucency rides on a backdrop blur egui cannot render, and without
793    /// one the drawing under a surface reads as noise through the words on it.
794    #[test]
795    fn no_surface_the_chrome_draws_is_see_through() {
796        let ctx = egui::Context::default();
797        let amber = egui::Color32::from_rgba_unmultiplied(255, 150, 108, 128);
798        let mut fills = Vec::new();
799        ctx.run_ui(egui::RawInput::default(), |ui| {
800            for berth in Berth::ALL {
801                for tint in [Tint::None, Tint::Over(amber)] {
802                    fills.push((
803                        berth,
804                        shell(ui, berth.shape(), berth.elevation(), tint).fill,
805                    ));
806                }
807            }
808        })
809        .drop_without_applying_deltas();
810        for (berth, fill) in fills {
811            if berth.elevation() == Elevation::Bare {
812                continue;
813            }
814            assert_eq!(
815                fill.a(),
816                255,
817                "{berth:?} is drawn at alpha {}, so the canvas shows through it",
818                fill.a(),
819            );
820        }
821    }
822
823    /// A tinted bar is the same bar in another state, not another object: the
824    /// wash goes over the fill it already had.
825    #[test]
826    fn a_tint_washes_over_the_fill_rather_than_replacing_it() {
827        let amber = egui::Color32::from_rgba_unmultiplied(255, 150, 108, 128);
828        let plain = egui::Color32::from_rgb(20, 20, 24);
829        let tinted = Tint::Over(amber).over(plain);
830        assert_ne!(tinted, plain, "the tint did not reach the fill");
831        assert_ne!(
832            tinted, amber,
833            "the tint replaced the fill instead of washing it"
834        );
835        assert_eq!(Tint::None.over(plain), plain);
836    }
837
838    /// Nothing the shell offers is smaller than a fingertip.
839    #[test]
840    fn the_tap_target_is_the_specs_and_the_tool_cell_is_no_smaller() {
841        assert_eq!(TAP, 44.0, "spec §2.3 fixes the target on every platform");
842        let smallest = TOOL.x.min(TOOL.y);
843        assert!(
844            smallest >= TAP,
845            "a tool cell is {smallest}, under the tap target",
846        );
847    }
848
849    /// The heights the mockup fixes, and the widths they imply: the bar is
850    /// `--bar-h` and stands a whole tap target inside it, and the rail is the
851    /// 64px column the user measured.
852    #[test]
853    fn the_chrome_stands_at_the_mockups_own_heights() {
854        assert_eq!(TOP_BAR_HEIGHT, 54.0);
855        assert_eq!(
856            TOOL.x + 2.0 * f32::from(PAD),
857            64.0,
858            "the tool cluster is not the mockup's 64px column",
859        );
860        for shape in [Shape::TopBar, Shape::Bar] {
861            assert_eq!(
862                shape.content_height(),
863                Some(TAP),
864                "{shape:?} does not stand a tap target's height",
865            );
866        }
867        assert_eq!(Shape::Panel.content_height(), None);
868        assert_eq!(Shape::StatusLine.content_height(), None);
869    }
870
871    /// The status line answers no pointer, and it is the only thing in the
872    /// frame that does not.
873    #[test]
874    fn only_the_status_line_is_deaf_to_the_pointer() {
875        let bare: Vec<Berth> = Berth::ALL
876            .into_iter()
877            .filter(|berth| berth.elevation() == Elevation::Bare)
878            .collect();
879        assert_eq!(bare, vec![Berth::StatusLine]);
880    }
881
882    /// The one curve, at its ends and over its hump. A spring that did not
883    /// overshoot would be an ease, and the overshoot is what the user asked
884    /// for by name.
885    #[test]
886    fn the_motion_curve_starts_still_ends_settled_and_overshoots_between() {
887        assert_eq!(spring(Progress::START), 0.0);
888        assert!(
889            (spring(Progress::DONE) - 1.0).abs() < 1e-3,
890            "the curve does not settle: {}",
891            spring(Progress::DONE),
892        );
893        let peak = (0..=100)
894            .map(|n| spring(Progress::new(n as f32 / 100.0)))
895            .fold(f32::MIN, f32::max);
896        assert!(
897            peak > 1.0,
898            "the curve never leaves the track, so nothing overshoots: {peak}",
899        );
900        assert!(peak < 1.3, "the overshoot is a bounce, not a lurch: {peak}");
901        assert_eq!(
902            Progress::new(f32::NAN),
903            Progress::DONE,
904            "a motion that lost its clock must land settled, not undefined",
905        );
906        assert_eq!(Progress::new(4.0), Progress::DONE);
907        assert_eq!(Progress::new(-1.0), Progress::START);
908        assert_eq!(Progress::new(0.25).reversed(), Progress::new(0.75));
909    }
910
911    /// The elevation the user asked to be brought in: straight down and soft,
912    /// where egui's own falls sideways and lands hard.
913    #[test]
914    fn the_shadow_falls_straight_down_and_no_further_than_the_mockups() {
915        let default = egui::Visuals::light().window_shadow;
916        let ours = lift(&egui::Visuals::light());
917        assert!(
918            default.offset[0] != 0 && default.offset[1] > SHADOW_OFFSET[1],
919            "precondition: egui's own shadow is the one the user called too far away",
920        );
921        assert_eq!(ours.offset, [0, 10], "the mockup's `--shadow` offset");
922        assert_eq!(ours.blur, 34, "the mockup's `--shadow` blur");
923        let alpha = f32::from(ours.color.a()) / 255.0;
924        assert!(
925            (alpha - SHADOW_ALPHA).abs() < 0.01,
926            "the shadow carries {alpha}, not the mockup's {SHADOW_ALPHA}",
927        );
928    }
929}