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}