use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::tui::project_ctl::state::ConfigFormView;
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
fn field_line(label: &str, original: Option<&str>, value: &str, focused: bool) -> String {
let marker = if value.trim() != original.unwrap_or("") {
"*"
} else {
""
};
let cursor = if focused { "> " } else { " " };
let shown = if value.is_empty() { "(empty)" } else { value };
format!("{cursor}{label}: {shown}{marker}")
}
pub fn body_lines(view: &ConfigFormView) -> Vec<String> {
use crate::tui::project_ctl::state::ConfigFormFocus;
let mut lines = vec![
field_line(
"default_branch",
view.default_branch.original.as_deref(),
&view.default_branch.value,
view.focus == ConfigFormFocus::DefaultBranch,
),
field_line(
"description",
view.description.original.as_deref(),
&view.description.value,
view.focus == ConfigFormFocus::Description,
),
field_line(
"stack_hint",
view.stack_hint.original.as_deref(),
&view.stack_hint.value,
view.focus == ConfigFormFocus::StackHint,
),
field_line(
"gh_user",
view.gh_user.original.as_deref(),
&view.gh_user.value,
view.focus == ConfigFormFocus::GhUser,
),
field_line(
"tags",
Some(&view.tags.original.join(", ")),
&view.tags.value,
view.focus == ConfigFormFocus::Tags,
),
];
if let Some(err) = &view.error {
lines.push(String::new());
lines.push(format!("ERROR: {err}"));
}
lines
}
const OVERLAY_WIDTH: u16 = 72;
const OVERLAY_HEIGHT: u16 = 12;
pub fn render(frame: &mut Frame, view: &ConfigFormView) {
let area = centered_rect(OVERLAY_WIDTH, OVERLAY_HEIGHT, frame.area());
frame.render_widget(Clear, area);
let title = Line::from(vec![Span::styled(
format!("Config — {}", view.project_name),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]);
let body = body_lines(view).join("\n");
let footer = "\n[Tab] next field [Enter] submit [Esc] cancel";
frame.render_widget(
Paragraph::new(format!("{body}{footer}"))
.style(Style::default().fg(Color::Reset))
.block(Block::default().borders(Borders::ALL).title(title)),
area,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::Project;
use crate::tui::project_ctl::state::ConfigFormFocus;
fn project() -> Project {
Project {
name: "widget".to_string(),
repo_url: "https://github.com/acme/widget".to_string(),
default_branch: "main".to_string(),
stack_hint: Some("rust".to_string()),
tags: vec!["backend".to_string()],
description: Some("the widget".to_string()),
gh_user: Some("acme-bot".to_string()),
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
fn form() -> ConfigFormView {
let mut state = crate::tui::project_ctl::state::ProjectCtlState {
projects_full: [("widget".to_string(), project())].into_iter().collect(),
..Default::default()
};
state.open_config_form("widget");
state.config_form.expect("form should be open")
}
#[test]
fn body_lines_marks_the_focused_row() {
let lines = body_lines(&form()).join("\n");
assert!(lines.contains("> default_branch: main"));
assert!(lines.contains(" description: the widget"));
}
#[test]
fn body_lines_marks_changed_fields() {
let mut view = form();
view.stack_hint.value = "python".to_string();
let lines = body_lines(&view).join("\n");
assert!(lines.contains("stack_hint: python*"));
assert!(lines.contains("gh_user: acme-bot") && !lines.contains("gh_user: acme-bot*"));
}
#[test]
fn body_lines_shows_inline_error() {
let mut view = form();
view.error = Some("project name is the identity key".to_string());
let lines = body_lines(&view);
assert!(
lines
.iter()
.any(|l| l.contains("ERROR: project name is the identity key"))
);
}
#[test]
fn body_lines_omits_error_section_when_none() {
let lines = body_lines(&form());
assert!(!lines.iter().any(|l| l.starts_with("ERROR:")));
}
#[test]
fn field_line_shows_empty_placeholder_for_a_blank_buffer() {
let line = field_line("description", Some("old"), "", false);
assert!(line.contains("(empty)"));
assert!(
line.contains('*'),
"cleared from a non-empty original is a change"
);
}
#[test]
fn tags_row_uses_the_joined_original_as_its_comparison_baseline() {
let view = form();
assert_eq!(view.focus, ConfigFormFocus::DefaultBranch);
let lines = body_lines(&view).join("\n");
assert!(lines.contains("tags: backend") && !lines.contains("tags: backend*"));
}
}