use std::path::Path;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Clear, Paragraph},
};
use super::overlay;
use crate::adapter::tui::form::{Field, Form};
const LABEL_COLOR: Color = Color::DarkGray;
const ACTIVE_VALUE_COLOR: Color = Color::White;
const IDLE_VALUE_COLOR: Color = Color::Gray;
const HINT_COLOR: Color = Color::DarkGray;
const ERROR_COLOR: Color = Color::Red;
const ERROR_PREFIX: &str = "! ";
const ACTIVE_MARKER: &str = "▎";
const CHOICE_OPEN: &str = "‹ ";
const CHOICE_CLOSE: &str = " ›";
const HINT: &str = " ⏎ save tab next ←→ edit esc cancel";
const TOP_PAD: u16 = 1;
const GAP: u16 = 1;
const FIELD_WIDTH: usize = 54;
const CANDIDATE_MARKER: &str = "▸";
const CANDIDATE_COLOR: Color = Color::DarkGray;
const CANDIDATE_SELECTED_COLOR: Color = Color::White;
pub fn render(frame: &mut Frame, area: Rect, form: &Form, error: Option<&str>) {
let body = field_lines(form);
let mut footer = Vec::new();
if let Some(error) = error {
footer.push(Line::from(Span::styled(
format!(" {ERROR_PREFIX}{error}"),
Style::default().fg(ERROR_COLOR),
)));
}
footer.push(Line::from(Span::styled(
HINT,
Style::default().fg(HINT_COLOR),
)));
let title = title_line(form.title());
let footer_height = footer.len() as u16;
let content = body
.iter()
.chain(&footer)
.chain(std::iter::once(&title))
.map(|line| line.width())
.max()
.unwrap_or(0) as u16;
let width = overlay::clamp_width(content, area);
let height =
(TOP_PAD + body.len() as u16 + GAP + footer_height + overlay::BORDERS).min(area.height);
let modal = overlay::centered(width, height, area);
overlay::dim_backdrop(frame, area);
overlay::draw_shadow(frame, modal, area);
frame.render_widget(Clear, modal);
let block = overlay::panel(title);
let inner = block.inner(modal);
frame.render_widget(block, modal);
let regions = Layout::vertical([
Constraint::Length(TOP_PAD),
Constraint::Length(body.len() as u16),
Constraint::Length(GAP),
Constraint::Length(footer_height),
])
.split(inner);
frame.render_widget(Paragraph::new(body), regions[1]);
frame.render_widget(Paragraph::new(footer), regions[3]);
}
fn field_lines(form: &Form) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for (index, field) in form.fields().iter().enumerate() {
let active = index == form.active();
lines.push(Line::from(Span::styled(
format!(" {}", field.label()),
Style::default().fg(LABEL_COLOR),
)));
lines.push(value_line(field, active));
if active {
lines.extend(candidate_lines(field));
}
lines.push(Line::default());
}
lines.pop();
lines
}
fn candidate_lines(field: &Field) -> Vec<Line<'static>> {
let highlighted = field.highlighted();
field
.candidates()
.iter()
.enumerate()
.map(|(index, candidate)| {
let name = Path::new(candidate)
.file_name()
.and_then(|component| component.to_str())
.unwrap_or(candidate);
let selected = index == highlighted;
let marker = if selected { CANDIDATE_MARKER } else { " " };
let color = if selected {
CANDIDATE_SELECTED_COLOR
} else {
CANDIDATE_COLOR
};
Line::from(vec![
Span::raw(" "),
Span::styled(marker, Style::default().fg(overlay::ACCENT_COLOR)),
Span::raw(" "),
Span::styled(name.to_string(), Style::default().fg(color)),
])
})
.collect()
}
fn value_line(field: &Field, active: bool) -> Line<'static> {
let mark = if active { ACTIVE_MARKER } else { " " };
let value_color = if active {
ACTIVE_VALUE_COLOR
} else {
IDLE_VALUE_COLOR
};
let mut spans = vec![
Span::raw(" "),
Span::styled(mark, Style::default().fg(overlay::ACCENT_COLOR)),
Span::raw(" "),
];
match field.visible(FIELD_WIDTH, active) {
None => {
spans.push(Span::styled(
CHOICE_OPEN,
Style::default().fg(overlay::ACCENT_COLOR),
));
spans.push(Span::styled(
field.value(),
Style::default().fg(value_color),
));
spans.push(Span::styled(
CHOICE_CLOSE,
Style::default().fg(overlay::ACCENT_COLOR),
));
},
Some((visible, cursor)) if active => {
let chars: Vec<char> = visible.chars().collect();
let before: String = chars.iter().take(cursor).collect();
let at: String = chars.get(cursor).map_or(" ".to_string(), char::to_string);
let after: String = chars.iter().skip(cursor + 1).collect();
spans.push(Span::styled(before, Style::default().fg(value_color)));
spans.push(Span::styled(
at,
Style::default()
.fg(value_color)
.add_modifier(Modifier::REVERSED),
));
spans.push(Span::styled(after, Style::default().fg(value_color)));
},
Some((visible, _)) => {
spans.push(Span::styled(visible, Style::default().fg(value_color)));
},
}
Line::from(spans)
}
fn title_line(title: &str) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(
title.to_string(),
Style::default()
.fg(overlay::ACCENT_COLOR)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
])
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::adapter::tui::form::Field;
#[test]
fn renders_a_form_with_an_active_field() {
let form = Form::new("New project", vec![
Field::text("Name"),
Field::path("Folder", "~/Projects"),
]);
let mut terminal = Terminal::new(TestBackend::new(56, 12)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), &form, None))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn renders_a_path_field_with_a_dropdown() {
let mut form = Form::new("New project", vec![Field::path("Folder", "~/w/pr")]);
form.set_active_candidates(vec![
"~/w/prism".to_string(),
"~/w/proto".to_string(),
"~/w/project".to_string(),
]);
let mut terminal = Terminal::new(TestBackend::new(56, 14)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), &form, None))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}