Skip to main content

jev_repl/
ui.rs

1//! Layout: a status strip, the transcript, a live view of the session, and the input line.
2
3use ratatui::Frame;
4use ratatui::layout::{Constraint, Layout, Rect};
5use ratatui::style::{Modifier, Style};
6use ratatui::text::{Line, Span, Text};
7use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
8
9use crate::app::{App, COMMANDS};
10use crate::builder::{Builder, Field};
11use crate::editor::Preview;
12use crate::format::*;
13use crate::sketch::Tag;
14use crate::{codegen, cost, highlight, lessons, mock, wrap};
15
16/// Width of the label column in builder mode.
17const LABEL: usize = 14;
18
19const SPINNER: [&str; 4] = ["⠋", "⠙", "⠹", "⠸"];
20
21pub fn render(frame: &mut Frame, app: &mut App) {
22    if app.sketch.is_some() {
23        sketch(frame, frame.area(), app);
24        return;
25    }
26    let [top, body, input] = Layout::vertical([
27        Constraint::Length(1),
28        Constraint::Min(3),
29        Constraint::Length(3),
30    ])
31    .areas(frame.area());
32
33    let [left, right] =
34        Layout::horizontal([Constraint::Min(40), Constraint::Length(36)]).areas(body);
35
36    status(frame, top, app);
37    transcript(frame, left, app);
38    panel(frame, right, app);
39    prompt(frame, input, app);
40    if app.builder.is_some() {
41        builder(frame, frame.area(), app);
42    }
43}
44
45fn status(frame: &mut Frame, area: Rect, app: &App) {
46    let mut spans = vec![
47        Span::styled(
48            " jev ",
49            Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
50        ),
51        dim("│ "),
52        Span::raw(app.model_name()),
53        dim(" │ "),
54    ];
55    spans.push(if app.mock {
56        Span::styled("MOCK", Style::new().fg(WARN).add_modifier(Modifier::BOLD))
57    } else {
58        Span::styled("LIVE", Style::new().fg(SCORE).add_modifier(Modifier::BOLD))
59    });
60    spans.push(dim(format!(
61        " │ {} question(s) │ threshold {:.2} │ lesson {}/{}",
62        app.session.questions.len(),
63        app.threshold,
64        app.lesson + 1,
65        lessons::LESSONS.len()
66    )));
67    frame.render_widget(Paragraph::new(Line::from(spans)), area);
68}
69
70fn transcript(frame: &mut Frame, area: Rect, app: &mut App) {
71    let block = Block::bordered()
72        .border_type(BorderType::Rounded)
73        .border_style(Style::new().fg(DIM))
74        .title(Line::from(dim(" transcript ")));
75    let inner = block.inner(area);
76    frame.render_widget(block, area);
77
78    let width = inner.width as usize;
79    let height = inner.height as usize;
80    let lines = wrap::wrap_all(&app.transcript, width);
81
82    // `scroll` counts lines back from the tail, so new output stays in view at rest.
83    let max_scroll = lines.len().saturating_sub(height);
84    app.scroll = app.scroll.min(max_scroll);
85    let end = lines.len() - app.scroll;
86    let start = end.saturating_sub(height);
87    frame.render_widget(
88        Paragraph::new(Text::from(lines[start..end].to_vec())),
89        inner,
90    );
91
92    if app.scroll > 0 {
93        let hint = format!(" {} line(s) below · Esc to follow ", app.scroll);
94        let w = (hint.len() as u16).min(area.width.saturating_sub(2));
95        let rect = Rect::new(
96            area.x + area.width.saturating_sub(w + 1),
97            area.y + area.height.saturating_sub(1),
98            w,
99            1,
100        );
101        frame.render_widget(Paragraph::new(Line::from(dim(hint))), rect);
102    }
103}
104
105fn panel(frame: &mut Frame, area: Rect, app: &App) {
106    let block = Block::bordered()
107        .border_type(BorderType::Rounded)
108        .border_style(Style::new().fg(DIM))
109        .title(Line::from(dim(" session ")));
110    let inner = block.inner(area);
111    frame.render_widget(block, area);
112
113    let mut lines: Vec<Line<'static>> = Vec::new();
114    lines.push(Line::from(Span::styled(
115        "state",
116        Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
117    )));
118    if app.session.state_is_empty() {
119        lines.push(Line::from(dim("(empty — type some text)")));
120    } else {
121        let preview = app.session.state_preview();
122        for line in wrap::wrap(&Line::from(preview), inner.width as usize)
123            .into_iter()
124            .take(6)
125        {
126            lines.push(line);
127        }
128    }
129    lines.push(Line::default());
130    lines.push(Line::from(Span::styled(
131        "questions",
132        Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
133    )));
134    if app.session.questions.is_empty() {
135        lines.push(Line::from(dim("(none — :noul :choice :score)")));
136    }
137    for (name, question) in &app.session.questions {
138        let kind = serde_json::to_value(question)
139            .ok()
140            .and_then(|v| v.get("type").and_then(|t| t.as_str()).map(str::to_owned))
141            .unwrap_or_else(|| "raw".into());
142        lines.push(Line::from(vec![
143            Span::styled("• ", Style::new().fg(color_for(&kind))),
144            Span::raw(name.clone()),
145            dim(format!("  {kind}")),
146        ]));
147    }
148
149    if !app.session.questions.is_empty() {
150        let estimate = cost::estimate(&app.session, &app.model_name());
151        lines.push(Line::default());
152        lines.push(Line::from(vec![
153            Span::styled("cost", Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)),
154            dim("  estimated"),
155        ]));
156        lines.push(Line::from(dim(format!(
157            "≈ {} in / {} out tok",
158            estimate.input_tokens, estimate.output_tokens
159        ))));
160        lines.push(Line::from(match app.rates {
161            Some(rates) => Span::styled(
162                format!(
163                    "{} per call",
164                    cost::usd(cost::price_estimate(&estimate, rates).total)
165                ),
166                Style::new().fg(SCORE),
167            ),
168            None => dim(":cost 0.20/1.00 to price it"),
169        }));
170    }
171
172    lines.push(Line::default());
173    lines.push(Line::from(Span::styled(
174        format!("lesson {}", app.lesson + 1),
175        Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
176    )));
177    lines.push(Line::from(dim(lessons::LESSONS[app.lesson].title)));
178    if let Some(suggested) = &app.suggested {
179        for line in wrap::wrap(
180            &Line::from(Span::styled(
181                format!("^T  {suggested}"),
182                Style::new().fg(SCORE),
183            )),
184            inner.width as usize,
185        )
186        .into_iter()
187        .take(4)
188        {
189            lines.push(line);
190        }
191    }
192
193    lines.push(Line::default());
194    for hint in [
195        "Enter   send the session",
196        "^T/^N   try / next lesson",
197        ":help   every command",
198        ":json   the request body",
199        ":rust   this session as code",
200    ] {
201        lines.push(Line::from(dim(hint)));
202    }
203
204    frame.render_widget(Paragraph::new(Text::from(lines)), inner);
205}
206
207fn prompt(frame: &mut Frame, area: Rect, app: &App) {
208    let title = if app.pending {
209        Line::from(vec![
210            Span::styled(
211                format!(" {} ", SPINNER[app.spinner % SPINNER.len()]),
212                Style::new().fg(ACCENT),
213            ),
214            dim("waiting for the API "),
215        ])
216    } else {
217        Line::from(dim(" ask "))
218    };
219    let block = Block::bordered()
220        .border_type(BorderType::Rounded)
221        .border_style(Style::new().fg(if app.pending { ACCENT } else { DIM }))
222        .title(title);
223    let inner = block.inner(area);
224    frame.render_widget(block, area);
225
226    let width = inner.width.saturating_sub(2) as usize;
227    let chars: Vec<char> = app.input.chars().collect();
228    let offset = app.cursor.saturating_sub(width);
229    let visible: String = chars[offset.min(chars.len())..].iter().collect();
230
231    let line = if app.input.is_empty() {
232        Line::from(vec![
233            Span::styled("› ", Style::new().fg(ACCENT)),
234            dim("type text to set the state, :help for commands, Enter to send"),
235        ])
236    } else {
237        let mut spans = vec![Span::styled("› ", Style::new().fg(ACCENT))];
238        spans.extend(highlight::command(&visible, |cmd| {
239            COMMANDS.iter().any(|(c, _)| *c == cmd)
240        }));
241        Line::from(spans)
242    };
243    frame.render_widget(Paragraph::new(line), inner);
244    frame.set_cursor_position((inner.x + 2 + (app.cursor - offset) as u16, inner.y));
245}
246
247/// The builder-mode popup: a form on the left, the JSON it produces on the right.
248fn builder(frame: &mut Frame, area: Rect, app: &App) {
249    let Some(b) = app.builder.as_ref() else {
250        return;
251    };
252    let popup = centered(area, 92, 86);
253    frame.render_widget(Clear, popup);
254    let block = Block::bordered()
255        .border_type(BorderType::Rounded)
256        .border_style(Style::new().fg(ACCENT))
257        .title(Line::from(Span::styled(
258            " build a question ",
259            Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
260        )))
261        .title_bottom(Line::from(dim(
262            " Tab move · Alt-←→ word · ^O add row · ^X drop row · ^S add question · Esc close ",
263        )));
264    let inner = block.inner(popup);
265    frame.render_widget(block, popup);
266
267    let [form_area, gutter, preview_area] = Layout::horizontal([
268        Constraint::Percentage(55),
269        Constraint::Length(2),
270        Constraint::Min(20),
271    ])
272    .areas(inner);
273    frame.render_widget(
274        Block::new()
275            .borders(Borders::LEFT)
276            .border_style(Style::new().fg(DIM)),
277        Rect {
278            x: gutter.x + 1,
279            ..gutter
280        },
281    );
282
283    let (lines, cursor) = form(b, form_area.width as usize);
284    frame.render_widget(Paragraph::new(Text::from(lines)), form_area);
285    if let Some((col, row)) = cursor
286        && row < form_area.height as usize
287    {
288        frame.set_cursor_position((
289            form_area.x + (col as u16).min(form_area.width.saturating_sub(1)),
290            form_area.y + row as u16,
291        ));
292    }
293
294    let [json_area, command_area] =
295        Layout::vertical([Constraint::Min(3), Constraint::Length(6)]).areas(preview_area);
296
297    let pretty = serde_json::to_string_pretty(&b.preview()).unwrap_or_default();
298    let mut json_lines = vec![Line::from(dim("questions"))];
299    json_lines.extend(highlight::json(&pretty));
300    frame.render_widget(Paragraph::new(Text::from(json_lines)), json_area);
301
302    let mut tail = vec![Line::from(dim("same thing, one line"))];
303    let command = b.as_command();
304    tail.extend(wrap::wrap(
305        &Line::from(highlight::command(&command, |c| {
306            COMMANDS.iter().any(|(k, _)| *k == c)
307        })),
308        command_area.width as usize,
309    ));
310    if let Some(message) = &b.message {
311        tail.push(Line::default());
312        tail.push(Line::from(Span::styled(
313            message.clone(),
314            Style::new().fg(BAD),
315        )));
316    }
317    frame.render_widget(Paragraph::new(Text::from(tail)), command_area);
318}
319
320/// The form rows, plus where the terminal cursor belongs.
321fn form(b: &Builder, width: usize) -> (Vec<Line<'static>>, Option<(usize, usize)>) {
322    let mut lines: Vec<Line<'static>> = Vec::new();
323    let mut cursor = None;
324    let focused = b.focused();
325    let field_width = width.saturating_sub(LABEL + 1).max(8);
326
327    let row = |lines: &mut Vec<Line<'static>>,
328               cursor: &mut Option<(usize, usize)>,
329               label: &str,
330               field: Field,
331               hint: &str| {
332        let is_focused = field == focused;
333        let text = b.text(field);
334        let (shown, offset) = view(text, b.cursor, field_width, is_focused);
335        let mut spans = vec![Span::styled(
336            format!("{label:LABEL$}"),
337            Style::new().fg(if is_focused { ACCENT } else { DIM }),
338        )];
339        if shown.is_empty() && !hint.is_empty() {
340            spans.push(dim(hint.to_owned()));
341        } else {
342            spans.push(Span::styled(
343                shown,
344                if is_focused {
345                    Style::new().add_modifier(Modifier::BOLD)
346                } else {
347                    Style::new()
348                },
349            ));
350        }
351        if is_focused {
352            *cursor = Some((LABEL + b.cursor.saturating_sub(offset), lines.len()));
353        }
354        lines.push(Line::from(spans));
355    };
356
357    row(
358        &mut lines,
359        &mut cursor,
360        "state",
361        Field::State,
362        "the text being judged",
363    );
364    lines.push(Line::default());
365    row(
366        &mut lines,
367        &mut cursor,
368        "name",
369        Field::Name,
370        "answers come back under this",
371    );
372    if !b.existing.is_empty() {
373        lines.push(Line::from(vec![
374            Span::raw(" ".repeat(LABEL)),
375            dim(format!("already asked: {}", b.existing.join(", "))),
376        ]));
377    }
378
379    // The type row is a cycler, not a text field.
380    let kind_focused = focused == Field::Kind;
381    lines.push(Line::from(vec![
382        Span::styled(
383            format!("{:LABEL$}", "type"),
384            Style::new().fg(if kind_focused { ACCENT } else { DIM }),
385        ),
386        Span::styled(
387            format!("‹ {} ›", b.kind.label()),
388            Style::new()
389                .fg(color_for(b.kind.label()))
390                .add_modifier(Modifier::BOLD),
391        ),
392        dim(format!("  {}", b.kind.about())),
393    ]));
394    if kind_focused {
395        lines.push(Line::from(vec![
396            Span::raw(" ".repeat(LABEL)),
397            dim("← → or n/c/s to switch"),
398        ]));
399    }
400    row(
401        &mut lines,
402        &mut cursor,
403        "instructions",
404        Field::Instructions,
405        "what the model should decide",
406    );
407    lines.push(Line::default());
408
409    match b.kind {
410        crate::builder::Kind::Noul => {
411            row(&mut lines, &mut cursor, "yes means", Field::Yes, "optional");
412            row(&mut lines, &mut cursor, "no means", Field::No, "optional");
413        }
414        crate::builder::Kind::Choice => {
415            for i in 0..b.options.len() {
416                row(
417                    &mut lines,
418                    &mut cursor,
419                    &format!("option {}", i + 1),
420                    Field::OptionLabel(i),
421                    "label",
422                );
423                row(
424                    &mut lines,
425                    &mut cursor,
426                    "  describe",
427                    Field::OptionDesc(i),
428                    "optional, but this is what sharpens it",
429                );
430            }
431        }
432        crate::builder::Kind::Score => {
433            for i in 0..b.levels.len() {
434                row(
435                    &mut lines,
436                    &mut cursor,
437                    &format!("level {i}"),
438                    Field::Level(i),
439                    if i == 0 { "lowest" } else { "" },
440                );
441            }
442        }
443    }
444    (lines, cursor)
445}
446
447/// Slide a long value so the cursor stays visible; returns the text and the column it starts at.
448fn view(text: &str, cursor: usize, width: usize, focused: bool) -> (String, usize) {
449    let chars: Vec<char> = text.chars().collect();
450    if chars.len() < width {
451        return (text.to_owned(), 0);
452    }
453    if !focused {
454        let cut: String = chars[..width.saturating_sub(1)].iter().collect();
455        return (format!("{cut}…"), 0);
456    }
457    let offset = cursor.saturating_sub(width.saturating_sub(1));
458    (chars[offset.min(chars.len())..].iter().collect(), offset)
459}
460
461/// Width of the sketch gutter: a six-letter tag, a problem mark, and the rule.
462const GUTTER: usize = 8;
463
464/// Sketch mode: the page on the left with a gutter saying what each line became, a preview on
465/// the right, and a status line that explains whatever the cursor is on.
466fn sketch(frame: &mut Frame, area: Rect, app: &mut App) {
467    let threshold = app.threshold;
468    let default_model = app.model_name();
469    let rates = app.rates;
470    let Some(ed) = app.sketch.as_mut() else {
471        return;
472    };
473    let parsed = ed.parsed();
474
475    frame.render_widget(Clear, area);
476    let block = Block::bordered()
477        .border_type(BorderType::Rounded)
478        .border_style(Style::new().fg(ACCENT))
479        .title(Line::from(Span::styled(
480            " sketch · the request as one page ",
481            Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
482        )))
483        .title_bottom(Line::from(dim(
484            " ^S apply · ^G apply & send · ^P preview · ^X/^U cut/paste line · Alt-↑↓ move line · Alt-←→ word · Esc close ",
485        )));
486    let inner = block.inner(area);
487    frame.render_widget(block, area);
488
489    let [page_area, status_area] =
490        Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(inner);
491    let [edit_area, gutter, preview_area] = Layout::horizontal([
492        Constraint::Percentage(56),
493        Constraint::Length(2),
494        Constraint::Min(24),
495    ])
496    .areas(page_area);
497    frame.render_widget(
498        Block::new()
499            .borders(Borders::LEFT)
500            .border_style(Style::new().fg(DIM)),
501        Rect {
502            x: gutter.x + 1,
503            ..gutter
504        },
505    );
506
507    // ---- the page ----
508    let height = edit_area.height as usize;
509    if height > 0 {
510        if ed.row < ed.top {
511            ed.top = ed.row;
512        } else if ed.row >= ed.top + height {
513            ed.top = ed.row + 1 - height;
514        }
515    }
516    let text_width = (edit_area.width as usize).saturating_sub(GUTTER).max(8);
517    let state_empty = parsed.tags.iter().all(|t| !matches!(t, Tag::State));
518    let mut lines: Vec<Line<'static>> = Vec::new();
519    let mut cursor = None;
520    let mut prev = None;
521    for (i, line) in ed.lines.iter().enumerate().skip(ed.top).take(height) {
522        let tag = parsed.tags.get(i).copied().unwrap_or(Tag::Blank);
523        let is_cur = i == ed.row;
524        let problem = parsed.problem_at(i).is_some();
525        // A long state reads better labelled once.
526        let label = if tag == Tag::State && prev == Some(Tag::State) {
527            ""
528        } else {
529            tag.label()
530        };
531        prev = Some(tag);
532
533        let tag_style = Style::new().fg(tag.color());
534        let mut spans = vec![
535            Span::styled(
536                format!("{label:<6}"),
537                if tag.is_head() {
538                    tag_style.add_modifier(Modifier::BOLD)
539                } else {
540                    tag_style
541                },
542            ),
543            Span::styled(
544                if problem { "!" } else { " " },
545                Style::new().fg(BAD).add_modifier(Modifier::BOLD),
546            ),
547            Span::styled("│", Style::new().fg(if is_cur { ACCENT } else { DIM })),
548        ];
549        let (shown, offset) = view(line, ed.col, text_width, is_cur);
550        if shown.is_empty() && i == 0 && state_empty {
551            spans.push(dim("the state — the text or JSON the questions are about"));
552        } else {
553            let style = match tag {
554                t if t.is_head() => Style::new().fg(t.color()).add_modifier(Modifier::BOLD),
555                Tag::Rule | Tag::Comment => Style::new().fg(DIM),
556                Tag::State | Tag::Blank => Style::new(),
557                t => Style::new().fg(t.color()),
558            };
559            spans.push(Span::styled(shown, style));
560        }
561        if is_cur {
562            cursor = Some((GUTTER + ed.col.saturating_sub(offset), lines.len()));
563        }
564        lines.push(Line::from(spans));
565    }
566    frame.render_widget(Paragraph::new(Text::from(lines)), edit_area);
567    if let Some((col, row)) = cursor {
568        frame.set_cursor_position((
569            edit_area.x + (col as u16).min(edit_area.width.saturating_sub(1)),
570            edit_area.y + row as u16,
571        ));
572    }
573
574    // ---- the preview ----
575    let mut tabs: Vec<Span<'static>> = Vec::new();
576    for (i, p) in Preview::ALL.iter().enumerate() {
577        if i > 0 {
578            tabs.push(dim(" · "));
579        }
580        tabs.push(if *p == ed.preview {
581            Span::styled(
582                p.label(),
583                Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
584            )
585        } else {
586            dim(p.label())
587        });
588    }
589    tabs.push(dim("   ^P"));
590    if !parsed.problems.is_empty() {
591        tabs.push(Span::styled(
592            format!("   {} problem(s)", parsed.problems.len()),
593            Style::new().fg(BAD),
594        ));
595    }
596    let mut preview: Vec<Line<'static>> = vec![Line::from(tabs), Line::default()];
597    // Problems go first: they are what to act on, and a long preview must not hide them.
598    if !parsed.problems.is_empty() {
599        preview.push(Line::from(Span::styled(
600            "problems",
601            Style::new().fg(BAD).add_modifier(Modifier::BOLD),
602        )));
603        for p in parsed.problems.iter().take(6) {
604            preview.push(Line::from(vec![
605                Span::styled(format!("  {:>3}  ", p.line + 1), Style::new().fg(BAD)),
606                Span::raw(p.message.clone()),
607            ]));
608        }
609        preview.push(Line::default());
610    }
611
612    let session = parsed.to_session();
613    let model = session.model.clone().unwrap_or(default_model);
614    match ed.preview {
615        Preview::Json => preview.extend(highlight::json(&session.request_json(&model))),
616        Preview::Answers => {
617            if session.questions.is_empty() {
618                preview.push(Line::from(dim(
619                    "  add a question below the --- line to see the shape of its answer",
620                )));
621            } else {
622                preview.push(Line::from(dim(
623                    "  simulated answers — the shape is real, the numbers are not",
624                )));
625                for (name, q) in &session.questions {
626                    let json = serde_json::to_value(q).unwrap_or_default();
627                    match mock::answer(&session.state, name, &json) {
628                        Some(a) => preview.extend(answer_lines(name, &a, threshold)),
629                        None => preview.push(Line::from(dim(format!(
630                            "  {name}: no simulation for this question shape"
631                        )))),
632                    }
633                }
634            }
635        }
636        Preview::Rust => {
637            preview.extend(highlight::rust(&codegen::rust(&session, &model, threshold)))
638        }
639        Preview::Cost => {
640            if session.questions.is_empty() {
641                preview.push(Line::from(dim(
642                    "  add a question below the --- line to see what a call would cost",
643                )));
644            } else {
645                preview.extend(cost_lines(
646                    &cost::estimate(&session, &model),
647                    rates,
648                    ":cost 0.20/1.00 prices it, dollars per million tokens",
649                    cost::thread(&session, &model).as_ref(),
650                ));
651            }
652        }
653    }
654    let wrapped = wrap::wrap_all(&preview, preview_area.width as usize);
655    frame.render_widget(Paragraph::new(Text::from(wrapped)), preview_area);
656
657    // ---- the status line: what the cursor is on ----
658    let tag = parsed.tags.get(ed.row).copied().unwrap_or(Tag::Blank);
659    let below_rule = parsed
660        .tags
661        .iter()
662        .position(|t| *t == Tag::Rule)
663        .is_some_and(|r| ed.row > r);
664    let status = if let Some(m) = &ed.message {
665        Span::styled(m.clone(), Style::new().fg(WARN))
666    } else if let Some(p) = parsed.problem_at(ed.row) {
667        Span::styled(p.message.clone(), Style::new().fg(BAD))
668    } else {
669        dim(hint_for(tag, below_rule))
670    };
671    frame.render_widget(
672        Paragraph::new(Line::from(vec![Span::raw(" "), status])),
673        status_area,
674    );
675}
676
677/// One line about the kind of line under the cursor — the notation explains itself in place.
678fn hint_for(tag: Tag, below_rule: bool) -> &'static str {
679    match tag {
680        Tag::State => "state — the text or JSON the questions are about; a --- line ends it",
681        Tag::Rule => "--- separates the state above from the questions below",
682        Tag::Blank if !below_rule => {
683            "state — the text or JSON the questions are about; a --- line ends it"
684        }
685        Tag::Blank => {
686            "name? asks yes/no · name: then `label = why` lines (choice) or `low < high` (score) · name! {json} sends it raw"
687        }
688        Tag::Comment => "a comment — ignored",
689        Tag::Model => "@model pins the model this session sends to",
690        Tag::Noul => {
691            "noul — the answer is the probability of yes; `yes:` and `no:` lines say what each means"
692        }
693        Tag::Yes | Tag::No => "what a yes or a no means — sharper criteria, higher confidence",
694        Tag::Choice => "choice — one label out of these options; `label = why` describes each",
695        Tag::Option => "an option — `label = when it applies`; a bare label works but is vaguer",
696        Tag::Score => {
697            "score — ordered levels, lowest first; the answer is a weighted position along them"
698        }
699        Tag::Level => "a level — write them lowest to highest, joined with <",
700        Tag::Raw => "raw — a JSON object sent as it is; it needs a `type`",
701        Tag::Json => "continues the raw JSON above",
702        Tag::Stray => "this line could not be placed",
703    }
704}
705
706fn centered(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
707    let [_, middle, _] = Layout::vertical([
708        Constraint::Percentage((100 - percent_y) / 2),
709        Constraint::Percentage(percent_y),
710        Constraint::Percentage((100 - percent_y) / 2),
711    ])
712    .areas(area);
713    let [_, center, _] = Layout::horizontal([
714        Constraint::Percentage((100 - percent_x) / 2),
715        Constraint::Percentage(percent_x),
716        Constraint::Percentage((100 - percent_x) / 2),
717    ])
718    .areas(middle);
719    center
720}