use petgraph::stable_graph::NodeIndex;
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Color, Style};
use ratatui::widgets::Widget;
use crate::graph::types::*;
use crate::parser::artifacts::RunStatus;
use super::app::App;
use super::run_status::{status_color, status_symbol};
const NODE_BOX_WIDTH: u16 = 24;
const NODE_BOX_HEIGHT: u16 = 3;
const LAYER_GAP: u16 = 12;
const NODE_GAP: u16 = 2;
pub struct GraphWidget<'a> {
app: &'a App,
}
impl<'a> GraphWidget<'a> {
pub fn new(app: &'a App) -> Self {
Self { app }
}
fn eff_layer_gap(&self) -> u16 {
(LAYER_GAP as f64 * self.app.zoom).max(4.0) as u16
}
fn eff_node_gap(&self) -> u16 {
(NODE_GAP as f64 * self.app.zoom).max(1.0) as u16
}
fn world_pos(&self, layer: usize, pos: usize) -> (i32, i32) {
let eff_lg = self.eff_layer_gap();
let eff_ng = self.eff_node_gap();
let wx = layer as i32 * (NODE_BOX_WIDTH as i32 + eff_lg as i32);
let wy = pos as i32 * (NODE_BOX_HEIGHT as i32 + eff_ng as i32);
(wx, wy)
}
fn to_screen(&self, wx: i32, wy: i32, area: Rect) -> Option<(u16, u16)> {
let sx = wx - self.app.viewport_x + area.x as i32;
let sy = wy - self.app.viewport_y + area.y as i32;
if sx >= area.x as i32
&& sy >= area.y as i32
&& sx < (area.x + area.width) as i32
&& sy < (area.y + area.height) as i32
{
Some((sx as u16, sy as u16))
} else {
None
}
}
fn set_cell(&self, buf: &mut Buffer, wx: i32, wy: i32, area: Rect, symbol: &str, style: Style) {
if let Some((sx, sy)) = self.to_screen(wx, wy, area) {
if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) {
cell.set_symbol(symbol);
cell.set_style(style);
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_hline(
&self,
buf: &mut Buffer,
wx_start: i32,
wx_end: i32,
wy: i32,
area: Rect,
symbol: &str,
style: Style,
) {
let (left, right) = if wx_start <= wx_end {
(wx_start, wx_end)
} else {
(wx_end, wx_start)
};
for wx in left..=right {
self.set_cell(buf, wx, wy, area, symbol, style);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_vline(
&self,
buf: &mut Buffer,
wx: i32,
wy_start: i32,
wy_end: i32,
area: Rect,
symbol: &str,
style: Style,
) {
let (top, bottom) = if wy_start <= wy_end {
(wy_start, wy_end)
} else {
(wy_end, wy_start)
};
for wy in top..=bottom {
self.set_cell(buf, wx, wy, area, symbol, style);
}
}
fn draw_edges(&self, buf: &mut Buffer, area: Rect) {
for edge in self.app.graph.edge_references() {
let source = edge.source();
let target = edge.target();
let (Some(&(sl, sp)), Some(&(tl, tp))) = (
self.app.layout.positions.get(&source),
self.app.layout.positions.get(&target),
) else {
continue;
};
let color = match edge.weight().edge_type {
EdgeType::Ref => Color::Gray,
EdgeType::Source => Color::DarkGray,
EdgeType::Test => Color::Cyan,
EdgeType::Exposure => Color::Red,
};
let style = Style::default().fg(color);
let (src_wx, src_wy) = self.world_pos(sl, sp);
let (tgt_wx, tgt_wy) = self.world_pos(tl, tp);
let src_right = src_wx + NODE_BOX_WIDTH as i32;
let src_mid_y = src_wy + NODE_BOX_HEIGHT as i32 / 2;
let tgt_left = tgt_wx;
let tgt_mid_y = tgt_wy + NODE_BOX_HEIGHT as i32 / 2;
let mid_x = (src_right + tgt_left) / 2;
if src_mid_y == tgt_mid_y {
self.draw_hline(buf, src_right, tgt_left - 1, src_mid_y, area, "─", style);
self.set_cell(buf, tgt_left - 1, tgt_mid_y, area, "▸", style);
} else {
if mid_x > src_right {
self.draw_hline(buf, src_right, mid_x - 1, src_mid_y, area, "─", style);
}
let (vy_start, vy_end) = if src_mid_y < tgt_mid_y {
(src_mid_y + 1, tgt_mid_y - 1)
} else {
(tgt_mid_y + 1, src_mid_y - 1)
};
if vy_start <= vy_end {
self.draw_vline(buf, mid_x, vy_start, vy_end, area, "│", style);
}
if tgt_left - 1 > mid_x {
self.draw_hline(buf, mid_x + 1, tgt_left - 2, tgt_mid_y, area, "─", style);
}
self.set_cell(buf, tgt_left - 1, tgt_mid_y, area, "▸", style);
if src_mid_y < tgt_mid_y {
self.set_cell(buf, mid_x, src_mid_y, area, "┐", style);
self.set_cell(buf, mid_x, tgt_mid_y, area, "└", style);
} else {
self.set_cell(buf, mid_x, src_mid_y, area, "┘", style);
self.set_cell(buf, mid_x, tgt_mid_y, area, "┌", style);
}
}
}
}
fn draw_nodes(&self, buf: &mut Buffer, area: Rect) {
for idx in self.app.graph.node_indices() {
let Some(&(layer, pos)) = self.app.layout.positions.get(&idx) else {
continue;
};
let (wx, wy) = self.world_pos(layer, pos);
let node = &self.app.graph[idx];
let is_selected = self.app.selected_node == Some(idx);
let run_status = self.app.node_run_status(&node.unique_id);
let node_fg = match run_status {
RunStatus::NeverRun => node_color(node.node_type),
_ => status_color(run_status),
};
let (border_style, content_style) = if is_selected {
(
Style::default().fg(Color::Black).bg(Color::White),
Style::default().fg(Color::Black).bg(Color::White),
)
} else {
(Style::default().fg(node_fg), Style::default().fg(node_fg))
};
let w = NODE_BOX_WIDTH as i32;
let h = NODE_BOX_HEIGHT as i32;
self.set_cell(buf, wx, wy, area, "┌", border_style);
for dx in 1..w - 1 {
self.set_cell(buf, wx + dx, wy, area, "─", border_style);
}
self.set_cell(buf, wx + w - 1, wy, area, "┐", border_style);
for dy in 1..h - 1 {
self.set_cell(buf, wx, wy + dy, area, "│", border_style);
for dx in 1..w - 1 {
self.set_cell(buf, wx + dx, wy + dy, area, " ", content_style);
}
self.set_cell(buf, wx + w - 1, wy + dy, area, "│", border_style);
}
self.set_cell(buf, wx, wy + h - 1, area, "└", border_style);
for dx in 1..w - 1 {
self.set_cell(buf, wx + dx, wy + h - 1, area, "─", border_style);
}
self.set_cell(buf, wx + w - 1, wy + h - 1, area, "┘", border_style);
let sym = status_symbol(run_status);
let display = node.display_name();
let label = format!("{} {}", sym, display);
let max_chars = (NODE_BOX_WIDTH - 2) as usize; let truncated = truncate_label(&label, max_chars);
let padded = format!(" {:<width$}", truncated, width = max_chars - 1);
let content_y = wy + 1;
for (i, ch) in padded.chars().enumerate() {
let cx = wx + 1 + i as i32;
if cx >= wx + w - 1 {
break;
}
self.set_cell(buf, cx, content_y, area, &ch.to_string(), content_style);
}
}
}
}
impl<'a> Widget for GraphWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
self.draw_edges(buf, area);
self.draw_nodes(buf, area);
}
}
fn node_color(node_type: NodeType) -> Color {
match node_type {
NodeType::Model => Color::Blue,
NodeType::Source => Color::Green,
NodeType::Seed => Color::Yellow,
NodeType::Snapshot => Color::Magenta,
NodeType::Test => Color::Cyan,
NodeType::Exposure => Color::Red,
NodeType::Phantom => Color::DarkGray,
}
}
fn truncate_label(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}…", &s[..max_len - 1])
}
}
pub fn hit_test_node(app: &App, screen_x: u16, screen_y: u16) -> Option<NodeIndex> {
let area = app.last_graph_area?;
let eff_lg = (LAYER_GAP as f64 * app.zoom).max(4.0) as u16;
let eff_ng = (NODE_GAP as f64 * app.zoom).max(1.0) as u16;
let wx = (screen_x as i32 - area.x as i32) + app.viewport_x;
let wy = (screen_y as i32 - area.y as i32) + app.viewport_y;
for (&node_idx, &(layer, pos)) in &app.layout.positions {
let node_wx = layer as i32 * (NODE_BOX_WIDTH as i32 + eff_lg as i32);
let node_wy = pos as i32 * (NODE_BOX_HEIGHT as i32 + eff_ng as i32);
if wx >= node_wx
&& wx < node_wx + NODE_BOX_WIDTH as i32
&& wy >= node_wy
&& wy < node_wy + NODE_BOX_HEIGHT as i32
{
return Some(node_idx);
}
}
None
}
pub fn node_world_center(layer: usize, pos: usize, zoom: f64) -> (i32, i32) {
let eff_lg = (LAYER_GAP as f64 * zoom).max(4.0) as u16;
let eff_ng = (NODE_GAP as f64 * zoom).max(1.0) as u16;
let wx = layer as i32 * (NODE_BOX_WIDTH as i32 + eff_lg as i32);
let wy = pos as i32 * (NODE_BOX_HEIGHT as i32 + eff_ng as i32);
let cx = wx + NODE_BOX_WIDTH as i32 / 2;
let cy = wy + NODE_BOX_HEIGHT as i32 / 2;
(cx, cy)
}