use egui::{Align, Layout, Response, RichText, Sense, Ui, Vec2};
use makeover_layout::{Act, Awaiting, Bar, Chart, Figure, Meter, State, Token, Tone};
use makeover_timing::activity_blink;
use std::time::Duration;
use crate::Palette;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WidgetStyle {
pub meter_height: f32,
pub meter_width: Option<f32>,
pub chart_height: f32,
pub radius: u8,
pub token_padding: Vec2,
pub figure_gap: f32,
pub figure_scale: f32,
pub mark_size: f32,
}
impl Default for WidgetStyle {
fn default() -> Self {
Self {
meter_height: 6.0,
meter_width: None,
chart_height: 200.0,
radius: 3,
token_padding: Vec2::new(6.0, 2.0),
figure_gap: 2.0,
figure_scale: 2.0,
mark_size: 8.0,
}
}
}
pub fn meter(ui: &mut Ui, meter: &Meter<'_>, palette: &Palette, style: &WidgetStyle) -> Response {
let width = style
.meter_width
.unwrap_or_else(|| ui.available_width().max(1.0));
ui.horizontal(|ui| {
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, style.meter_height), Sense::hover());
ui.painter().rect_filled(rect, style.radius, palette.sunken);
let share = if meter.total == 0 {
0.0
} else {
(f64::from(meter.done) / f64::from(meter.total)).min(1.0)
};
#[expect(
clippy::cast_possible_truncation,
reason = "a share is 0..=1 and the product is a width in points"
)]
let filled = (f64::from(rect.width()) * share) as f32;
if filled > 0.0 {
let mut fill = rect;
fill.set_width(filled);
ui.painter()
.rect_filled(fill, style.radius, palette.tone(meter.tone));
}
let reading = match meter.label {
Some(label) => format!("{}/{} {label}", meter.done, meter.total),
None => format!("{}/{}", meter.done, meter.total),
};
ui.label(RichText::new(reading).color(palette.content_muted));
response
})
.inner
}
pub fn token(
ui: &mut Ui,
label: &str,
kind: Token,
tone: Tone,
latched: bool,
palette: &Palette,
style: &WidgetStyle,
) -> Response {
let painted = palette.tone(tone);
let radius = style.radius;
let sense = if kind.interactive() {
Sense::click()
} else {
Sense::hover()
};
let ink = match kind {
Token::Badge => palette.content,
Token::Chip { .. } if latched => palette.page,
Token::Chip { .. } => painted,
};
let galley = ui.painter().layout_no_wrap(
label.to_owned(),
egui::TextStyle::Body.resolve(ui.style()),
ink,
);
let size = galley.size() + style.token_padding * 2.0;
let (rect, response) = ui.allocate_exact_size(size, sense);
if kind == Token::Badge {
ui.painter().rect(
rect,
radius,
palette.tone_surface(tone),
egui::Stroke::new(1.0, palette.tone_edge(tone)),
egui::StrokeKind::Inside,
);
} else if latched {
ui.painter().rect_filled(rect, radius, painted);
} else {
ui.painter().rect_stroke(
rect,
radius,
egui::Stroke::new(1.0, painted),
egui::StrokeKind::Inside,
);
}
ui.painter()
.galley(rect.center() - galley.size() / 2.0, galley, ink);
let role = if kind.interactive() {
egui::WidgetType::Button
} else {
egui::WidgetType::Label
};
response.widget_info(|| {
let mut info = egui::WidgetInfo::labeled(role, ui.is_enabled(), label);
if kind.interactive() {
info.selected = Some(latched);
}
info
});
response
}
pub fn act(ui: &mut Ui, act: &Act<'_>, palette: &Palette, _style: &WidgetStyle) -> Response {
let disabled = act.state.is_some_and(State::suppresses_interaction);
let label = match act.key {
Some(key) => format!("{} ({key})", act.label),
None => act.label.to_owned(),
};
let colour = if disabled {
palette.content_muted
} else {
palette.tone(act.tone)
};
let mut button = egui::Button::new(RichText::new(label).color(colour));
if act.commits {
let frame = if disabled {
palette.content_muted
} else {
palette.content
};
button = button.stroke(egui::Stroke::new(2.0, frame));
}
let drawn = ui.add_enabled(!disabled, button);
match act.hint {
Some(hint) => drawn.on_hover_text(hint),
None => drawn,
}
}
pub fn figure(
ui: &mut Ui,
figure: &Figure<'_>,
palette: &Palette,
style: &WidgetStyle,
) -> Response {
ui.with_layout(Layout::top_down(Align::Min), |ui| {
let value = match figure.change {
Some(change) => format!("{} {change}", figure.value),
None => figure.value.to_owned(),
};
let size = egui::TextStyle::Body.resolve(ui.style()).size * style.figure_scale;
let shown = ui.label(
RichText::new(value)
.color(palette.tone(figure.tone))
.size(size)
.strong(),
);
ui.add_space(style.figure_gap);
ui.label(RichText::new(figure.caption).color(palette.content_muted));
shown
})
.inner
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Progress {
pub delivered: Option<u64>,
pub elapsed: Option<Duration>,
}
pub fn activity(ui: &mut Ui, reduced: bool, palette: &Palette, style: &WidgetStyle) -> Response {
let (rect, response) = ui.allocate_exact_size(Vec2::splat(style.mark_size), Sense::hover());
let lit = match activity_blink(reduced) {
None => true,
Some(half) => {
let half = half.as_secs_f64();
if half <= 0.0 {
true
} else {
let phase = ui.input(|input| input.time).rem_euclid(half * 2.0);
let lit = phase < half;
let next = if lit { half } else { half * 2.0 } - phase;
ui.ctx()
.request_repaint_after(Duration::from_secs_f64(next.max(0.0)));
lit
}
}
};
let colour = if lit { palette.action } else { palette.sunken };
ui.painter().rect_filled(rect, style.radius, colour);
response
}
pub fn awaiting(
ui: &mut Ui,
awaiting: Awaiting,
progress: Progress,
reduced: bool,
palette: &Palette,
style: &WidgetStyle,
) -> Response {
let (Some(total), Some(done)) = (awaiting.amount, progress.delivered) else {
return activity(ui, reduced, palette, style);
};
let width = style
.meter_width
.unwrap_or_else(|| ui.available_width().max(1.0));
ui.horizontal(|ui| {
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, style.meter_height), Sense::hover());
ui.painter().rect_filled(rect, style.radius, palette.sunken);
let share = if total == 0 {
0.0
} else {
#[expect(
clippy::cast_precision_loss,
reason = "a byte count past 2^53 is not a wait anyone is watching a bar for"
)]
let share = (done as f64 / total as f64).min(1.0);
share
};
#[expect(
clippy::cast_possible_truncation,
reason = "a share is 0..=1 and the product is a width in points"
)]
let filled = (f64::from(rect.width()) * share) as f32;
if filled > 0.0 {
let mut fill = rect;
fill.set_width(filled);
ui.painter().rect_filled(fill, style.radius, palette.action);
}
let reading = match progress.elapsed {
Some(elapsed) => format!("{done}/{total} {}s", elapsed.as_secs()),
None => format!("{done}/{total}"),
};
ui.label(RichText::new(reading).color(palette.content_muted));
response
})
.inner
}
pub fn chart(
ui: &mut Ui,
chart: &Chart<'_>,
bars: &[Bar<'_>],
palette: &Palette,
style: &WidgetStyle,
) -> Response {
let width = ui.available_width().max(1.0);
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, style.chart_height), Sense::hover());
let font = egui::TextStyle::Small.resolve(ui.style());
let band = ui.text_style_height(&egui::TextStyle::Small);
let floor = (rect.bottom() - band).max(rect.top());
let standing = floor - rect.top();
if bars.is_empty() {
return response;
}
#[expect(
clippy::cast_precision_loss,
reason = "a bar count is small and this is a width in points"
)]
let each = rect.width() / bars.len() as f32;
for (index, bar) in bars.iter().enumerate() {
#[expect(
clippy::cast_precision_loss,
reason = "an index into the bars, which are few"
)]
let left = rect.left() + each * index as f32;
let reached = standing * bar.fraction(chart);
let mut column = egui::Rect::from_min_size(
egui::Pos2::new(left, floor - reached),
Vec2::new(each, reached),
);
if column.height() < 1.0 {
column.set_top(floor - 1.0);
}
ui.painter()
.rect_filled(column, style.radius, palette.tone(chart.tone));
ui.painter().text(
egui::Pos2::new(left + each / 2.0, floor),
egui::Align2::CENTER_TOP,
bar.at,
font.clone(),
palette.content_muted,
);
}
response
}
#[cfg(test)]
mod tests {
use super::*;
fn announced(
draw: impl FnMut(&mut Ui),
) -> Vec<(
egui::accesskit::Role,
String,
Option<egui::accesskit::Toggled>,
)> {
let ctx = egui::Context::default();
ctx.enable_accesskit();
let mut draw = draw;
let input = || egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(600.0, 400.0),
)),
..Default::default()
};
let _ = ctx.run_ui(input(), &mut draw);
let out = ctx.run_ui(input(), &mut draw);
out.platform_output
.accesskit_update
.expect("accesskit is on")
.nodes
.iter()
.map(|(_, node)| {
(
node.role(),
node.label()
.or_else(|| node.value())
.unwrap_or_default()
.to_owned(),
node.toggled(),
)
})
.collect()
}
#[test]
fn a_chip_is_announced_as_a_control_and_says_whether_it_is_held() {
let p = palette();
let style = WidgetStyle::default();
let drawn = announced(|ui| {
token(
ui,
"C#",
Token::Chip { removable: false },
Tone::Neutral,
true,
&p,
&style,
);
});
let chip = drawn
.iter()
.find(|(role, name, _)| *role == egui::accesskit::Role::Button && name == "C#")
.unwrap_or_else(|| panic!("the chip is not in the tree: {drawn:?}"));
assert_eq!(
chip.2,
Some(egui::accesskit::Toggled::True),
"a latched chip is held down and says so: {drawn:?}"
);
}
#[test]
fn a_badge_is_announced_as_the_text_it_is() {
let p = palette();
let style = WidgetStyle::default();
let drawn = announced(|ui| {
token(ui, "wav", Token::Badge, Tone::Neutral, false, &p, &style);
});
assert!(
drawn
.iter()
.any(|(role, name, _)| *role == egui::accesskit::Role::Label && name == "wav"),
"{drawn:?}"
);
assert!(
!drawn
.iter()
.any(|(role, _, _)| *role == egui::accesskit::Role::Button),
"a badge answers nothing and must not claim to: {drawn:?}"
);
}
fn palette() -> Palette {
use egui::Color32;
Palette {
page: Color32::from_rgb(1, 1, 1),
raised: Color32::from_rgb(2, 2, 2),
overlay: Color32::from_rgb(3, 3, 3),
well: Color32::from_rgb(4, 4, 4),
sunken: Color32::from_rgb(5, 5, 5),
bevel_light: Color32::from_rgb(6, 6, 6),
bevel_dark: Color32::from_rgb(7, 7, 7),
elevation: Color32::from_black_alpha(40),
content: Color32::from_rgb(20, 20, 20),
content_secondary: Color32::from_rgb(120, 120, 120),
content_muted: Color32::from_rgb(21, 21, 21),
action: Color32::from_rgb(22, 22, 22),
danger: Color32::from_rgb(23, 23, 23),
success: Color32::from_rgb(24, 24, 24),
warning: Color32::from_rgb(25, 25, 25),
info: Color32::from_rgb(26, 26, 26),
border: Color32::from_rgb(200, 200, 200),
info_surface: Color32::from_rgb(201, 201, 201),
success_surface: Color32::from_rgb(202, 202, 202),
warning_surface: Color32::from_rgb(203, 203, 203),
danger_surface: Color32::from_rgb(204, 204, 204),
row_stripe: Color32::from_rgb(205, 205, 205),
row_hover: Color32::from_rgb(206, 206, 206),
row_rule: Color32::from_rgb(207, 207, 207),
row_selected: Color32::from_rgb(208, 208, 208),
}
}
#[test]
fn every_tone_resolves_and_no_two_share_a_colour() {
let p = palette();
let all = [
p.tone(Tone::Neutral),
p.tone(Tone::Info),
p.tone(Tone::Success),
p.tone(Tone::Warning),
p.tone(Tone::Danger),
];
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(a, b, "two tones resolved to one colour");
}
}
assert_eq!(p.tone(Tone::Neutral), p.content, "neutral is ordinary text");
}
#[test]
fn a_meter_draws_and_an_overrun_does_not_panic() {
let p = palette();
let style = WidgetStyle::default();
egui::__run_test_ui(|ui| {
meter(ui, &Meter::new(3, 6), &p, &style);
meter(ui, &Meter::new(9, 6), &p, &style);
meter(ui, &Meter::new(0, 0), &p, &style);
meter(ui, &Meter::new(u32::MAX, u32::MAX), &p, &style);
});
}
#[test]
fn a_wait_draws_a_bar_only_when_something_is_counting_it() {
let p = palette();
let style = WidgetStyle::default();
egui::__run_test_ui(|ui| {
awaiting(
ui,
Awaiting::unmeasured(),
Progress::default(),
false,
&p,
&style,
);
awaiting(
ui,
Awaiting::of(41_943_040),
Progress::default(),
false,
&p,
&style,
);
awaiting(
ui,
Awaiting::of(41_943_040),
Progress {
delivered: Some(10_485_760),
elapsed: Some(Duration::from_secs(3)),
},
false,
&p,
&style,
);
awaiting(
ui,
Awaiting::of(0),
Progress {
delivered: Some(9),
elapsed: None,
},
false,
&p,
&style,
);
awaiting(
ui,
Awaiting::of(4),
Progress {
delivered: Some(9),
elapsed: None,
},
false,
&p,
&style,
);
});
}
#[test]
fn reduced_motion_stills_the_mark_and_does_not_remove_it() {
let p = palette();
let style = WidgetStyle::default();
egui::__run_test_ui(|ui| {
let still = activity(ui, true, &p, &style);
let blinking = activity(ui, false, &p, &style);
assert_eq!(
still.rect.size(),
blinking.rect.size(),
"the mark occupies the same space either way"
);
});
}
#[test]
fn a_chip_answers_a_click_and_a_badge_does_not() {
let p = palette();
let style = WidgetStyle::default();
egui::__run_test_ui(|ui| {
let badge = token(ui, "beta", Token::Badge, Tone::Info, false, &p, &style);
assert!(!badge.sense.senses_click(), "a badge answers no click");
let chip = token(
ui,
"drums",
Token::Chip { removable: false },
Tone::Neutral,
false,
&p,
&style,
);
assert!(chip.sense.senses_click(), "a chip answers a click");
});
}
#[test]
fn a_disabled_control_is_drawn_and_does_not_answer() {
let p = palette();
let style = WidgetStyle::default();
egui::__run_test_ui(|ui| {
let live = act(ui, &Act::new("Save"), &p, &style);
assert!(live.enabled());
let gone = act(ui, &Act::new("Save").state(State::Disabled), &p, &style);
assert!(!gone.enabled(), "a disabled control still answers");
});
}
#[test]
fn a_control_shows_the_key_the_description_named() {
let p = palette();
let style = WidgetStyle::default();
egui::__run_test_ui(|ui| {
act(ui, &Act::new("New").key("n"), &p, &style);
act(ui, &Act::new("New"), &p, &style);
});
}
#[test]
fn a_figure_draws_its_movement_beside_its_value() {
let p = palette();
let style = WidgetStyle::default();
egui::__run_test_ui(|ui| {
figure(ui, &Figure::new("17", "Current streak"), &p, &style);
figure(
ui,
&Figure::new("17", "Current streak")
.change("+3")
.tone(Tone::Success),
&p,
&style,
);
});
}
}