1use crate::app::{App, ExplorerPane, LoadPhase, Overlay};
5use crate::keymap::{Context, hints};
6use crate::markdown::truncate_to_width;
7use crate::theme::{GLYPH_BROKEN, GLYPH_OK, GLYPH_WARN, SPINNER_FRAMES};
8use crate::{Tab, ui};
9use ratatui::Frame;
10use ratatui::layout::{Constraint, Layout, Rect};
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Clear, Paragraph};
14
15pub fn draw(frame: &mut Frame, app: &App) {
17 let [tab_bar, body, status, hint_bar] = Layout::vertical([
18 Constraint::Length(1),
19 Constraint::Min(1),
20 Constraint::Length(1),
21 Constraint::Length(1),
22 ])
23 .areas(frame.area());
24
25 draw_tabs(frame, app, tab_bar);
26 if let Some(error) = &app.load_error {
27 let text = Paragraph::new(vec![
28 Line::default(),
29 Line::from(Span::styled(
30 format!("{GLYPH_BROKEN} could not load bundle"),
31 app.theme.error(),
32 )),
33 Line::from(error.as_str()),
34 Line::default(),
35 Line::from(Span::styled("R retries · q quits", app.theme.dim())),
36 ]);
37 frame.render_widget(text, body);
38 } else if app.snapshot.is_some() {
39 match app.tab {
40 Tab::Explorer => ui::explorer::draw(frame, app, body),
41 Tab::Graph => ui::graph::draw(frame, app, body),
42 Tab::Trust => ui::trust::draw(frame, app, body),
43 Tab::Computations => ui::computations::draw(frame, app, body),
44 }
45 } else {
46 let spinner = SPINNER_FRAMES[usize::try_from(app.tick).unwrap_or(0) % SPINNER_FRAMES.len()];
47 frame.render_widget(
48 Paragraph::new(format!("\n {spinner} loading bundle…")),
49 body,
50 );
51 }
52 draw_status(frame, app, status);
53 draw_hints(frame, app, hint_bar);
54
55 for overlay in &app.overlays {
56 match overlay {
57 Overlay::Refactor(state) => ui::refactor_modal::draw(frame, app, body, state),
58 other => ui::overlays::draw(frame, app, body, other),
59 }
60 }
61
62 draw_toasts(frame, app, body);
63}
64
65fn draw_tabs(frame: &mut Frame, app: &App, area: Rect) {
66 let stats = app.snapshot.as_ref().map(|s| &s.stats);
67 let stale_badge = stats.map_or(0, |s| s.stale);
68 let labels: [(Tab, String); 4] = [
69 (Tab::Explorer, "[1]Explorer".to_string()),
70 (Tab::Graph, "[2]Graph".to_string()),
71 (
72 Tab::Trust,
73 if stale_badge > 0 {
74 format!("[3]Trust {GLYPH_WARN}{stale_badge}")
75 } else {
76 "[3]Trust".to_string()
77 },
78 ),
79 (Tab::Computations, "[4]Compute".to_string()),
80 ];
81 let mut spans = vec![Span::styled(
82 format!(" okf studio ─ {} ", app.root_name),
83 app.theme.accent().add_modifier(Modifier::BOLD),
84 )];
85 for (tab, label) in labels {
86 let style = if tab == app.tab {
87 app.theme.accent().add_modifier(Modifier::REVERSED)
88 } else {
89 Style::default()
90 };
91 spans.push(Span::styled(format!(" {label} "), style));
92 spans.push(Span::raw(" "));
93 }
94 frame.render_widget(Paragraph::new(Line::from(spans)), area);
95}
96
97fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
98 let theme = &app.theme;
99 let mut spans: Vec<Span<'static>> = Vec::new();
100 if let Some(snapshot) = &app.snapshot {
101 let errors = snapshot.validation.error_count();
102 let warnings = snapshot.validation.warning_count();
103 if snapshot.validation.is_conformant() {
104 spans.push(Span::styled(format!(" {GLYPH_OK} conformant"), theme.ok()));
105 } else {
106 spans.push(Span::styled(
107 format!(" {GLYPH_BROKEN} not conformant"),
108 theme.error(),
109 ));
110 }
111 spans.push(Span::styled(
112 format!(" {GLYPH_WARN} {warnings} warnings"),
113 if warnings > 0 {
114 theme.warn()
115 } else {
116 theme.dim()
117 },
118 ));
119 spans.push(Span::styled(
120 format!(" {GLYPH_BROKEN} {errors} errors"),
121 if errors > 0 {
122 theme.error()
123 } else {
124 theme.dim()
125 },
126 ));
127 spans.push(Span::styled(
128 format!(
129 " │ {} concepts │ {} stale │ today {}",
130 snapshot.stats.concepts, snapshot.stats.stale, snapshot.today
131 ),
132 Style::default(),
133 ));
134 }
135 match app.loading {
136 LoadPhase::Idle => {}
137 LoadPhase::Reloading | LoadPhase::Applying => {
138 let spinner =
139 SPINNER_FRAMES[usize::try_from(app.tick).unwrap_or(0) % SPINNER_FRAMES.len()];
140 let what = if app.loading == LoadPhase::Reloading {
141 "reloading"
142 } else {
143 "applying"
144 };
145 spans.push(Span::styled(format!(" │ {spinner} {what}"), theme.accent()));
146 }
147 }
148 frame.render_widget(Paragraph::new(Line::from(spans)), area);
149}
150
151fn draw_hints(frame: &mut Frame, app: &App, area: Rect) {
152 let context = match (app.tab, app.explorer.pane) {
153 (Tab::Explorer, ExplorerPane::Tree) => Context::Tree,
154 (Tab::Explorer, ExplorerPane::Viewer) => Context::Viewer,
155 (Tab::Graph, _) => Context::Graph,
156 (Tab::Trust, _) => Context::Trust,
157 (Tab::Computations, _) => Context::Computations,
158 };
159 let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
160 for binding in hints(context).take(5) {
161 spans.push(Span::styled(binding.label.to_string(), app.theme.accent()));
162 spans.push(Span::styled(
163 format!(" {} ", binding.help),
164 app.theme.dim(),
165 ));
166 }
167 spans.push(Span::styled("/".to_string(), app.theme.accent()));
168 spans.push(Span::styled(" search ".to_string(), app.theme.dim()));
169 spans.push(Span::styled("?".to_string(), app.theme.accent()));
170 spans.push(Span::styled(" help ".to_string(), app.theme.dim()));
171 spans.push(Span::styled("q".to_string(), app.theme.accent()));
172 spans.push(Span::styled(" quit".to_string(), app.theme.dim()));
173 frame.render_widget(Paragraph::new(Line::from(spans)), area);
174}
175
176fn draw_toasts(frame: &mut Frame, app: &App, body: Rect) {
177 for (i, toast) in app.toasts.iter().rev().take(3).enumerate() {
178 let text = truncate_to_width(&toast.text, usize::from(body.width).saturating_sub(4));
179 let width = u16::try_from(crate::markdown::str_width(&text)).unwrap_or(body.width) + 2;
180 let i16 = u16::try_from(i).unwrap_or(0);
181 if body.height < 2 + i16 {
182 break;
183 }
184 let area = Rect {
185 x: body.x + body.width.saturating_sub(width + 1),
186 y: body.y + body.height - 1 - i16,
187 width: width.min(body.width),
188 height: 1,
189 };
190 let style = if toast.error {
191 app.theme.error().add_modifier(Modifier::REVERSED)
192 } else {
193 app.theme.ok().add_modifier(Modifier::REVERSED)
194 };
195 frame.render_widget(Clear, area);
196 frame.render_widget(Paragraph::new(format!(" {text} ")).style(style), area);
197 }
198}