use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Padding, Paragraph};
use crate::tui::app::App;
use crate::tui::format::{cut, hang};
use crate::tui::project::{self, Addable, Field, Form, Listing, ProjectView, Row, Shown, Stat};
use crate::tui::theme::Theme;
const LABEL_WIDTH: usize = 11;
const DETAIL_ROWS: u16 = 2;
fn pane<'a>(border: Style) -> Block<'a> {
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(border)
.padding(Padding::horizontal(1))
}
pub(in crate::tui) fn render(frame: &mut Frame, area: Rect, app: &mut App) {
let theme = app.theme;
let [left, right] =
Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(area);
let block = pane(theme.border_focus).title(Span::styled(" configuration ", theme.title));
let inner = block.inner(left);
frame.render_widget(block, left);
if app.config.is_some() {
let hint = app.field_hint();
let listing = app.project_listing();
let view: (&Listing, _) = (&listing, &mut app.project_view);
render_rows(frame, inner, view, hint.as_deref(), &theme);
} else {
frame.render_widget(
Paragraph::new(Span::styled("No configuration read.", theme.dim)),
inner,
);
}
let block = pane(theme.border).title(Span::styled(" stats ", theme.title));
let inner = block.inner(right);
frame.render_widget(block, right);
let lines = stat_lines(&project::stats(app), (inner.width, inner.height), &theme);
frame.render_widget(Paragraph::new(lines), inner);
}
fn render_rows(
frame: &mut Frame,
area: Rect,
(listing, view): (&Listing, &mut ProjectView),
hint: Option<&str>,
theme: &Theme,
) {
let [list, detail] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(DETAIL_ROWS)]).areas(area);
let (rows, fields) = (&listing.rows, &listing.fields);
view.selected = view.selected.min(fields.len().saturating_sub(1));
let selected = fields.get(view.selected).copied().unwrap_or(0);
let height = usize::from(list.height).max(1);
let top = match selected.checked_sub(1).and_then(|above| rows.get(above)) {
Some(Row::Heading(_)) => selected - 1,
_ => selected,
};
if top < view.offset {
view.offset = top;
} else if selected >= view.offset + height {
view.offset = selected + 1 - height;
}
let width = usize::from(list.width);
let name_width = rows
.iter()
.filter_map(|row| match row {
Row::Field(field) => Some(field.name.chars().count() + 2),
Row::Heading(_) => None,
})
.max()
.unwrap_or(0)
.min(width * 3 / 5);
let lines: Vec<Line> = rows
.iter()
.enumerate()
.skip(view.offset)
.take(height)
.map(|(at, row)| match row {
Row::Heading(text) => Line::from(Span::styled(cut(text, width), theme.title)),
Row::Field(field) => field_line(field, (name_width, width), at == selected, theme),
})
.collect();
frame.render_widget(Paragraph::new(lines), list);
if let Some(form) = &view.form {
frame.render_widget(
Paragraph::new(form_lines(form, detail.width, hint, theme)),
detail,
);
} else if let Some(Row::Field(field)) = rows.get(selected) {
let style = if field.error.is_some() {
theme.error
} else {
theme.dim
};
let lines: Vec<Line> = hang(&field.detail(hint), detail.width, 0)
.into_iter()
.take(usize::from(DETAIL_ROWS))
.map(|line| Line::from(Span::styled(line, style)))
.collect();
frame.render_widget(Paragraph::new(lines), detail);
}
}
fn form_lines(form: &Form, width: u16, hint: Option<&str>, theme: &Theme) -> Vec<Line<'static>> {
let columns = usize::from(width);
let prompt = |text: String| {
let text = cut(&text, columns / 2);
let room = u16::try_from(columns.saturating_sub(text.chars().count())).unwrap_or(0);
(Span::styled(text, theme.title), room)
};
let choices = |names: &[&str], at: usize| -> Vec<Span<'static>> {
names
.iter()
.enumerate()
.flat_map(|(index, name)| {
let style = if index == at {
theme.selected
} else {
Style::new()
};
[Span::styled(format!(" {name} "), style), Span::raw(" ")]
})
.collect()
};
let note = |error: &Option<String>, hint: String| match error {
Some(error) => Line::from(Span::styled(cut(error, columns), theme.error)),
None => Line::from(Span::styled(cut(&hint, columns), theme.dim)),
};
match form {
Form::Value {
path,
kind,
optional,
input,
error,
} => {
let (prompt, room) = prompt(format!("{} = ", path.field()));
let mut line = vec![prompt];
line.extend(input.line(room, Style::new()).spans);
let empty = if *optional { ", empty unsets" } else { "" };
let hint = hint.map(|hint| format!("; {hint}")).unwrap_or_default();
vec![
Line::from(line),
note(error, format!("{}{empty}{hint}", kind.describe())),
]
},
Form::Adding(at) => {
let names: Vec<&str> = Addable::ALL.iter().map(|what| what.as_str()).collect();
let mut line = vec![Span::styled("add ", theme.title)];
line.extend(choices(&names, *at));
vec![
Line::from(line),
note(&None, "←/→ choose, Enter picks, Esc cancels".to_string()),
]
},
Form::Name { what, input, error } => {
let (prompt, room) = prompt(format!("new {} name: ", what.as_str()));
let mut line = vec![prompt];
line.extend(input.line(room, Style::new()).spans);
let hint = match what {
Addable::Topic => "a-z, 0-9 and _, not already used",
Addable::Provider | Addable::Target => "a-z, 0-9 and _",
};
vec![Line::from(line), note(error, hint.to_string())]
},
Form::Kind {
what,
name,
choice,
error,
} => {
let asked = if *what == Addable::Provider {
"protocol"
} else {
"kind"
};
let (prompt, _) = prompt(format!("{} {name} {asked}: ", what.as_str()));
let mut line = vec![prompt];
line.extend(choices(what.kinds(), *choice));
vec![
Line::from(line),
note(error, "←/→ choose, Enter adds, Esc cancels".to_string()),
]
},
}
}
fn field_line(
field: &Field,
(name_width, width): (usize, usize),
selected: bool,
theme: &Theme,
) -> Line<'static> {
let name = cut(field.name, name_width.saturating_sub(2));
let name = format!(" {name:<pad$}", pad = name_width.saturating_sub(2));
let value_style = match (&field.shown, field.lock.is_some()) {
(_, true) | (Shown::Default(_) | Shown::Unset, _) => theme.dim,
(Shown::VaultRef, _) => theme.info,
(Shown::Value(_) | Shown::Set, _) => Style::new(),
};
let room = width.saturating_sub(name_width + 1);
let value = field.shown.text();
let mut marks = String::new();
if field.env {
marks.push_str(" (env)");
}
if let Some(user) = &field.lock {
let long = format!(" (used by {user})");
let fits = value.chars().count() + marks.chars().count() + long.chars().count() <= room;
marks.push_str(if fits { &long } else { " (used)" });
}
let flag = if field.error.is_some() { " !" } else { "" };
let room = room.saturating_sub(flag.len());
let value = cut(value, room.saturating_sub(marks.chars().count()));
let marks = cut(&marks, room.saturating_sub(value.chars().count()));
let name_style = if field.lock.is_some() {
theme.dim
} else {
Style::new()
};
let line = Line::from(vec![
Span::styled(name, name_style),
Span::raw(" "),
Span::styled(value, value_style),
Span::styled(marks, theme.dim),
Span::styled(flag, theme.error),
]);
if selected {
line.style(theme.selected)
} else {
line
}
}
fn stat_line(stat: &Stat, width: usize, theme: &Theme) -> Vec<Line<'static>> {
match stat {
Stat::Heading(text) => vec![Line::from(Span::styled(cut(text, width), theme.title))],
Stat::Note(text) => vec![Line::from(Span::styled(
cut(&format!(" {text}"), width),
theme.dim,
))],
Stat::Pair(label, value)
if label.chars().count() > LABEL_WIDTH
|| LABEL_WIDTH + 3 + value.chars().count() > width =>
{
let indent = " ".repeat((LABEL_WIDTH + 3).min(width / 3));
vec![
Line::from(Span::styled(cut(&format!(" {label}"), width), theme.dim)),
Line::from(cut(&format!("{indent}{value}"), width)),
]
},
Stat::Pair(label, value) => {
let label = format!(" {label:<LABEL_WIDTH$} ");
let value = cut(value, width.saturating_sub(label.chars().count()));
vec![Line::from(vec![
Span::styled(label, theme.dim),
Span::raw(value),
])]
},
}
}
fn stat_lines(stats: &[Stat], (width, height): (u16, u16), theme: &Theme) -> Vec<Line<'static>> {
let width = usize::from(width);
let height = usize::from(height);
let mut groups: Vec<Vec<Vec<Line<'static>>>> = Vec::new();
for stat in stats {
let lines = stat_line(stat, width, theme);
match (stat, groups.last_mut()) {
(Stat::Heading(_), _) | (_, None) => groups.push(vec![lines]),
(_, Some(group)) => group.push(lines),
}
}
let count = |group: &Vec<Vec<Line>>| group.iter().map(Vec::len).sum::<usize>();
let blanks = groups.iter().map(count).sum::<usize>() + groups.len().saturating_sub(1) <= height;
let mut lines: Vec<Line<'static>> = Vec::new();
for group in groups {
let blank = usize::from(blanks && !lines.is_empty());
let mut room = height.saturating_sub(lines.len() + blank);
let first = group.iter().take(2).map(Vec::len).sum::<usize>();
if first > room {
continue;
}
if blank == 1 {
lines.push(Line::from(""));
}
for stat in group {
if stat.len() > room {
break;
}
room -= stat.len();
lines.extend(stat);
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::theme::ColorLevel;
fn texts(lines: &[Line]) -> Vec<String> {
lines.iter().map(ToString::to_string).collect()
}
fn stats() -> Vec<Stat> {
let pair = |label: &str, value: &str| Stat::Pair(label.into(), value.into());
vec![
Stat::Heading("cost"),
pair("all", "$1"),
Stat::Heading("dataset"),
pair("topics", "2"),
pair("questions", "6"),
Stat::Heading("models"),
pair("a-model-with-a-long-name", "$1"),
pair("gen", "$0"),
]
}
#[test]
fn groups_are_parted_by_a_blank_line_when_all_fit() {
let theme = Theme::new(ColorLevel::TrueColor);
let lines = texts(&stat_lines(&stats(), (30, 11), &theme));
assert_eq!(lines.len(), 11, "{lines:?}");
assert_eq!((lines[2].as_str(), lines[6].as_str()), ("", ""));
}
#[test]
fn short_on_rows_the_blanks_go_then_whole_stats() {
let theme = Theme::new(ColorLevel::TrueColor);
let lines = texts(&stat_lines(&stats(), (30, 10), &theme));
assert_eq!(lines.len(), 9, "{lines:?}");
assert!(!lines.contains(&String::new()), "{lines:?}");
let lines = texts(&stat_lines(&stats(), (30, 8), &theme));
assert_eq!(lines.len(), 8, "{lines:?}");
assert_eq!(
lines.last().map(|line| line.trim()),
Some("$1"),
"the long model name and its value go together: {lines:?}"
);
let lines = texts(&stat_lines(&stats(), (30, 7), &theme));
assert!(
!lines.iter().any(|line| line == "models"),
"no heading without a stat under it: {lines:?}"
);
assert_eq!(lines.len(), 5);
}
}