use crate::model::{Source, World};
use ratatui::prelude::*;
use ratatui::symbols::Marker;
use ratatui::widgets::canvas::{Canvas, Circle, Line as CanvasLine, Points};
use ratatui::widgets::{Block, Borders, Paragraph};
#[derive(Clone, Copy, PartialEq, Debug)]
enum Side {
Left,
Center,
Right,
}
struct Node {
name: String,
x: f64,
y: f64,
color: Color,
center: bool,
side: Side,
}
pub struct GraphView {
nodes: Vec<Node>,
edges: Vec<(usize, usize)>,
selected: usize,
hidden_needed: usize,
hidden_deps: usize,
history: Vec<String>,
}
const PER_SIDE: usize = 16;
const RADIUS: f64 = 48.0;
impl GraphView {
pub fn build(world: &World, center: &str) -> GraphView {
let color_of = |name: &str| -> Color {
match world.packages.get(name) {
Some(p) if p.source == Source::Flatpak => Color::Blue,
_ if world.is_manual(name) => Color::Green,
_ => Color::Yellow,
}
};
let needed: Vec<String> = dedup(world.rdeps_of(center));
let deps: Vec<String> = dedup(world.deps_of(center));
let hidden_needed = needed.len().saturating_sub(PER_SIDE);
let hidden_deps = deps.len().saturating_sub(PER_SIDE);
let mut nodes = vec![Node {
name: center.to_string(),
x: 0.0,
y: 0.0,
color: color_of(center),
center: true,
side: Side::Center,
}];
let mut edges = Vec::new();
place_side(&needed, PER_SIDE, Side::Left, &color_of, &mut nodes, &mut edges);
place_side(&deps, PER_SIDE, Side::Right, &color_of, &mut nodes, &mut edges);
let selected = 0;
GraphView {
nodes,
edges,
selected,
hidden_needed,
hidden_deps,
history: Vec::new(),
}
}
fn column(&self, side: Side) -> Vec<usize> {
let mut v: Vec<usize> = (0..self.nodes.len())
.filter(|&i| self.nodes[i].side == side)
.collect();
v.sort_by(|&a, &b| {
self.nodes[b]
.y
.partial_cmp(&self.nodes[a].y)
.unwrap_or(std::cmp::Ordering::Equal)
});
v
}
pub fn move_vertical(&mut self, dir: isize) {
let col = self.column(self.nodes[self.selected].side);
if col.is_empty() {
return;
}
let pos = col.iter().position(|&i| i == self.selected).unwrap_or(0);
let next = (pos as isize + dir).clamp(0, col.len() as isize - 1) as usize;
self.selected = col[next];
}
pub fn move_horizontal(&mut self, dir: isize) {
let order = |s: Side| match s {
Side::Left => 0isize,
Side::Center => 1,
Side::Right => 2,
};
let y = self.nodes[self.selected].y;
let mut target = order(self.nodes[self.selected].side) + dir;
while (0..=2).contains(&target) {
let side = match target {
0 => Side::Left,
1 => Side::Center,
_ => Side::Right,
};
let col = self.column(side);
if let Some(&best) = col.iter().min_by(|&&a, &&b| {
(self.nodes[a].y - y)
.abs()
.partial_cmp(&(self.nodes[b].y - y).abs())
.unwrap_or(std::cmp::Ordering::Equal)
}) {
self.selected = best;
return;
}
target += dir;
}
}
pub fn center_name(&self) -> &str {
self.nodes
.iter()
.find(|n| n.center)
.map(|n| n.name.as_str())
.unwrap_or("")
}
pub fn recenter(&mut self, world: &World) {
let name = self.nodes[self.selected].name.clone();
if name == self.center_name() {
return; }
let mut history = std::mem::take(&mut self.history);
history.push(self.center_name().to_string());
*self = GraphView::build(world, &name);
self.history = history;
}
pub fn back(&mut self, world: &World) -> bool {
let Some(prev) = self.history.pop() else {
return false;
};
let history = std::mem::take(&mut self.history);
*self = GraphView::build(world, &prev);
self.history = history;
true
}
pub fn selected_name(&self) -> &str {
&self.nodes[self.selected].name
}
pub fn render(&self, f: &mut ratatui::Frame, area: Rect) {
let center_name = self.center_name();
let left_bg = Color::Rgb(14, 20, 34);
let right_bg = Color::Rgb(32, 23, 14);
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
let title = Line::from(vec![
Span::styled(" graph: ", Style::new().dim()),
Span::styled(center_name.to_string(), Style::new().bold().cyan()),
Span::styled(" now: ", Style::new().dim()),
Span::styled(self.selected_name().to_string(), Style::new().bold().white()),
]);
f.render_widget(Paragraph::new(title), chunks[0]);
let halves =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
f.render_widget(
Paragraph::new("needed by")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(150, 175, 225)).bold().bg(left_bg)),
halves[0],
);
f.render_widget(
Paragraph::new("depends on")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(225, 180, 140)).bold().bg(right_bg)),
halves[1],
);
let ca = chunks[2];
const X_SPAN: f64 = 72.0;
const Y_SPAN: f64 = 52.0;
let char_w = (2.0 * X_SPAN) / (ca.width.max(1) as f64);
let row_h = (2.0 * Y_SPAN) / (ca.height.max(1) as f64);
let canvas = Canvas::default()
.block(Block::default().borders(Borders::ALL))
.marker(Marker::Braille)
.x_bounds([-X_SPAN, X_SPAN])
.y_bounds([-Y_SPAN, Y_SPAN])
.paint(|ctx| {
for &(a, b) in &self.edges {
ctx.draw(&CanvasLine {
x1: self.nodes[a].x,
y1: self.nodes[a].y,
x2: self.nodes[b].x,
y2: self.nodes[b].y,
color: Color::DarkGray,
});
}
ctx.layer();
for (i, node) in self.nodes.iter().enumerate() {
if i == self.selected {
ctx.draw(&Circle {
x: node.x,
y: node.y,
radius: if node.center { 3.0 } else { 2.4 },
color: Color::White,
});
ctx.draw(&Points {
coords: &[(node.x, node.y)],
color: Color::White,
});
} else {
ctx.draw(&Circle {
x: node.x,
y: node.y,
radius: if node.center { 2.6 } else { 1.7 },
color: node.color,
});
}
}
ctx.layer();
for (i, node) in self.nodes.iter().enumerate() {
let style = if i == self.selected {
Style::new().fg(Color::White).bold()
} else {
Style::new().fg(node.color)
};
const GAP: f64 = 1.9; let room = match node.side {
Side::Left => node.x - GAP + X_SPAN,
Side::Right => X_SPAN - (node.x + GAP),
Side::Center => X_SPAN,
};
let fits = ((room / char_w).floor() as usize).clamp(6, 26);
let label = truncate(&node.name, fits);
let width = label.chars().count() as f64 * char_w;
let lift = if node.y > row_h {
row_h
} else if node.y < -row_h {
-row_h
} else {
0.0
};
let (lx, ly) = match node.side {
Side::Left => (node.x - GAP - width, node.y + lift),
Side::Right => (node.x + GAP, node.y + lift),
Side::Center => (node.x - width / 2.0, node.y + 4.0),
};
ctx.print(lx, ly, Span::styled(label, style));
}
});
f.render_widget(canvas, chunks[2]);
let mid = ca.x + ca.width / 2;
let band = ca.width / 24; let buf = f.buffer_mut();
for y in (ca.y + 1)..(ca.y + ca.height.saturating_sub(1)) {
for x in (ca.x + 1)..(ca.x + ca.width.saturating_sub(1)) {
let bg = if x + band < mid {
Some(left_bg)
} else if x > mid + band {
Some(right_bg)
} else {
None };
if let Some(bg) = bg
&& let Some(cell) = buf.cell_mut((x, y))
{
cell.set_bg(bg);
}
}
}
let legend = Line::from(vec![
Span::raw(" "),
Span::styled("● manual", Style::new().green()),
Span::raw(" "),
Span::styled("● auto", Style::new().yellow()),
Span::raw(" "),
Span::styled("● flatpak", Style::new().blue()),
]);
f.render_widget(Paragraph::new(legend), chunks[3]);
let mut controls =
" ←↓↑→ / hjkl move · Enter dig in · Esc back · q quit graph".to_string();
if self.hidden_needed > 0 || self.hidden_deps > 0 {
controls.push_str(&format!(
" · +{} needed, +{} deps hidden",
self.hidden_needed, self.hidden_deps
));
}
f.render_widget(
Paragraph::new(Line::from(Span::styled(controls, Style::new().dim()))),
chunks[4],
);
}
}
fn place_side(
names: &[String],
cap: usize,
side: Side,
color_of: &dyn Fn(&str) -> Color,
nodes: &mut Vec<Node>,
edges: &mut Vec<(usize, usize)>,
) {
let shown = names.len().min(cap);
if shown == 0 {
return;
}
const SWEEP_DEG: f64 = 80.0;
let y_top = RADIUS * SWEEP_DEG.to_radians().sin();
let inner_radius = RADIUS * 0.6;
for (i, name) in names.iter().take(shown).enumerate() {
let frac = (i as f64 + 1.0) / (shown as f64 + 1.0); let y = y_top - 2.0 * y_top * frac;
let inner = shown > 6 && i % 2 == 1;
let radius = if inner { inner_radius } else { RADIUS };
const POLE_FRAC: f64 = 0.6; let t = (y / y_top).clamp(-1.0, 1.0);
let bow = (t * SWEEP_DEG.to_radians()).cos();
let x_mag = radius * (POLE_FRAC + (1.0 - POLE_FRAC) * bow);
let x = if side == Side::Left { -x_mag } else { x_mag };
let idx = nodes.len();
nodes.push(Node {
name: name.clone(),
x,
y,
color: color_of(name),
center: false,
side,
});
edges.push((0, idx));
}
}
fn dedup(src: &[String]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
src.iter()
.filter(|s| seen.insert((*s).clone()))
.cloned()
.collect()
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn busy_world() -> (World, Vec<String>, Vec<String>) {
let needed: Vec<String> = (0..12).map(|i| format!("needs-me-{i:02}")).collect();
let deps: Vec<String> = (0..5).map(|i| format!("i-need-{i:02}")).collect();
let mut edges: Vec<(&str, &str)> = Vec::new();
for n in &needed {
edges.push((n, "center"));
}
for d in &deps {
edges.push(("center", d));
}
(World::from_edges(&edges, &["center"]), needed, deps)
}
fn side_of(g: &GraphView, name: &str) -> Side {
g.nodes.iter().find(|n| n.name == name).unwrap().side
}
#[test]
fn no_two_nodes_on_a_side_share_a_height() {
let (w, _, _) = busy_world();
let g = GraphView::build(&w, "center");
for side in [Side::Left, Side::Right] {
let ys: Vec<f64> = g
.nodes
.iter()
.filter(|n| n.side == side)
.map(|n| n.y)
.collect();
for (i, a) in ys.iter().enumerate() {
for b in ys.iter().skip(i + 1) {
assert!((a - b).abs() > 1.0, "two nodes share a row: {a} vs {b}");
}
}
}
}
#[test]
fn planets_never_collapse_onto_the_centre_line() {
let (w, _, _) = busy_world();
let g = GraphView::build(&w, "center");
let floor = RADIUS * 0.6 * 0.5;
for n in g.nodes.iter().filter(|n| !n.center) {
assert!(
n.x.abs() > floor,
"{} pinches toward the centre line (x={}, floor={floor})",
n.name,
n.x
);
}
}
#[test]
fn sides_are_on_the_side_they_claim() {
let (w, needed, deps) = busy_world();
let g = GraphView::build(&w, "center");
for n in &needed {
assert_eq!(side_of(&g, n), Side::Left, "{n} should be a 'needed by'");
}
for d in &deps {
assert_eq!(side_of(&g, d), Side::Right, "{d} should be a 'depends on'");
}
for n in g.nodes.iter().filter(|n| n.side == Side::Left) {
assert!(n.x < 0.0, "left-side node has positive x");
}
for n in g.nodes.iter().filter(|n| n.side == Side::Right) {
assert!(n.x > 0.0, "right-side node has negative x");
}
}
#[test]
fn labels_stay_inside_the_canvas() {
let (w, _, _) = busy_world();
let g = GraphView::build(&w, "center");
let max_x = g.nodes.iter().map(|n| n.x.abs()).fold(0.0, f64::max);
assert!(max_x < 72.0, "a planet is drawn off-canvas (x={max_x})");
}
#[test]
fn caps_each_side_and_reports_what_it_hid() {
let needed: Vec<String> = (0..30).map(|i| format!("n{i:02}")).collect();
let edges: Vec<(&str, &str)> = needed.iter().map(|n| (n.as_str(), "center")).collect();
let w = World::from_edges(&edges, &["center"]);
let g = GraphView::build(&w, "center");
assert_eq!(g.nodes.iter().filter(|n| n.side == Side::Left).count(), PER_SIDE);
assert_eq!(g.hidden_needed, 30 - PER_SIDE);
}
#[test]
fn starts_on_the_package_you_are_inspecting() {
let (w, _, _) = busy_world();
let g = GraphView::build(&w, "center");
assert_eq!(g.selected_name(), "center");
}
#[test]
fn vertical_movement_never_leaves_its_column() {
let (w, _, _) = busy_world();
let mut g = GraphView::build(&w, "center");
g.move_horizontal(-1); assert_eq!(side_of(&g, g.selected_name()), Side::Left);
for _ in 0..40 {
g.move_vertical(1);
assert_eq!(side_of(&g, g.selected_name()), Side::Left);
}
for _ in 0..40 {
g.move_vertical(-1);
assert_eq!(side_of(&g, g.selected_name()), Side::Left);
}
}
#[test]
fn horizontal_movement_walks_left_centre_right() {
let (w, _, _) = busy_world();
let mut g = GraphView::build(&w, "center");
g.move_horizontal(-1);
assert_eq!(side_of(&g, g.selected_name()), Side::Left);
g.move_horizontal(1);
assert_eq!(side_of(&g, g.selected_name()), Side::Center);
g.move_horizontal(1);
assert_eq!(side_of(&g, g.selected_name()), Side::Right);
g.move_horizontal(1); assert_eq!(side_of(&g, g.selected_name()), Side::Right);
}
#[test]
fn digging_in_and_backing_out_retraces_your_steps() {
let (w, _, _) = busy_world();
let mut g = GraphView::build(&w, "center");
g.move_horizontal(-1); let first_hop = g.selected_name().to_string();
g.recenter(&w);
assert_eq!(g.center_name(), first_hop, "Enter re-centres on the node");
assert!(g.back(&w), "Esc unwinds one step");
assert_eq!(g.center_name(), "center");
assert!(!g.back(&w), "no history left, so the caller closes the graph");
}
#[test]
fn recentring_on_the_current_centre_is_a_no_op() {
let (w, _, _) = busy_world();
let mut g = GraphView::build(&w, "center");
g.recenter(&w); assert_eq!(g.center_name(), "center");
assert!(!g.back(&w), "should not have pushed a pointless history entry");
}
#[test]
fn truncation_never_splits_a_multibyte_character() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("0123456789", 5), "0123…");
let wide = "日本語パッケージ名";
assert_eq!(truncate(wide, 4).chars().count(), 4);
assert_eq!(truncate("café-utils", 5), "café…");
}
}