#![forbid(unsafe_code)]
use egui::{
Color32, ComboBox, CornerRadius, Margin, Painter, Rect, Response, RichText, Shape, Stroke,
TextEdit, Ui,
};
use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State};
#[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 content: Color32,
pub content_muted: Color32,
pub danger: 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 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);
}
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,
}
const fn control_shape(kind: FieldKind) -> Control {
match kind {
FieldKind::Select => Control::Chosen,
FieldKind::Radio => Control::Listed,
FieldKind::Checkbox => Control::Toggled,
_ => Control::Typed,
}
}
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 control(
ui: &mut Ui,
field: &Field<'_>,
filling: Filling<'_>,
palette: &Palette,
style: &FieldStyle,
) -> Response {
let mut discard = String::new();
let mut off = false;
match control_shape(field.kind) {
Control::Typed => {
let text = match filling {
Filling::Text(text) => text,
_ => &mut discard,
};
let mut edit = if matches!(field.kind, FieldKind::Textarea) {
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 = ui.radio_value(
value,
opt.value.to_owned(),
RichText::new(opt.label).color(palette.content),
);
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 = shown_label(field.options, value);
ComboBox::from_id_salt(field.name)
.selected_text(RichText::new(shown).color(palette.content))
.show_ui(ui, |ui| {
for opt in field.options {
ui.selectable_value(
value,
opt.value.to_owned(),
RichText::new(opt.label).color(palette.content),
);
}
})
.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,
content: Color32::from_rgb(5, 5, 5),
content_muted: Color32::from_rgb(6, 6, 6),
danger: Color32::from_rgb(7, 7, 7),
}
}
#[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 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 {
value: "7",
label: "One week",
}];
assert_eq!(shown_label(&spelled, "7"), "One week");
}
#[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,
] {
assert_eq!(control_shape(k), Control::Typed, "{k:?} is typed into");
}
}
#[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 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_a_focused_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 focused = field(
ui,
&f,
Filling::Text(&mut text),
Some(State::Focus),
&p,
&style,
)
.unwrap();
assert!(focused.enabled(), "focus is a thing you can still click");
});
}
#[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);
}
}