Skip to main content

okf_studio/ui/
computations.rs

1//! Workspace 4 — Computations Playground: contract inspection and an
2//! execution-free invocation builder.
3
4use crate::app::{App, CompPane};
5use crate::markdown::truncate_to_width;
6use crate::theme::{GLYPH_BROKEN, GLYPH_COMPUTATION, GLYPH_OK};
7use crate::ui::widgets::input_line;
8use okf_core::ComputationSource;
9use ratatui::Frame;
10use ratatui::layout::{Constraint, Layout, Rect};
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Paragraph};
14
15/// Draws the computations 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.
21pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
22    let theme = &app.theme;
23    let snapshot = app.snapshot.as_ref().expect("drawn only with a snapshot");
24    let block = Block::new()
25        .borders(Borders::ALL)
26        .title(format!(
27            " Computations ── {} contract(s) ",
28            snapshot.contracts.len()
29        ))
30        .border_style(theme.dim());
31    let inner = block.inner(area);
32    frame.render_widget(block, area);
33
34    if snapshot.contracts.is_empty() {
35        frame.render_widget(
36            Paragraph::new("\n no Attested Computation concepts in this bundle"),
37            inner,
38        );
39        return;
40    }
41
42    let [list_area, detail_area] =
43        Layout::horizontal([Constraint::Length(26), Constraint::Min(20)]).areas(inner);
44    draw_list(frame, app, list_area);
45    draw_detail(frame, app, detail_area);
46}
47
48fn draw_list(frame: &mut Frame, app: &App, area: Rect) {
49    let theme = &app.theme;
50    let snapshot = app.snapshot.as_ref().expect("drawn only with a snapshot");
51    let focused = app.computations.pane == CompPane::List;
52    let mut lines = vec![Line::from(Span::styled(
53        "CONTRACTS",
54        if focused {
55            theme.accent().add_modifier(Modifier::BOLD)
56        } else {
57            theme.dim()
58        },
59    ))];
60    for (ix, info) in snapshot.contracts.iter().enumerate() {
61        let selected = ix == app.computations.selected;
62        let marker = if selected { "▸" } else { " " };
63        let (verdict, style) = if info.healthy() {
64            (GLYPH_OK, theme.ok())
65        } else {
66            (GLYPH_BROKEN, theme.error())
67        };
68        let mut line = Line::from(vec![
69            Span::raw(format!("{marker} ")),
70            Span::styled(format!("{GLYPH_COMPUTATION} "), theme.accent()),
71            Span::raw(format!("{:<16}", truncate_to_width(info.id.name(), 16))),
72            Span::styled(verdict.to_string(), style),
73        ]);
74        if selected && focused {
75            line = line.style(theme.selection());
76        }
77        lines.push(line);
78    }
79    frame.render_widget(Paragraph::new(lines), area);
80}
81
82#[allow(clippy::too_many_lines)]
83fn draw_detail(frame: &mut Frame, app: &App, area: Rect) {
84    let theme = &app.theme;
85    let snapshot = app.snapshot.as_ref().expect("drawn only with a snapshot");
86    let Some(info) = snapshot.contracts.get(app.computations.selected) else {
87        return;
88    };
89    let contract = &info.contract;
90    let width = usize::from(area.width).saturating_sub(2);
91    let form_focused = app.computations.pane == CompPane::Form;
92
93    let mut lines: Vec<Line<'static>> = Vec::new();
94    let meta = snapshot.meta(&info.id);
95    lines.push(Line::from(vec![
96        Span::styled(
97            format!("{} ", info.id),
98            Style::default().add_modifier(Modifier::BOLD),
99        ),
100        Span::styled(
101            format!(
102                "· runtime: {} · {}",
103                contract.runtime.clone().unwrap_or_else(|| "?".into()),
104                meta.map_or_else(String::new, |m| format!(
105                    "{} {}",
106                    crate::theme::status_glyph(&m.status),
107                    crate::theme::tier_glyph(m.tier)
108                ))
109            ),
110            theme.dim(),
111        ),
112    ]));
113    lines.push(Line::default());
114
115    // Contract card.
116    lines.push(Line::from(Span::styled("CONTRACT", theme.accent())));
117    if contract.parameters.is_empty() {
118        lines.push(Line::from(Span::styled("  (no parameters)", theme.dim())));
119    }
120    for parameter in &contract.parameters {
121        lines.push(Line::from(vec![
122            Span::styled("  parameter  ".to_string(), theme.dim()),
123            Span::raw(parameter.to_string()),
124        ]));
125    }
126    for (field, raw, resolved) in &info.path_checks {
127        let (glyph, style) = if resolved.is_some() {
128            (GLYPH_OK, theme.ok())
129        } else {
130            (GLYPH_BROKEN, theme.error())
131        };
132        lines.push(Line::from(vec![
133            Span::styled(format!("  {field:<11}"), theme.dim()),
134            Span::raw(truncate_to_width(raw, width.saturating_sub(16))),
135            Span::styled(format!(" {glyph}"), style),
136        ]));
137    }
138    if let Some(executor) = &contract.executor
139        && !executor.receipt.is_empty()
140    {
141        lines.push(Line::from(vec![
142            Span::styled("  receipt    ".to_string(), theme.dim()),
143            Span::raw(executor.receipt.join(", ")),
144        ]));
145    }
146    for issue in &info.issues {
147        lines.push(Line::from(Span::styled(
148            format!("  {GLYPH_BROKEN} {issue}"),
149            theme.error(),
150        )));
151    }
152    lines.push(Line::default());
153
154    // Computation block.
155    match &contract.computation {
156        ComputationSource::Inline(inline) => {
157            let verdict = match &info.syntax {
158                Some(Ok(())) => format!(" syntax {GLYPH_OK}"),
159                Some(Err(_)) => format!(" syntax {GLYPH_BROKEN}"),
160                None => String::new(),
161            };
162            lines.push(Line::from(Span::styled(
163                format!(
164                    "COMPUTATION (inline · {}{verdict})",
165                    inline.language.clone().unwrap_or_else(|| "?".into())
166                ),
167                theme.accent(),
168            )));
169            for code_line in inline.code.lines().take(10) {
170                lines.push(Line::from(vec![
171                    Span::styled("│ ".to_string(), theme.dim()),
172                    Span::raw(truncate_to_width(code_line, width.saturating_sub(2))),
173                ]));
174            }
175            if inline.code.lines().count() > 10 {
176                lines.push(Line::from(Span::styled("│ …", theme.dim())));
177            }
178        }
179        ComputationSource::File(path) => {
180            lines.push(Line::from(vec![
181                Span::styled("COMPUTATION ".to_string(), theme.accent()),
182                Span::raw(format!("file: {path}")),
183            ]));
184        }
185        ComputationSource::Missing => {
186            lines.push(Line::from(Span::styled(
187                format!("COMPUTATION {GLYPH_BROKEN} missing"),
188                theme.error(),
189            )));
190        }
191    }
192    lines.push(Line::default());
193
194    // Playground.
195    lines.push(Line::from(Span::styled(
196        "PLAYGROUND ── invocation builder",
197        if form_focused {
198            theme.accent().add_modifier(Modifier::BOLD)
199        } else {
200            theme.accent()
201        },
202    )));
203    let mut args: Vec<String> = Vec::new();
204    for (ix, parameter) in contract.parameters.iter().enumerate() {
205        let name = parameter.name.clone().unwrap_or_default();
206        let key = format!("{}\u{0}{}", info.id, name);
207        let value = app
208            .computations
209            .values
210            .get(&key)
211            .cloned()
212            .unwrap_or_default();
213        let type_ = parameter.type_.clone().unwrap_or_else(|| "string".into());
214        let valid = check_type(&type_, &value);
215        let required = parameter.is_required();
216        let verdict = if value.is_empty() {
217            if required {
218                Span::styled(format!(" {GLYPH_BROKEN} required"), app.theme.error())
219            } else {
220                Span::styled(format!(" ({type_}, opt)"), app.theme.dim())
221            }
222        } else if valid {
223            Span::styled(format!(" {GLYPH_OK} {type_}"), app.theme.ok())
224        } else {
225            Span::styled(format!(" {GLYPH_BROKEN} not a {type_}"), app.theme.error())
226        };
227        let active = form_focused && ix == app.computations.field;
228        let mut line = input_line(
229            &format!("  {name:<14}"),
230            &value,
231            active,
232            theme,
233            width.saturating_sub(16),
234        );
235        line.spans.push(verdict);
236        lines.push(line);
237        if !value.is_empty() || required {
238            args.push(format!("{name}={value}"));
239        }
240    }
241    lines.push(Line::default());
242    lines.push(Line::from(vec![
243        Span::styled("  ▶ call sketch:  ".to_string(), theme.dim()),
244        Span::styled(
245            format!("{}({})", info.id.name(), args.join(", ")),
246            Style::default().add_modifier(Modifier::BOLD),
247        ),
248    ]));
249    if let Some(executor) = &contract.executor
250        && !executor.receipt.is_empty()
251    {
252        lines.push(Line::from(vec![
253            Span::styled("  ▶ expected receipt: ".to_string(), theme.dim()),
254            Span::raw(executor.receipt.join(", ")),
255        ]));
256    }
257    lines.push(Line::from(Span::styled(
258        "  Tab focuses the form · Ctrl+Y copies the sketch",
259        theme.dim(),
260    )));
261
262    let visible: Vec<Line<'static>> = lines.into_iter().take(usize::from(area.height)).collect();
263    frame.render_widget(Paragraph::new(visible), area);
264}
265
266/// Live type check for a playground value.
267fn check_type(type_: &str, value: &str) -> bool {
268    if value.is_empty() {
269        return false;
270    }
271    match type_.to_ascii_lowercase().as_str() {
272        "number" | "float" | "integer" | "int" => value.parse::<f64>().is_ok(),
273        "boolean" | "bool" => matches!(value, "true" | "false"),
274        _ => true,
275    }
276}