1use egui::{
11 pos2, vec2, Color32, FontData, FontDefinitions, FontFamily, FontId, Rect, Rounding, Shadow,
12 Stroke, Style, TextStyle, Visuals,
13};
14
15pub const BLACK: Color32 = Color32::from_gray(0x00);
17pub const DARK_GRAY: Color32 = Color32::from_gray(0x55);
19pub const LIGHT_GRAY: Color32 = Color32::from_gray(0xAA);
21pub const WHITE: Color32 = Color32::from_gray(0xFF);
23pub const TROUGH: Color32 = Color32::from_gray(0x80);
25
26#[cfg(test)]
28const ALLOWED: [Color32; 6] = [
29 BLACK,
30 DARK_GRAY,
31 LIGHT_GRAY,
32 WHITE,
33 TROUGH,
34 Color32::TRANSPARENT,
35];
36
37pub const BOLD: &str = "bold";
39
40const TEXT_SIZE: f32 = 12.0;
42
43pub fn apply(ctx: &egui::Context) {
47 ctx.set_theme(egui::Theme::Light);
48 ctx.set_style(next_style());
49 install_fonts(ctx);
50}
51
52pub 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
87pub 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 scroller_chrome(ui, &scoped.inner, scoped.response.rect, line);
107 scoped.inner
108}
109
110pub const ARROW: f32 = 18.0;
112
113pub 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
127fn 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
142fn 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
161fn 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
179pub 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
234pub 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
248pub 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
308fn 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
319pub fn content(ui: &mut egui::Ui) {
321 ui.visuals_mut().extreme_bg_color = WHITE;
322}
323
324pub 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; v.faint_bg_color = WHITE; 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 v.selection.bg_fill = LIGHT_GRAY;
356 v.selection.stroke = line(BLACK);
357 v.text_cursor.stroke = line(BLACK);
358
359 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 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 style.spacing.interact_size.y = 17.0;
387 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 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 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 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 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 assert_eq!(offset, 21.0, "down arrow scrolled one line");
560 }
561
562 #[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 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}