#![forbid(unsafe_code)]
use egui::{
Color32, ComboBox, CornerRadius, Margin, Painter, Rect, Response, RichText, Shape, Slider,
Stroke, TextEdit, Ui,
};
use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State, Tone};
use std::ops::RangeInclusive;
pub mod table;
pub mod widget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
pub page: Color32,
pub raised: Color32,
pub overlay: Color32,
pub well: Color32,
pub sunken: Color32,
pub bevel_light: Color32,
pub bevel_dark: Color32,
pub elevation: Color32,
pub content: Color32,
pub content_secondary: Color32,
pub content_muted: Color32,
pub action: Color32,
pub danger: Color32,
pub success: Color32,
pub warning: Color32,
pub info: Color32,
}
impl Palette {
#[must_use]
pub const fn fill(&self, fill: Fill) -> Option<Color32> {
match fill {
Fill::Page => Some(self.page),
Fill::Raised => Some(self.raised),
Fill::Overlay => Some(self.overlay),
Fill::Well => Some(self.well),
Fill::Sunken => Some(self.sunken),
_ => None,
}
}
#[must_use]
pub const fn tone(&self, tone: Tone) -> Color32 {
match tone {
Tone::Neutral => self.content,
Tone::Info => self.info,
Tone::Success => self.success,
Tone::Warning => self.warning,
Tone::Danger => self.danger,
}
}
#[must_use]
pub const fn cast(&self) -> egui::Shadow {
egui::Shadow {
offset: [0, 2],
blur: 24,
spread: 0,
color: self.elevation,
}
}
#[must_use]
pub const fn edge(&self, edge: Edge) -> Color32 {
match edge {
Edge::Light => self.bevel_light,
Edge::Dark => self.bevel_dark,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrameStyle {
pub radius: CornerRadius,
pub margin: Margin,
pub stroke: f32,
}
impl Default for FrameStyle {
fn default() -> Self {
Self {
radius: CornerRadius::ZERO,
margin: Margin::ZERO,
stroke: 1.0,
}
}
}
pub fn paint_bevel(painter: &Painter, rect: Rect, bevel: Bevel, palette: &Palette, stroke: f32) {
let (top_left, bottom_right) = bevel.edges();
let r = rect.shrink(stroke / 2.0);
painter.add(Shape::line(
vec![r.left_bottom(), r.left_top(), r.right_top()],
Stroke::new(stroke, palette.edge(top_left)),
));
painter.add(Shape::line(
vec![r.right_top(), r.right_bottom(), r.left_bottom()],
Stroke::new(stroke, palette.edge(bottom_right)),
));
}
pub fn frame<R>(
ui: &mut Ui,
depth: Depth,
palette: &Palette,
style: FrameStyle,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> R {
let mut f = egui::Frame::new()
.corner_radius(style.radius)
.inner_margin(style.margin);
if let Some(fill) = depth.fill().and_then(|f| palette.fill(f)) {
f = f.fill(fill);
}
if depth.fill() == Some(Fill::Overlay) {
f = f.shadow(palette.cast());
}
let framed = f.show(ui, add_contents);
if let Some(bevel) = depth.bevel() {
paint_bevel(
ui.painter(),
framed.response.rect,
bevel,
palette,
style.stroke,
);
}
framed.inner
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FieldStyle {
pub frame: FrameStyle,
pub gap: f32,
pub group_gap: f32,
pub required_marker: &'static str,
}
impl Default for FieldStyle {
fn default() -> Self {
Self {
frame: FrameStyle::default(),
gap: 0.0,
group_gap: 0.0,
required_marker: "*",
}
}
}
#[derive(Debug, Default)]
pub enum Filling<'a> {
#[default]
Absent,
Text(&'a mut String),
On(&'a mut bool),
}
fn label_text(field: &Field<'_>, style: &FieldStyle) -> String {
if field.required {
format!("{} {}", field.label, style.required_marker)
} else {
field.label.to_owned()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Control {
Typed,
Chosen,
Listed,
Toggled,
Slid,
}
const fn control_shape(kind: FieldKind) -> Control {
match kind {
FieldKind::Select => Control::Chosen,
FieldKind::Radio => Control::Listed,
FieldKind::Checkbox => Control::Toggled,
FieldKind::Range => Control::Slid,
_ => Control::Typed,
}
}
fn shape_of(field: &Field<'_>) -> Control {
match control_shape(field.kind) {
Control::Slid if !field.bounded() => Control::Typed,
shape => shape,
}
}
fn extent(field: &Field<'_>) -> Option<RangeInclusive<f64>> {
let min = field.min?.parse::<f64>().ok()?;
let max = field.max?.parse::<f64>().ok()?;
Some(min..=max)
}
fn decimals(step: Option<&str>) -> Option<usize> {
let step = step?;
Some(match step.split_once('.') {
Some((_, fraction)) => fraction.trim_end_matches('0').len(),
None => 0,
})
}
fn shown_label<'a>(options: &'a [Choice<'a>], value: &'a str) -> &'a str {
options
.iter()
.find(|opt| opt.value == value)
.map_or(value, |opt| opt.label)
}
fn chosen_text<'a>(field: &'a Field<'a>, value: &'a str, palette: &Palette) -> (&'a str, Color32) {
match field.placeholder {
Some(ghost) if value.is_empty() => (ghost, palette.content_muted),
_ => (shown_label(field.options, value), palette.content),
}
}
fn option_color(value: &str, option: &str, palette: &Palette) -> Color32 {
if value == option {
palette.content
} else {
palette.content_secondary
}
}
fn control(
ui: &mut Ui,
field: &Field<'_>,
filling: Filling<'_>,
palette: &Palette,
style: &FieldStyle,
) -> Response {
let mut discard = String::new();
let mut off = false;
match shape_of(field) {
Control::Slid => {
let value = match filling {
Filling::Text(text) => text,
_ => &mut discard,
};
let Some(extent) = extent(field) else {
return ui.label(RichText::new(value.as_str()).color(palette.content));
};
let mut number = value.parse::<f64>().unwrap_or(*extent.start());
let mut slider = Slider::new(&mut number, extent).text("");
if let Some(places) = decimals(field.step) {
slider = slider.max_decimals(places);
}
if let Some(step) = field.step.and_then(|s| s.parse::<f64>().ok()) {
slider = slider.step_by(step);
}
let response = ui.add(slider);
if response.changed() {
*value = match decimals(field.step) {
Some(places) => format!("{number:.places$}"),
None => number.to_string(),
};
}
response
}
Control::Typed => {
let text = match filling {
Filling::Text(text) => text,
_ => &mut discard,
};
let mut edit = if field.kind.multiline() {
TextEdit::multiline(text)
} else {
TextEdit::singleline(text)
}
.frame(egui::Frame::NONE)
.margin(Margin::ZERO)
.text_color(palette.content)
.password(field.kind.confidential());
if let Some(ghost) = field.placeholder {
edit = edit.hint_text(RichText::new(ghost).color(palette.content_muted));
}
frame(ui, Depth::Well, palette, style.frame, |ui| ui.add(edit))
}
Control::Toggled => {
let on = match filling {
Filling::On(on) => on,
_ => &mut off,
};
ui.checkbox(on, RichText::new(field.label).color(palette.content))
}
Control::Listed => {
let value = match filling {
Filling::Text(text) => text,
_ => &mut discard,
};
let group = ui.vertical(|ui| {
let mut answered: Option<Response> = None;
for opt in field.options {
let picked = if let Some(reason) = opt.unavailable {
ui.horizontal(|ui| {
let picked = ui
.add_enabled_ui(false, |ui| {
ui.radio_value(
value,
opt.value.to_owned(),
RichText::new(opt.label).color(palette.content_muted),
)
})
.inner;
ui.label(RichText::new(reason).color(palette.content_muted));
picked
})
.inner
} else {
ui.radio_value(
value,
opt.value.to_owned(),
RichText::new(opt.label).color(option_color(value, opt.value, palette)),
)
};
answered = Some(match answered {
Some(prev) => prev.union(picked),
None => picked,
});
}
answered
});
group.inner.unwrap_or(group.response)
}
Control::Chosen => {
let value = match filling {
Filling::Text(text) => text,
_ => &mut discard,
};
let (shown, tone) = chosen_text(field, value, palette);
ComboBox::from_id_salt(field.name)
.selected_text(RichText::new(shown).color(tone))
.show_ui(ui, |ui| {
for opt in field.options {
if let Some(reason) = opt.unavailable {
ui.add_enabled_ui(false, |ui| {
ui.selectable_value(
value,
opt.value.to_owned(),
RichText::new(format!("{} {reason}", opt.label))
.color(palette.content_muted),
);
});
continue;
}
ui.selectable_value(
value,
opt.value.to_owned(),
RichText::new(opt.label).color(option_color(value, opt.value, palette)),
);
}
})
.response
}
}
}
pub fn field(
ui: &mut Ui,
field: &Field<'_>,
filling: Filling<'_>,
state: Option<State>,
palette: &Palette,
style: &FieldStyle,
) -> Option<Response> {
if !field.kind.visible() {
return None;
}
let enabled = !state.is_some_and(State::suppresses_interaction);
let text = if enabled {
palette.content
} else {
palette.content_muted
};
let response = ui
.vertical(|ui| {
ui.spacing_mut().item_spacing.y = style.gap;
if !field.kind.labels_itself() {
ui.label(RichText::new(label_text(field, style)).color(text));
}
let response = ui
.add_enabled_ui(enabled, |ui| control(ui, field, filling, palette, style))
.inner;
if let Some(hint) = field.hint {
ui.label(RichText::new(hint).color(palette.content_muted));
}
if let Some(error) = field.error {
ui.label(RichText::new(error).color(palette.danger));
}
response
})
.inner;
Some(response)
}
pub fn group<'a>(
ui: &mut Ui,
fields: &'a [Field<'a>],
show_extended: bool,
style: &FieldStyle,
mut draw: impl FnMut(&mut Ui, &'a Field<'a>),
) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = style.group_gap;
for f in fields {
if f.extended && !show_extended {
continue;
}
draw(ui, f);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn palette(well: Color32) -> Palette {
Palette {
page: Color32::from_rgb(1, 1, 1),
raised: Color32::from_rgb(2, 2, 2),
overlay: Color32::from_rgb(3, 3, 3),
well,
sunken: Color32::from_rgb(4, 4, 4),
bevel_light: Color32::WHITE,
bevel_dark: Color32::BLACK,
elevation: Color32::from_black_alpha(46),
content: Color32::from_rgb(5, 5, 5),
content_secondary: Color32::from_rgb(55, 55, 55),
content_muted: Color32::from_rgb(6, 6, 6),
action: Color32::from_rgb(7, 7, 7),
danger: Color32::from_rgb(8, 8, 8),
success: Color32::from_rgb(9, 9, 9),
warning: Color32::from_rgb(10, 10, 10),
info: Color32::from_rgb(11, 11, 11),
}
}
#[test]
fn the_cast_hands_egui_the_themes_tone() {
let p = palette(Color32::from_rgb(9, 9, 9));
let cast = p.cast();
assert_eq!(cast.color, p.elevation);
assert!(cast.blur > 0, "a cast shadow is soft");
assert_eq!(cast.offset, [0, 2], "it falls downward and only a little");
}
#[test]
fn a_well_resolves_to_its_own_token() {
let w = Color32::from_rgb(9, 9, 9);
let p = palette(w);
assert_eq!(p.fill(Fill::Well), Some(w));
assert_ne!(p.fill(Fill::Well), Some(p.page));
}
#[test]
fn every_intent_is_a_plain_lookup() {
let p = palette(Color32::from_rgb(9, 9, 9));
assert_eq!(p.fill(Fill::Page), Some(p.page));
assert_eq!(p.fill(Fill::Raised), Some(p.raised));
assert_eq!(p.fill(Fill::Overlay), Some(p.overlay));
}
#[test]
fn sunken_is_neither_the_well_nor_the_page() {
let p = palette(Color32::from_rgb(9, 9, 9));
assert_eq!(p.fill(Fill::Sunken), Some(p.sunken));
assert_ne!(p.fill(Fill::Sunken), p.fill(Fill::Well));
assert_ne!(p.fill(Fill::Sunken), p.fill(Fill::Page));
}
#[test]
fn a_raised_region_never_resolves_to_the_well_fill() {
let p = palette(Color32::from_rgb(9, 9, 9));
let raised = Depth::Raised.fill().and_then(|f| p.fill(f));
let well = Depth::Well.fill().and_then(|f| p.fill(f));
assert_eq!(raised, Some(p.raised));
assert_ne!(raised, well);
}
#[test]
fn an_overlay_is_cast_onto_the_page_and_takes_no_edge() {
let p = palette(Color32::from_rgb(9, 9, 9));
assert_eq!(
Depth::Overlay.fill().and_then(|f| p.fill(f)),
Some(p.overlay)
);
assert_eq!(Depth::Overlay.bevel(), None);
assert_eq!(p.cast().color, p.elevation);
}
#[test]
fn the_lit_edge_swaps_when_a_card_is_pressed() {
let p = palette(Color32::from_rgb(9, 9, 9));
let (tl, _) = Depth::Raised.bevel().unwrap().edges();
let (ptl, _) = Depth::Raised.pressed().bevel().unwrap().edges();
assert_eq!(p.edge(tl), p.bevel_light);
assert_eq!(p.edge(ptl), p.bevel_dark);
}
#[test]
fn flat_asks_for_neither_fill_nor_edge() {
assert!(Depth::Flat.fill().is_none());
assert!(Depth::Flat.bevel().is_none());
}
#[test]
fn a_select_keeps_a_value_none_of_its_options_carries() {
let options = [
Choice::plain("1"),
Choice::plain("3"),
Choice::plain("7"),
Choice::plain("14"),
];
assert_eq!(shown_label(&options, "10"), "10");
let spelled = [Choice::new("7", "One week")];
assert_eq!(shown_label(&spelled, "7"), "One week");
}
#[test]
fn an_unanswered_chooser_reads_its_ghost_text_and_reads_it_muted() {
let p = palette(Color32::from_rgb(9, 9, 9));
let options = [Choice::new("sp404", "SP-404")];
let field = Field {
placeholder: Some("Select device..."),
..Field::select("device", "Conform for device", &options)
};
assert_eq!(
chosen_text(&field, "", &p),
("Select device...", p.content_muted),
"ghost text is not an answer, so it takes the tone the typed kinds' ghost text does"
);
assert_eq!(chosen_text(&field, "sp404", &p), ("SP-404", p.content));
}
#[test]
fn a_wrong_answer_is_not_an_absent_one() {
let p = palette(Color32::from_rgb(9, 9, 9));
let options = [Choice::plain("1"), Choice::plain("7")];
let field = Field {
placeholder: Some("Pick one"),
..Field::select("retention", "Keep backups for", &options)
};
assert_eq!(chosen_text(&field, "10", &p), ("10", p.content));
}
#[test]
fn a_chooser_with_no_ghost_text_is_unchanged() {
let p = palette(Color32::from_rgb(9, 9, 9));
let options = [Choice::plain("1")];
let field = Field::select("retention", "Keep backups for", &options);
assert_eq!(chosen_text(&field, "", &p), ("", p.content));
}
#[test]
fn a_range_is_slid_and_a_number_is_typed_into() {
assert_eq!(control_shape(FieldKind::Range), Control::Slid);
assert_eq!(control_shape(FieldKind::Number), Control::Typed);
}
#[test]
fn a_range_missing_an_end_falls_back_to_a_well() {
let whole = Field::range("review", "Review above", "0", "1");
assert_eq!(shape_of(&whole), Control::Slid);
let half = Field {
max: Some("1"),
..Field::new(FieldKind::Range, "review", "Review above")
};
assert_eq!(shape_of(&half), Control::Typed);
assert_eq!(extent(&half), None);
let dated = Field::range("when", "When", "2026-08-01", "2026-08-31");
assert_eq!(extent(&dated), None);
}
#[test]
fn the_step_decides_how_a_dragged_value_is_written_back() {
assert_eq!(decimals(None), None);
assert_eq!(decimals(Some("1")), Some(0));
assert_eq!(decimals(Some("0.01")), Some(2));
assert_eq!(decimals(Some("0.10")), Some(1));
}
#[test]
fn an_unavailable_option_is_drawn_muted_rather_than_dropped() {
let p = palette(Color32::from_rgb(9, 9, 9));
let options = [
Choice::new("chromatic", "Chromatic"),
Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
];
assert!(options[0].available());
assert!(!options[1].available());
assert_eq!(
option_color("chromatic", options[0].value, &p),
p.content,
"the chosen option is the emphasised thing"
);
assert_eq!(
option_color("chromatic", options[1].value, &p),
p.content_secondary,
"and `option_color` never mutes: the unavailable path is what does"
);
}
#[test]
fn only_a_required_field_is_marked() {
let style = FieldStyle::default();
let plain = Field::new(FieldKind::Text, "title", "Title");
assert_eq!(label_text(&plain, &style), "Title");
let required = Field {
required: true,
..plain
};
assert_eq!(label_text(&required, &style), "Title *");
let house = FieldStyle {
required_marker: "(required)",
..style
};
assert_eq!(label_text(&required, &house), "Title (required)");
}
#[test]
fn a_select_and_a_checkbox_are_pressed_and_everything_else_is_typed_into() {
assert_eq!(control_shape(FieldKind::Select), Control::Chosen);
assert_eq!(control_shape(FieldKind::Radio), Control::Listed);
assert_eq!(control_shape(FieldKind::Checkbox), Control::Toggled);
for k in [
FieldKind::Text,
FieldKind::Secret,
FieldKind::Number,
FieldKind::Email,
FieldKind::Url,
FieldKind::Tel,
FieldKind::Textarea,
FieldKind::Rich,
] {
assert_eq!(control_shape(k), Control::Typed, "{k:?} is typed into");
}
assert!(FieldKind::Rich.multiline());
assert!(FieldKind::Textarea.multiline());
assert!(!FieldKind::Text.multiline());
}
#[test]
fn the_two_option_taking_kinds_are_drawn_differently_on_purpose() {
assert!(FieldKind::Select.offers_options());
assert!(FieldKind::Radio.offers_options());
assert_ne!(
control_shape(FieldKind::Select),
control_shape(FieldKind::Radio)
);
}
#[test]
fn an_unchosen_option_is_secondary_and_never_muted() {
let p = palette(Color32::from_rgb(4, 4, 4));
assert_eq!(option_color("wav", "wav", &p), p.content);
assert_eq!(option_color("wav", "aiff", &p), p.content_secondary);
assert_ne!(option_color("wav", "aiff", &p), p.content_muted);
}
#[test]
fn a_hidden_field_draws_nothing_and_answers_nothing() {
let f = Field::new(FieldKind::Hidden, "id", "Id");
let p = palette(Color32::from_rgb(9, 9, 9));
egui::__run_test_ui(|ui| {
let drawn = field(ui, &f, Filling::Absent, None, &p, &FieldStyle::default());
assert!(drawn.is_none());
});
}
#[test]
fn a_disabled_field_stops_answering_and_an_unstated_one_does_not() {
let f = Field::new(FieldKind::Text, "title", "Title");
let p = palette(Color32::from_rgb(9, 9, 9));
let style = FieldStyle::default();
egui::__run_test_ui(|ui| {
let mut text = String::from("x");
let disabled = field(
ui,
&f,
Filling::Text(&mut text),
Some(State::Disabled),
&p,
&style,
)
.unwrap();
assert!(!disabled.enabled());
let mut text = String::from("x");
let plain = field(ui, &f, Filling::Text(&mut text), None, &p, &style).unwrap();
assert!(plain.enabled(), "an unstated field still answers");
});
}
#[test]
fn a_field_described_one_way_and_filled_another_is_drawn_inert() {
let f = Field::new(FieldKind::Checkbox, "done", "Done");
let p = palette(Color32::from_rgb(9, 9, 9));
let mut text = String::from("untouched");
egui::__run_test_ui(|ui| {
let drawn = field(
ui,
&f,
Filling::Text(&mut text),
None,
&p,
&FieldStyle::default(),
);
assert!(drawn.is_some());
});
assert_eq!(text, "untouched");
}
#[test]
fn the_disclosure_belongs_to_the_form_and_not_to_the_field() {
let fields = [
Field::new(FieldKind::Text, "title", "Title"),
Field {
extended: true,
..Field::new(FieldKind::Text, "notes", "Notes")
},
];
let style = FieldStyle::default();
let mut closed = Vec::new();
egui::__run_test_ui(|ui| {
group(ui, &fields, false, &style, |_, f| closed.push(f.name));
});
assert_eq!(closed, ["title"]);
let mut open = Vec::new();
egui::__run_test_ui(|ui| {
group(ui, &fields, true, &style, |_, f| open.push(f.name));
});
assert_eq!(open, ["title", "notes"]);
}
#[test]
fn the_default_frame_is_square_and_one_point() {
let d = FrameStyle::default();
assert_eq!(d.radius, CornerRadius::ZERO);
assert_eq!(d.margin, Margin::ZERO);
assert!((d.stroke - 1.0).abs() < f32::EPSILON);
}
}