Skip to main content

okf_studio/ui/
shell.rs

1//! The persistent chrome: tab bar, status bar, hint bar, toasts, and the
2//! overlay stack.
3
4use 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
15/// Draws the whole UI.
16pub 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}