1use 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
16const 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 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
247fn 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
320fn 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 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
447fn 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
461const GUTTER: usize = 8;
463
464fn 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 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 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 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 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 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
677fn 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}