dwarves-gui/src/room.rs
//! **The code room, as a pane sees it** — the ONE seam every code-graph pane asks
//! through, and the ONE callout it leads with.
//!
//! Since Mímir v2 the code graph is nornir's code room. A pane here does not scan a
//! table, walk an edge or read a source file: it ASKS a reader surface by name
//! (`Viz.CrateGraph`, `Knowledge.TestReach`, `Knowledge.Source` …) through the forge
//! (`CodeRoom.Answer`, proxied to the embedded nornir) and draws the answer. One read
//! path: UI → forge → embedded nornir → room.
//!
//! Every answer is a [`RoomAnswer`] with a state by name. The rule each pane follows:
//!
//! * a MEASURED answer (`complete` · `partial` · `empty_fact`) is drawn, with the chip
//! of its state when it is not `complete`, its evidence text, and the room it was
//! read from (workspace · repo@seq (commit) · nodes · edges) — the line a person
//! checks against the server;
//! * anything else is drawn as the state and its evidence. Never a number, never a
//! row, never a sample.
use std::sync::Arc;
pub use nornir_warehouse_embed::room::{room_line_of, RoomAnswer, RoomState};
/// Where a pane's room answers come from. Injected, so a test can stand a pane on
/// canned answers; the shipped source is [`ForgeRoom`].
///
/// `Send + Sync`: a pane asks on a WORKER thread (a room is folded from the log on its
/// first ask — seconds to minutes), never on the frame that is trying to draw.
pub trait RoomSource: Send + Sync {
/// Ask one surface. Total — a failure is an answer with a state.
fn ask(&self, surface: &str, params: &serde_json::Value) -> RoomAnswer;
/// Where this source asks (`CodeRoom.Answer @ <forge> / <workspace>`).
fn describe(&self) -> String;
}
/// The shipped source: the forge's `CodeRoom.Answer`, for the selected workspace.
pub struct ForgeRoom {
pub endpoint: String,
pub token: String,
pub workspace: String,
}
impl ForgeRoom {
pub fn new(
endpoint: impl Into<String>,
token: impl Into<String>,
workspace: impl Into<String>,
) -> Self {
Self {
endpoint: endpoint.into(),
token: token.into(),
workspace: workspace.into(),
}
}
/// The same source as a shared handle (what a pane's worker clones).
pub fn shared(endpoint: &str, token: &str, workspace: &str) -> Arc<dyn RoomSource> {
Arc::new(Self::new(endpoint, token, workspace))
}
}
impl RoomSource for ForgeRoom {
fn ask(&self, surface: &str, params: &serde_json::Value) -> RoomAnswer {
nornir_warehouse_embed::room::ask(
&self.endpoint,
&self.token,
&self.workspace,
surface,
params,
)
}
fn describe(&self) -> String {
format!(
"CodeRoom.Answer @ {} / {}",
self.endpoint,
if self.workspace.is_empty() {
"(no workspace)"
} else {
&self.workspace
}
)
}
}
const RED: egui::Color32 = egui::Color32::from_rgb(0xc6, 0x28, 0x28);
const TEAL: egui::Color32 = egui::Color32::from_rgb(0x00, 0x69, 0x5c);
const AMBER: egui::Color32 = egui::Color32::from_rgb(0x8d, 0x6e, 0x00);
/// The chip of an answer's state: its label and colour. `complete` has one too
/// (teal) — a pane that draws numbers says what they rest on, also when all is well.
pub fn chip_of(a: &RoomAnswer) -> (String, egui::Color32) {
match a.kind {
RoomState::Complete => ("COMPLETE".into(), TEAL),
RoomState::Partial => ("PARTIAL — LOWER BOUND".into(), AMBER),
RoomState::EmptyFact => ("NOTHING FOUND".into(), TEAL),
RoomState::EmptyGap => ("EMPTY — INPUT MISSING".into(), RED),
RoomState::Queued => (
format!("QUEUED ON JERA — JOB {}", a.job_id().unwrap_or("?")),
AMBER,
),
RoomState::Refused | RoomState::NotAsked | RoomState::Unstated => {
(a.state.to_uppercase().replace('_', " "), RED)
}
}
}
fn chip(ui: &mut egui::Ui, text: &str, color: egui::Color32) {
ui.label(
egui::RichText::new(format!(" {text} "))
.small()
.strong()
.color(egui::Color32::WHITE)
.background_color(color),
);
}
/// **The callout every room-fed pane leads with**: what was asked, the state as a
/// chip, the evidence text in the reader's own words, the room it was read from, and
/// where it was asked. `what` names the layer in the pane's words ("crate graph").
pub fn callout(ui: &mut egui::Ui, what: &str, a: &RoomAnswer) {
let (label, color) = chip_of(a);
ui.horizontal_wrapped(|ui| {
chip(ui, &label, color);
ui.strong(what);
let ev = a.evidence_text();
if !ev.is_empty() {
ui.label(egui::RichText::new(ev).small());
}
});
let room = a.room_line();
ui.horizontal_wrapped(|ui| {
if !room.is_empty() {
ui.label(
egui::RichText::new(format!("room: {room}"))
.small()
.monospace(),
);
}
ui.weak(format!("via {}", a.via));
});
}
/// The line a pane shows while its read is in flight: how long, and — when the
/// server put the answer on its jera queue — which job it waits for.
pub fn asking_line(started: std::time::Instant) -> String {
let mut line = format!(
"asking the code room — {:.1}s (off the UI thread)",
started.elapsed().as_secs_f32()
);
for w in nornir_warehouse_embed::room::waiting() {
line.push_str(" · ");
line.push_str(&w);
}
line
}
/// A room read in flight: the worker's receiver and when it started. The frame polls
/// it; a robot seam blocks on it.
pub struct Pending<T> {
rx: std::sync::mpsc::Receiver<T>,
pub started: std::time::Instant,
}
impl<T: Send + 'static> Pending<T> {
/// Run `read` on a worker thread named `name`.
pub fn spawn(name: &str, read: impl FnOnce() -> T + Send + 'static) -> Self {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name(name.to_string())
.spawn(move || {
let _ = tx.send(read());
})
.expect("spawn a code-room read worker");
Self {
rx,
started: std::time::Instant::now(),
}
}
/// The result if the worker finished (`Err(())` = the worker died).
pub fn try_take(&self) -> Option<Result<T, ()>> {
match self.rx.try_recv() {
Ok(v) => Some(Ok(v)),
Err(std::sync::mpsc::TryRecvError::Empty) => None,
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err(())),
}
}
/// Block until the worker answers (`Err(())` = it died).
// `()` says all there is: the worker died. Same shape as `try_take`'s, which every
// pane's `land` takes.
#[allow(clippy::result_unit_err)]
pub fn wait(self) -> Result<T, ()> {
self.rx.recv().map_err(|_| ())
}
}
/// The answer a pane holds when its read worker died before answering.
pub fn worker_died(surface: &str, source: &dyn RoomSource) -> RoomAnswer {
let mut a = RoomAnswer::pending(surface, &source.describe());
a.state = "read_failed".to_string();
a.note = "the read worker died before it answered".to_string();
a
}
#[cfg(test)]
pub mod testing {
//! Canned room answers for a pane's unit tests (no socket).
use super::*;
use std::collections::BTreeMap;
use std::sync::Mutex;
/// A source answering canned bodies per surface; an unfed surface refuses
/// `invalid_argument`, exactly as the reader does for a name it does not know.
#[derive(Default)]
pub struct CannedRoom {
pub answers: Mutex<BTreeMap<String, serde_json::Value>>,
pub asked: Mutex<Vec<(String, serde_json::Value)>>,
}
impl CannedRoom {
pub fn new(answers: &[(&str, serde_json::Value)]) -> Arc<Self> {
let me = Self::default();
for (s, v) in answers {
me.answers.lock().unwrap().insert(s.to_string(), v.clone());
}
Arc::new(me)
}
pub fn set(&self, surface: &str, body: serde_json::Value) {
self.answers
.lock()
.unwrap()
.insert(surface.to_string(), body);
}
}
impl RoomSource for CannedRoom {
fn ask(&self, surface: &str, params: &serde_json::Value) -> RoomAnswer {
self.asked
.lock()
.unwrap()
.push((surface.to_string(), params.clone()));
let body = self.answers.lock().unwrap().get(surface).cloned().unwrap_or_else(|| {
serde_json::json!({ "state": "invalid_argument", "refused": true, "note": format!("`{surface}` is not fed in this test") })
});
RoomAnswer::from_body(surface, &self.describe(), body)
}
fn describe(&self) -> String {
"canned room (test)".to_string()
}
}
/// A refusal body, as the door sends one.
pub fn refusal(state: &str, note: &str) -> serde_json::Value {
serde_json::json!({ "state": state, "refused": true, "note": note })
}
/// A `room` identity block.
pub fn room(
workspace: &str,
repo: &str,
seq: i64,
nodes: u64,
edges: u64,
) -> serde_json::Value {
serde_json::json!({
"workspace": workspace, "universe": false,
"repos": [{ "repo": repo, "seq": seq, "sha": "0123456789abcdef0123" }],
"nodes": nodes, "edges_total": edges,
})
}
}
#[cfg(test)]
mod queued_tests {
use super::*;
/// An answer the server put on its jera queue is drawn as the job it waits
/// for — amber, never a number, never red as if it failed.
#[test]
fn a_queued_answer_is_drawn_as_its_jera_job() {
let body = serde_json::json!({ "state": "running", "job_id": "j-1", "note": "running on jera — job j-1 (running 4s); ask again for the answer" });
let a = RoomAnswer::from_body("Viz.CrateGraph", "via", body);
let (label, color) = chip_of(&a);
assert_eq!((label.as_str(), color), ("QUEUED ON JERA — JOB j-1", AMBER));
assert!(!a.is_measured() && a.rows("crates").is_empty());
assert!(asking_line(std::time::Instant::now()).starts_with("asking the code room — "));
}
}