use egui::{
Color32, Context, CornerRadius, FontFamily, FontId, Margin, Pos2, Shadow, Stroke, Style,
TextStyle, Theme, ThemePreference, Visuals, vec2,
};
#[derive(Clone, Copy)]
pub struct Palette {
pub bg: Color32,
pub bar: Color32,
pub surface: Color32,
pub raised: Color32,
pub border: Color32,
pub text: Color32,
pub text_muted: Color32,
pub text_faint: Color32,
pub accent: Color32,
pub on_accent: Color32,
pub accent_text: Color32,
pub dmd_off: Color32,
pub success: Color32,
pub warning: Color32,
pub error: Color32,
pub info: Color32,
}
impl Palette {
pub const DARK: Palette = Palette {
bg: Color32::from_rgb(16, 16, 21),
bar: Color32::from_rgb(11, 11, 15),
surface: Color32::from_rgb(25, 25, 33),
raised: Color32::from_rgb(36, 37, 48),
border: Color32::from_rgb(48, 49, 63),
text: Color32::from_rgb(236, 234, 242),
text_muted: Color32::from_rgb(164, 162, 180),
text_faint: Color32::from_rgb(110, 109, 124),
accent: Color32::from_rgb(255, 138, 31),
on_accent: Color32::from_rgb(28, 14, 0),
accent_text: Color32::from_rgb(255, 166, 77),
dmd_off: Color32::from_rgb(46, 30, 20),
success: Color32::from_rgb(92, 208, 138),
warning: Color32::from_rgb(242, 193, 78),
error: Color32::from_rgb(242, 95, 92),
info: Color32::from_rgb(76, 201, 240),
};
pub const LIGHT: Palette = Palette {
bg: Color32::from_rgb(243, 242, 247),
bar: Color32::from_rgb(252, 252, 254),
surface: Color32::from_rgb(255, 255, 255),
raised: Color32::from_rgb(232, 231, 239),
border: Color32::from_rgb(211, 210, 222),
text: Color32::from_rgb(23, 22, 29),
text_muted: Color32::from_rgb(84, 82, 98),
text_faint: Color32::from_rgb(134, 132, 148),
accent: Color32::from_rgb(240, 120, 16),
on_accent: Color32::from_rgb(28, 14, 0),
accent_text: Color32::from_rgb(166, 75, 0),
dmd_off: Color32::from_rgb(232, 218, 204),
success: Color32::from_rgb(24, 132, 74),
warning: Color32::from_rgb(168, 112, 0),
error: Color32::from_rgb(196, 48, 44),
info: Color32::from_rgb(18, 118, 168),
};
pub fn of(theme: Theme) -> Palette {
match theme {
Theme::Dark => Self::DARK,
Theme::Light => Self::LIGHT,
}
}
pub fn tint(&self, color: Color32, alpha: u8) -> Color32 {
let a = alpha as f32 / 255.0;
let mix = |c: u8, s: u8| (c as f32 * a + s as f32 * (1.0 - a)).round() as u8;
Color32::from_rgb(
mix(color.r(), self.surface.r()),
mix(color.g(), self.surface.g()),
mix(color.b(), self.surface.b()),
)
}
}
pub fn pal(ctx: &Context) -> Palette {
Palette::of(ctx.theme())
}
pub const RADIUS: u8 = 10;
pub const RADIUS_SMALL: u8 = 6;
pub fn theme_from_db(value: Option<&str>) -> ThemePreference {
match value {
Some("light") => ThemePreference::Light,
Some("dark") => ThemePreference::Dark,
_ => ThemePreference::System,
}
}
pub fn theme_to_db(pref: ThemePreference) -> &'static str {
match pref {
ThemePreference::Light => "light",
ThemePreference::Dark => "dark",
ThemePreference::System => "system",
}
}
pub fn setup(ctx: &Context, preference: ThemePreference) {
ctx.set_style_of(Theme::Dark, style(Theme::Dark));
ctx.set_style_of(Theme::Light, style(Theme::Light));
ctx.set_theme(preference);
}
fn style(theme: Theme) -> Style {
let p = Palette::of(theme);
let mut v = match theme {
Theme::Dark => Visuals::dark(),
Theme::Light => Visuals::light(),
};
v.panel_fill = p.bg;
v.window_fill = p.surface;
v.window_stroke = Stroke::new(1.0, p.border);
v.window_corner_radius = CornerRadius::same(RADIUS);
v.window_shadow = Shadow {
offset: [0, 8],
blur: 24,
spread: 0,
color: Color32::from_black_alpha(if theme == Theme::Dark { 140 } else { 40 }),
};
v.popup_shadow = Shadow {
offset: [0, 4],
blur: 12,
spread: 0,
color: Color32::from_black_alpha(if theme == Theme::Dark { 110 } else { 30 }),
};
v.menu_corner_radius = CornerRadius::same(RADIUS_SMALL);
v.extreme_bg_color = p.raised;
v.faint_bg_color = p.surface;
v.code_bg_color = p.raised;
v.hyperlink_color = p.info;
v.warn_fg_color = p.warning;
v.error_fg_color = p.error;
v.slider_trailing_fill = true;
let w = &mut v.widgets;
w.noninteractive.bg_fill = p.surface;
w.noninteractive.weak_bg_fill = p.surface;
w.noninteractive.bg_stroke = Stroke::new(1.0, p.border);
w.noninteractive.fg_stroke = Stroke::new(1.0, p.text);
w.inactive.bg_fill = p.raised;
w.inactive.weak_bg_fill = p.raised;
w.inactive.bg_stroke = Stroke::new(1.0, p.border);
w.inactive.fg_stroke = Stroke::new(1.0, p.text);
w.hovered.bg_fill = p.tint(p.accent, 46);
w.hovered.weak_bg_fill = p.tint(p.accent, 46);
w.hovered.bg_stroke = Stroke::new(1.0, p.accent);
w.hovered.fg_stroke = Stroke::new(1.5, p.text);
w.active.bg_fill = p.tint(p.accent, 80);
w.active.weak_bg_fill = p.tint(p.accent, 80);
w.active.bg_stroke = Stroke::new(1.0, p.accent);
w.active.fg_stroke = Stroke::new(2.0, p.text);
w.open.bg_fill = p.raised;
w.open.weak_bg_fill = p.raised;
w.open.bg_stroke = Stroke::new(1.0, p.accent);
w.open.fg_stroke = Stroke::new(1.0, p.text);
for s in [
&mut w.noninteractive,
&mut w.inactive,
&mut w.hovered,
&mut w.active,
&mut w.open,
] {
s.corner_radius = CornerRadius::same(RADIUS_SMALL);
s.expansion = 0.0;
}
v.selection.bg_fill = p.accent;
v.selection.stroke = Stroke::new(1.5, p.on_accent);
v.text_cursor.stroke = Stroke::new(2.0, p.accent);
let mut style = Style {
visuals: v,
..Style::default()
};
style.spacing.item_spacing = vec2(8.0, 7.0);
style.spacing.button_padding = vec2(11.0, 5.0);
style.spacing.window_margin = Margin::same(16);
style.spacing.menu_margin = Margin::same(6);
style.spacing.combo_height = 360.0;
use FontFamily::{Monospace, Proportional};
style.text_styles = [
(TextStyle::Heading, FontId::new(21.0, Proportional)),
(TextStyle::Body, FontId::new(13.5, Proportional)),
(TextStyle::Button, FontId::new(13.5, Proportional)),
(TextStyle::Small, FontId::new(11.0, Proportional)),
(TextStyle::Monospace, FontId::new(12.5, Monospace)),
]
.into();
style
}
fn glyph(c: char) -> Option<[u8; 7]> {
Some(match c {
'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'D' => [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E],
'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
'3' => [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
'6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
_ => return None,
})
}
pub fn dmd_size(text: &str, pitch: f32) -> egui::Vec2 {
let n = text.chars().count() as f32;
vec2((n * 6.0 - 1.0).max(0.0) * pitch, 7.0 * pitch)
}
pub fn paint_dmd(
painter: &egui::Painter,
top_left: Pos2,
text: &str,
pitch: f32,
lit: Color32,
off: Color32,
) {
let r = pitch * 0.38;
for (i, c) in text.chars().enumerate() {
let Some(rows) = glyph(c) else { continue };
let x0 = top_left.x + i as f32 * 6.0 * pitch;
for (y, row) in rows.iter().enumerate() {
for x in 0..5 {
let on = row & (0x10 >> x) != 0;
let center = Pos2::new(
x0 + (x as f32 + 0.5) * pitch,
top_left.y + (y as f32 + 0.5) * pitch,
);
painter.circle_filled(center, r, if on { lit } else { off });
}
}
}
}
pub fn dmd_wordmark(ui: &mut egui::Ui, pitch: f32) -> egui::Response {
let p = pal(ui.ctx());
let text = "PINREADY";
let (rect, resp) = ui.allocate_exact_size(dmd_size(text, pitch), egui::Sense::hover());
paint_dmd(ui.painter(), rect.min, text, pitch, p.accent, p.dmd_off);
resp
}
pub fn primary_button(
text: impl Into<String>,
p: &Palette,
enabled: bool,
) -> egui::Button<'static> {
let (fill, fg) = if enabled {
(p.accent, p.on_accent)
} else {
(p.raised, p.text_faint)
};
egui::Button::new(egui::RichText::new(text.into()).color(fg).strong())
.fill(fill)
.stroke(Stroke::NONE)
.min_size(vec2(112.0, 30.0))
}
pub fn secondary_button(text: impl Into<String>, p: &Palette) -> egui::Button<'static> {
egui::Button::new(egui::RichText::new(text.into()).color(p.text))
.fill(Color32::TRANSPARENT)
.stroke(Stroke::new(1.0, p.border))
.min_size(vec2(0.0, 30.0))
}
pub fn clean(text: &str) -> &str {
text.trim_end()
.trim_end_matches([':', '\u{ff1a}', '\u{2026}'])
.trim_end()
}
pub fn page_header(ui: &mut egui::Ui, title: &str, subtitle: Option<&str>) {
let p = pal(ui.ctx());
ui.label(
egui::RichText::new(clean(title))
.size(24.0)
.color(p.text)
.strong(),
);
if let Some(sub) = subtitle {
ui.label(egui::RichText::new(sub).color(p.text_muted));
}
ui.add_space(12.0);
}
pub fn section<R>(
ui: &mut egui::Ui,
title: &str,
intro: Option<&str>,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let p = pal(ui.ctx());
let r = egui::Frame::new()
.fill(p.surface)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(RADIUS))
.inner_margin(Margin::symmetric(18, 14))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(
egui::RichText::new(clean(title))
.size(17.0)
.color(p.text)
.strong(),
);
if let Some(intro) = intro {
ui.label(egui::RichText::new(intro).color(p.text_muted));
}
ui.add_space(6.0);
add_contents(ui)
})
.inner;
ui.add_space(12.0);
r
}
pub fn field_label(ui: &mut egui::Ui, text: &str) -> egui::Response {
let p = pal(ui.ctx());
ui.label(egui::RichText::new(clean(text)).color(p.text).strong())
}