use eframe::egui;
use egui::Ui;
use facett::look::Theme as LookTheme; use facett::table::Table;
use facett::Facet;
use serde_json::{json, Value};
use traits::ArtifactId;
use crate::data::UiData;
pub fn default_look_preset() -> usize {
let want = LookTheme::from_os(egui::os::OperatingSystem::from_target_os()).name;
LookTheme::preset_names().iter().position(|n| *n == want).unwrap_or(1)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Tab {
Status,
Repos,
Browse,
Archive,
Artifact,
Upload,
}
pub struct HolgerUiApp {
data: UiData,
tab: Tab,
repo: String,
namespace: String,
name: String,
version: String,
upload_path: String,
notice: Option<String>,
look_preset: usize,
}
impl HolgerUiApp {
pub fn new(mut data: UiData) -> Self {
data.refresh_status();
data.refresh_repos();
let repo = data
.repos
.selected_repo()
.map(|r| r.name.clone())
.unwrap_or_default();
Self {
data,
tab: Tab::Status,
repo,
namespace: String::new(),
name: String::new(),
version: String::new(),
upload_path: String::new(),
notice: None,
look_preset: default_look_preset(),
}
}
pub fn look_theme(&self) -> LookTheme {
let presets = LookTheme::PRESETS;
presets[self.look_preset.min(presets.len() - 1)]()
}
pub fn set_look_preset(&mut self, idx: usize) -> String {
self.look_preset = idx.min(LookTheme::PRESETS.len() - 1);
self.look_theme().name
}
pub fn apply_look(&self, ctx: &egui::Context) {
self.look_theme().apply(ctx);
}
pub fn look_state_json(&self) -> Value {
let theme = self.look_theme();
let pal = theme.to_legacy_palette();
let hex = |c: egui::Color32| format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b());
json!({
"preset": theme.name,
"preset_index": self.look_preset,
"presets": LookTheme::preset_names(),
"dark": theme.is_dark(),
"palette": {
"bg": hex(pal.bg),
"text": hex(pal.text),
"text_dim": hex(pal.text_dim),
"accent": hex(pal.accent),
"panel_bg": hex(pal.panel_bg),
"node_fill": hex(pal.node_fill),
},
})
}
fn status_tab(&mut self, ui: &mut Ui) {
if ui.button("⟳ refresh").clicked() {
self.data.refresh_status();
}
ui.separator();
let s = &self.data.status;
if let Some(err) = &s.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
}
egui::Grid::new("status_grid").striped(true).show(ui, |ui| {
ui.label("status");
ui.label(if s.status.is_empty() { "—" } else { &s.status });
ui.end_row();
ui.label("version");
ui.label(if s.version.is_empty() { "—" } else { &s.version });
ui.end_row();
ui.label("uptime (s)");
ui.label(s.uptime_seconds.to_string());
ui.end_row();
});
}
fn repos_tab(&mut self, ui: &mut Ui) {
if ui.button("⟳ refresh").clicked() {
self.data.refresh_repos();
}
ui.separator();
if let Some(err) = &self.data.repos.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
}
let entries: Vec<(usize, String)> = self
.data
.repos
.repos
.iter()
.enumerate()
.map(|(i, r)| (i, r.name.clone()))
.collect();
let selected = self.data.repos.selected;
let mut clicked = None;
ui.horizontal_wrapped(|ui| {
for (i, name) in &entries {
if ui.selectable_label(selected == Some(*i), name).clicked() {
clicked = Some(*i);
}
}
});
if let Some(i) = clicked {
self.data.select_repo(i);
self.repo = entries[i].1.clone();
}
ui.separator();
let mut table = Table::new(
"repositories",
vec![
"name".into(),
"type".into(),
"writable".into(),
"archive".into(),
],
);
for r in &self.data.repos.repos {
table.push_row(vec![
r.name.clone(),
r.repo_type.clone(),
r.writable.to_string(),
r.has_archive.to_string(),
]);
}
if let Some(sel) = self.data.repos.selected {
table.select_row(sel);
}
table.ui(ui);
}
fn browse_tab(&mut self, ui: &mut Ui) {
let target = self
.data
.repos
.selected_repo()
.map(|r| r.name.clone());
ui.label(format!(
"browsing repo: {}",
target.clone().unwrap_or_else(|| "(none selected)".into())
));
let do_refresh = ui
.add_enabled(target.is_some(), egui::Button::new("⟳ refresh"))
.clicked();
if do_refresh {
if let Some(repo) = target {
self.data.refresh_browse(&repo, None);
}
}
ui.separator();
if let Some(err) = &self.data.browse.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
}
let mut table = Table::new(
"browse",
vec![
"name".into(),
"version".into(),
"size".into(),
"type".into(),
],
);
for e in &self.data.browse.entries {
table.push_row(vec![
e.name.clone(),
e.version.clone(),
e.size_bytes.to_string(),
e.content_type.clone(),
]);
}
table.ui(ui);
ui.separator();
let loaded = self.data.browse.entries.len();
let has_more = !self.data.browse.next_page_token.is_empty();
ui.horizontal(|ui| {
ui.label(format!(
"{loaded} shown{}",
if has_more { " (more available)" } else { "" }
));
if has_more && ui.button("load more").clicked() {
self.data.load_more_browse();
}
});
}
fn archive_tab(&mut self, ui: &mut Ui) {
let target = self
.data
.repos
.selected_repo()
.map(|r| r.name.clone());
ui.label(format!(
"archive of repo: {}",
target.clone().unwrap_or_else(|| "(none selected)".into())
));
let do_refresh = ui
.add_enabled(target.is_some(), egui::Button::new("⟳ refresh"))
.clicked();
if do_refresh {
if let Some(repo) = target {
self.data.refresh_archive(&repo, None);
}
}
ui.separator();
if let Some(err) = &self.data.archive.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
}
let a = &self.data.archive;
egui::Grid::new("archive_stats").striped(true).show(ui, |ui| {
ui.label("files");
ui.label(a.file_count.to_string());
ui.end_row();
ui.label("uncompressed bytes");
ui.label(a.total_uncompressed_bytes.to_string());
ui.end_row();
ui.label("archive");
ui.label(if a.archive_path.is_empty() {
"—"
} else {
&a.archive_path
});
ui.end_row();
});
ui.separator();
let mut table = Table::new("archive", vec!["path".into()]);
for path in &self.data.archive.files {
table.push_row(vec![path.clone()]);
}
table.ui(ui);
}
fn artifact_tab(&mut self, ui: &mut Ui) {
egui::Grid::new("artifact_inputs").show(ui, |ui| {
ui.label("repository");
ui.text_edit_singleline(&mut self.repo);
ui.end_row();
ui.label("namespace");
ui.text_edit_singleline(&mut self.namespace);
ui.end_row();
ui.label("name");
ui.text_edit_singleline(&mut self.name);
ui.end_row();
ui.label("version");
ui.text_edit_singleline(&mut self.version);
ui.end_row();
});
if ui.button("fetch").clicked() {
let id = ArtifactId {
namespace: if self.namespace.is_empty() {
None
} else {
Some(self.namespace.clone())
},
name: self.name.clone(),
version: self.version.clone(),
};
let repo = self.repo.clone();
self.data.fetch_artifact(&repo, id);
}
ui.separator();
let a = &self.data.artifact;
if let Some(err) = &a.error {
ui.colored_label(egui::Color32::RED, format!("error: {err}"));
} else if a.repository.is_empty() {
ui.label("enter an artifact id and fetch");
} else if a.found {
ui.label(format!(
"✔ {} / {} {} — {} bytes ({})",
a.repository, a.name, a.version, a.size_bytes, a.content_type
));
} else {
ui.colored_label(egui::Color32::YELLOW, "not found");
}
}
fn upload_tab(&mut self, ui: &mut Ui) {
let writable = self.data.repos.upload_enabled();
let target = self.data.repos.selected_repo().map(|r| r.name.clone());
ui.label(format!(
"target repo: {}",
target.clone().unwrap_or_else(|| "(none selected)".into())
));
if !writable {
ui.colored_label(
egui::Color32::YELLOW,
"selected repo is read-only — pick a writable repo on the Repos tab",
);
}
egui::Grid::new("upload_inputs").show(ui, |ui| {
ui.label("name");
ui.text_edit_singleline(&mut self.name);
ui.end_row();
ui.label("version");
ui.text_edit_singleline(&mut self.version);
ui.end_row();
ui.label("file path");
ui.text_edit_singleline(&mut self.upload_path);
ui.end_row();
});
let do_upload = ui
.add_enabled(writable, egui::Button::new("upload"))
.clicked();
if do_upload {
if let Some(repo) = target {
match std::fs::read(&self.upload_path) {
Ok(bytes) => {
let id = ArtifactId {
namespace: None,
name: self.name.clone(),
version: self.version.clone(),
};
self.notice = Some(match self.data.put_artifact(&repo, &id, &bytes) {
Ok(()) => format!("uploaded {} bytes to {repo}", bytes.len()),
Err(e) => format!("upload failed: {e}"),
});
}
Err(e) => self.notice = Some(format!("read failed: {e}")),
}
}
}
if let Some(n) = &self.notice {
ui.separator();
ui.label(n);
}
}
}
impl eframe::App for HolgerUiApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.apply_look(&ui.ctx().clone());
egui::Panel::top("tabs").show_inside(ui, |ui| {
ui.horizontal(|ui| {
ui.heading("holger");
ui.separator();
ui.selectable_value(&mut self.tab, Tab::Status, "Status");
ui.selectable_value(&mut self.tab, Tab::Repos, "Repos");
ui.selectable_value(&mut self.tab, Tab::Browse, "Browse");
ui.selectable_value(&mut self.tab, Tab::Archive, "Archive");
ui.selectable_value(&mut self.tab, Tab::Artifact, "Artifact");
ui.selectable_value(&mut self.tab, Tab::Upload, "Upload");
});
ui.horizontal(|ui| {
ui.label("look:")
.on_hover_text("Unified facett look & feel — re-themes every view");
let names = LookTheme::preset_names();
let mut pick = None;
for (i, name) in names.iter().enumerate() {
if ui.selectable_label(self.look_preset == i, name).clicked() {
pick = Some(i);
}
}
if let Some(i) = pick {
self.set_look_preset(i);
}
});
});
egui::CentralPanel::default().show_inside(ui, |ui| match self.tab {
Tab::Status => self.status_tab(ui),
Tab::Repos => self.repos_tab(ui),
Tab::Browse => self.browse_tab(ui),
Tab::Archive => self.archive_tab(ui),
Tab::Artifact => self.artifact_tab(ui),
Tab::Upload => self.upload_tab(ui),
});
}
}
#[cfg(test)]
#[allow(deprecated)] mod look_tests {
use super::*;
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::HolgerObject;
#[cfg(feature = "testmatrix")]
fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status(component, check, ok, detail);
}
fn app() -> HolgerUiApp {
let routes = FastRoutes::new(Vec::new());
let holger: std::sync::Arc<dyn HolgerObject> = std::sync::Arc::new(LocalHolger::new(routes));
let data = UiData::new(holger).expect("runtime");
HolgerUiApp::new(data)
}
#[test]
fn default_preset_is_os_resolved_and_roster_is_facett_presets() {
let app = app();
let s = app.look_state_json();
let presets = s["presets"].as_array().expect("look state has a presets roster");
assert_eq!(
presets.len(),
LookTheme::PRESETS.len(),
"the look switcher lists every facett preset: {s}"
);
let want = LookTheme::PRESETS[default_look_preset()]().name;
assert_eq!(s["preset"], want, "first-launch preset is the OS default: {s}");
assert_eq!(s["preset_index"].as_u64(), Some(default_look_preset() as u64));
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"look_default_preset_and_roster",
presets.len() == LookTheme::PRESETS.len()
&& s["preset"] == want
&& s["preset_index"].as_u64() == Some(default_look_preset() as u64),
&format!("roster {} presets, OS default = {}", presets.len(), want),
);
}
#[test]
fn switching_preset_rethemes_the_resolved_palette() {
let mut app = app();
let names = LookTheme::preset_names();
let device = names.iter().position(|n| n == "device").expect("device preset exists");
let win_dark = names.iter().position(|n| n == "windows-dark").expect("windows-dark exists");
let name = app.set_look_preset(win_dark);
assert_eq!(name, "windows-dark");
let win_state = app.look_state_json();
let win_bg = win_state["palette"]["bg"].clone();
let name = app.set_look_preset(device);
assert_eq!(name, "device");
let dev_state = app.look_state_json();
assert_eq!(dev_state["preset"], "device", "active preset switched: {dev_state}");
let dev_bg = dev_state["palette"]["bg"].clone();
assert_ne!(win_bg, dev_bg, "device must paint a different surface than windows-dark");
let roles = ["bg", "text", "text_dim", "accent", "panel_bg", "node_fill"];
for role in roles {
let c = dev_state["palette"][role].as_str().unwrap_or("");
assert!(
c.starts_with('#') && c.len() == 7,
"palette role `{role}` must be a #rrggbb colour, got {c:?}: {dev_state}"
);
}
let all_hex = roles.iter().all(|role| {
let c = dev_state["palette"][role].as_str().unwrap_or("");
c.starts_with('#') && c.len() == 7
});
let _ = all_hex;
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"look_switch_rethemes_palette",
dev_state["preset"] == "device" && win_bg != dev_bg && all_hex,
&format!("windows-dark bg={win_bg} != device bg={dev_bg}, all 6 roles #rrggbb={all_hex}"),
);
}
#[test]
fn apply_publishes_the_coherent_legacy_palette_into_egui() {
let mut app = app();
let device = LookTheme::preset_names().iter().position(|n| n == "device").unwrap();
app.set_look_preset(device);
let ctx = egui::Context::default();
app.apply_look(&ctx);
let theme = app.look_theme();
assert_eq!(
ctx.style().spacing.item_spacing,
theme.metrics.item_spacing_vec(),
"apply installed the preset's spacing into the egui Style"
);
let mut got = String::new();
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
got = facett::theme(ui).name.to_string();
});
});
assert_eq!(got, "device", "facett views resolve the applied preset's palette");
#[cfg(feature = "testmatrix")]
fstatus(
"holger-ui",
"look_apply_publishes_legacy_palette",
ctx.style().spacing.item_spacing == theme.metrics.item_spacing_vec() && got == "device",
&format!("egui spacing matches preset; facett::theme resolves \"{got}\""),
);
}
}