use brep_render::engine_state::EngineState;
use eframe::egui;
use std::collections::HashMap;
pub const BUSY_DELAY: f64 = 0.2;
const ELAPSED_AFTER: f64 = 3.0;
#[derive(Debug, Clone, PartialEq)]
pub struct Activity {
pub key: String,
pub kind: &'static str,
pub label: String,
pub document: Option<u64>,
pub cancellable: bool,
}
impl Activity {
pub fn new(key: impl Into<String>, kind: &'static str, label: impl Into<String>) -> Self {
Self { key: key.into(), kind, label: label.into(), document: None, cancellable: false }
}
}
pub fn engine_activities(engine: &EngineState, document: u64, title: Option<&str>) -> Vec<Activity> {
let of = |what: &str| match title {
Some(title) => format!("{what} ({title})"),
None => what.to_string(),
};
let mut out = Vec::new();
let mut push = |kind: &'static str, label: String, cancellable: bool| {
out.push(Activity {
key: format!("{kind}:{document}"),
kind,
label,
document: Some(document),
cancellable,
});
};
if engine.run_pending() {
let preview = engine.expression_preview().map(|p| p.label.clone());
let label = match (engine.run_progress(), title) {
(Some(p), None) if preview.is_some() => format!(
"Previewing {} — {} ({}/{})",
preview.as_deref().unwrap_or_default(),
p.feature_id,
p.index + 1,
p.total
),
(None, None) if preview.is_some() => {
format!("Previewing {}", preview.as_deref().unwrap_or_default())
}
(Some(p), None) => {
format!("Rebuilding model — {} ({}/{})", p.feature_id, p.index + 1, p.total)
}
(Some(p), Some(title)) => {
format!("Rebuilding {title} — {} ({}/{})", p.feature_id, p.index + 1, p.total)
}
(None, None) => "Rebuilding model".to_string(),
(None, Some(title)) => format!("Rebuilding {title}"),
};
push("run", label, true);
}
if engine.mesh_imports_pending() {
push("meshImport", of("Reconstructing mesh"), false);
}
if engine.step_probes_pending() {
push("stepProbe", of("Reading STEP structure"), false);
}
if engine.sheet_lines_pending() {
push("sheetLines", of("Drawing sheet views"), false);
}
if engine.topology_pending() {
push("topology", of("Loading exact geometry"), false);
}
if engine.queries_pending() {
push("query", of("Measuring"), false);
}
out
}
#[derive(Default)]
pub struct BusyIndicator {
activities: Vec<Activity>,
since: HashMap<String, f64>,
busy_since: Option<f64>,
now: f64,
cancel: Option<Activity>,
hits: HashMap<String, egui::Rect>,
}
impl BusyIndicator {
pub fn new() -> Self {
Self::default()
}
pub fn update(&mut self, activities: Vec<Activity>, now: f64) {
self.now = now;
if activities.is_empty() {
self.busy_since = None;
self.since.clear();
} else {
self.busy_since.get_or_insert(now);
self.since.retain(|key, _| activities.iter().any(|a| &a.key == key));
for activity in &activities {
self.since.entry(activity.key.clone()).or_insert(now);
}
}
self.activities = activities;
}
pub fn shown(&self) -> bool {
self.busy_since.is_some_and(|since| self.now - since >= BUSY_DELAY)
}
pub fn request_repaint(&self, ctx: &egui::Context) {
match self.busy_since {
None => {}
Some(_) if self.shown() => ctx.request_repaint(),
Some(since) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(
(since + BUSY_DELAY - self.now).max(0.0),
)),
}
}
pub fn take_cancel(&mut self) -> Option<Activity> {
self.cancel.take()
}
fn display_label(&self, activity: &Activity) -> String {
let elapsed = self.since.get(&activity.key).map_or(0.0, |since| self.now - since);
if elapsed >= ELAPSED_AFTER {
format!("{}… {}", activity.label, format_elapsed(elapsed))
} else {
format!("{}…", activity.label)
}
}
pub fn show(&mut self, ui: &mut egui::Ui) {
self.hits.clear();
if !self.shown() {
return;
}
let Some(primary) = self.activities.first().cloned() else { return };
let tooltip = |ui: &mut egui::Ui, this: &Self| {
for activity in &this.activities {
ui.label(this.display_label(activity));
}
};
if primary.cancellable {
let cancel = ui.small_button("Cancel").on_hover_text(
"Stop the run. The model keeps the last completed result; \
edit or delete the slow feature to rebuild.",
);
self.hits.insert("busy:cancel".into(), cancel.rect);
if cancel.clicked() {
self.cancel = Some(primary.clone());
}
}
let more = self.activities.len() - 1;
if more > 0 {
let chip = ui.weak(format!("+{more} more")).on_hover_ui(|ui| tooltip(ui, self));
self.hits.insert("busy:more".into(), chip.rect);
}
let label = ui
.add(egui::Label::new(self.display_label(&primary)).truncate())
.on_hover_ui(|ui| tooltip(ui, self));
let spinner = ui.add(egui::Spinner::new().size(14.0));
self.hits.insert("busy".into(), label.rect.union(spinner.rect));
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
pub fn state_json(&self) -> String {
let activities: Vec<serde_json::Value> = self
.activities
.iter()
.map(|a| {
serde_json::json!({
"kind": a.kind,
"label": a.label,
"document": a.document,
"cancellable": a.cancellable,
"elapsed": self.since.get(&a.key).map_or(0.0, |since| self.now - since),
})
})
.collect();
serde_json::json!({ "shown": self.shown(), "activities": activities }).to_string()
}
}
fn format_elapsed(seconds: f64) -> String {
let whole = seconds.floor() as u64;
if whole < 60 {
format!("{whole} s")
} else {
format!("{} m {:02} s", whole / 60, whole % 60)
}
}
pub static HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] = &[
crate::automation::hit_keys::HitKeyDoc { panel: "status", prefix: "busy", meaning: "the working indicator (spinner and label) at the right of the status bar; present only while it shows, and hovering it lists everything in flight", command: None },
crate::automation::hit_keys::HitKeyDoc { panel: "status", prefix: "busy:more", meaning: "the `+N more` chip beside the working indicator when several things are in flight; hovering it lists them", command: None },
crate::automation::hit_keys::HitKeyDoc { panel: "status", prefix: "busy:cancel", meaning: "cancel the in-flight history run named by the working indicator", command: None },
];