use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::config::PlanningCycle;
use crate::planning::Milestone;
use super::modal::modal_rect;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlanningSegment {
#[default]
Cycles,
Milestones,
}
impl PlanningSegment {
pub fn label(self) -> &'static str {
match self {
Self::Cycles => "Cycles",
Self::Milestones => "Milestones",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanningKind {
Cycle,
Milestone,
}
impl PlanningKind {
pub fn noun(self) -> &'static str {
match self {
Self::Cycle => "cycle",
Self::Milestone => "milestone",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanningRow {
pub name: String,
pub description: String,
pub date_a: String,
pub date_b: Option<String>,
pub issues: usize,
}
pub fn issues_word(n: usize) -> &'static str {
if n == 1 { "issue" } else { "issues" }
}
pub fn row_line(row: &PlanningRow) -> String {
let mut text = format!("{} | {} | {}", row.name, row.description, row.date_a);
if let Some(date_b) = &row.date_b {
text.push_str(" | ");
text.push_str(date_b);
}
text.push_str(&format!(" | {} {}", row.issues, issues_word(row.issues)));
text
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanningPrompt {
ConfirmDelete { name: String, referencing: usize },
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PlanningModal {
pub segment: PlanningSegment,
pub selected: usize,
pub prompt: Option<PlanningPrompt>,
}
impl PlanningModal {
pub fn new() -> Self {
Self::default()
}
pub fn move_selected(&mut self, delta: isize, len: usize) {
if len == 0 {
self.selected = 0;
return;
}
let next = self.selected as isize + delta;
self.selected = next.clamp(0, len as isize - 1) as usize;
}
pub fn switch_segment(&mut self) {
self.selected = 0;
self.segment = match self.segment {
PlanningSegment::Cycles => PlanningSegment::Milestones,
PlanningSegment::Milestones => PlanningSegment::Cycles,
};
}
pub fn render(
&self,
f: &mut Frame,
area: Rect,
cycles: &[PlanningRow],
milestones: &[PlanningRow],
) {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
self.render_with_bindings(
f,
area,
cycles,
milestones,
&bindings,
crate::keybindings::InputContext::PlanningManagement,
);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
cycles: &[PlanningRow],
milestones: &[PlanningRow],
bindings: &crate::keybindings::EffectiveBindings,
context: crate::keybindings::InputContext,
) {
let (rows, label) = match self.segment {
PlanningSegment::Cycles => (cycles, "Cycles"),
PlanningSegment::Milestones => (milestones, "Milestones"),
};
let rect = modal_rect(area);
let inner = rect.height.saturating_sub(2) as usize;
let view_h = if self.prompt.is_some() {
inner.saturating_sub(4)
} else {
inner.saturating_sub(3)
};
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
format!(" {label} — Tab to switch segment "),
Style::default().fg(Color::DarkGray),
)));
if rows.is_empty() {
if self.prompt.is_none() {
lines.push(Line::from(format!(
"No {label} yet — press n to create one."
)));
}
} else {
let sel = usize::min(self.selected, rows.len() - 1);
let top = sel.saturating_sub(view_h.saturating_sub(1));
let end = (top + view_h.max(1)).min(rows.len());
for (i, row) in rows
.iter()
.enumerate()
.skip(top)
.take(end.saturating_sub(top))
{
let cursor = if i == sel { "> " } else { " " };
let style = if i == sel {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::styled(cursor, style),
Span::styled(row_line(row), style),
]));
}
}
lines.push(Line::from(""));
match &self.prompt {
Some(PlanningPrompt::ConfirmDelete { name, referencing }) => {
lines.push(Line::from(Span::styled(
format!(
"Delete '{name}'? {referencing} {} reference it. [y/N]",
issues_word(*referencing)
),
Style::default().fg(Color::Cyan),
)));
}
None => {
lines.push(Line::from(Span::styled(
format!(
"{}: move · {}: switch · {}: new · {}: edit · {}: delete · {}: close",
bindings.label(context, crate::keybindings::TuiAction::MoveDown),
bindings.label(context, crate::keybindings::TuiAction::Next),
bindings.label(context, crate::keybindings::TuiAction::NewPlanningItem),
bindings.label(context, crate::keybindings::TuiAction::EditPlanningItem),
bindings.label(context, crate::keybindings::TuiAction::DeletePlanningItem),
bindings.label(context, crate::keybindings::TuiAction::Close),
),
Style::default().fg(Color::DarkGray),
)));
}
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Planned items ")
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines).block(block).style(Style::default());
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
#[derive(Debug, Clone)]
pub struct PlanningForm {
pub kind: PlanningKind,
pub rows: Vec<(String, String)>,
pub cursor: usize,
pub editing: Option<String>,
}
impl PlanningForm {
pub fn new(kind: PlanningKind) -> Self {
let labels: Vec<&str> = match kind {
PlanningKind::Cycle => vec!["Name", "Description", "Start date", "Due date"],
PlanningKind::Milestone => vec!["Name", "Description", "Target date"],
};
Self {
kind,
rows: labels
.into_iter()
.map(|l| (l.to_string(), String::new()))
.collect(),
cursor: 0,
editing: None,
}
}
pub fn from_cycle(name: &str, cycle: &PlanningCycle) -> Self {
Self {
kind: PlanningKind::Cycle,
rows: vec![
("Name".into(), name.to_string()),
(
"Description".into(),
cycle.description.clone().unwrap_or_default(),
),
(
"Start date".into(),
cycle.start_date.clone().unwrap_or_default(),
),
(
"Due date".into(),
cycle.due_date.clone().unwrap_or_default(),
),
],
cursor: 0,
editing: Some(name.to_string()),
}
}
pub fn from_milestone(m: &Milestone) -> Self {
Self {
kind: PlanningKind::Milestone,
rows: vec![
("Name".into(), m.name.clone()),
("Description".into(), m.description.clone()),
("Target date".into(), m.target_date.clone()),
],
cursor: 0,
editing: Some(m.name.clone()),
}
}
pub fn is_new(&self) -> bool {
self.editing.is_none()
}
pub fn name(&self) -> &str {
self.field_value(0)
}
pub fn description(&self) -> &str {
self.field_value(1)
}
pub fn date_a(&self) -> &str {
self.field_value(2)
}
pub fn date_b(&self) -> &str {
match self.kind {
PlanningKind::Cycle => self.field_value(3),
PlanningKind::Milestone => "",
}
}
fn field_value(&self, index: usize) -> &str {
self.rows
.get(index)
.map(|(_, value)| value.as_str())
.unwrap_or("")
}
pub fn focused_label(&self) -> &str {
&self.rows[self.cursor].0
}
pub fn focused_value(&self) -> &str {
self.field_value(self.cursor)
}
pub fn focused_is_date(&self) -> bool {
self.cursor >= 2
}
pub fn focused_date_is_complete(&self) -> bool {
self.focused_is_date() && crate::ui::date_picker::is_complete_date(self.focused_value())
}
pub fn set_focused_date(&mut self, value: &str) {
if self.focused_is_date() {
if let Some((_, current)) = self.rows.get_mut(self.cursor) {
*current = value.to_string();
}
}
}
pub fn focus_next(&mut self) {
self.cursor = (self.cursor + 1) % self.rows.len();
}
pub fn focus_prev(&mut self) {
self.cursor = (self.cursor + self.rows.len() - 1) % self.rows.len();
}
pub fn insert_char(&mut self, c: char) {
if self.focused_is_date() {
if let Some(next) = crate::ui::date_picker::type_date(self.focused_value(), c) {
if let Some((_, value)) = self.rows.get_mut(self.cursor) {
*value = next;
}
}
return;
}
if let Some((_, value)) = self.rows.get_mut(self.cursor) {
value.push(c);
}
}
pub fn delete_char(&mut self) {
if self.focused_is_date() {
let next = crate::ui::date_picker::backspace_date(self.focused_value());
if let Some((_, value)) = self.rows.get_mut(self.cursor) {
*value = next;
}
return;
}
if let Some((_, value)) = self.rows.get_mut(self.cursor) {
value.pop();
}
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
self.render_with_bindings(
f,
area,
&bindings,
crate::keybindings::InputContext::PlanningForm,
);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
bindings: &crate::keybindings::EffectiveBindings,
context: crate::keybindings::InputContext,
) {
let rect = modal_rect(area);
let title = if self.is_new() {
format!(" New {} ", self.kind.noun())
} else {
format!(" Edit {} ", self.kind.noun())
};
let mut lines: Vec<Line> = Vec::new();
for (i, (label, value)) in self.rows.iter().enumerate() {
let cursor = if i == self.cursor { "> " } else { " " };
let style = if i == self.cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let shown = if value.is_empty() {
"(empty)"
} else {
value.as_str()
};
lines.push(Line::from(vec![
Span::styled(cursor, style),
Span::styled(format!("{label}: "), style),
Span::styled(shown, style),
]));
}
lines.push(Line::from(""));
let hint = format!(
"{}: save/pick date · {}: cancel · {}: next field · {}: date{}",
bindings.label(context, crate::keybindings::TuiAction::Submit),
bindings.label(context, crate::keybindings::TuiAction::Cancel),
bindings.label(context, crate::keybindings::TuiAction::Next),
bindings.label(context, crate::keybindings::TuiAction::OpenPicker),
if self.is_new() {
""
} else {
" · renaming rewrites issues"
},
);
lines.push(Line::from(Span::styled(
hint,
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines).block(block).style(Style::default());
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn row(
name: &str,
description: &str,
date_a: &str,
date_b: Option<&str>,
issues: usize,
) -> PlanningRow {
PlanningRow {
name: name.into(),
description: description.into(),
date_a: date_a.into(),
date_b: date_b.map(str::to_string),
issues,
}
}
fn cycle_rows() -> Vec<PlanningRow> {
vec![
row("Sprint 3", "kickoff", "2026-10-01", Some("2026-10-14"), 1),
row("Sprint 4", "", "", Some(""), 0),
]
}
fn milestone_rows() -> Vec<PlanningRow> {
vec![
row("GA", "ship it", "2026-12-31", None, 3),
row("Beta", "", "2026-11-30", None, 2),
]
}
fn render_modal(modal: &PlanningModal) -> Vec<String> {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let cycles = cycle_rows();
let milestones = milestone_rows();
terminal
.draw(|f| modal.render(f, f.area(), &cycles, &milestones))
.unwrap();
let buffer = terminal.backend().buffer();
let mut rows: Vec<String> = Vec::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
}
rows.push(line);
}
rows
}
#[test]
fn row_line_for_cycle_includes_all_fields() {
assert_eq!(
row_line(&row(
"Sprint 3",
"kickoff",
"2026-10-01",
Some("2026-10-14"),
1
)),
"Sprint 3 | kickoff | 2026-10-01 | 2026-10-14 | 1 issue"
);
assert_eq!(
row_line(&row(
"Sprint 5",
"next",
"2026-11-01",
Some("2026-11-15"),
2
)),
"Sprint 5 | next | 2026-11-01 | 2026-11-15 | 2 issues"
);
}
#[test]
fn row_line_for_milestone_omits_due_date() {
assert_eq!(
row_line(&row("GA", "ship it", "2026-12-31", None, 3)),
"GA | ship it | 2026-12-31 | 3 issues"
);
}
#[test]
fn row_line_renders_missing_fields_blank() {
assert_eq!(
row_line(&row("Sprint 4", "", "", Some(""), 0)),
"Sprint 4 | | | | 0 issues"
);
}
#[test]
fn renders_cycle_rows_with_cursor_and_footer() {
let modal = PlanningModal {
selected: 1,
..PlanningModal::default()
};
let rows = render_modal(&modal);
let row_of =
|needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
let sprint3 = row_of("Sprint 3 | kickoff | 2026-10-01 | 2026-10-14 | 1 issue")
.expect("first cycle renders with fields and count");
let sprint4 = row_of("Sprint 4 |").expect("second cycle renders");
assert!(sprint3 < sprint4, "cycles render in declaration order");
assert!(
rows[sprint4].contains('>'),
"selected cycle row shows the cursor"
);
assert!(
!rows[sprint3].contains('>'),
"unselected cycle row has no cursor"
);
assert!(
rows.iter().any(|r| r.contains("Down / j: move")),
"browse hint renders on the cycles segment"
);
}
#[test]
fn renders_milestone_segment_after_switch() {
let mut modal = PlanningModal::new();
modal.switch_segment();
assert_eq!(modal.segment, PlanningSegment::Milestones);
let rows = render_modal(&modal);
let row_of =
|needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
let ga = row_of("GA | ship it | 2026-12-31 | 3 issues")
.expect("milestone rows render on the milestones segment");
let footer =
row_of("Down / j: move").expect("footer hint renders beneath the milestones list");
assert!(
ga < footer,
"milestone rows render above the footer hint (ga at {ga}, footer at {footer})"
);
}
#[test]
fn renders_empty_segment_notice() {
let modal = PlanningModal::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| modal.render(f, f.area(), &[], &[]))
.unwrap();
let buffer = terminal.backend().buffer();
let mut rows: Vec<String> = Vec::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
}
rows.push(line);
}
assert!(
rows.iter()
.any(|r| r.contains("No Cycles yet — press n to create one.")),
"empty segment shows a create hint: {:?}",
rows
);
}
#[test]
fn renders_confirm_delete_prompt() {
let modal = PlanningModal {
prompt: Some(PlanningPrompt::ConfirmDelete {
name: "Sprint 3".into(),
referencing: 2,
}),
..PlanningModal::default()
};
let rows = render_modal(&modal);
assert!(
rows.iter()
.any(|r| r.contains("Delete 'Sprint 3'? 2 issues reference it. [y/N]")),
"confirm prompt shows the referencing count: {:?}",
rows
);
}
#[test]
fn scroll_window_keeps_selected_in_view_on_small_terminal() {
let many: Vec<PlanningRow> = (0..40)
.map(|i| row(&format!("Item {i}"), "", "2026-01-01", Some(""), 0))
.collect();
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).unwrap();
let modal = PlanningModal {
selected: 39,
..PlanningModal::default()
};
terminal
.draw(|f| modal.render(f, f.area(), &many, &[]))
.unwrap();
let buffer = terminal.backend().buffer();
let mut rows: Vec<String> = Vec::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
}
rows.push(line);
}
assert!(
rows.iter().any(|r| r.contains("Item 39")),
"the selected (deep) row stays visible via the scroll window"
);
assert!(
!rows.iter().any(|r| r.contains("Item 0")),
"the first row scrolls out of the window"
);
}
#[test]
fn move_selected_clamps_and_scrolls() {
let mut modal = PlanningModal::new();
modal.move_selected(-3, 2);
assert_eq!(modal.selected, 0);
modal.move_selected(5, 2);
assert_eq!(modal.selected, 1);
modal.move_selected(1, 0);
assert_eq!(modal.selected, 0);
}
#[test]
fn smoke_tiny_20x5_does_not_panic() {
let modal = PlanningModal::new();
let backend = TestBackend::new(20, 5);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| modal.render(f, f.area(), &cycle_rows(), &milestone_rows()))
.unwrap();
}
#[test]
fn form_labels_match_kind() {
let cycle = PlanningForm::new(PlanningKind::Cycle);
assert_eq!(cycle.rows.len(), 4);
let labels: Vec<&str> = cycle.rows.iter().map(|(l, _)| l.as_str()).collect();
assert_eq!(labels, ["Name", "Description", "Start date", "Due date"]);
let milestone = PlanningForm::new(PlanningKind::Milestone);
assert_eq!(milestone.rows.len(), 3);
let labels: Vec<&str> = milestone.rows.iter().map(|(l, _)| l.as_str()).collect();
assert_eq!(labels, ["Name", "Description", "Target date"]);
assert!(milestone.is_new());
assert!(cycle.is_new());
}
#[test]
fn form_prefills_from_cycle_and_milestone() {
let cycle = PlanningForm::from_cycle(
"Sprint 3",
&PlanningCycle {
description: Some("kickoff".into()),
start_date: Some("2026-10-01".into()),
due_date: Some("2026-10-14".into()),
},
);
assert_eq!(cycle.name(), "Sprint 3");
assert_eq!(cycle.description(), "kickoff");
assert_eq!(cycle.date_a(), "2026-10-01");
assert_eq!(cycle.date_b(), "2026-10-14");
assert!(!cycle.is_new());
assert_eq!(cycle.editing.as_deref(), Some("Sprint 3"));
let milestone = PlanningForm::from_milestone(&Milestone {
name: "GA".into(),
target_date: "2026-12-31".into(),
description: "ship it".into(),
created: "2026-01-01".into(),
updated: "2026-01-02".into(),
file_name: "2026-01-01-ga.md".into(),
});
assert_eq!(milestone.name(), "GA");
assert_eq!(milestone.description(), "ship it");
assert_eq!(milestone.date_a(), "2026-12-31");
assert_eq!(milestone.date_b(), "", "milestones have no due-date field");
assert_eq!(milestone.editing.as_deref(), Some("GA"));
}
#[test]
fn form_cursor_and_typing() {
let mut form = PlanningForm::new(PlanningKind::Cycle);
assert_eq!(form.focused_label(), "Name");
form.insert_char('S');
form.insert_char('3');
assert_eq!(form.name(), "S3");
form.focus_next();
assert_eq!(form.focused_label(), "Description");
form.insert_char('k');
assert_eq!(form.description(), "k");
form.delete_char();
assert_eq!(form.description(), "");
form.focus_prev();
assert_eq!(form.focused_label(), "Name");
form.focus_next();
form.focus_next();
form.focus_next();
form.focus_next();
assert_eq!(form.focused_label(), "Name", "Tab wraps to the first field");
form.focus_prev();
assert_eq!(
form.focused_label(),
"Due date",
"Shift+Tab wraps to the last field"
);
}
#[test]
fn form_render_smokes_at_80x24_and_tiny() {
for (w, h) in [(80, 24), (20, 5)] {
let form = PlanningForm::from_milestone(&Milestone {
name: "GA".into(),
target_date: "2026-12-31".into(),
description: "ship it".into(),
created: "2026-01-01".into(),
updated: "2026-01-02".into(),
file_name: "2026-01-01-ga.md".into(),
});
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| form.render(f, f.area())).unwrap();
}
}
#[test]
fn edit_form_renders_edit_title() {
let form = PlanningForm::from_cycle(
"Sprint 3",
&PlanningCycle {
description: None,
start_date: None,
due_date: None,
},
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| form.render(f, f.area())).unwrap();
let buffer = terminal.backend().buffer();
let mut rows: Vec<String> = Vec::new();
for y in 0..buffer.area().height {
let mut line = String::new();
for x in 0..buffer.area().width {
line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
}
rows.push(line);
}
assert!(
rows.iter().any(|r| r.contains("Edit cycle")),
"edit form titles itself: {rows:?}"
);
}
}