use eframe::egui::{self, Color32, CornerRadius, FontId, Pos2, Rect, RichText, Stroke, StrokeKind, Vec2};
use super::facett_theme::Theme;
pub fn status_chip(theme: &Theme, status: &str) -> (&'static str, Color32) {
let s = status.to_ascii_lowercase();
match s.as_str() {
"pass" | "passed" | "ok" | "succeeded" | "done" => ("PASS", super::facett_theme::GREEN),
"fail" | "failed" | "error" => ("FAIL", super::facett_theme::RED),
"ignored" | "ignore" => ("IGN", theme.text_dim),
"stalled" | "stall" => ("STALL", super::facett_theme::AMBER),
"skip" | "skipped" => ("SKIP", Color32::from_rgb(150, 150, 160)),
"running" | "in_progress" => ("RUN", super::facett_theme::AMBER),
_ => ("?", theme.text_dim),
}
}
pub fn status_dot(ui: &mut egui::Ui, theme: &Theme, color: Color32, label: &str, n: usize) {
ui.label(RichText::new("●").color(color));
ui.label(RichText::new(format!("{n} {label}")).color(theme.text));
ui.add_space(6.0);
}
pub fn zebra_row(painter: &egui::Painter, theme: &Theme, rect: Rect, odd: bool) {
if odd {
painter.rect_filled(rect, CornerRadius::same(2), theme.zebra(true));
}
}
pub fn hover_row(painter: &egui::Painter, theme: &Theme, rect: Rect, hovered: bool) {
if hovered {
painter.rect_filled(rect, CornerRadius::same(2), theme.hover());
}
}
pub fn health_pill(painter: &egui::Painter, theme: &Theme, score: f64, top_left: Pos2, w: f32, h: f32) {
let pill = Rect::from_min_size(Pos2::new(top_left.x + 4.0, top_left.y + 9.0), Vec2::new(w - 12.0, h - 18.0));
let col = theme.health_color(score);
painter.rect_filled(pill, CornerRadius::same(7), col.linear_multiply(0.28));
painter.rect_stroke(pill, CornerRadius::same(7), Stroke::new(1.5, col), StrokeKind::Inside);
painter.text(
pill.center(),
egui::Align2::CENTER_CENTER,
format!("{score:.0}"),
FontId::proportional(16.0),
col,
);
}
pub fn sparkline(painter: &egui::Painter, theme: &Theme, series: &[f64], rect: Rect) {
if series.len() < 2 {
return;
}
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for &v in series {
lo = lo.min(v);
hi = hi.max(v);
}
let span = (hi - lo).max(1.0);
let n = series.len();
let pts: Vec<Pos2> = series
.iter()
.enumerate()
.map(|(i, &v)| {
let t = i as f32 / (n - 1) as f32;
let x = rect.min.x + t * rect.width();
let norm = ((v - lo) / span) as f32;
let y = rect.max.y - norm * rect.height();
Pos2::new(x, y)
})
.collect();
for w in pts.windows(2) {
painter.line_segment([w[0], w[1]], Stroke::new(1.4, theme.point));
}
if let Some(last) = pts.last() {
painter.circle_filled(*last, 1.8, theme.point);
}
}
pub fn graph_node(
painter: &egui::Painter,
theme: &Theme,
rect: Rect,
label: &str,
stroke: Color32,
selected: bool,
) {
let fill = if selected {
theme.accent.linear_multiply(0.18)
} else {
theme.node_fill
};
painter.rect_filled(rect, CornerRadius::same(6), fill);
painter.rect_stroke(
rect,
CornerRadius::same(6),
Stroke::new(if selected { 2.5 } else { 1.5 }, stroke),
StrokeKind::Inside,
);
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::proportional(13.0),
theme.text,
);
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TabItem {
pub id: String,
pub label: String,
}
impl TabItem {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self { id: id.into(), label: label.into() }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BarControl {
pub key: String,
pub kind: String,
pub value: String,
}
impl BarControl {
pub fn picker(key: impl Into<String>, value: impl Into<String>) -> Self {
Self { key: key.into(), kind: "picker".into(), value: value.into() }
}
pub fn toggle(key: impl Into<String>, on: bool) -> Self {
Self { key: key.into(), kind: "toggle".into(), value: if on { "on" } else { "off" }.into() }
}
}
pub struct TabBar<'a> {
id_salt: &'a str,
tabs: &'a [TabItem],
active: &'a str,
split: usize,
controls: Vec<BarControl>,
}
impl<'a> TabBar<'a> {
pub fn new(id_salt: &'a str, tabs: &'a [TabItem], active: &'a str) -> Self {
let split = tabs.len().div_ceil(2);
Self { id_salt, tabs, active, split, controls: Vec::new() }
}
pub fn split_at(mut self, split: usize) -> Self {
self.split = split.min(self.tabs.len());
self
}
pub fn with_control(mut self, c: BarControl) -> Self {
self.controls.push(c);
self
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"tabs": self.tabs.iter().map(|t| serde_json::json!({
"id": t.id,
"label": t.label,
"active": t.id == self.active,
})).collect::<Vec<_>>(),
"active": self.active,
"split": self.split.min(self.tabs.len()),
"controls": self.controls.iter().map(|c| serde_json::json!({
"key": c.key,
"kind": c.kind,
"value": c.value,
})).collect::<Vec<_>>(),
})
}
pub fn show(
&self,
ui: &mut egui::Ui,
theme: &Theme,
mut left: impl FnMut(&mut egui::Ui),
mut right: impl FnMut(&mut egui::Ui),
) -> Option<String> {
let split = self.split.min(self.tabs.len());
let mut clicked: Option<String> = None;
ui.horizontal(|ui| {
left(ui);
right(ui);
});
let mut paint_row = |ui: &mut egui::Ui, items: &[TabItem]| {
ui.horizontal(|ui| {
for t in items {
let is_active = t.id == self.active;
let mut rt = RichText::new(&t.label);
if is_active {
rt = rt.color(theme.accent).strong();
}
if ui.selectable_label(is_active, rt).clicked() && !is_active {
clicked = Some(t.id.clone());
}
}
});
};
let _ = &self.id_salt; paint_row(ui, &self.tabs[..split]);
paint_row(ui, &self.tabs[split..]);
clicked
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tabbar_state_json_reports_tabs_active_and_controls() {
let tabs = vec![
TabItem::new("Timeline", "🧵 Timeline"),
TabItem::new("DepGraph", "🔗 Dep Graph"),
TabItem::new("Release", "🚀 Release"),
];
let bar = TabBar::new("top", &tabs, "DepGraph")
.split_at(2)
.with_control(BarControl::picker("workspace", "nornir"))
.with_control(BarControl::picker("palette", "sci-fi"))
.with_control(BarControl::toggle("live", true));
let s = bar.state_json();
assert_eq!(s["tabs"].as_array().unwrap().len(), 3);
assert_eq!(s["tabs"][1]["id"], "DepGraph");
assert_eq!(s["tabs"][1]["label"], "🔗 Dep Graph");
assert_eq!(s["tabs"][1]["active"], true);
assert_eq!(s["tabs"][0]["active"], false);
assert_eq!(s["active"], "DepGraph");
assert_eq!(s["split"], 2);
let ctrls = s["controls"].as_array().unwrap();
assert_eq!(ctrls.len(), 3);
assert_eq!(ctrls[0]["key"], "workspace");
assert_eq!(ctrls[0]["kind"], "picker");
assert_eq!(ctrls[0]["value"], "nornir");
assert_eq!(ctrls[1]["key"], "palette");
assert_eq!(ctrls[1]["value"], "sci-fi");
assert_eq!(ctrls[2]["key"], "live");
assert_eq!(ctrls[2]["kind"], "toggle");
assert_eq!(ctrls[2]["value"], "on");
}
#[test]
fn status_chip_vocabulary_is_palette_aware() {
let d = Theme::default();
assert_eq!(status_chip(&d, "pass").0, "PASS");
assert_eq!(status_chip(&d, "PASS").0, "PASS"); assert_eq!(status_chip(&d, "failed").0, "FAIL");
assert_eq!(status_chip(&d, "skip").0, "SKIP");
assert_eq!(status_chip(&d, "???").1, d.text_dim);
assert_eq!(
status_chip(&Theme::hugin_noir(), "???").1,
Theme::hugin_noir().text_dim,
"unknown chip colour shifts with the palette",
);
assert_eq!(status_chip(&d, "pass").1, super::super::facett_theme::GREEN);
}
}