use iced::font::Weight;
use iced::{Color, Font, Theme, color};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Palette {
pub ink: Color,
pub bark: Color,
pub parchment: Color,
pub slate: Color,
pub jacket: Color,
pub jacket_hover: Color,
pub boot: Color,
pub moss: Color,
pub edge: Color,
pub scrim: Color,
}
pub const DARK: Palette = Palette {
ink: color!(0x1B1D1A),
bark: color!(0x242723),
parchment: color!(0xE8E4D9),
slate: color!(0x8B9187),
jacket: color!(0x5E94D6),
jacket_hover: color!(0x7CA9E0),
boot: color!(0xE3B23C),
moss: color!(0x5F9169),
edge: color!(0x3A3E37),
scrim: Color {
a: 0.8,
..color!(0x11120F)
},
};
pub const LIGHT: Palette = Palette {
ink: color!(0xF5F1E6),
bark: color!(0xEBE6D8),
parchment: color!(0x1B1D1A),
slate: color!(0x5C6258),
jacket: color!(0x2E66A8),
jacket_hover: color!(0x245489),
boot: color!(0x8A5F00),
moss: color!(0x3B6B46),
edge: color!(0xCFC8B6),
scrim: Color {
a: 0.4,
..color!(0x1B1D1A)
},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Swatch {
Ink,
Bark,
Parchment,
Slate,
Boot,
Moss,
Edge,
Scrim,
}
impl Palette {
pub fn get(&self, swatch: Swatch) -> Color {
match swatch {
Swatch::Ink => self.ink,
Swatch::Bark => self.bark,
Swatch::Parchment => self.parchment,
Swatch::Slate => self.slate,
Swatch::Boot => self.boot,
Swatch::Moss => self.moss,
Swatch::Edge => self.edge,
Swatch::Scrim => self.scrim,
}
}
}
pub fn palette(theme: &Theme) -> &'static Palette {
if theme.extended_palette().is_dark {
&DARK
} else {
&LIGHT
}
}
pub fn theme(mode: iced::theme::Mode) -> Theme {
let (name, p) = match mode {
iced::theme::Mode::Light => ("Bombadil Light", &LIGHT),
_ => ("Bombadil", &DARK),
};
Theme::custom(
name.to_string(),
iced::theme::Palette {
background: p.ink,
text: p.parchment,
primary: p.jacket,
success: p.moss,
warning: p.boot,
danger: p.boot,
},
)
}
pub const LABEL: f32 = 12.0;
pub const DATA: f32 = 13.0;
pub const BODY: f32 = 14.0;
pub const TITLE: f32 = 18.0;
pub const DISPLAY: f32 = 26.0;
pub const SPACE_BASE: f32 = 4.0;
pub const SPACE_1: f32 = SPACE_BASE;
pub const SPACE_2: f32 = SPACE_BASE * 2.0;
pub const SPACE_3: f32 = SPACE_BASE * 4.0;
pub const SPACE_4: f32 = SPACE_BASE * 6.0;
pub const HANDLE_WIDTH: f32 = 6.0;
pub const RAIL_WIDTH: f32 = 44.0;
pub const TAB_UNDERLINE: f32 = 2.0;
pub const TOPBAR_HEIGHT: f32 = 44.0;
pub const HAIRLINE: f32 = 1.0;
pub const RADIUS: f32 = 6.0;
pub const RADIUS_SURFACE: f32 = 10.0;
pub const RADIUS_PILL: f32 = 999.0;
pub const SELECTED_MARKER: f32 = 3.0;
pub const MODAL_WIDTH: f32 = 680.0;
pub const MODAL_BODY_HEIGHT: f32 = 420.0;
pub const OVERLAY_MAX_HEIGHT: f32 = 560.0;
pub const DRAWER_MAX_HEIGHT: f32 = 220.0;
pub const INDEX_LABEL_WIDTH: f32 = 72.0;
pub const PREFS_WIDTH: f32 = 860.0;
pub const PREFS_BODY_HEIGHT: f32 = 420.0;
pub const PREFS_SECTION_WIDTH: f32 = 150.0;
pub const CONTEXT_MENU_WIDTH: f32 = 320.0;
pub const MENU_WIDTH: f32 = 220.0;
pub const TREE_ROW_HEIGHT: f32 = 38.0;
pub const PROJECT_ROW_HEIGHT: f32 = 52.0;
pub const DISCLOSURE_WIDTH: f32 = 16.0;
pub const GLYPH_COLUMN_WIDTH: f32 = 20.0;
pub const DEP_NAME_COLUMN_WIDTH: f32 = 200.0;
pub const DEP_CONSTRAINT_COLUMN_WIDTH: f32 = 100.0;
pub const DEP_VERSION_COLUMN_WIDTH: f32 = 220.0;
pub const FONT_PROSE: Font = Font::with_name("Atkinson Hyperlegible Next");
pub const FONT_PROSE_SEMIBOLD: Font = Font {
weight: Weight::Semibold,
..FONT_PROSE
};
pub const FONT_DATA: Font = Font::with_name("IBM Plex Mono");
pub const ATKINSON_REGULAR: &[u8] = include_bytes!("../fonts/AtkinsonHyperlegibleNext-Regular.ttf");
pub const ATKINSON_SEMIBOLD: &[u8] =
include_bytes!("../fonts/AtkinsonHyperlegibleNext-SemiBold.ttf");
pub const PLEX_MONO_REGULAR: &[u8] = include_bytes!("../fonts/IBMPlexMono-Regular.ttf");
use iced::widget::text;
pub fn tinted(swatch: Swatch) -> impl Fn(&Theme) -> text::Style {
move |theme| text::Style {
color: Some(palette(theme).get(swatch)),
}
}
pub fn text_muted(theme: &Theme) -> text::Style {
tinted(Swatch::Slate)(theme)
}
pub fn text_warning(theme: &Theme) -> text::Style {
tinted(Swatch::Boot)(theme)
}
pub fn fill(swatch: Swatch) -> impl Fn(&Theme) -> iced::widget::container::Style {
move |theme| iced::widget::container::Style {
background: Some(iced::Background::Color(palette(theme).get(swatch))),
..iced::widget::container::Style::default()
}
}
pub fn surface(theme: &Theme) -> iced::widget::container::Style {
let p = palette(theme);
iced::widget::container::Style {
background: Some(iced::Background::Color(p.bark)),
..iced::widget::container::Style::default()
}
}
pub fn panel(theme: &Theme) -> iced::widget::container::Style {
let p = palette(theme);
iced::widget::container::Style {
background: Some(iced::Background::Color(p.bark)),
border: iced::Border {
color: p.edge,
width: HAIRLINE,
radius: RADIUS_SURFACE.into(),
},
..iced::widget::container::Style::default()
}
}
pub fn hairline_row<'a, Message: 'a>() -> iced::Element<'a, Message> {
iced::widget::container(
iced::widget::Space::new()
.width(iced::Length::Fill)
.height(HAIRLINE),
)
.width(iced::Length::Fill)
.style(fill(Swatch::Edge))
.into()
}
pub fn hairline_column<'a, Message: 'a>() -> iced::Element<'a, Message> {
iced::widget::container(
iced::widget::Space::new()
.width(HAIRLINE)
.height(iced::Length::Fill),
)
.height(iced::Length::Fill)
.style(fill(Swatch::Edge))
.into()
}
pub fn badge<'a, Message: 'a>(label: &'a str) -> iced::Element<'a, Message> {
iced::widget::container(iced::widget::text(label).font(FONT_PROSE).size(LABEL))
.padding([0.0, SPACE_2])
.style(|t| {
let p = palette(t);
iced::widget::container::Style {
text_color: Some(p.jacket),
border: iced::Border {
color: p.jacket,
width: HAIRLINE,
radius: RADIUS_PILL.into(),
},
..iced::widget::container::Style::default()
}
})
.into()
}
pub fn text_field(
theme: &Theme,
status: iced::widget::text_input::Status,
) -> iced::widget::text_input::Style {
use iced::widget::text_input::Status;
let p = palette(theme);
iced::widget::text_input::Style {
background: iced::Background::Color(match status {
Status::Disabled => p.ink,
_ => p.bark,
}),
border: iced::Border {
color: match status {
Status::Focused { .. } => p.jacket,
Status::Hovered => p.slate,
_ => p.edge,
},
width: HAIRLINE,
radius: RADIUS.into(),
},
icon: p.slate,
placeholder: p.slate,
value: p.parchment,
selection: Color {
a: 0.35,
..p.jacket
},
}
}
use iced::widget::button;
pub fn button_primary(theme: &Theme, status: button::Status) -> button::Style {
let p = palette(theme);
let fill = match status {
button::Status::Hovered | button::Status::Pressed => Some(p.jacket_hover),
button::Status::Disabled => None,
button::Status::Active => Some(p.jacket),
};
button::Style {
background: fill.map(iced::Background::Color),
text_color: if status == button::Status::Disabled {
p.slate
} else {
p.ink
},
border: iced::Border {
radius: RADIUS.into(),
..iced::Border::default()
},
..button::Style::default()
}
}
pub fn button_quiet(theme: &Theme, status: button::Status) -> button::Style {
let p = palette(theme);
button::Style {
background: match status {
button::Status::Hovered | button::Status::Pressed => {
Some(iced::Background::Color(p.bark))
}
_ => None,
},
text_color: match status {
button::Status::Disabled => p.slate,
_ => p.parchment,
},
border: iced::Border {
color: p.edge,
width: match status {
button::Status::Hovered | button::Status::Pressed => HAIRLINE,
_ => 0.0,
},
radius: RADIUS.into(),
},
..button::Style::default()
}
}
pub fn button_secondary(theme: &Theme, status: button::Status) -> button::Style {
let p = palette(theme);
button::Style {
background: match status {
button::Status::Hovered | button::Status::Pressed => {
Some(iced::Background::Color(p.bark))
}
_ => None,
},
text_color: match status {
button::Status::Disabled => p.slate,
_ => p.parchment,
},
border: iced::Border {
color: if status == button::Status::Disabled {
p.edge
} else {
p.slate
},
width: HAIRLINE,
radius: RADIUS.into(),
},
..button::Style::default()
}
}
pub fn button_choice(selected: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme, status| {
let p = palette(theme);
button::Style {
background: match (selected, status) {
(true, _) | (_, button::Status::Hovered) | (_, button::Status::Pressed) => {
Some(iced::Background::Color(p.bark))
}
_ => None,
},
text_color: if selected { p.parchment } else { p.slate },
border: iced::Border {
color: if selected { p.jacket } else { p.edge },
width: if selected { SELECTED_MARKER } else { HAIRLINE },
radius: RADIUS.into(),
},
..button::Style::default()
}
}
}
pub fn button_danger(theme: &Theme, status: button::Status) -> button::Style {
let p = palette(theme);
button::Style {
background: match status {
button::Status::Disabled => None,
_ => Some(iced::Background::Color(p.boot)),
},
text_color: if status == button::Status::Disabled {
p.slate
} else {
p.ink
},
border: iced::Border {
radius: RADIUS.into(),
..iced::Border::default()
},
..button::Style::default()
}
}
pub fn button_bare(resting: Swatch) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme, status| {
let p = palette(theme);
button::Style {
background: match status {
button::Status::Hovered | button::Status::Pressed => {
Some(iced::Background::Color(p.bark))
}
_ => None,
},
text_color: match status {
button::Status::Hovered | button::Status::Pressed => p.parchment,
button::Status::Disabled | button::Status::Active => p.get(resting),
},
border: iced::Border {
radius: RADIUS.into(),
..iced::Border::default()
},
..button::Style::default()
}
}
}
#[cfg(test)]
fn relative_luminance(colour: Color) -> f32 {
fn channel(c: f32) -> f32 {
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * channel(colour.r) + 0.7152 * channel(colour.g) + 0.0722 * channel(colour.b)
}
#[cfg(test)]
pub fn contrast_ratio(a: Color, b: Color) -> f32 {
let (light, dark) = {
let (la, lb) = (relative_luminance(a), relative_luminance(b));
if la > lb { (la, lb) } else { (lb, la) }
};
(light + 0.05) / (dark + 0.05)
}
pub fn labeled_value<'a, Message: 'a>(
label: &'static str,
value: String,
) -> iced::Element<'a, Message> {
iced::widget::row![
iced::widget::text(label).size(BODY).style(text_muted),
iced::widget::text(value)
.font(FONT_DATA)
.size(DATA)
.style(text_muted),
]
.spacing(SPACE_1)
.into()
}
pub fn labeled_prose<'a, Message: 'a>(
label: &'static str,
value: &'static str,
) -> iced::Element<'a, Message> {
iced::widget::row![
iced::widget::text(label).size(BODY).style(text_muted),
iced::widget::text(value)
.font(FONT_PROSE)
.size(BODY)
.style(text_muted),
]
.spacing(SPACE_1)
.into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum State {
Present,
Absent,
Drifted,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Glyph {
pub glyph: char,
pub swatch: Swatch,
}
pub fn state_glyph(state: State) -> Glyph {
match state {
State::Present => Glyph {
glyph: '\u{25cf}', swatch: Swatch::Moss,
},
State::Absent => Glyph {
glyph: '\u{25cb}', swatch: Swatch::Slate,
},
State::Drifted => Glyph {
glyph: '\u{25cd}', swatch: Swatch::Boot,
},
}
}
pub fn state_meaning(state: State) -> &'static str {
match state {
State::Present => "Present and up to date",
State::Absent => "Not there yet: no environment, or not installed",
State::Drifted => "There, but out of step with what is declared",
}
}
pub fn tip<'a, M: 'a>(
content: impl Into<iced::Element<'a, M>>,
text: impl Into<String>,
position: iced::widget::tooltip::Position,
) -> iced::Element<'a, M> {
iced::widget::tooltip(
content,
iced::widget::container(iced::widget::text(text.into()).size(LABEL))
.padding(SPACE_1)
.style(panel),
position,
)
.into()
}
pub fn wait_tip<'a, M: 'a>(
content: impl Into<iced::Element<'a, M>>,
waiting: Option<&str>,
) -> iced::Element<'a, M> {
match waiting {
Some(label) => tip(
content,
format!("Wait for {label} to finish"),
iced::widget::tooltip::Position::Bottom,
),
None => content.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_state_says_what_it_means() {
let all = [State::Present, State::Absent, State::Drifted].map(state_meaning);
for (i, a) in all.iter().enumerate() {
assert!(!a.trim().is_empty());
assert!(!all[i + 1..].contains(a), "{a} is said twice");
}
}
#[test]
fn each_state_gets_a_distinct_glyph_and_swatch() {
let all = [State::Present, State::Absent, State::Drifted].map(state_glyph);
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(a.glyph, b.glyph);
assert_ne!(a.swatch, b.swatch);
}
}
}
#[test]
fn only_the_drifted_state_uses_the_attention_colour() {
assert_eq!(state_glyph(State::Drifted).swatch, Swatch::Boot);
assert_ne!(state_glyph(State::Present).swatch, Swatch::Boot);
assert_ne!(state_glyph(State::Absent).swatch, Swatch::Boot);
}
#[test]
fn a_state_glyph_is_legible_as_a_graphic_in_both_palettes() {
for (mode, p) in PALETTES {
for state in [State::Present, State::Absent, State::Drifted] {
for (ground_name, ground) in [("ink", p.ink), ("bark", p.bark)] {
let ratio = contrast_ratio(p.get(state_glyph(state).swatch), ground);
assert!(
ratio >= 3.0,
"{mode}: the {state:?} glyph is {ratio:.2}:1 on {ground_name}"
);
}
}
}
}
#[test]
fn text_styles_follow_the_theme() {
let dark = theme(iced::theme::Mode::Dark);
let light = theme(iced::theme::Mode::Light);
assert_eq!(text_muted(&dark).color, Some(DARK.slate));
assert_eq!(text_muted(&light).color, Some(LIGHT.slate));
assert_eq!(tinted(Swatch::Boot)(&light).color, Some(LIGHT.boot));
}
fn painted(style: button::Style, ground: Color) -> (Color, Color) {
let bg = match style.background {
Some(iced::Background::Color(c)) => c,
_ => ground,
};
(style.text_color, bg)
}
#[test]
fn a_disabled_button_does_not_look_like_an_enabled_one() {
type Role = fn(&Theme, button::Status) -> button::Style;
for mode in [iced::theme::Mode::Dark, iced::theme::Mode::Light] {
let t = theme(mode);
for (role, style) in [
("primary", button_primary as Role),
("secondary", button_secondary),
("quiet", button_quiet),
("danger", button_danger),
] {
assert_ne!(
style(&t, button::Status::Active).text_color,
style(&t, button::Status::Disabled).text_color,
"{mode:?}: a disabled {role} button looks enabled"
);
}
}
}
#[test]
fn every_role_is_legible_in_every_state() {
use button::Status::*;
type Role = Box<dyn Fn(&Theme, button::Status) -> button::Style>;
for (mode_name, mode) in [
("dark", iced::theme::Mode::Dark),
("light", iced::theme::Mode::Light),
] {
let t = theme(mode);
let p = palette(&t);
let roles: [(&str, Role); 6] = [
("primary", Box::new(button_primary)),
("secondary", Box::new(button_secondary)),
("quiet", Box::new(button_quiet)),
("danger", Box::new(button_danger)),
("choice/on", Box::new(button_choice(true))),
("choice/off", Box::new(button_choice(false))),
];
for (role, style) in &roles {
for status in [Active, Hovered, Pressed, Disabled] {
for (ground_name, ground) in [("ink", p.ink), ("bark", p.bark)] {
let (fg, bg) = painted(style(&t, status), ground);
let ratio = contrast_ratio(fg, bg);
assert!(
ratio >= 4.5,
"{mode_name}: {role} {status:?} on {ground_name} is {ratio:.2}:1"
);
}
}
}
}
}
#[test]
fn the_type_scale_is_strictly_ordered() {
let steps = [LABEL, DATA, BODY, TITLE, DISPLAY];
for pair in steps.windows(2) {
assert!(
pair[0] < pair[1],
"{:?} must be smaller than {:?}",
pair[0],
pair[1]
);
}
}
const PALETTES: [(&str, &Palette); 2] = [("dark", &DARK), ("light", &LIGHT)];
#[test]
fn every_text_colour_is_legible_on_every_ground_in_both_palettes() {
for (mode, p) in PALETTES {
for (name, colour) in [
("parchment", p.parchment),
("slate", p.slate),
("jacket", p.jacket),
("boot", p.boot),
] {
for (ground_name, ground) in [("ink", p.ink), ("bark", p.bark)] {
let ratio = contrast_ratio(colour, ground);
assert!(
ratio >= 4.5,
"{mode}: {name} on {ground_name} is {ratio:.2}:1, below WCAG AA (4.5:1)"
);
}
}
}
}
#[test]
fn text_on_a_filled_button_is_legible_in_both_palettes() {
for (mode, p) in PALETTES {
for (name, fill) in [
("jacket", p.jacket),
("jacket_hover", p.jacket_hover),
("boot", p.boot),
] {
let ratio = contrast_ratio(p.ink, fill);
assert!(ratio >= 4.5, "{mode}: ink on {name} is {ratio:.2}:1");
}
assert!(
contrast_ratio(p.parchment, p.jacket) < 4.5,
"{mode}: parchment on jacket clears AA now -- revisit why filled buttons use ink"
);
}
}
#[test]
fn the_hairline_is_visible_in_both_palettes() {
for (mode, p) in PALETTES {
for (name, ground) in [("ink", p.ink), ("bark", p.bark)] {
let ratio = contrast_ratio(p.edge, ground);
assert!(
ratio >= 1.2,
"{mode}: the hairline is {ratio:.2}:1 on {name}"
);
}
}
}
#[test]
fn the_theme_built_for_a_mode_reads_back_as_that_palette() {
assert_eq!(palette(&theme(iced::theme::Mode::Dark)), &DARK);
assert_eq!(palette(&theme(iced::theme::Mode::Light)), &LIGHT);
assert_eq!(palette(&theme(iced::theme::Mode::None)), &DARK);
}
#[test]
fn the_dark_palette_is_unchanged() {
assert_eq!(DARK.ink, color!(0x1B1D1A));
assert_eq!(DARK.parchment, color!(0xE8E4D9));
assert_eq!(DARK.slate, color!(0x8B9187));
assert_eq!(DARK.jacket, color!(0x5E94D6));
}
#[test]
fn spacing_is_a_multiple_of_the_base() {
for s in [SPACE_1, SPACE_2, SPACE_3, SPACE_4] {
assert_eq!(s % SPACE_BASE, 0.0, "{s} is off the 4pt grid");
}
}
}