Skip to main content

improv_gui/
theme.rs

1//! The NeXTSTEP egui theme for the Improv GUI.
2//!
3//! Lotus Improv ran on NeXTSTEP's 2-bit grayscale display: four grays and no
4//! color at all. This module builds an [`egui::Style`] from exactly those four
5//! levels (plus the scroller trough), with square corners and 1 px strokes.
6//! Roles follow `docs/reviews/2026-10-03-nextstep-visual-spec.md` §1. Text is
7//! the bundled Arimo (§2), a Helvetica-metric face, installed by
8//! [`install_fonts`]. It is purely cosmetic: no behavior depends on it.
9
10use egui::{
11    pos2, vec2, Color32, FontData, FontDefinitions, FontFamily, FontId, Rect, Rounding, Shadow,
12    Stroke, Style, TextStyle, Visuals,
13};
14
15/// Key-window title bars, grid rules, text, bevel dark edges.
16pub const BLACK: Color32 = Color32::from_gray(0x00);
17/// Desktop, category tile faces, non-key title bars, disabled / weak text.
18pub const DARK_GRAY: Color32 = Color32::from_gray(0x55);
19/// Window and panel background, control faces, the selection fill.
20pub const LIGHT_GRAY: Color32 = Color32::from_gray(0xAA);
21/// Grid cells, text fields, pressed faces, bevel light edges.
22pub const WHITE: Color32 = Color32::from_gray(0xFF);
23/// The scroller trough (the one value outside the four levels, spec §4.8).
24pub const TROUGH: Color32 = Color32::from_gray(0x80);
25
26/// Every color the theme may use. `TRANSPARENT` is "no stroke / no shadow".
27#[cfg(test)]
28const ALLOWED: [Color32; 6] = [
29    BLACK,
30    DARK_GRAY,
31    LIGHT_GRAY,
32    WHITE,
33    TROUGH,
34    Color32::TRANSPARENT,
35];
36
37/// The font name and family of the bold face, for window and menu titles.
38pub const BOLD: &str = "bold";
39
40/// NeXT's one text size: Helvetica 12 everywhere (§2).
41const TEXT_SIZE: f32 = 12.0;
42
43/// Apply the NeXTSTEP look to `ctx` at startup: pin the light theme slot
44/// first, so a system theme change can't swap in egui's stock visuals, then
45/// set [`next_style`] into that slot and [`install_fonts`].
46pub fn apply(ctx: &egui::Context) {
47    ctx.set_theme(egui::Theme::Light);
48    ctx.set_style(next_style());
49    install_fonts(ctx);
50}
51
52/// Register the bundled Arimo with `ctx`: Regular first in the proportional
53/// family, ahead of egui's default fonts (kept as glyph fallback), and Bold as
54/// the named family [`BOLD`]. [`TextStyle::Heading`] is pointed at the bold
55/// family here rather than in [`next_style`], because a style naming an
56/// unregistered family panics when text is laid out; call this after
57/// `set_style`. Monospace is left as egui's default.
58pub fn install_fonts(ctx: &egui::Context) {
59    let mut fonts = FontDefinitions::default();
60    fonts.font_data.insert(
61        "Arimo".into(),
62        FontData::from_static(include_bytes!("../assets/fonts/Arimo-Regular.ttf")),
63    );
64    fonts.font_data.insert(
65        BOLD.into(),
66        FontData::from_static(include_bytes!("../assets/fonts/Arimo-Bold.ttf")),
67    );
68    let fallback = fonts.families[&FontFamily::Proportional].clone();
69    fonts
70        .families
71        .entry(FontFamily::Proportional)
72        .or_default()
73        .insert(0, "Arimo".into());
74    fonts.families.insert(
75        FontFamily::Name(BOLD.into()),
76        std::iter::once(BOLD.to_string()).chain(fallback).collect(),
77    );
78    ctx.set_fonts(fonts);
79    ctx.all_styles_mut(|s| {
80        s.text_styles.insert(
81            TextStyle::Heading,
82            FontId::new(TEXT_SIZE, FontFamily::Name(BOLD.into())),
83        );
84    });
85}
86
87/// Show `area` NeXT-style: the bar always there, its trough [`TROUGH`],
88/// with [`scroller_chrome`] on top (one line = one interact row).
89/// egui paints the trough with `extreme_bg_color`, which is WHITE for text
90/// edits, so it is swapped for the scroller only and restored for `add`.
91pub fn next_scroll<R>(
92    ui: &mut egui::Ui,
93    area: egui::ScrollArea,
94    add: impl FnOnce(&mut egui::Ui) -> R,
95) -> egui::scroll_area::ScrollAreaOutput<R> {
96    let scoped = ui.scope(|ui| {
97        ui.visuals_mut().extreme_bg_color = TROUGH;
98        area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
99            .show(ui, |ui| {
100                content(ui);
101                add(ui)
102            })
103    });
104    let line = ui.spacing().interact_size.y + ui.spacing().item_spacing.y;
105    // The scope holds just the area, so its rect is the area plus its bar.
106    scroller_chrome(ui, &scoped.inner, scoped.response.rect, line);
107    scoped.inner
108}
109
110/// §4.8 arrow buttons: 18 px squares.
111pub const ARROW: f32 = 18.0;
112
113/// The vertical trough at the right of a non-floating scroll area's `outer`
114/// rect (area plus bar), then its up and down arrow buttons grouped at the
115/// trough's bottom end.
116pub fn scroller_rects(outer: Rect, bar_width: f32) -> [Rect; 3] {
117    let trough = outer.with_min_x(outer.max.x - bar_width);
118    let button = |n: f32| {
119        Rect::from_min_size(
120            pos2(trough.max.x - ARROW, trough.max.y - n * ARROW),
121            vec2(ARROW, ARROW),
122        )
123    };
124    [trough, button(2.0), button(1.0)]
125}
126
127/// Where a scroller knob spans along `track` for a `view` long window at
128/// `offset` into `content`: grown to `min_len`, kept inside the track.
129fn knob_span(
130    offset: f32,
131    view: f32,
132    content: f32,
133    track: std::ops::RangeInclusive<f32>,
134    min_len: f32,
135) -> (f32, f32) {
136    let at = |v: f32| egui::remap_clamp(v, 0.0..=content.max(view), track.clone());
137    let (a, b) = (at(offset), at(offset + view));
138    let grow = (min_len - (b - a)).max(0.0) / 2.0;
139    ((a - grow).max(*track.start()), (b + grow).min(*track.end()))
140}
141
142/// Drag `knob` (it senses clicks too, so the egui bar under it never jumps):
143/// the scroll offset change for this frame's drag, mapping the knob's free
144/// travel (`track_len` minus the knob) onto the `max` scrollable points.
145fn knob_drag(
146    ui: &egui::Ui,
147    id: egui::Id,
148    knob: Rect,
149    axis: usize,
150    track_len: f32,
151    max: f32,
152) -> f32 {
153    let r = ui.interact(knob, id, egui::Sense::click_and_drag());
154    if !r.dragged() {
155        return 0.0;
156    }
157    let free = (track_len - knob.size()[axis]).max(1.0);
158    r.drag_delta()[axis] * max / free
159}
160
161/// Store `offset` (clamped to `0..=max`) on `axis` of `out`'s area if it
162/// moved, and repaint so the area shows it.
163fn scroll_to<R>(
164    ui: &egui::Ui,
165    out: &egui::scroll_area::ScrollAreaOutput<R>,
166    axis: usize,
167    offset: f32,
168    max: f32,
169) {
170    let offset = offset.clamp(0.0, max);
171    if offset != out.state.offset[axis] {
172        let mut state = out.state;
173        state.offset[axis] = offset;
174        state.store(ui.ctx(), out.id);
175        ui.ctx().request_repaint();
176    }
177}
178
179/// Repaint a shown vertical scroller NeXT-style: a knob with a 1 px bevel
180/// (white top-left, black bottom-right) above two grouped arrow buttons, each
181/// click scrolling `line` points. `outer` is the area plus its bar as laid
182/// out (`out.inner_rect` is the pre-shrink rect, so it can't be used). The
183/// knob drags 1:1 with the pointer (ours, not egui's whole-trough mapping),
184/// so it also works when the bar is drawn at a viewport edge egui's own bar
185/// is scrolled away from (a grid wider than its window).
186pub fn scroller_chrome<R>(
187    ui: &egui::Ui,
188    out: &egui::scroll_area::ScrollAreaOutput<R>,
189    outer: Rect,
190    line: f32,
191) {
192    let scroll = &ui.spacing().scroll;
193    let [trough, up, down] = scroller_rects(outer, scroll.bar_width);
194    let p = ui.painter();
195    p.rect_filled(trough, Rounding::ZERO, TROUGH);
196    let track = trough.min.y..=up.min.y.max(trough.min.y);
197    let view = trough.height();
198    let (y0, y1) = knob_span(
199        out.state.offset.y,
200        view,
201        out.content_size.y,
202        track.clone(),
203        scroll.handle_min_length,
204    );
205    let knob = p.round_rect_to_pixels(Rect::from_x_y_ranges(trough.x_range(), y0..=y1));
206    raised(p, knob);
207    let max = (out.content_size.y - view).max(0.0);
208    let mut offset = out.state.offset.y;
209    for (rect, sign) in [(up, -1.0), (down, 1.0)] {
210        raised(p, rect);
211        let c = rect.center();
212        let (tip, base) = (c.y + sign * 4.0, c.y - sign * 4.0);
213        p.add(egui::Shape::convex_polygon(
214            vec![pos2(c.x, tip), pos2(c.x + 5.0, base), pos2(c.x - 5.0, base)],
215            BLACK,
216            Stroke::NONE,
217        ));
218        if ui
219            .interact(
220                rect,
221                out.id.with(("arrow", sign as i8)),
222                egui::Sense::click(),
223            )
224            .clicked()
225        {
226            offset += sign * line;
227        }
228    }
229    let track_len = track.end() - track.start();
230    offset += knob_drag(ui, out.id.with("knob"), knob, 1, track_len, max);
231    scroll_to(ui, out, 1, offset, max);
232}
233
234/// The §4.4 horizontal scroller in `bar` (the bottom strip, right of the row
235/// tiles): its left and right arrow buttons grouped at the LEFT end, as in
236/// the reference, then the knob's track.
237pub fn hscroller_rects(bar: Rect) -> [Rect; 3] {
238    let button = |n: f32| {
239        Rect::from_min_size(
240            pos2(bar.min.x + n * ARROW, bar.max.y - ARROW),
241            vec2(ARROW, ARROW),
242        )
243    };
244    let track = bar.with_min_x((bar.min.x + 2.0 * ARROW).min(bar.max.x));
245    [button(0.0), button(1.0), track]
246}
247
248/// Draw and run a horizontal scroller in `bar` for `out` (an area whose own
249/// bar is hidden): grouped arrows scroll `line` points per click, the beveled
250/// knob drags, and a click in the trough beside the knob pages by a view.
251pub fn hscroller_chrome<R>(
252    ui: &egui::Ui,
253    out: &egui::scroll_area::ScrollAreaOutput<R>,
254    bar: Rect,
255    line: f32,
256) {
257    let [left, right, track] = hscroller_rects(bar);
258    let p = ui.painter();
259    p.rect_filled(bar, Rounding::ZERO, TROUGH);
260    let view = out.inner_rect.width();
261    let max = (out.content_size.x - view).max(0.0);
262    let (x0, x1) = knob_span(
263        out.state.offset.x,
264        view,
265        out.content_size.x,
266        track.min.x..=track.max.x,
267        ui.spacing().scroll.handle_min_length,
268    );
269    let knob = p.round_rect_to_pixels(Rect::from_x_y_ranges(x0..=x1, bar.y_range()));
270    let mut offset = out.state.offset.x;
271    let trough = ui.interact(track, out.id.with("htrough"), egui::Sense::click());
272    if let Some(at) = trough
273        .clicked()
274        .then(|| trough.interact_pointer_pos().or(trough.hover_pos()))
275        .flatten()
276    {
277        if at.x < knob.min.x {
278            offset -= view;
279        } else if at.x > knob.max.x {
280            offset += view;
281        }
282    }
283    raised(p, knob);
284    for (rect, sign) in [(left, -1.0), (right, 1.0)] {
285        raised(p, rect);
286        let c = rect.center();
287        let (tip, base) = (c.x + sign * 4.0, c.x - sign * 4.0);
288        p.add(egui::Shape::convex_polygon(
289            vec![pos2(tip, c.y), pos2(base, c.y + 5.0), pos2(base, c.y - 5.0)],
290            BLACK,
291            Stroke::NONE,
292        ));
293        if ui
294            .interact(
295                rect,
296                out.id.with(("harrow", sign as i8)),
297                egui::Sense::click(),
298            )
299            .clicked()
300        {
301            offset += sign * line;
302        }
303    }
304    offset += knob_drag(ui, out.id.with("hknob"), knob, 0, track.width(), max);
305    scroll_to(ui, out, 0, offset, max);
306}
307
308/// A raised NeXT face: [`LIGHT_GRAY`] with a white top-left, black
309/// bottom-right 1 px bevel.
310fn raised(p: &egui::Painter, r: Rect) {
311    let fill = |min, max, c| p.rect_filled(Rect::from_min_max(min, max), Rounding::ZERO, c);
312    fill(r.min, r.max, LIGHT_GRAY);
313    fill(r.min, pos2(r.max.x, r.min.y + 1.0), WHITE);
314    fill(r.min, pos2(r.min.x + 1.0, r.max.y), WHITE);
315    fill(pos2(r.min.x, r.max.y - 1.0), r.max, BLACK);
316    fill(pos2(r.max.x - 1.0, r.min.y), r.max, BLACK);
317}
318
319/// Undo the trough color inside scrolled content, so text edits stay WHITE.
320pub fn content(ui: &mut egui::Ui) {
321    ui.visuals_mut().extreme_bg_color = WHITE;
322}
323
324/// Build the NeXTSTEP [`Style`]. Applied once at startup by [`apply`].
325pub fn next_style() -> Style {
326    let mut style = Style::default();
327    for (ts, size) in [
328        (TextStyle::Body, TEXT_SIZE),
329        (TextStyle::Button, TEXT_SIZE),
330        (TextStyle::Heading, TEXT_SIZE),
331    ] {
332        style
333            .text_styles
334            .insert(ts, FontId::new(size, FontFamily::Proportional));
335    }
336    let mut v = Visuals::light();
337    let line = |c| Stroke::new(1.0_f32, c);
338
339    v.override_text_color = Some(BLACK);
340    v.window_fill = LIGHT_GRAY;
341    v.panel_fill = LIGHT_GRAY;
342    v.extreme_bg_color = WHITE; // text edits, scroll bar background
343    v.faint_bg_color = WHITE; // striped rows
344    v.code_bg_color = WHITE;
345    v.hyperlink_color = BLACK;
346    v.warn_fg_color = BLACK;
347    v.error_fg_color = BLACK;
348    v.window_rounding = Rounding::ZERO;
349    v.menu_rounding = Rounding::ZERO;
350    v.window_shadow = Shadow::NONE;
351    v.popup_shadow = Shadow::NONE;
352    v.window_stroke = line(BLACK);
353    // Gray, never blue and never inverted; the black stroke keeps a selected
354    // cell visible on a gray row.
355    v.selection.bg_fill = LIGHT_GRAY;
356    v.selection.stroke = line(BLACK);
357    v.text_cursor.stroke = line(BLACK);
358
359    // Idle and hovered controls are the same raised gray face: NeXT
360    // highlights only on press, when the face turns white.
361    let face = |fill| egui::style::WidgetVisuals {
362        bg_fill: fill,
363        weak_bg_fill: fill,
364        bg_stroke: line(BLACK),
365        fg_stroke: line(BLACK),
366        rounding: Rounding::ZERO,
367        expansion: 0.0,
368    };
369    let w = &mut v.widgets;
370    w.inactive = face(LIGHT_GRAY);
371    w.hovered = face(LIGHT_GRAY);
372    w.active = face(WHITE);
373    w.open = face(WHITE);
374    // Labels, frames, separators. `weak_bg_fill` is egui's fade target, so
375    // weak and disabled text come out exactly DARK_GRAY (black halfway to
376    // LIGHT_GRAY).
377    w.noninteractive = egui::style::WidgetVisuals {
378        bg_stroke: line(DARK_GRAY),
379        ..face(LIGHT_GRAY)
380    };
381
382    style.visuals = v;
383    style.spacing.item_spacing = egui::vec2(6.0, 4.0);
384    style.spacing.button_padding = egui::vec2(8.0, 3.0);
385    // §3: 17 px rows, header bands and tiles.
386    style.spacing.interact_size.y = 17.0;
387    // §4.8: an always-there 19 px trough with a square knob; the bevel and
388    // grouped arrows are painted over it by `scroller_chrome`.
389    style.spacing.scroll = egui::style::ScrollStyle {
390        floating: false,
391        bar_width: 19.0,
392        bar_inner_margin: 0.0,
393        bar_outer_margin: 0.0,
394        handle_min_length: 19.0,
395        foreground_color: false,
396        ..egui::style::ScrollStyle::solid()
397    };
398    style
399}
400
401#[cfg(test)]
402#[cfg_attr(coverage_nightly, coverage(off))]
403mod tests {
404    use super::*;
405
406    /// Every `Color32` in `visuals`, found through its `Debug` form
407    /// (`#RR_GG_BB_AA`), so a field added in a future egui is still checked.
408    fn colors(visuals: &Visuals) -> Vec<Color32> {
409        let dbg = format!("{visuals:?}");
410        let hex = |s: &str| u8::from_str_radix(s, 16).expect("hex byte");
411        dbg.match_indices('#')
412            .filter_map(|(i, _)| dbg.get(i + 1..i + 12))
413            .map(|c| {
414                let [r, g, b, a] = [&c[0..2], &c[3..5], &c[6..8], &c[9..11]].map(hex);
415                Color32::from_rgba_premultiplied(r, g, b, a)
416            })
417            .collect()
418    }
419
420    #[test]
421    fn visuals_use_only_the_grayscale_palette() {
422        let v = next_style().visuals;
423        let found = colors(&v);
424        assert!(found.len() > 20, "Debug scan found only {found:?}");
425        for c in found {
426            assert!(c.r() == c.g() && c.g() == c.b(), "{c:?} is not gray");
427            assert!(ALLOWED.contains(&c), "{c:?} is not in the palette");
428        }
429        assert_eq!(v.selection.bg_fill, LIGHT_GRAY);
430        assert_eq!(v.panel_fill, LIGHT_GRAY);
431        assert_eq!(v.window_fill, LIGHT_GRAY);
432        assert_eq!(v.extreme_bg_color, WHITE);
433        assert_eq!(v.text_color(), BLACK);
434        assert_eq!(v.weak_text_color(), DARK_GRAY);
435    }
436
437    #[test]
438    fn install_fonts_puts_arimo_first_and_registers_bold() {
439        let ctx = egui::Context::default();
440        ctx.set_style(next_style());
441        install_fonts(&ctx);
442        // Fonts are swapped in at the start of a pass; laying out a heading
443        // proves the bold family is bound (an unbound one panics).
444        let _ = ctx.run(egui::RawInput::default(), |ctx| {
445            egui::CentralPanel::default().show(ctx, |ui| ui.heading("Improv"));
446        });
447        ctx.fonts(|f| {
448            let fonts = f.lock();
449            let defs = fonts.fonts.definitions();
450            let prop = &defs.families[&FontFamily::Proportional];
451            assert_eq!(prop[0], "Arimo");
452            assert!(prop.len() > 1, "egui's fonts must stay as fallback");
453            assert_eq!(defs.families[&FontFamily::Name(BOLD.into())][0], BOLD);
454        });
455        let heading = &ctx.style().text_styles[&TextStyle::Heading];
456        assert_eq!(*heading, FontId::new(12.0, FontFamily::Name(BOLD.into())));
457        assert_eq!(ctx.style().text_styles[&TextStyle::Body].size, 12.0);
458    }
459
460    #[test]
461    fn apply_pins_the_light_slot() {
462        let ctx = egui::Context::default();
463        apply(&ctx);
464        // What a system ThemeChanged to light does.
465        ctx.set_theme(egui::Theme::Light);
466        assert_eq!(ctx.style().visuals.selection.bg_fill, LIGHT_GRAY);
467    }
468
469    #[test]
470    fn corners_are_square_and_strokes_one_pixel() {
471        let v = next_style().visuals;
472        assert_eq!(v.window_rounding, Rounding::ZERO);
473        assert_eq!(v.menu_rounding, Rounding::ZERO);
474        let w = &v.widgets;
475        for s in [w.noninteractive, w.inactive, w.hovered, w.active, w.open] {
476            assert_eq!(s.rounding, Rounding::ZERO);
477            assert_eq!(s.bg_stroke.width, 1.0);
478            assert_eq!(s.expansion, 0.0);
479        }
480    }
481
482    #[test]
483    fn grouped_arrows_sit_at_the_trough_bottom_and_scroll() {
484        let ctx = egui::Context::default();
485        ctx.set_style(next_style());
486        let frame = |events: Vec<egui::Event>| {
487            let mut shown = None;
488            let raw = egui::RawInput {
489                screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 300.0))),
490                events,
491                ..Default::default()
492            };
493            let full = ctx.run(raw, |ctx| {
494                egui::CentralPanel::default().show(ctx, |ui| {
495                    let area = egui::ScrollArea::vertical().max_height(200.0);
496                    let s = ui.scope(|ui| {
497                        next_scroll(ui, area, |ui| {
498                            for i in 0..50 {
499                                ui.label(format!("row {i}"));
500                            }
501                        })
502                    });
503                    shown = Some((s.inner.id, s.response.rect));
504                });
505            });
506            let (id, inner) = shown.expect("scroll area shown");
507            (full, id, inner)
508        };
509
510        let (full, id, outer) = frame(Vec::new());
511        let [trough, up, down] = scroller_rects(outer, 19.0);
512        assert_eq!(trough.height(), 200.0, "the trough is the area's height");
513        assert_eq!(down.max, trough.max, "down arrow ends the trough");
514        assert_eq!(up.max.y, down.min.y, "up arrow sits right above it");
515        assert_eq!((up.width(), up.height()), (ARROW, ARROW));
516        for button in [up, down] {
517            let painted = full.shapes.iter().any(|c| match &c.shape {
518                egui::Shape::Path(p) => {
519                    p.points.len() == 3 && p.points.iter().all(|q| button.contains(*q))
520                }
521                _ => false,
522            });
523            assert!(painted, "a triangle is painted in {button:?}");
524        }
525        // The up arrow's tip is its topmost point.
526        let tips: Vec<f32> = full
527            .shapes
528            .iter()
529            .filter_map(|c| match &c.shape {
530                egui::Shape::Path(p) if p.points.len() == 3 && up.contains(p.points[0]) => {
531                    Some(p.points[0].y)
532                }
533                _ => None,
534            })
535            .collect();
536        assert!(
537            tips.first().is_some_and(|y| *y < up.center().y),
538            "up points up"
539        );
540
541        let at = down.center();
542        let press = |pressed| egui::Event::PointerButton {
543            pos: at,
544            button: egui::PointerButton::Primary,
545            pressed,
546            modifiers: egui::Modifiers::NONE,
547        };
548        let _ = frame(vec![
549            egui::Event::PointerMoved(at),
550            press(true),
551            press(false),
552        ]);
553        let _ = frame(Vec::new());
554        let offset = egui::scroll_area::State::load(&ctx, id)
555            .expect("state")
556            .offset
557            .y;
558        // Exactly one line (17 px row + 4 px spacing), not a trough jump.
559        assert_eq!(offset, 21.0, "down arrow scrolled one line");
560    }
561
562    /// The horizontal scroller: arrows grouped at the bar's left end; the
563    /// right arrow scrolls a line, the left one clamps at 0, a trough click
564    /// right of the knob pages by a view, and the knob drags.
565    #[test]
566    fn horizontal_scroller_groups_arrows_left_and_scrolls() {
567        let ctx = egui::Context::default();
568        ctx.set_style(next_style());
569        let bar = Rect::from_min_size(pos2(10.0, 260.0), vec2(300.0, 19.0));
570        let frame = |events: Vec<egui::Event>| {
571            let mut out = None;
572            let raw = egui::RawInput {
573                screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 300.0))),
574                events,
575                ..Default::default()
576            };
577            let full = ctx.run(raw, |ctx| {
578                egui::CentralPanel::default().show(ctx, |ui| {
579                    let o = egui::ScrollArea::horizontal()
580                        .max_width(200.0)
581                        .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
582                        .show(ui, |ui| {
583                            ui.allocate_exact_size(vec2(1000.0, 20.0), egui::Sense::hover())
584                        });
585                    hscroller_chrome(ui, &o, bar, 20.0);
586                    out = Some((o.state.offset.x, o.inner_rect.width()));
587                });
588            });
589            (full, out.unwrap())
590        };
591        let click = |at: egui::Pos2| {
592            let b = |pressed| egui::Event::PointerButton {
593                pos: at,
594                button: egui::PointerButton::Primary,
595                pressed,
596                modifiers: egui::Modifiers::NONE,
597            };
598            frame(vec![egui::Event::PointerMoved(at)]);
599            frame(vec![b(true)]);
600            frame(vec![b(false)]);
601            frame(Vec::new()).1
602        };
603        let (full, (x, view)) = frame(Vec::new());
604        assert_eq!((x, view), (0.0, 200.0));
605        let [left, right, track] = hscroller_rects(bar);
606        assert_eq!(left.min, pos2(bar.min.x, bar.max.y - ARROW));
607        assert_eq!(right.min.x, left.max.x, "the arrows are grouped");
608        assert_eq!(track.min.x, right.max.x);
609        let tips: Vec<egui::Pos2> = full
610            .shapes
611            .iter()
612            .filter_map(|c| match &c.shape {
613                egui::Shape::Path(p) if p.points.len() == 3 => Some(p.points[0]),
614                _ => None,
615            })
616            .collect();
617        assert!(
618            tips.iter()
619                .any(|t| left.contains(*t) && t.x < left.center().x),
620            "left points left"
621        );
622        assert!(
623            tips.iter()
624                .any(|t| right.contains(*t) && t.x > right.center().x),
625            "right points right"
626        );
627
628        assert_eq!(click(left.center()).0, 0.0, "clamped at the start");
629        assert_eq!(click(right.center()).0, 20.0, "one line right");
630        assert_eq!(
631            click(pos2(track.max.x - 2.0, track.center().y)).0,
632            220.0,
633            "a page right"
634        );
635        // Drag the knob 10 px: the free travel maps onto the 800 px scroll.
636        let (x0, x1) = knob_span(220.0, 200.0, 1000.0, track.min.x..=track.max.x, 19.0);
637        let from = pos2((x0 + x1) / 2.0, track.center().y);
638        let to = from + vec2(10.0, 0.0);
639        let b = |at, pressed| egui::Event::PointerButton {
640            pos: at,
641            button: egui::PointerButton::Primary,
642            pressed,
643            modifiers: egui::Modifiers::NONE,
644        };
645        frame(vec![egui::Event::PointerMoved(from)]);
646        frame(vec![b(from, true)]);
647        frame(vec![egui::Event::PointerMoved(to)]);
648        frame(vec![b(to, false)]);
649        let after = frame(Vec::new()).1 .0;
650        let free = track.width() - (x1 - x0);
651        assert!(
652            (after - (220.0 + 10.0 * 800.0 / free)).abs() < 0.5,
653            "{after}"
654        );
655    }
656
657    #[test]
658    fn scrollers_are_solid_and_19_px() {
659        let s = next_style().spacing.scroll;
660        assert_eq!(s.bar_width, 19.0);
661        assert!(!s.floating);
662        assert!(!s.foreground_color);
663    }
664}