rust: app design system (theme, widgets) from Leviathan
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
//! Design system: colour tokens, radii and typography (Inter for UI, JetBrains Mono for bytes).
|
||||
//! Widgets read `Tokens::get(ctx)` instead of hard-coding colours, so every theme applies
|
||||
//! everywhere. Copied from Leviathan Desktop so both apps share one look.
|
||||
|
||||
use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ThemeKind {
|
||||
/// Charcoal panels, blue accent: a pro-tool dark.
|
||||
#[default]
|
||||
Pro,
|
||||
/// Near-black surfaces, violet accent.
|
||||
Midnight,
|
||||
Light,
|
||||
}
|
||||
|
||||
impl ThemeKind {
|
||||
pub const ALL: [ThemeKind; 3] = [ThemeKind::Pro, ThemeKind::Midnight, ThemeKind::Light];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ThemeKind::Pro => "Pro (dark)",
|
||||
ThemeKind::Midnight => "Midnight",
|
||||
ThemeKind::Light => "Light",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
ThemeKind::Pro => "pro",
|
||||
ThemeKind::Midnight => "midnight",
|
||||
ThemeKind::Light => "light",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|k| k.id() == s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tokens {
|
||||
pub kind: ThemeKind,
|
||||
pub chrome: Color32,
|
||||
pub dock: Color32,
|
||||
pub card: Color32,
|
||||
pub card_border: Color32,
|
||||
pub tab_strip: Color32,
|
||||
pub field: Color32,
|
||||
pub field_border: Color32,
|
||||
pub hover: Color32,
|
||||
pub row_selected: Color32,
|
||||
pub text: Color32,
|
||||
pub text_dim: Color32,
|
||||
pub text_faint: Color32,
|
||||
pub accent: Color32,
|
||||
pub accent_soft: Color32,
|
||||
pub accent_text: Color32,
|
||||
pub danger: Color32,
|
||||
pub warning: Color32,
|
||||
pub success: Color32,
|
||||
pub separator: Color32,
|
||||
/// Hex view background and byte-class colours.
|
||||
pub canvas: Color32,
|
||||
pub byte_null: Color32,
|
||||
pub byte_print: Color32,
|
||||
pub byte_space: Color32,
|
||||
pub byte_ctrl: Color32,
|
||||
pub byte_high: Color32,
|
||||
pub byte_ff: Color32,
|
||||
pub hit: Color32,
|
||||
pub bookmark: Color32,
|
||||
pub patched: Color32,
|
||||
pub radius: f32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
pub fn for_kind(kind: ThemeKind) -> Self {
|
||||
let rgb = Color32::from_rgb;
|
||||
match kind {
|
||||
ThemeKind::Pro => Tokens {
|
||||
kind,
|
||||
chrome: rgb(38, 38, 40),
|
||||
dock: rgb(30, 30, 32),
|
||||
card: rgb(44, 44, 47),
|
||||
card_border: rgb(28, 28, 30),
|
||||
tab_strip: rgb(34, 34, 36),
|
||||
field: rgb(30, 30, 32),
|
||||
field_border: rgb(72, 72, 76),
|
||||
hover: rgb(60, 60, 64),
|
||||
row_selected: rgb(40, 66, 102),
|
||||
text: rgb(226, 226, 230),
|
||||
text_dim: rgb(170, 170, 178),
|
||||
text_faint: rgb(118, 118, 126),
|
||||
accent: rgb(55, 142, 240),
|
||||
accent_soft: Color32::from_rgba_unmultiplied(55, 142, 240, 56),
|
||||
accent_text: Color32::WHITE,
|
||||
danger: rgb(236, 91, 98),
|
||||
warning: rgb(232, 176, 70),
|
||||
success: rgb(80, 196, 128),
|
||||
separator: rgb(24, 24, 26),
|
||||
canvas: rgb(26, 26, 28),
|
||||
byte_null: rgb(84, 84, 92),
|
||||
byte_print: rgb(214, 222, 235),
|
||||
byte_space: rgb(120, 190, 160),
|
||||
byte_ctrl: rgb(232, 176, 70),
|
||||
byte_high: rgb(196, 140, 236),
|
||||
byte_ff: rgb(236, 110, 110),
|
||||
hit: Color32::from_rgba_unmultiplied(232, 176, 70, 90),
|
||||
bookmark: rgb(80, 196, 128),
|
||||
patched: rgb(236, 91, 98),
|
||||
radius: 4.0,
|
||||
},
|
||||
ThemeKind::Midnight => Tokens {
|
||||
kind,
|
||||
chrome: rgb(18, 18, 21),
|
||||
dock: rgb(14, 14, 16),
|
||||
card: rgb(24, 24, 28),
|
||||
card_border: rgb(40, 40, 46),
|
||||
tab_strip: rgb(18, 18, 21),
|
||||
field: rgb(32, 32, 37),
|
||||
field_border: rgb(52, 52, 60),
|
||||
hover: rgb(42, 42, 50),
|
||||
row_selected: rgb(52, 44, 96),
|
||||
text: rgb(236, 236, 242),
|
||||
text_dim: rgb(150, 150, 162),
|
||||
text_faint: rgb(96, 96, 108),
|
||||
accent: rgb(139, 124, 246),
|
||||
accent_soft: Color32::from_rgba_unmultiplied(139, 124, 246, 50),
|
||||
accent_text: rgb(240, 238, 255),
|
||||
separator: rgb(34, 34, 40),
|
||||
canvas: rgb(12, 12, 14),
|
||||
byte_null: rgb(70, 70, 82),
|
||||
byte_print: rgb(220, 224, 240),
|
||||
byte_space: rgb(110, 200, 180),
|
||||
byte_ctrl: rgb(240, 190, 90),
|
||||
byte_high: rgb(170, 150, 255),
|
||||
byte_ff: rgb(255, 120, 140),
|
||||
hit: Color32::from_rgba_unmultiplied(240, 190, 90, 80),
|
||||
bookmark: rgb(110, 220, 150),
|
||||
patched: rgb(255, 110, 120),
|
||||
..Tokens::for_kind(ThemeKind::Pro)
|
||||
},
|
||||
ThemeKind::Light => Tokens {
|
||||
kind,
|
||||
chrome: rgb(236, 236, 240),
|
||||
dock: rgb(226, 226, 231),
|
||||
card: rgb(250, 250, 252),
|
||||
card_border: rgb(214, 214, 220),
|
||||
tab_strip: rgb(232, 232, 236),
|
||||
field: rgb(255, 255, 255),
|
||||
field_border: rgb(200, 200, 208),
|
||||
hover: rgb(222, 222, 230),
|
||||
row_selected: rgb(204, 222, 250),
|
||||
text: rgb(28, 28, 32),
|
||||
text_dim: rgb(84, 84, 96),
|
||||
text_faint: rgb(140, 140, 150),
|
||||
accent: rgb(30, 110, 220),
|
||||
accent_soft: Color32::from_rgba_unmultiplied(30, 110, 220, 40),
|
||||
accent_text: Color32::WHITE,
|
||||
danger: rgb(200, 50, 56),
|
||||
warning: rgb(176, 120, 10),
|
||||
success: rgb(30, 140, 80),
|
||||
separator: rgb(214, 214, 220),
|
||||
canvas: rgb(252, 252, 253),
|
||||
byte_null: rgb(180, 180, 190),
|
||||
byte_print: rgb(30, 34, 44),
|
||||
byte_space: rgb(20, 130, 100),
|
||||
byte_ctrl: rgb(176, 110, 0),
|
||||
byte_high: rgb(120, 70, 200),
|
||||
byte_ff: rgb(200, 50, 56),
|
||||
hit: Color32::from_rgba_unmultiplied(240, 180, 40, 110),
|
||||
bookmark: rgb(30, 150, 80),
|
||||
patched: rgb(210, 40, 50),
|
||||
radius: 4.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(ctx: &egui::Context) -> Tokens {
|
||||
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::new("nexus-theme"))).unwrap_or_else(|| Tokens::for_kind(ThemeKind::Pro))
|
||||
}
|
||||
|
||||
pub fn dark(&self) -> bool {
|
||||
self.kind != ThemeKind::Light
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let mut add = |name: &str, bytes: &'static [u8]| {
|
||||
fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes)));
|
||||
};
|
||||
add("Inter", include_bytes!("../assets/fonts/Inter-Regular.ttf"));
|
||||
add("Inter-SemiBold", include_bytes!("../assets/fonts/Inter-SemiBold.ttf"));
|
||||
add("JetBrainsMono", include_bytes!("../assets/fonts/JetBrainsMono-Regular.ttf"));
|
||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||
// The bundled Inter has its private-use-area cmap stripped, so phosphor's icons win there.
|
||||
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Inter".into());
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "JetBrainsMono".into());
|
||||
let mut semibold = vec!["Inter-SemiBold".to_owned()];
|
||||
semibold.extend(fonts.families[&FontFamily::Proportional].iter().cloned());
|
||||
fonts.families.insert(FontFamily::Name("semibold".into()), semibold);
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
pub fn semibold(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name("semibold".into()))
|
||||
}
|
||||
|
||||
pub fn mono(size: f32) -> FontId {
|
||||
FontId::monospace(size)
|
||||
}
|
||||
|
||||
pub fn apply(ctx: &egui::Context, kind: ThemeKind) {
|
||||
let t = Tokens::for_kind(kind);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("nexus-theme"), t));
|
||||
let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() };
|
||||
v.panel_fill = t.card;
|
||||
v.window_fill = t.card;
|
||||
v.window_stroke = Stroke::new(1.0, t.card_border);
|
||||
v.extreme_bg_color = t.field;
|
||||
v.faint_bg_color = t.tab_strip;
|
||||
v.code_bg_color = t.field;
|
||||
v.hyperlink_color = t.accent;
|
||||
v.selection.bg_fill = t.accent_soft;
|
||||
v.selection.stroke = Stroke::new(1.0, t.accent);
|
||||
v.override_text_color = Some(t.text);
|
||||
v.warn_fg_color = t.warning;
|
||||
v.error_fg_color = t.danger;
|
||||
let r = egui::CornerRadius::same(t.radius as u8);
|
||||
v.window_corner_radius = egui::CornerRadius::same(8);
|
||||
v.menu_corner_radius = egui::CornerRadius::same(6);
|
||||
for (w, fill) in [
|
||||
(&mut v.widgets.noninteractive, t.card),
|
||||
(&mut v.widgets.inactive, t.field),
|
||||
(&mut v.widgets.hovered, t.hover),
|
||||
(&mut v.widgets.active, t.hover),
|
||||
(&mut v.widgets.open, t.hover),
|
||||
] {
|
||||
w.bg_fill = fill;
|
||||
w.weak_bg_fill = fill;
|
||||
w.corner_radius = r;
|
||||
w.fg_stroke.color = t.text;
|
||||
}
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator);
|
||||
v.widgets.noninteractive.fg_stroke.color = t.text_dim;
|
||||
v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border);
|
||||
v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.text_faint);
|
||||
v.widgets.active.bg_stroke = Stroke::new(1.0, t.accent);
|
||||
v.popup_shadow = egui::epaint::Shadow { offset: [0, 6], blur: 18, spread: 0, color: Color32::from_black_alpha(if t.dark() { 120 } else { 40 }) };
|
||||
v.window_shadow = v.popup_shadow;
|
||||
ctx.set_visuals(v);
|
||||
ctx.all_styles_mut(|s| {
|
||||
s.text_styles = [
|
||||
(TextStyle::Small, FontId::proportional(11.0)),
|
||||
(TextStyle::Body, FontId::proportional(13.0)),
|
||||
(TextStyle::Button, FontId::proportional(13.0)),
|
||||
(TextStyle::Heading, semibold(16.0)),
|
||||
(TextStyle::Monospace, FontId::monospace(12.5)),
|
||||
]
|
||||
.into();
|
||||
s.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||
s.spacing.button_padding = egui::vec2(10.0, 4.0);
|
||||
s.spacing.interact_size.y = 24.0;
|
||||
s.spacing.scroll = egui::style::ScrollStyle::thin();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Shared widgets: section headers, buttons, chips, key/value grids, virtual lists, empty states.
|
||||
//! Colours always come from `Tokens`.
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
|
||||
/// Small uppercase section header with a hairline.
|
||||
pub fn section(ui: &mut Ui, title: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(title.to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6));
|
||||
ui.add_space(1.0);
|
||||
}
|
||||
|
||||
/// An icon + label button in the secondary style.
|
||||
pub fn button(ui: &mut Ui, glyph: &str, label: &str) -> Response {
|
||||
ui.add(egui::Button::new(format!("{glyph} {label}")))
|
||||
}
|
||||
|
||||
/// Filled accent button for a panel's primary action.
|
||||
pub fn primary(ui: &mut Ui, glyph: &str, label: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add(egui::Button::new(RichText::new(format!("{glyph} {label}")).color(t.accent_text)).fill(t.accent))
|
||||
}
|
||||
|
||||
/// A square icon-only button with a tooltip.
|
||||
pub fn icon_button(ui: &mut Ui, glyph: &str, tip: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, t.radius, t.hover);
|
||||
}
|
||||
let c = if resp.hovered() { t.text } else { t.text_dim };
|
||||
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, glyph, egui::FontId::proportional(15.0), c);
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
pub fn chip(ui: &mut Ui, text: &str, color: Color32) -> Response {
|
||||
let font = theme::semibold(10.5);
|
||||
let galley = ui.painter().layout_no_wrap(text.to_owned(), font, color);
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(12.0, 4.0), Sense::hover());
|
||||
ui.painter().rect_filled(rect, 9.0, color.gamma_multiply(0.18));
|
||||
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, color);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Two-column key/value grid; clicking a value copies it.
|
||||
pub fn kv(ui: &mut Ui, id: &str, rows: &[(&str, String)]) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Grid::new(id).num_columns(2).spacing(vec2(12.0, 4.0)).striped(false).show(ui, |ui| {
|
||||
for (k, v) in rows {
|
||||
ui.label(RichText::new(*k).color(t.text_dim));
|
||||
let r = ui.add(egui::Label::new(RichText::new(v).font(theme::mono(12.0))).sense(Sense::click()).wrap());
|
||||
if r.on_hover_text("Click to copy").clicked() {
|
||||
ui.ctx().copy_text(v.clone());
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A virtualised list of `n` rows of height `h`; `row` draws row `i` and returns whether it
|
||||
/// was clicked. Hover and selection are painted for you.
|
||||
pub fn rows(ui: &mut Ui, id: &str, n: usize, h: f32, selected: Option<usize>, mut row: impl FnMut(&mut Ui, usize)) -> Option<usize> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut clicked = None;
|
||||
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, false]).show_rows(ui, h, n, |ui, range| {
|
||||
for i in range {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click());
|
||||
if selected == Some(i) {
|
||||
ui.painter().rect_filled(rect, 3.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover.gamma_multiply(0.6));
|
||||
} else if i % 2 == 1 {
|
||||
ui.painter().rect_filled(rect, 0.0, t.text.gamma_multiply(0.025));
|
||||
}
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(6.0, 0.0))).layout(Layout::left_to_right(Align::Center)));
|
||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
// A row never grows: long text truncates (hover shows it in full where the panel adds it).
|
||||
child.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
|
||||
row(&mut child, i);
|
||||
if resp.clicked() {
|
||||
clicked = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Centered empty state.
|
||||
pub fn empty(ui: &mut Ui, glyph: &str, title: &str, hint: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(28.0);
|
||||
ui.label(RichText::new(glyph).size(30.0).color(t.text_faint));
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(title).font(theme::semibold(13.5)).color(t.text_dim));
|
||||
ui.label(RichText::new(hint).color(t.text_faint).size(12.0));
|
||||
});
|
||||
}
|
||||
|
||||
/// Dim helper text.
|
||||
pub fn hint(ui: &mut Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(RichText::new(text).color(t.text_faint).size(11.5));
|
||||
}
|
||||
|
||||
/// A small counter line ("1,234 results"), with an optional truncation note.
|
||||
pub fn count(ui: &mut Ui, n: usize, noun: &str, capped: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let s = format!("{} {noun}{}", group(n as u64), if capped { " (capped)" } else { "" });
|
||||
ui.label(RichText::new(s).color(if capped { t.warning } else { t.text_dim }).size(11.5));
|
||||
}
|
||||
|
||||
/// 1234567 → "1,234,567"
|
||||
pub fn group(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn human_size(n: u64) -> String {
|
||||
const U: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let mut v = n as f64;
|
||||
let mut i = 0;
|
||||
while v >= 1024.0 && i < 4 {
|
||||
v /= 1024.0;
|
||||
i += 1;
|
||||
}
|
||||
if i == 0 { format!("{n} B") } else { format!("{v:.1} {}", U[i]) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn helpers() {
|
||||
assert_eq!(super::group(1234567), "1,234,567");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user