1use crate::app::{App, ColorBy};
4use crate::graph::{EdgeKind, NodeKind};
5use crate::markdown::truncate_to_width;
6use crate::theme::{GLYPH_BROKEN, GLYPH_COMPUTATION, GLYPH_INDEX, GLYPH_WARN, tier_glyph};
7use ratatui::Frame;
8use ratatui::layout::{Constraint, Layout, Rect};
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::symbols::Marker;
11use ratatui::text::{Line, Span};
12use ratatui::widgets::canvas::{Canvas, Line as CanvasLine};
13use ratatui::widgets::{Block, Borders, Paragraph};
14
15#[allow(clippy::too_many_lines)]
22pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
23 let theme = &app.theme;
24 let snapshot = app.snapshot.as_ref().expect("drawn only with a snapshot");
25 let model = &snapshot.graph;
26 let included = app.graph_included(snapshot);
27 let visible_nodes = included.iter().filter(|&&i| i).count();
28
29 let [canvas_area, status_area] =
30 Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(area);
31
32 let focus_label = app
33 .graph
34 .focus
35 .as_ref()
36 .map_or(String::new(), |(_, k)| format!(" · focus {k}-hop"));
37 let title = format!(
38 " Graph ── {} nodes · {} edges ── layout: {} ── color: {}{focus_label} ",
39 visible_nodes,
40 model.edges.len(),
41 app.graph.mode.name(),
42 app.graph.color_by.name(),
43 );
44 let legend = format!(
45 " ◆ human-reviewed ● machine-confirmed ○ unverified {GLYPH_WARN} stale {GLYPH_BROKEN} broken "
46 );
47 let block = Block::new()
48 .borders(Borders::ALL)
49 .title(truncate_to_width(
50 &title,
51 usize::from(area.width).saturating_sub(2),
52 ))
53 .title_bottom(Line::from(Span::styled(
54 truncate_to_width(&legend, usize::from(area.width).saturating_sub(2)),
55 theme.dim(),
56 )))
57 .border_style(theme.dim());
58 let inner = block.inner(canvas_area);
59
60 let half_w = 1.2 / app.graph.zoom;
62 let aspect = f64::from(inner.height) * 2.0 / f64::from(inner.width.max(1));
63 let half_h = half_w * aspect;
64 let (cx, cy) = app.graph.pan;
65 let x_bounds = [cx - half_w, cx + half_w];
66 let y_bounds = [cy - half_h, cy + half_h];
67
68 let positions = &app.graph.layout.positions;
69 let pos_of = |ix: usize| positions.get(&model.nodes[ix].key).copied();
70 let filter = app
71 .graph
72 .filter_input
73 .clone()
74 .unwrap_or_else(|| app.graph.filter.clone());
75 let matches_filter = |ix: usize| -> bool {
76 filter.is_empty() || crate::search::fuzzy_match(&filter, &model.nodes[ix].label).is_some()
77 };
78 let selected_ix = app
79 .graph
80 .selected
81 .as_ref()
82 .and_then(|key| model.nodes.iter().position(|n| &n.key == key));
83
84 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
86 let label_budget = ((app.graph.zoom * 8.0) as usize).clamp(3, 40);
87 let mut by_degree: Vec<usize> = (0..model.nodes.len()).filter(|&i| included[i]).collect();
88 by_degree.sort_by_key(|&i| std::cmp::Reverse(model.nodes[i].degree));
89 let labeled: std::collections::HashSet<usize> =
90 by_degree.into_iter().take(label_budget).collect();
91
92 let canvas = Canvas::default()
93 .marker(Marker::Braille)
94 .x_bounds(x_bounds)
95 .y_bounds(y_bounds)
96 .paint(|ctx| {
97 for edge in &model.edges {
98 if !included[edge.from] || !included[edge.to] {
99 continue;
100 }
101 if edge.kind == EdgeKind::Derivation && !app.graph.show_derivations {
102 continue;
103 }
104 let (Some(a), Some(b)) = (pos_of(edge.from), pos_of(edge.to)) else {
105 continue;
106 };
107 let selected_edge = selected_ix == Some(edge.from) || selected_ix == Some(edge.to);
108 let color = if !app.theme.color {
109 Color::Reset
110 } else if selected_edge {
111 Color::Cyan
112 } else {
113 match edge.kind {
114 EdgeKind::Broken => Color::Red,
115 EdgeKind::Derivation => Color::Magenta,
116 EdgeKind::Source => Color::DarkGray,
117 EdgeKind::Link => Color::Gray,
118 }
119 };
120 ctx.draw(&CanvasLine {
121 x1: a.0,
122 y1: a.1,
123 x2: b.0,
124 y2: b.1,
125 color,
126 });
127 }
128 ctx.layer();
129 for (ix, node) in model.nodes.iter().enumerate() {
130 if !included[ix] {
131 continue;
132 }
133 let Some((x, y)) = pos_of(ix) else { continue };
134 let selected = selected_ix == Some(ix);
135 let dimmed = !matches_filter(ix);
136 let (glyph, mut style) = node_appearance(app, snapshot, ix);
137 if dimmed {
138 style = theme.dim();
139 }
140 if selected {
141 style = style.add_modifier(Modifier::REVERSED);
142 }
143 let text = if selected || (labeled.contains(&ix) && !dimmed) {
144 format!("{glyph} {}", node.label)
145 } else {
146 glyph.to_string()
147 };
148 ctx.print(x, y, Line::from(Span::styled(text, style)));
149 }
150 });
151 frame.render_widget(block, canvas_area);
152 frame.render_widget(canvas, inner);
153
154 let status: Line<'static> = app.graph.filter_input.as_ref().map_or_else(
156 || {
157 selected_ix.map_or_else(
158 || {
159 Line::from(Span::styled(
160 " Tab selects a node · Enter opens it · f focus mode".to_string(),
161 theme.dim(),
162 ))
163 },
164 |ix| {
165 let node = &model.nodes[ix];
166 node.id.as_ref().map_or_else(
167 || Line::from(Span::raw(format!(" ▸ {}", node.label))),
168 |id| {
169 let meta = snapshot.meta(id);
170 let text = meta.map_or_else(
171 || format!(" ▸ {id}"),
172 |m| {
173 format!(
174 " ▸ {id} — {} · {} · {} out / {} in · {} source(s)",
175 snapshot
176 .bundle
177 .get(id)
178 .and_then(|c| c
179 .type_()
180 .map(std::borrow::Cow::into_owned))
181 .unwrap_or_default(),
182 tier_glyph(m.tier),
183 m.out_degree,
184 m.in_degree,
185 m.source_count
186 )
187 },
188 );
189 Line::from(Span::raw(text))
190 },
191 )
192 },
193 )
194 },
195 |input| {
196 Line::from(vec![
197 Span::styled(" filter ⌕ ".to_string(), theme.accent()),
198 Span::raw(input.clone()),
199 Span::styled("█".to_string(), theme.accent()),
200 ])
201 },
202 );
203 frame.render_widget(Paragraph::new(status), status_area);
204}
205
206fn node_appearance(
208 app: &App,
209 snapshot: &crate::snapshot::Snapshot,
210 ix: usize,
211) -> (&'static str, Style) {
212 let theme = &app.theme;
213 let node = &snapshot.graph.nodes[ix];
214 match node.kind {
215 NodeKind::Phantom => return (GLYPH_BROKEN, theme.error()),
216 NodeKind::Source => return (GLYPH_INDEX, theme.dim()),
217 NodeKind::Computation => {
218 if app.graph.color_by == ColorBy::Trust {
219 return (GLYPH_COMPUTATION, theme.accent());
220 }
221 }
222 NodeKind::Concept => {}
223 }
224 let Some(meta) = node.id.as_ref().and_then(|id| snapshot.meta(id)) else {
225 return ("●", Style::default());
226 };
227 match app.graph.color_by {
228 ColorBy::Trust => (tier_glyph(meta.tier), theme.tier(meta.tier)),
229 ColorBy::Status => (
230 crate::theme::status_glyph(&meta.status),
231 theme.status(&meta.status),
232 ),
233 ColorBy::Staleness => {
234 if meta.stale {
235 (GLYPH_WARN, theme.warn())
236 } else if meta.stale_in_days.is_some() {
237 ("⏳", theme.warn())
238 } else {
239 ("●", theme.ok())
240 }
241 }
242 ColorBy::Type => {
243 if meta.is_computation {
244 (GLYPH_COMPUTATION, theme.accent())
245 } else {
246 ("●", Style::default())
247 }
248 }
249 ColorBy::Diagnostics => {
250 if meta.diag_errors > 0 {
251 (GLYPH_BROKEN, theme.error())
252 } else if meta.diag_warnings + meta.lint_findings > 0 {
253 (GLYPH_WARN, theme.warn())
254 } else {
255 ("●", theme.ok())
256 }
257 }
258 }
259}