Skip to main content

okf_studio/ui/
graph.rs

1//! Workspace 2 — Graph: the link graph on a Braille canvas.
2
3use 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/// Draws the graph workspace.
16///
17/// # Panics
18///
19/// Panics if called before the first snapshot has landed; the shell draws
20/// a loading screen instead of calling any workspace until then.
21#[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    // Aspect-corrected bounds: terminal cells are ~2× taller than wide.
61    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    // Label budget scales with zoom; highest-degree nodes win.
85    #[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    // Status line: filter input or selected-node summary.
155    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
206/// The glyph and style for a node under the active coloring dimension.
207fn 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}