crates/knut-popviz/src/app.rs
//! **The popviz desktop UI itself** (feature `gui`).
//!
//! This used to live inside `src/main.rs`, which made it unreachable from any
//! test: an integration test can link the *library*, never the binary. The
//! consequence was that `knut-popviz`'s "robot" tests drove [`PopvizState`]
//! through `update_json` and asserted `state_json` — a state round-trip that
//! never touched a widget, so nothing in this crate had ever asserted that a
//! click reaches the pixels, or that the progress bar's *fill* tracks
//! [`PopvizState::fraction`].
//!
//! So the app lives here and `main.rs` is the thin `eframe::run_native` shell
//! over it — one writer, both paths. [`PopvizApp::ui`] takes a plain
//! `&mut egui::Ui` (no `eframe::Frame`) so an `egui_kittest` harness can drive
//! the identical widget tree the desktop window shows; the `eframe::App` impl
//! just forwards to it.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::{LoadParams, Phase, PopvizState, Target, run_load};
/// The popviz application: the shared, introspectable [`PopvizState`] plus the
/// UI-local widget bindings (the text fields and the target toggle).
pub struct PopvizApp {
/// The load state the background thread writes and the UI renders. Shared
/// so a test can pose the UI at any phase without running a load.
pub state: Arc<Mutex<PopvizState>>,
pub params: LoadParams,
pub scale_str: String,
/// UI target toggle + warehouse path (assembled into `params.target` at start).
pub to_iceberg: bool,
pub warehouse: String,
pub running: bool,
/// Kick off the load on the first frame (`--autostart` / `KNUT_POPVIZ_AUTOSTART`)
/// so the GUI can be driven headlessly by a robot test.
pub autostart: bool,
pub state_path: std::path::PathBuf,
last_dump: Instant,
}
impl PopvizApp {
pub fn new(params: LoadParams) -> Self {
let state = Arc::new(Mutex::new(PopvizState::new(params.scale)));
let (to_iceberg, warehouse) = match ¶ms.target {
Target::Iceberg { warehouse } => (true, warehouse.clone()),
// The GUI drives a local warehouse; a catalog lake is knut-popload's.
Target::Falkor | Target::IcebergRest(_) => (false, "/tmp/knut_popviz_warehouse".to_string()),
};
Self {
scale_str: params.scale.to_string(),
to_iceberg,
warehouse,
state,
params,
running: false,
autostart: std::env::var("KNUT_POPVIZ_AUTOSTART").is_ok()
|| std::env::args().any(|a| a == "--autostart"),
state_path: std::env::var("KNUT_POPVIZ_STATE")
.map(Into::into)
.unwrap_or_else(|_| std::env::temp_dir().join("knut_popviz_state.json")),
last_dump: Instant::now(),
}
}
/// Always-on introspection: overwrite the state JSON (~300 ms throttle).
fn dump_if_due(&mut self) {
if self.last_dump.elapsed() < Duration::from_millis(300) {
return;
}
self.last_dump = Instant::now();
let json = self.state.lock().unwrap().state_json();
if let Ok(bytes) = serde_json::to_vec_pretty(&json) {
let _ = std::fs::write(&self.state_path, bytes);
}
}
/// Reset the state and spawn the load on a background thread (which wakes the
/// UI after each batch). Shared by the Start button and `--autostart`.
pub fn start_load(&mut self, ctx: &egui::Context) {
self.params.target = if self.to_iceberg {
Target::Iceberg {
warehouse: self.warehouse.clone(),
}
} else {
Target::Falkor
};
*self.state.lock().unwrap() = PopvizState::new(self.params.scale);
self.running = true;
let st = self.state.clone();
let params = self.params.clone();
let ctx2 = ctx.clone();
std::thread::spawn(move || run_load(params, st, move || ctx2.request_repaint()));
}
/// **The whole popviz UI**, against a plain `egui::Ui`.
///
/// This is the single widget-tree writer: the desktop window (through the
/// `eframe::App` impl below) and the headless `egui_kittest` robot render
/// exactly these widgets, so a robot assertion about a click or a fill width
/// is an assertion about the shipped app.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
self.dump_if_due();
if self.autostart && !self.running {
self.autostart = false;
self.start_load(&ctx);
}
let s = self.state.lock().unwrap().clone();
// While loading, keep ticking so the bars animate even between wakeups.
if self.running && !matches!(s.phase, Phase::Done | Phase::Failed) {
ctx.request_repaint_after(Duration::from_millis(200));
}
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.heading("knut · population bulk loader");
ui.label("knut-native bulk insert — no falkordb_bulk_loader");
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("FalkorDB:");
ui.text_edit_singleline(&mut self.params.url);
ui.label("graph:");
ui.add(egui::TextEdit::singleline(&mut self.params.graph).desired_width(80.0));
ui.label("scale:");
if ui
.add(egui::TextEdit::singleline(&mut self.scale_str).desired_width(70.0))
.changed()
&& let Ok(v) = self.scale_str.parse()
{
self.params.scale = v;
}
ui.checkbox(&mut self.params.drop, "drop first");
});
ui.horizontal(|ui| {
ui.label("target:");
ui.selectable_value(&mut self.to_iceberg, false, "FalkorDB");
ui.selectable_value(&mut self.to_iceberg, true, "Iceberg");
if self.to_iceberg {
ui.label("warehouse:");
ui.add(egui::TextEdit::singleline(&mut self.warehouse).desired_width(220.0));
}
});
let idle_or_done = !self.running || matches!(s.phase, Phase::Done | Phase::Failed);
if ui
.add_enabled(idle_or_done, egui::Button::new("▶ Start load"))
.clicked()
{
self.start_load(&ctx);
}
ui.separator();
ui.label(format!(
"→ {} · phase: {} · {:.0} rows/s · {:.1}s",
s.target,
s.phase.label(),
s.rows_per_s(),
s.elapsed_s
));
// The overall progress bar. Its FILL is the only pixel-level
// projection of `PopvizState::fraction()` in the app, which is why
// the robot suite measures its fill edge rather than re-reading the
// fraction it was handed.
ui.add(
egui::ProgressBar::new(s.fraction())
.show_percentage()
.text(format!("{} / {}", s.loaded(), s.total())),
);
if let Some(e) = &s.error {
ui.colored_label(egui::Color32::from_rgb(220, 80, 80), e);
}
ui.separator();
// Per-label progress via the shared facett-pop component (cut-over).
use facett_pop::Facet;
let mut pv = facett_pop::ProgressView::new("ingest");
pv.phase = s.phase.label().to_string();
pv.items = s
.labels
.iter()
.map(|l| facett_pop::ProgressItem {
label: l.name.clone(),
done: l.loaded as u64,
total: l.total as u64,
})
.collect();
pv.ui(ui);
});
}
}
impl eframe::App for PopvizApp {
// eframe 0.34: `ui` is the required entry; `update(ctx)` is deprecated.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
PopvizApp::ui(self, ui);
}
}