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 · ^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
373    // The type row is a cycler, not a text field.
374    let kind_focused = focused == Field::Kind;
375    lines.push(Line::from(vec![
376        Span::styled(
377            format!("{:LABEL$}", "type"),
378            Style::new().fg(if kind_focused { ACCENT } else { DIM }),
379        ),
380        Span::styled(
381            format!("‹ {} ›", b.kind.label()),
382            Style::new()
383                .fg(color_for(b.kind.label()))
384                .add_modifier(Modifier::BOLD),
385        ),
386        dim(format!("  {}", b.kind.about())),
387    ]));
388    if kind_focused {
389        lines.push(Line::from(vec![
390            Span::raw(" ".repeat(LABEL)),
391            dim("← → or n/c/s to switch"),
392        ]));
393    }
394    row(
395        &mut lines,
396        &mut cursor,
397        "instructions",
398        Field::Instructions,
399        "what the model should decide",
400    );
401    lines.push(Line::default());
402
403    match b.kind {
404        crate::builder::Kind::Noul => {
405            row(&mut lines, &mut cursor, "yes means", Field::Yes, "optional");
406            row(&mut lines, &mut cursor, "no means", Field::No, "optional");
407        }
408        crate::builder::Kind::Choice => {
409            for i in 0..b.options.len() {
410                row(
411                    &mut lines,
412                    &mut cursor,
413                    &format!("option {}", i + 1),
414                    Field::OptionLabel(i),
415                    "label",
416                );
417                row(
418                    &mut lines,
419                    &mut cursor,
420                    "  describe",
421                    Field::OptionDesc(i),
422                    "optional, but this is what sharpens it",
423                );
424            }
425        }
426        crate::builder::Kind::Score => {
427            for i in 0..b.levels.len() {
428                row(
429                    &mut lines,
430                    &mut cursor,
431                    &format!("level {i}"),
432                    Field::Level(i),
433                    if i == 0 { "lowest" } else { "" },
434                );
435            }
436        }
437    }
438    (lines, cursor)
439}
440
441/// Slide a long value so the cursor stays visible; returns the text and the column it starts at.
442fn view(text: &str, cursor: usize, width: usize, focused: bool) -> (String, usize) {
443    let chars: Vec<char> = text.chars().collect();
444    if chars.len() < width {
445        return (text.to_owned(), 0);
446    }
447    if !focused {
448        let cut: String = chars[..width.saturating_sub(1)].iter().collect();
449        return (format!("{cut}…"), 0);
450    }
451    let offset = cursor.saturating_sub(width.saturating_sub(1));
452    (chars[offset.min(chars.len())..].iter().collect(), offset)
453}
454
455/// Width of the sketch gutter: a six-letter tag, a problem mark, and the rule.
456const GUTTER: usize = 8;
457
458/// Sketch mode: the page on the left with a gutter saying what each line became, a preview on
459/// the right, and a status line that explains whatever the cursor is on.
460fn sketch(frame: &mut Frame, area: Rect, app: &mut App) {
461    let threshold = app.threshold;
462    let default_model = app.model_name();
463    let rates = app.rates;
464    let Some(ed) = app.sketch.as_mut() else {
465        return;
466    };
467    let parsed = ed.parsed();
468
469    frame.render_widget(Clear, area);
470    let block = Block::bordered()
471        .border_type(BorderType::Rounded)
472        .border_style(Style::new().fg(ACCENT))
473        .title(Line::from(Span::styled(
474            " sketch · the request as one page ",
475            Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
476        )))
477        .title_bottom(Line::from(dim(
478            " ^S apply · ^G apply & send · ^P preview · ^X/^U cut/paste line · Alt-↑↓ move line · Esc close ",
479        )));
480    let inner = block.inner(area);
481    frame.render_widget(block, area);
482
483    let [page_area, status_area] =
484        Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(inner);
485    let [edit_area, gutter, preview_area] = Layout::horizontal([
486        Constraint::Percentage(56),
487        Constraint::Length(2),
488        Constraint::Min(24),
489    ])
490    .areas(page_area);
491    frame.render_widget(
492        Block::new()
493            .borders(Borders::LEFT)
494            .border_style(Style::new().fg(DIM)),
495        Rect {
496            x: gutter.x + 1,
497            ..gutter
498        },
499    );
500
501    // ---- the page ----
502    let height = edit_area.height as usize;
503    if height > 0 {
504        if ed.row < ed.top {
505            ed.top = ed.row;
506        } else if ed.row >= ed.top + height {
507            ed.top = ed.row + 1 - height;
508        }
509    }
510    let text_width = (edit_area.width as usize).saturating_sub(GUTTER).max(8);
511    let state_empty = parsed.tags.iter().all(|t| !matches!(t, Tag::State));
512    let mut lines: Vec<Line<'static>> = Vec::new();
513    let mut cursor = None;
514    let mut prev = None;
515    for (i, line) in ed.lines.iter().enumerate().skip(ed.top).take(height) {
516        let tag = parsed.tags.get(i).copied().unwrap_or(Tag::Blank);
517        let is_cur = i == ed.row;
518        let problem = parsed.problem_at(i).is_some();
519        // A long state reads better labelled once.
520        let label = if tag == Tag::State && prev == Some(Tag::State) {
521            ""
522        } else {
523            tag.label()
524        };
525        prev = Some(tag);
526
527        let tag_style = Style::new().fg(tag.color());
528        let mut spans = vec![
529            Span::styled(
530                format!("{label:<6}"),
531                if tag.is_head() {
532                    tag_style.add_modifier(Modifier::BOLD)
533                } else {
534                    tag_style
535                },
536            ),
537            Span::styled(
538                if problem { "!" } else { " " },
539                Style::new().fg(BAD).add_modifier(Modifier::BOLD),
540            ),
541            Span::styled("│", Style::new().fg(if is_cur { ACCENT } else { DIM })),
542        ];
543        let (shown, offset) = view(line, ed.col, text_width, is_cur);
544        if shown.is_empty() && i == 0 && state_empty {
545            spans.push(dim("the state — the text or JSON the questions are about"));
546        } else {
547            let style = match tag {
548                t if t.is_head() => Style::new().fg(t.color()).add_modifier(Modifier::BOLD),
549                Tag::Rule | Tag::Comment => Style::new().fg(DIM),
550                Tag::State | Tag::Blank => Style::new(),
551                t => Style::new().fg(t.color()),
552            };
553            spans.push(Span::styled(shown, style));
554        }
555        if is_cur {
556            cursor = Some((GUTTER + ed.col.saturating_sub(offset), lines.len()));
557        }
558        lines.push(Line::from(spans));
559    }
560    frame.render_widget(Paragraph::new(Text::from(lines)), edit_area);
561    if let Some((col, row)) = cursor {
562        frame.set_cursor_position((
563            edit_area.x + (col as u16).min(edit_area.width.saturating_sub(1)),
564            edit_area.y + row as u16,
565        ));
566    }
567
568    // ---- the preview ----
569    let mut tabs: Vec<Span<'static>> = Vec::new();
570    for (i, p) in Preview::ALL.iter().enumerate() {
571        if i > 0 {
572            tabs.push(dim(" · "));
573        }
574        tabs.push(if *p == ed.preview {
575            Span::styled(
576                p.label(),
577                Style::new().fg(ACCENT).add_modifier(Modifier::BOLD),
578            )
579        } else {
580            dim(p.label())
581        });
582    }
583    tabs.push(dim("   ^P"));
584    if !parsed.problems.is_empty() {
585        tabs.push(Span::styled(
586            format!("   {} problem(s)", parsed.problems.len()),
587            Style::new().fg(BAD),
588        ));
589    }
590    let mut preview: Vec<Line<'static>> = vec![Line::from(tabs), Line::default()];
591    // Problems go first: they are what to act on, and a long preview must not hide them.
592    if !parsed.problems.is_empty() {
593        preview.push(Line::from(Span::styled(
594            "problems",
595            Style::new().fg(BAD).add_modifier(Modifier::BOLD),
596        )));
597        for p in parsed.problems.iter().take(6) {
598            preview.push(Line::from(vec![
599                Span::styled(format!("  {:>3}  ", p.line + 1), Style::new().fg(BAD)),
600                Span::raw(p.message.clone()),
601            ]));
602        }
603        preview.push(Line::default());
604    }
605
606    let session = parsed.to_session();
607    let model = session.model.clone().unwrap_or(default_model);
608    match ed.preview {
609        Preview::Json => preview.extend(highlight::json(&session.request_json(&model))),
610        Preview::Answers => {
611            if session.questions.is_empty() {
612                preview.push(Line::from(dim(
613                    "  add a question below the --- line to see the shape of its answer",
614                )));
615            } else {
616                preview.push(Line::from(dim(
617                    "  simulated answers — the shape is real, the numbers are not",
618                )));
619                for (name, q) in &session.questions {
620                    let json = serde_json::to_value(q).unwrap_or_default();
621                    match mock::answer(&session.state, name, &json) {
622                        Some(a) => preview.extend(answer_lines(name, &a, threshold)),
623                        None => preview.push(Line::from(dim(format!(
624                            "  {name}: no simulation for this question shape"
625                        )))),
626                    }
627                }
628            }
629        }
630        Preview::Rust => {
631            preview.extend(highlight::rust(&codegen::rust(&session, &model, threshold)))
632        }
633        Preview::Cost => {
634            if session.questions.is_empty() {
635                preview.push(Line::from(dim(
636                    "  add a question below the --- line to see what a call would cost",
637                )));
638            } else {
639                preview.extend(cost_lines(
640                    &cost::estimate(&session, &model),
641                    rates,
642                    ":cost 0.20/1.00 prices it, dollars per million tokens",
643                ));
644            }
645        }
646    }
647    let wrapped = wrap::wrap_all(&preview, preview_area.width as usize);
648    frame.render_widget(Paragraph::new(Text::from(wrapped)), preview_area);
649
650    // ---- the status line: what the cursor is on ----
651    let tag = parsed.tags.get(ed.row).copied().unwrap_or(Tag::Blank);
652    let below_rule = parsed
653        .tags
654        .iter()
655        .position(|t| *t == Tag::Rule)
656        .is_some_and(|r| ed.row > r);
657    let status = if let Some(m) = &ed.message {
658        Span::styled(m.clone(), Style::new().fg(WARN))
659    } else if let Some(p) = parsed.problem_at(ed.row) {
660        Span::styled(p.message.clone(), Style::new().fg(BAD))
661    } else {
662        dim(hint_for(tag, below_rule))
663    };
664    frame.render_widget(
665        Paragraph::new(Line::from(vec![Span::raw(" "), status])),
666        status_area,
667    );
668}
669
670/// One line about the kind of line under the cursor — the notation explains itself in place.
671fn hint_for(tag: Tag, below_rule: bool) -> &'static str {
672    match tag {
673        Tag::State => "state — the text or JSON the questions are about; a --- line ends it",
674        Tag::Rule => "--- separates the state above from the questions below",
675        Tag::Blank if !below_rule => {
676            "state — the text or JSON the questions are about; a --- line ends it"
677        }
678        Tag::Blank => {
679            "name? asks yes/no · name: then `label = why` lines (choice) or `low < high` (score) · name! {json} sends it raw"
680        }
681        Tag::Comment => "a comment — ignored",
682        Tag::Model => "@model pins the model this session sends to",
683        Tag::Noul => {
684            "noul — the answer is the probability of yes; `yes:` and `no:` lines say what each means"
685        }
686        Tag::Yes | Tag::No => "what a yes or a no means — sharper criteria, higher confidence",
687        Tag::Choice => "choice — one label out of these options; `label = why` describes each",
688        Tag::Option => "an option — `label = when it applies`; a bare label works but is vaguer",
689        Tag::Score => {
690            "score — ordered levels, lowest first; the answer is a weighted position along them"
691        }
692        Tag::Level => "a level — write them lowest to highest, joined with <",
693        Tag::Raw => "raw — a JSON object sent as it is; it needs a `type`",
694        Tag::Json => "continues the raw JSON above",
695        Tag::Stray => "this line could not be placed",
696    }
697}
698
699fn centered(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
700    let [_, middle, _] = Layout::vertical([
701        Constraint::Percentage((100 - percent_y) / 2),
702        Constraint::Percentage(percent_y),
703        Constraint::Percentage((100 - percent_y) / 2),
704    ])
705    .areas(area);
706    let [_, center, _] = Layout::horizontal([
707        Constraint::Percentage((100 - percent_x) / 2),
708        Constraint::Percentage(percent_x),
709        Constraint::Percentage((100 - percent_x) / 2),
710    ])
711    .areas(middle);
712    center
713}