use egui::{Align, Layout, Response, RichText, Sense, Ui, Vec2};
use makeover_layout::{Act, Figure, Meter, State, Token, Tone};
use crate::Palette;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WidgetStyle {
pub meter_height: f32,
pub meter_width: Option<f32>,
pub radius: u8,
pub token_padding: Vec2,
pub figure_gap: f32,
pub figure_scale: f32,
}
impl Default for WidgetStyle {
fn default() -> Self {
Self {
meter_height: 6.0,
meter_width: None,
radius: 3,
token_padding: Vec2::new(6.0, 2.0),
figure_gap: 2.0,
figure_scale: 2.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 = match kind {
Token::Badge => u8::MAX,
Token::Chip { .. } => style.radius,
};
let sense = if kind.interactive() {
Sense::click()
} else {
Sense::hover()
};
let ink = if latched { palette.page } else { 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 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);
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)
};
ui.add_enabled(
!disabled,
egui::Button::new(RichText::new(label).color(colour)),
)
}
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
}
#[cfg(test)]
mod tests {
use super::*;
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),
}
}
#[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_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,
);
});
}
}