use std::collections::{BTreeMap, HashMap, VecDeque};
use std::path::PathBuf;
use eframe::egui::{self, Color32, Pos2};
use egui_snarl::ui::{PinInfo, SnarlStyle, SnarlViewer};
use egui_snarl::{InPin, InPinId, NodeId, OutPin, OutPinId, Snarl};
use super::funnel_view::{FunnelView, NodeStat, PlanView};
const X_SPACING: f32 = 250.0;
const Y_SPACING: f32 = 120.0;
enum Src {
Local(PathBuf),
Remote { endpoint: String, token: String },
}
struct FunnelNode {
id: String,
kind: String,
title: String,
status: NodeStat,
targets: Vec<String>,
}
pub struct FunnelTabState {
src: Src,
workspace: String,
loaded: bool,
error: Option<String>,
view: Option<FunnelView>,
sel_plan: Option<String>,
snarl: Snarl<FunnelNode>,
built_for: Option<String>,
style: SnarlStyle,
}
impl FunnelTabState {
pub fn local(root: PathBuf) -> Self {
Self::with(Src::Local(root), String::new())
}
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(Src::Remote { endpoint, token }, workspace)
}
fn with(src: Src, workspace: String) -> Self {
Self {
src,
workspace,
loaded: false,
error: None,
view: None,
sel_plan: None,
snarl: Snarl::new(),
built_for: None,
style: SnarlStyle::new(),
}
}
pub(crate) fn set_workspace(&mut self, workspace: String) {
self.workspace = workspace;
self.loaded = false;
self.error = None;
self.view = None;
self.sel_plan = None;
self.built_for = None;
self.snarl = Snarl::new();
}
fn load_view(&self) -> Result<FunnelView, String> {
match &self.src {
Src::Local(root) => crate::funnel::Store::open(root)
.map(|store| FunnelView::from_funnel(&store.funnel))
.map_err(|e| format!("{e:#}")),
Src::Remote { endpoint, token } => {
super::remote::funnel_show(endpoint, token, &self.workspace)
.map_err(|e| format!("{e:#}"))
}
}
}
fn ensure_loaded(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
match self.load_view() {
Ok(v) => {
if self.sel_plan.is_none() {
self.sel_plan = v.plans.first().map(|p| p.id.clone());
}
self.view = Some(v);
}
Err(e) => self.error = Some(e),
}
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
self.ensure_loaded();
if let Some(err) = self.error.clone() {
ui.colored_label(Color32::RED, err);
return;
}
let Some(view) = self.view.as_ref() else {
ui.label("loading funnelโฆ");
return;
};
if view.plans.is_empty() {
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.heading("๐ Funnel โ no plans yet");
ui.label("Feed the funnel from the CLI, then it appears here:");
ui.monospace("nornir funnel submit \"<krav / prompt>\"");
ui.monospace("nornir funnel plan <idea-id> \"<summary>\"");
ui.monospace("nornir funnel node <plan-id> --kind โฆ --needs โฆ");
});
return;
}
let sel = self
.sel_plan
.clone()
.filter(|s| view.plans.iter().any(|p| &p.id == s))
.unwrap_or_else(|| view.plans[0].id.clone());
let to_build = if self.built_for.as_deref() != Some(sel.as_str()) {
view.plans.iter().find(|p| p.id == sel).cloned()
} else {
None
};
if let Some(plan) = to_build {
self.snarl = build_snarl(&plan);
self.built_for = Some(plan.id.clone());
}
self.sel_plan = Some(sel.clone());
egui::TopBottomPanel::top("funnel_controls").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label("plan:");
let cur = self
.view
.as_ref()
.and_then(|v| v.plans.iter().find(|p| p.id == sel))
.map(|p| format!("{} ยท {}", p.id, p.status))
.unwrap_or_else(|| sel.clone());
egui::ComboBox::from_id_source("funnel_plan")
.selected_text(cur)
.show_ui(ui, |ui| {
let plans: Vec<(String, String)> = self
.view
.as_ref()
.map(|v| v.plans.iter().map(|p| (p.id.clone(), p.status.clone())).collect())
.unwrap_or_default();
for (id, status) in plans {
if ui
.selectable_label(
self.sel_plan.as_deref() == Some(&id),
format!("{id} ยท {status}"),
)
.clicked()
{
self.sel_plan = Some(id);
}
}
});
if ui.button("โป reload").on_hover_text("re-read the funnel").clicked() {
self.loaded = false;
self.view = None;
self.built_for = None;
}
ui.separator();
legend(ui);
});
});
egui::CentralPanel::default().show_inside(ui, |ui| {
if let Some(plan) = self.view.as_ref().and_then(|v| v.plans.iter().find(|p| p.id == sel)) {
let edges: usize = plan.nodes.iter().map(|n| n.deps.len()).sum();
ui.horizontal(|ui| {
ui.strong(&plan.summary);
ui.weak(format!("ยท {} nodes, {} deps", plan.nodes.len(), edges));
});
if !plan.idea_text.is_empty() {
ui.label(
egui::RichText::new(format!("๐ก {}", plan.idea_text))
.italics()
.size(11.0)
.color(Color32::from_gray(160)),
);
}
ui.add_space(2.0);
}
let mut viewer = FunnelViewer;
self.snarl.show(&mut viewer, &self.style, "funnel_snarl_canvas", ui);
});
}
}
fn topo_layers(plan: &PlanView) -> Vec<usize> {
let n = plan.nodes.len();
let idx: HashMap<&str, usize> =
plan.nodes.iter().enumerate().map(|(i, nd)| (nd.id.as_str(), i)).collect();
let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut indeg = vec![0usize; n];
for (v, node) in plan.nodes.iter().enumerate() {
for dep in &node.deps {
if let Some(&u) = idx.get(dep.as_str()) {
dependents[u].push(v);
indeg[v] += 1;
}
}
}
let mut layer = vec![0usize; n];
let mut queue: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
while let Some(u) = queue.pop_front() {
for &v in &dependents[u] {
layer[v] = layer[v].max(layer[u] + 1);
indeg[v] -= 1;
if indeg[v] == 0 {
queue.push_back(v);
}
}
}
layer
}
fn build_snarl(plan: &PlanView) -> Snarl<FunnelNode> {
let mut snarl = Snarl::new();
let layers = topo_layers(plan);
let mut by_layer: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (i, &l) in layers.iter().enumerate() {
by_layer.entry(l).or_default().push(i);
}
let max_rows = by_layer.values().map(|v| v.len()).max().unwrap_or(1) as f32;
let mid = (max_rows - 1.0) * 0.5 * Y_SPACING;
let mut sid: HashMap<String, NodeId> = HashMap::new();
for (layer, members) in &by_layer {
let count = members.len() as f32;
for (row, &ni) in members.iter().enumerate() {
let node = &plan.nodes[ni];
let x = 40.0 + *layer as f32 * X_SPACING;
let y = 40.0 + mid + (row as f32 - (count - 1.0) * 0.5) * Y_SPACING;
let id = snarl.insert_node(
Pos2::new(x, y),
FunnelNode {
id: node.id.clone(),
kind: node.kind.clone(),
title: node.title.clone(),
status: node.status,
targets: node.targets.clone(),
},
);
sid.insert(node.id.clone(), id);
}
}
for node in &plan.nodes {
let Some(&to) = sid.get(&node.id) else { continue };
for dep in &node.deps {
if let Some(&from) = sid.get(dep) {
snarl.connect(OutPinId { node: from, output: 0 }, InPinId { node: to, input: 0 });
}
}
}
snarl
}
fn legend(ui: &mut egui::Ui) {
for st in [
NodeStat::Pending,
NodeStat::Ready,
NodeStat::InProgress,
NodeStat::Done,
NodeStat::Blocked,
NodeStat::Failed,
] {
ui.colored_label(st.color(), st.glyph());
ui.weak(st.label());
ui.add_space(4.0);
}
}
struct FunnelViewer;
impl SnarlViewer<FunnelNode> for FunnelViewer {
fn title(&mut self, node: &FunnelNode) -> String {
node.id.clone()
}
fn inputs(&mut self, _node: &FunnelNode) -> usize {
1
}
fn outputs(&mut self, _node: &FunnelNode) -> usize {
1
}
fn show_header(
&mut self,
node: NodeId,
_inputs: &[InPin],
_outputs: &[OutPin],
ui: &mut egui::Ui,
_scale: f32,
snarl: &mut Snarl<FunnelNode>,
) {
let n = &snarl[node];
ui.horizontal(|ui| {
ui.colored_label(n.status.color(), n.status.glyph());
ui.strong(&n.id);
if !n.kind.is_empty() {
ui.label(egui::RichText::new(&n.kind).monospace().size(11.0).color(Color32::from_gray(170)));
}
});
}
fn has_body(&mut self, node: &FunnelNode) -> bool {
!node.title.is_empty() || !node.targets.is_empty()
}
fn show_body(
&mut self,
node: NodeId,
_inputs: &[InPin],
_outputs: &[OutPin],
ui: &mut egui::Ui,
_scale: f32,
snarl: &mut Snarl<FunnelNode>,
) {
let n = &snarl[node];
ui.vertical(|ui| {
ui.set_max_width(190.0);
if !n.title.is_empty() {
ui.label(egui::RichText::new(&n.title).size(11.0));
}
if !n.targets.is_empty() {
ui.label(
egui::RichText::new(format!("โ {}", n.targets.join(", ")))
.size(10.0)
.color(Color32::from_gray(150)),
);
}
ui.colored_label(n.status.color(), n.status.label());
});
}
fn show_input(
&mut self,
pin: &InPin,
_ui: &mut egui::Ui,
_scale: f32,
snarl: &mut Snarl<FunnelNode>,
) -> PinInfo {
let color = snarl.get_node(pin.id.node).map(|n| n.status.color()).unwrap_or(Color32::GRAY);
PinInfo::circle().with_fill(color)
}
fn show_output(
&mut self,
pin: &OutPin,
_ui: &mut egui::Ui,
_scale: f32,
snarl: &mut Snarl<FunnelNode>,
) -> PinInfo {
let color = snarl.get_node(pin.id.node).map(|n| n.status.color()).unwrap_or(Color32::GRAY);
PinInfo::triangle().with_fill(color)
}
fn connect(&mut self, _from: &OutPin, _to: &InPin, _snarl: &mut Snarl<FunnelNode>) {}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::funnel_view::NodeView;
fn node(id: &str, deps: &[&str]) -> NodeView {
NodeView {
id: id.into(),
kind: "test".into(),
title: String::new(),
status: NodeStat::Pending,
targets: Vec::new(),
deps: deps.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn topo_layers_longest_path() {
let plan = PlanView {
id: "p-001".into(),
summary: String::new(),
status: "active".into(),
idea_text: String::new(),
nodes: vec![
node("n-1", &[]),
node("n-2", &[]),
node("n-3", &["n-1"]),
node("n-4", &["n-2"]),
node("n-5", &["n-3", "n-2"]),
],
};
let layers = topo_layers(&plan);
assert_eq!(layers, vec![0, 0, 1, 1, 2]);
}
#[test]
fn topo_layers_tolerates_dangling_dep() {
let plan = PlanView {
id: "p-002".into(),
summary: String::new(),
status: "draft".into(),
idea_text: String::new(),
nodes: vec![node("n-1", &["nope"]), node("n-2", &["n-1"])],
};
let layers = topo_layers(&plan);
assert_eq!(layers, vec![0, 1]);
let snarl = build_snarl(&plan);
assert_eq!(snarl.nodes().count(), 2);
assert_eq!(snarl.wires().count(), 1);
}
}