use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::tui::app::{App, SatPersonaState};
use crate::tui::theme::Palette;
const PERSONAS: [&str; 3] = ["junior", "senior", "maintainer"];
const LABEL_COL_WIDTH: usize = 11;
const SCORE_COL_WIDTH: usize = 5;
const SLIDER_PAD: usize = 2;
const MIN_SLIDER: usize = 6;
pub fn render(frame: &mut Frame, area: Rect, app: &App, palette: &Palette) {
let title = match &app.sat_verdict {
Some(v) if v.to_uppercase().contains("PASS") => Span::styled(
" SAT PERSONA DIALS · PASS ✓ ",
Style::new()
.fg(palette.state_pass)
.add_modifier(Modifier::BOLD),
),
Some(v) if v.to_uppercase().contains("FAIL") => Span::styled(
" SAT PERSONA DIALS · the forge cooled ✖ ",
Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
),
Some(_) => Span::styled(
" SAT PERSONA DIALS · judging… ",
Style::new().fg(palette.violet).add_modifier(Modifier::BOLD),
),
None => Span::styled(
" SAT PERSONA DIALS ",
Style::new().fg(palette.fg_2).add_modifier(Modifier::BOLD),
),
};
let block = Block::new()
.borders(Borders::ALL)
.border_style(Style::new().fg(palette.rule))
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let slider_width = (inner.width as usize)
.saturating_sub(LABEL_COL_WIDTH + SCORE_COL_WIDTH + SLIDER_PAD * 2)
.max(MIN_SLIDER);
let mut lines: Vec<Line> = Vec::new();
let mut scores_present = 0;
let mut scored_sum = 0.0_f32;
for persona in PERSONAS {
let state = app.sat_scores.get(persona);
let line = persona_line(persona, state, palette, slider_width);
lines.push(line);
if let Some(s) = state
&& s.target > 0.0
{
scores_present += 1;
scored_sum += s.target;
}
}
lines.push(Line::raw(""));
let blended = if scores_present > 0 {
scored_sum / scores_present as f32
} else {
0.0
};
let breakdown = Line::from(vec![
Span::styled(
format!("judging {scores_present}/3"),
Style::new().fg(palette.fg_2),
),
Span::styled(" · ", Style::new().fg(palette.fg_4)),
Span::styled(
format!("blended {blended:.1}"),
Style::new().fg(palette.fg_0),
),
Span::styled(" · ", Style::new().fg(palette.fg_4)),
Span::styled("threshold 75.0", Style::new().fg(palette.fg_3)),
]);
lines.push(breakdown);
lines.push(Line::raw(""));
lines.push(Line::from(vec![Span::styled(
" JUDGES ",
Style::new()
.fg(palette.bg_0)
.bg(palette.violet)
.add_modifier(Modifier::BOLD),
)]));
let judge_rows = [
("J1", "ensemble · gpt-tier", app.sat_scores.get("junior")),
("J2", "ensemble · claude-tier", app.sat_scores.get("senior")),
(
"J3",
"ensemble · arbitration",
app.sat_scores.get("maintainer"),
),
];
for (jid, attr, st) in judge_rows {
let score = match st {
Some(s) if s.target > 0.0 => format!("{:>3.0}", s.target),
_ => " —".into(),
};
lines.push(Line::from(vec![
Span::styled(format!(" {jid} "), Style::new().fg(palette.fg_2)),
Span::styled(attr, Style::new().fg(palette.violet)),
Span::styled(" ", Style::new()),
Span::styled(score, Style::new().fg(palette.fg_0)),
]));
}
if !app.blueprints.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::from(vec![Span::styled(
" BLUEPRINTS ",
Style::new()
.fg(palette.bg_0)
.bg(palette.copper)
.add_modifier(Modifier::BOLD),
)]));
for b in app.blueprints.iter().take(3) {
use crate::tui::app::BlueprintKind;
let (label, bg) = match b.kind {
BlueprintKind::Reuse => ("REUSE", palette.copper),
BlueprintKind::Avoid => ("AVOID", palette.amber),
};
let badge = Span::styled(
format!(" {label} "),
Style::new()
.fg(palette.bg_0)
.bg(bg)
.add_modifier(Modifier::BOLD),
);
let short = &b.run_id[..8.min(b.run_id.len())];
lines.push(Line::from(vec![
badge,
Span::raw(" "),
Span::styled(format!("#{short}"), Style::new().fg(palette.cyan)),
Span::styled(" ", Style::new()),
Span::styled(
format!("{:.2}", b.similarity),
Style::new().fg(palette.fg_2),
),
]));
}
if app.blueprints.len() > 3 {
lines.push(Line::styled(
format!(
" + {} more · press [b] for full list",
app.blueprints.len() - 3
),
Style::new().fg(palette.fg_3),
));
}
}
let para = Paragraph::new(lines).style(Style::new().bg(palette.bg_0));
frame.render_widget(para, inner);
}
fn persona_line<'a>(
name: &'a str,
state: Option<&SatPersonaState>,
palette: &Palette,
slider_width: usize,
) -> Line<'a> {
let (slider, score_text, score_color) = match state {
Some(s) if s.target > 0.0 => {
let pos = ((s.current / 100.0).clamp(0.0, 1.0) * (slider_width as f32)) as usize;
let pos = pos.min(slider_width.saturating_sub(1));
let mut s_str = String::with_capacity(slider_width + 4);
s_str.push('╟');
for i in 0..slider_width {
if i == pos {
s_str.push('●');
} else {
s_str.push('─');
}
}
s_str.push('╢');
(s_str, format!("{:>3.0}", s.target), palette.fg_0)
}
_ => {
let mut s_str = String::with_capacity(slider_width + 4);
s_str.push('╟');
for _ in 0..slider_width {
s_str.push('─');
}
s_str.push('╢');
(s_str, " —".to_string(), palette.fg_4)
}
};
Line::from(vec![
Span::styled(
format!("{name:<width$} ", width = LABEL_COL_WIDTH - 1),
Style::new().fg(palette.fg_2),
),
Span::styled(slider, Style::new().fg(palette.violet)),
Span::raw(" "),
Span::styled(score_text, Style::new().fg(score_color)),
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn label_col_width_fits_longest_persona() {
assert!(LABEL_COL_WIDTH > "maintainer".len());
}
#[test]
fn three_personas_defined() {
assert_eq!(PERSONAS.len(), 3);
assert!(PERSONAS.contains(&"junior"));
assert!(PERSONAS.contains(&"senior"));
assert!(PERSONAS.contains(&"maintainer"));
}
}