1use ratatui::Frame;
8use ratatui::layout::Rect;
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Clear, Paragraph};
12
13use crate::config::PlanningCycle;
14use crate::planning::Milestone;
15
16use super::modal::modal_rect;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum PlanningSegment {
21 #[default]
22 Cycles,
23 Milestones,
24}
25
26impl PlanningSegment {
27 pub fn label(self) -> &'static str {
28 match self {
29 Self::Cycles => "Cycles",
30 Self::Milestones => "Milestones",
31 }
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum PlanningKind {
38 Cycle,
39 Milestone,
40}
41
42impl PlanningKind {
43 pub fn noun(self) -> &'static str {
44 match self {
45 Self::Cycle => "cycle",
46 Self::Milestone => "milestone",
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct PlanningRow {
57 pub name: String,
58 pub description: String,
59 pub date_a: String,
60 pub date_b: Option<String>,
61 pub issues: usize,
62}
63
64pub fn issues_word(n: usize) -> &'static str {
65 if n == 1 { "issue" } else { "issues" }
66}
67
68pub fn row_line(row: &PlanningRow) -> String {
71 let mut text = format!("{} | {} | {}", row.name, row.description, row.date_a);
72 if let Some(date_b) = &row.date_b {
73 text.push_str(" | ");
74 text.push_str(date_b);
75 }
76 text.push_str(&format!(" | {} {}", row.issues, issues_word(row.issues)));
77 text
78}
79
80pub fn rows_lines(rows: &[PlanningRow], selected: usize, view_h: usize) -> Vec<Line<'static>> {
89 if rows.is_empty() {
90 return Vec::new();
91 }
92 let sel = usize::min(selected, rows.len() - 1);
93 let top = sel.saturating_sub(view_h.saturating_sub(1));
94 let end = (top + view_h.max(1)).min(rows.len());
95 let mut lines = Vec::new();
96 for (i, row) in rows
97 .iter()
98 .enumerate()
99 .skip(top)
100 .take(end.saturating_sub(top))
101 {
102 let cursor = if i == sel { "> " } else { " " };
103 let style = if i == sel {
104 Style::default()
105 .fg(Color::Yellow)
106 .add_modifier(Modifier::BOLD)
107 } else {
108 Style::default().fg(Color::White)
109 };
110 lines.push(Line::from(vec![
111 Span::styled(cursor, style),
112 Span::styled(row_line(row), style),
113 ]));
114 }
115 lines
116}
117
118pub fn prompt_line(prompt: &PlanningPrompt) -> Line<'static> {
121 match prompt {
122 PlanningPrompt::ConfirmDelete { name, referencing } => Line::from(Span::styled(
123 format!(
124 "Delete '{name}'? {referencing} {} reference it. [y/N]",
125 issues_word(*referencing)
126 ),
127 Style::default().fg(Color::Cyan),
128 )),
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum PlanningPrompt {
135 ConfirmDelete { name: String, referencing: usize },
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Default)]
143pub struct PlanningModal {
144 pub segment: PlanningSegment,
146 pub selected: usize,
148 pub prompt: Option<PlanningPrompt>,
150}
151
152impl PlanningModal {
153 pub fn new() -> Self {
154 Self::default()
155 }
156
157 pub fn move_selected(&mut self, delta: isize, len: usize) {
159 if len == 0 {
160 self.selected = 0;
161 return;
162 }
163 let next = self.selected as isize + delta;
164 self.selected = next.clamp(0, len as isize - 1) as usize;
165 }
166
167 pub fn switch_segment(&mut self) {
168 self.selected = 0;
169 self.segment = match self.segment {
170 PlanningSegment::Cycles => PlanningSegment::Milestones,
171 PlanningSegment::Milestones => PlanningSegment::Cycles,
172 };
173 }
174
175 pub fn move_selected_to(&mut self, row: usize, len: usize) {
178 if len == 0 {
179 self.selected = 0;
180 return;
181 }
182 self.selected = row.min(len - 1);
183 }
184
185 pub fn render(
187 &self,
188 f: &mut Frame,
189 area: Rect,
190 cycles: &[PlanningRow],
191 milestones: &[PlanningRow],
192 ) {
193 let bindings =
194 crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
195 .expect("built-in bindings are valid");
196 self.render_with_bindings(
197 f,
198 area,
199 cycles,
200 milestones,
201 &bindings,
202 crate::keybindings::InputContext::PlanningManagement,
203 );
204 }
205
206 pub fn render_with_bindings(
207 &self,
208 f: &mut Frame,
209 area: Rect,
210 cycles: &[PlanningRow],
211 milestones: &[PlanningRow],
212 bindings: &crate::keybindings::EffectiveBindings,
213 context: crate::keybindings::InputContext,
214 ) {
215 let (rows, label) = match self.segment {
216 PlanningSegment::Cycles => (cycles, "Cycles"),
217 PlanningSegment::Milestones => (milestones, "Milestones"),
218 };
219 let rect = modal_rect(area);
220 let inner = rect.height.saturating_sub(2) as usize;
221 let view_h = if self.prompt.is_some() {
222 inner.saturating_sub(4)
223 } else {
224 inner.saturating_sub(3)
225 };
226
227 let mut lines: Vec<Line> = Vec::new();
228 lines.push(Line::from(Span::styled(
229 format!(" {label} — Tab to switch segment "),
230 Style::default().fg(Color::DarkGray),
231 )));
232
233 if rows.is_empty() {
234 if self.prompt.is_none() {
235 lines.push(Line::from(format!(
236 "No {label} yet — press n to create one."
237 )));
238 }
239 } else {
240 lines.extend(rows_lines(rows, self.selected, view_h));
241 }
242
243 lines.push(Line::from(""));
244 match &self.prompt {
245 Some(prompt) => lines.push(prompt_line(prompt)),
246 None => {
247 lines.push(Line::from(Span::styled(
248 format!(
249 "{}: move · {}: switch · {}: new · {}: edit · {}: delete · {}: close",
250 bindings.label(context, crate::keybindings::TuiAction::MoveDown),
251 bindings.label(context, crate::keybindings::TuiAction::Next),
252 bindings.label(context, crate::keybindings::TuiAction::NewPlanningItem),
253 bindings.label(context, crate::keybindings::TuiAction::EditPlanningItem),
254 bindings.label(context, crate::keybindings::TuiAction::DeletePlanningItem),
255 bindings.label(context, crate::keybindings::TuiAction::Close),
256 ),
257 Style::default().fg(Color::DarkGray),
258 )));
259 }
260 }
261
262 let block = Block::default()
263 .borders(Borders::ALL)
264 .title(" Planned items ")
265 .style(Style::default().fg(Color::Cyan));
266 let para = Paragraph::new(lines).block(block).style(Style::default());
267 f.render_widget(Clear, rect);
268 f.render_widget(para, rect);
269 }
270}
271
272#[derive(Debug, Clone)]
276pub struct PlanningForm {
277 pub kind: PlanningKind,
278 pub rows: Vec<(String, String)>,
279 pub cursor: usize,
280 pub editing: Option<String>,
281}
282
283impl PlanningForm {
284 pub fn new(kind: PlanningKind) -> Self {
286 let labels: Vec<&str> = match kind {
287 PlanningKind::Cycle => vec!["Name", "Description", "Start date", "Due date"],
288 PlanningKind::Milestone => vec!["Name", "Description", "Target date"],
289 };
290 Self {
291 kind,
292 rows: labels
293 .into_iter()
294 .map(|l| (l.to_string(), String::new()))
295 .collect(),
296 cursor: 0,
297 editing: None,
298 }
299 }
300
301 pub fn from_cycle(name: &str, cycle: &PlanningCycle) -> Self {
303 Self {
304 kind: PlanningKind::Cycle,
305 rows: vec![
306 ("Name".into(), name.to_string()),
307 (
308 "Description".into(),
309 cycle.description.clone().unwrap_or_default(),
310 ),
311 (
312 "Start date".into(),
313 cycle.start_date.clone().unwrap_or_default(),
314 ),
315 (
316 "Due date".into(),
317 cycle.due_date.clone().unwrap_or_default(),
318 ),
319 ],
320 cursor: 0,
321 editing: Some(name.to_string()),
322 }
323 }
324
325 pub fn from_milestone(m: &Milestone) -> Self {
327 Self {
328 kind: PlanningKind::Milestone,
329 rows: vec![
330 ("Name".into(), m.name.clone()),
331 ("Description".into(), m.description.clone()),
332 ("Target date".into(), m.target_date.clone()),
333 ],
334 cursor: 0,
335 editing: Some(m.name.clone()),
336 }
337 }
338
339 pub fn is_new(&self) -> bool {
340 self.editing.is_none()
341 }
342
343 pub fn name(&self) -> &str {
345 self.field_value(0)
346 }
347
348 pub fn description(&self) -> &str {
349 self.field_value(1)
350 }
351
352 pub fn date_a(&self) -> &str {
354 self.field_value(2)
355 }
356
357 pub fn date_b(&self) -> &str {
359 match self.kind {
360 PlanningKind::Cycle => self.field_value(3),
361 PlanningKind::Milestone => "",
362 }
363 }
364
365 fn field_value(&self, index: usize) -> &str {
366 self.rows
367 .get(index)
368 .map(|(_, value)| value.as_str())
369 .unwrap_or("")
370 }
371
372 pub fn focused_label(&self) -> &str {
373 &self.rows[self.cursor].0
374 }
375
376 pub fn focused_value(&self) -> &str {
378 self.field_value(self.cursor)
379 }
380
381 pub fn focused_is_date(&self) -> bool {
384 self.cursor >= 2
385 }
386
387 pub fn focused_date_is_complete(&self) -> bool {
389 self.focused_is_date() && crate::ui::date_picker::is_complete_date(self.focused_value())
390 }
391
392 pub fn set_focused_date(&mut self, value: &str) {
394 if self.focused_is_date() {
395 if let Some((_, current)) = self.rows.get_mut(self.cursor) {
396 *current = value.to_string();
397 }
398 }
399 }
400
401 pub fn focus_next(&mut self) {
402 self.cursor = (self.cursor + 1) % self.rows.len();
403 }
404
405 pub fn focus_prev(&mut self) {
406 self.cursor = (self.cursor + self.rows.len() - 1) % self.rows.len();
407 }
408
409 pub fn insert_char(&mut self, c: char) {
410 if self.focused_is_date() {
411 if let Some(next) = crate::ui::date_picker::type_date(self.focused_value(), c) {
412 if let Some((_, value)) = self.rows.get_mut(self.cursor) {
413 *value = next;
414 }
415 }
416 return;
417 }
418 if let Some((_, value)) = self.rows.get_mut(self.cursor) {
419 value.push(c);
420 }
421 }
422
423 pub fn delete_char(&mut self) {
424 if self.focused_is_date() {
425 let next = crate::ui::date_picker::backspace_date(self.focused_value());
426 if let Some((_, value)) = self.rows.get_mut(self.cursor) {
427 *value = next;
428 }
429 return;
430 }
431 if let Some((_, value)) = self.rows.get_mut(self.cursor) {
432 value.pop();
433 }
434 }
435
436 pub fn render(&self, f: &mut Frame, area: Rect) {
438 let bindings =
439 crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
440 .expect("built-in bindings are valid");
441 self.render_with_bindings(
442 f,
443 area,
444 &bindings,
445 crate::keybindings::InputContext::PlanningForm,
446 );
447 }
448
449 pub fn render_with_bindings(
450 &self,
451 f: &mut Frame,
452 area: Rect,
453 bindings: &crate::keybindings::EffectiveBindings,
454 context: crate::keybindings::InputContext,
455 ) {
456 let rect = area;
461 let title = if self.is_new() {
462 format!(" New {} ", self.kind.noun())
463 } else {
464 format!(" Edit {} ", self.kind.noun())
465 };
466
467 let mut lines: Vec<Line> = Vec::new();
468 for (i, (label, value)) in self.rows.iter().enumerate() {
469 let cursor = if i == self.cursor { "> " } else { " " };
470 let style = if i == self.cursor {
471 Style::default()
472 .fg(Color::Yellow)
473 .add_modifier(Modifier::BOLD)
474 } else {
475 Style::default().fg(Color::White)
476 };
477 let shown = if value.is_empty() {
478 "(empty)"
479 } else {
480 value.as_str()
481 };
482 lines.push(Line::from(vec![
483 Span::styled(cursor, style),
484 Span::styled(format!("{label}: "), style),
485 Span::styled(shown, style),
486 ]));
487 }
488
489 lines.push(Line::from(""));
490 let hint = format!(
491 "{}: save/pick date · {}: cancel · {}: next field · {}: date{}",
492 bindings.label(context, crate::keybindings::TuiAction::Submit),
493 bindings.label(context, crate::keybindings::TuiAction::Cancel),
494 bindings.label(context, crate::keybindings::TuiAction::Next),
495 bindings.label(context, crate::keybindings::TuiAction::OpenPicker),
496 if self.is_new() {
497 ""
498 } else {
499 " · renaming rewrites issues"
500 },
501 );
502 lines.push(Line::from(Span::styled(
503 hint,
504 Style::default().fg(Color::DarkGray),
505 )));
506
507 let block = Block::default()
508 .borders(Borders::ALL)
509 .title(title)
510 .style(Style::default().fg(Color::Cyan));
511 let para = Paragraph::new(lines).block(block).style(Style::default());
512 f.render_widget(Clear, rect);
513 f.render_widget(para, rect);
514 }
515}
516
517#[derive(Debug, Clone, PartialEq, Eq, Default)]
528pub struct EpicViewState {
529 pub selected: usize,
531 pub member: usize,
533}
534
535impl EpicViewState {
536 #[must_use]
537 pub fn new() -> Self {
538 Self::default()
539 }
540
541 pub fn move_selected(&mut self, delta: isize, len: usize) {
544 self.selected = clamp_cursor(self.selected, delta, len);
545 self.member = 0;
546 }
547
548 pub fn move_member(&mut self, delta: isize, len: usize) {
550 self.member = clamp_cursor(self.member, delta, len);
551 }
552
553 #[must_use]
558 pub fn target_member(&self, entries: &[crate::commands::view::EpicViewEntry]) -> Option<u32> {
559 let entry = entries.get(self.selected)?;
560 entry.members.get(self.member).map(|member| member.number)
561 }
562
563 pub fn render(
569 &self,
570 f: &mut Frame,
571 area: Rect,
572 entries: &[crate::commands::view::EpicViewEntry],
573 ) {
574 let rect = modal_rect(area);
575 let view_h = (rect.height.saturating_sub(2) as usize).saturating_sub(3);
578 let mut lines: Vec<Line> = Vec::new();
579 lines.push(Line::from(Span::styled(
580 format!(" Epics — {} in declaration order ", entries.len()),
581 Style::default().fg(Color::DarkGray),
582 )));
583 if entries.is_empty() {
584 lines.push(Line::from(
585 "No epics yet — create one with `tix epic create`.",
586 ));
587 } else {
588 for line in epic_lines(entries, self, view_h).into_iter().take(view_h) {
591 lines.push(line);
592 }
593 }
594 lines.push(Line::from(""));
595 lines.push(Line::from(Span::styled(
597 "Enter: open the member issue directly · move · ←/→: member · Esc: close",
598 Style::default().fg(Color::DarkGray),
599 )));
600 let block = Block::default()
601 .borders(Borders::ALL)
602 .title(" Epics ")
603 .style(Style::default().fg(Color::Cyan));
604 let para = Paragraph::new(lines).block(block).style(Style::default());
605 f.render_widget(Clear, rect);
606 f.render_widget(para, rect);
607 }
608}
609
610fn clamp_cursor(current: usize, delta: isize, len: usize) -> usize {
611 if len == 0 {
612 return 0;
613 }
614 let next = current as isize + delta;
615 next.clamp(0, len as isize - 1) as usize
616}
617
618#[must_use]
626pub fn epic_lines(
627 entries: &[crate::commands::view::EpicViewEntry],
628 state: &EpicViewState,
629 view_h: usize,
630) -> Vec<Line<'static>> {
631 if entries.is_empty() {
632 return Vec::new();
633 }
634 let selected = state.selected.min(entries.len() - 1);
635 let height = |entry: &crate::commands::view::EpicViewEntry| {
642 1 + entry.epic.detail.len() + 1 + entry.members.len().max(1)
643 };
644 let mut start = selected;
645 let mut budget = view_h.max(1).saturating_sub(height(&entries[selected]));
646 while start > 0 {
647 let above = height(&entries[start - 1]);
648 if above > budget {
649 break;
650 }
651 budget -= above;
652 start -= 1;
653 }
654 let mut lines: Vec<Line<'static>> = Vec::new();
655 for (index, entry) in entries.iter().enumerate().skip(start) {
656 if lines.len() >= view_h.max(1) {
657 break;
658 }
659 let is_selected = index == selected;
660 let style = if is_selected {
661 Style::default()
662 .fg(Color::Yellow)
663 .add_modifier(Modifier::BOLD)
664 } else {
665 Style::default().fg(Color::White)
666 };
667 lines.push(Line::from(vec![
668 Span::styled(if is_selected { "> " } else { " " }, style),
669 Span::styled(entry.epic.heading.clone(), style),
670 ]));
671 for detail in &entry.epic.detail {
672 lines.push(Line::from(vec![
673 Span::styled(" ", Style::default()),
674 Span::styled(detail.clone(), Style::default().fg(Color::Gray)),
675 ]));
676 }
677 if entry.members.is_empty() {
678 lines.push(Line::from(Span::styled(
679 " members: (none)",
680 Style::default().fg(Color::DarkGray),
681 )));
682 continue;
683 }
684 lines.push(Line::from(Span::styled(
685 " members:",
686 Style::default().fg(Color::DarkGray),
687 )));
688 let member_cursor = if is_selected {
689 state.member.min(entry.members.len() - 1)
690 } else {
691 usize::MAX
692 };
693 for (member_index, member) in entry.members.iter().enumerate() {
694 let marker = if member_index == member_cursor {
695 " > "
696 } else {
697 " "
698 };
699 lines.push(Line::from(Span::styled(
700 format!("{marker}{}", member.display),
701 Style::default().fg(Color::Cyan),
702 )));
703 }
704 }
705 lines
706}
707
708#[cfg(test)]
709mod tests {
710 #[allow(clippy::wildcard_imports)]
711 use super::*;
712 use ratatui::Terminal;
713 use ratatui::backend::TestBackend;
714
715 fn row(
716 name: &str,
717 description: &str,
718 date_a: &str,
719 date_b: Option<&str>,
720 issues: usize,
721 ) -> PlanningRow {
722 PlanningRow {
723 name: name.into(),
724 description: description.into(),
725 date_a: date_a.into(),
726 date_b: date_b.map(str::to_string),
727 issues,
728 }
729 }
730
731 fn cycle_rows() -> Vec<PlanningRow> {
732 vec![
733 row("Sprint 3", "kickoff", "2026-10-01", Some("2026-10-14"), 1),
734 row("Sprint 4", "", "", Some(""), 0),
735 ]
736 }
737
738 fn milestone_rows() -> Vec<PlanningRow> {
739 vec![
740 row("GA", "ship it", "2026-12-31", None, 3),
741 row("Beta", "", "2026-11-30", None, 2),
742 ]
743 }
744
745 fn render_modal(modal: &PlanningModal) -> Vec<String> {
747 let backend = TestBackend::new(80, 24);
748 let mut terminal = Terminal::new(backend).unwrap();
749 let cycles = cycle_rows();
750 let milestones = milestone_rows();
751 terminal
752 .draw(|f| modal.render(f, f.area(), &cycles, &milestones))
753 .unwrap();
754 let buffer = terminal.backend().buffer();
755 let mut rows: Vec<String> = Vec::new();
756 for y in 0..buffer.area().height {
757 let mut line = String::new();
758 for x in 0..buffer.area().width {
759 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
760 }
761 rows.push(line);
762 }
763 rows
764 }
765
766 fn render_form(form: &PlanningForm) -> Vec<String> {
768 let backend = TestBackend::new(80, 24);
769 let mut terminal = Terminal::new(backend).unwrap();
770 terminal.draw(|f| form.render(f, f.area())).unwrap();
771 let buffer = terminal.backend().buffer();
772 (0..buffer.area().height)
773 .map(|y| {
774 (0..buffer.area().width)
775 .map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
776 .collect()
777 })
778 .collect()
779 }
780
781 #[test]
785 fn form_editor_fills_the_whole_content_area() {
786 let form = PlanningForm::new(PlanningKind::Cycle);
787 let rows = render_form(&form);
788 assert!(
789 rows[0].contains("New cycle"),
790 "the title is on the content's first row: {:?}",
791 rows[0]
792 );
793 assert!(
794 rows[0].starts_with('┌') && rows[0].ends_with('┐'),
795 "the border spans the full width: {:?}",
796 rows[0]
797 );
798 assert!(
799 rows[23].starts_with('└') && rows[23].ends_with('┘'),
800 "the border reaches the last row: {:?}",
801 rows[23]
802 );
803 }
804
805 #[test]
806 fn row_line_for_cycle_includes_all_fields() {
807 assert_eq!(
808 row_line(&row(
809 "Sprint 3",
810 "kickoff",
811 "2026-10-01",
812 Some("2026-10-14"),
813 1
814 )),
815 "Sprint 3 | kickoff | 2026-10-01 | 2026-10-14 | 1 issue"
816 );
817 assert_eq!(
818 row_line(&row(
819 "Sprint 5",
820 "next",
821 "2026-11-01",
822 Some("2026-11-15"),
823 2
824 )),
825 "Sprint 5 | next | 2026-11-01 | 2026-11-15 | 2 issues"
826 );
827 }
828
829 #[test]
830 fn row_line_for_milestone_omits_due_date() {
831 assert_eq!(
832 row_line(&row("GA", "ship it", "2026-12-31", None, 3)),
833 "GA | ship it | 2026-12-31 | 3 issues"
834 );
835 }
836
837 #[test]
838 fn row_line_renders_missing_fields_blank() {
839 assert_eq!(
840 row_line(&row("Sprint 4", "", "", Some(""), 0)),
841 "Sprint 4 | | | | 0 issues"
842 );
843 }
844
845 #[test]
846 fn renders_cycle_rows_with_cursor_and_footer() {
847 let modal = PlanningModal {
848 selected: 1,
849 ..PlanningModal::default()
850 };
851 let rows = render_modal(&modal);
852
853 let row_of =
854 |needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
855 let sprint3 = row_of("Sprint 3 | kickoff | 2026-10-01 | 2026-10-14 | 1 issue")
856 .expect("first cycle renders with fields and count");
857 let sprint4 = row_of("Sprint 4 |").expect("second cycle renders");
858 assert!(sprint3 < sprint4, "cycles render in declaration order");
859
860 assert!(
861 rows[sprint4].contains('>'),
862 "selected cycle row shows the cursor"
863 );
864 assert!(
865 !rows[sprint3].contains('>'),
866 "unselected cycle row has no cursor"
867 );
868 assert!(
869 rows.iter().any(|r| r.contains("Down / j: move")),
870 "browse hint renders on the cycles segment"
871 );
872 }
873
874 #[test]
875 fn renders_milestone_segment_after_switch() {
876 let mut modal = PlanningModal::new();
877 modal.switch_segment();
878 assert_eq!(modal.segment, PlanningSegment::Milestones);
879 let rows = render_modal(&modal);
880
881 let row_of =
882 |needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
883 let ga = row_of("GA | ship it | 2026-12-31 | 3 issues")
884 .expect("milestone rows render on the milestones segment");
885 let footer =
886 row_of("Down / j: move").expect("footer hint renders beneath the milestones list");
887 assert!(
888 ga < footer,
889 "milestone rows render above the footer hint (ga at {ga}, footer at {footer})"
890 );
891 }
892
893 #[test]
894 fn renders_empty_segment_notice() {
895 let modal = PlanningModal::new();
896 let backend = TestBackend::new(80, 24);
897 let mut terminal = Terminal::new(backend).unwrap();
898 terminal
899 .draw(|f| modal.render(f, f.area(), &[], &[]))
900 .unwrap();
901 let buffer = terminal.backend().buffer();
902 let mut rows: Vec<String> = Vec::new();
903 for y in 0..buffer.area().height {
904 let mut line = String::new();
905 for x in 0..buffer.area().width {
906 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
907 }
908 rows.push(line);
909 }
910 assert!(
911 rows.iter()
912 .any(|r| r.contains("No Cycles yet — press n to create one.")),
913 "empty segment shows a create hint: {:?}",
914 rows
915 );
916 }
917
918 #[test]
919 fn renders_confirm_delete_prompt() {
920 let modal = PlanningModal {
921 prompt: Some(PlanningPrompt::ConfirmDelete {
922 name: "Sprint 3".into(),
923 referencing: 2,
924 }),
925 ..PlanningModal::default()
926 };
927 let rows = render_modal(&modal);
928 assert!(
929 rows.iter()
930 .any(|r| r.contains("Delete 'Sprint 3'? 2 issues reference it. [y/N]")),
931 "confirm prompt shows the referencing count: {:?}",
932 rows
933 );
934 }
935
936 #[test]
937 fn scroll_window_keeps_selected_in_view_on_small_terminal() {
938 let many: Vec<PlanningRow> = (0..40)
939 .map(|i| row(&format!("Item {i}"), "", "2026-01-01", Some(""), 0))
940 .collect();
941 let backend = TestBackend::new(60, 20);
942 let mut terminal = Terminal::new(backend).unwrap();
943 let modal = PlanningModal {
944 selected: 39,
945 ..PlanningModal::default()
946 };
947 terminal
948 .draw(|f| modal.render(f, f.area(), &many, &[]))
949 .unwrap();
950 let buffer = terminal.backend().buffer();
951 let mut rows: Vec<String> = Vec::new();
952 for y in 0..buffer.area().height {
953 let mut line = String::new();
954 for x in 0..buffer.area().width {
955 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
956 }
957 rows.push(line);
958 }
959 assert!(
960 rows.iter().any(|r| r.contains("Item 39")),
961 "the selected (deep) row stays visible via the scroll window"
962 );
963 assert!(
964 !rows.iter().any(|r| r.contains("Item 0")),
965 "the first row scrolls out of the window"
966 );
967 }
968
969 #[test]
970 fn move_selected_clamps_and_scrolls() {
971 let mut modal = PlanningModal::new();
972 modal.move_selected(-3, 2);
973 assert_eq!(modal.selected, 0);
974 modal.move_selected(5, 2);
975 assert_eq!(modal.selected, 1);
976 modal.move_selected(1, 0);
977 assert_eq!(modal.selected, 0);
978 }
979
980 #[test]
981 fn smoke_tiny_20x5_does_not_panic() {
982 let modal = PlanningModal::new();
983 let backend = TestBackend::new(20, 5);
984 let mut terminal = Terminal::new(backend).unwrap();
985 terminal
986 .draw(|f| modal.render(f, f.area(), &cycle_rows(), &milestone_rows()))
987 .unwrap();
988 }
989
990 #[test]
991 fn form_labels_match_kind() {
992 let cycle = PlanningForm::new(PlanningKind::Cycle);
993 assert_eq!(cycle.rows.len(), 4);
994 let labels: Vec<&str> = cycle.rows.iter().map(|(l, _)| l.as_str()).collect();
995 assert_eq!(labels, ["Name", "Description", "Start date", "Due date"]);
996
997 let milestone = PlanningForm::new(PlanningKind::Milestone);
998 assert_eq!(milestone.rows.len(), 3);
999 let labels: Vec<&str> = milestone.rows.iter().map(|(l, _)| l.as_str()).collect();
1000 assert_eq!(labels, ["Name", "Description", "Target date"]);
1001 assert!(milestone.is_new());
1002 assert!(cycle.is_new());
1003 }
1004
1005 #[test]
1006 fn form_prefills_from_cycle_and_milestone() {
1007 let cycle = PlanningForm::from_cycle(
1008 "Sprint 3",
1009 &PlanningCycle {
1010 description: Some("kickoff".into()),
1011 start_date: Some("2026-10-01".into()),
1012 due_date: Some("2026-10-14".into()),
1013 },
1014 );
1015 assert_eq!(cycle.name(), "Sprint 3");
1016 assert_eq!(cycle.description(), "kickoff");
1017 assert_eq!(cycle.date_a(), "2026-10-01");
1018 assert_eq!(cycle.date_b(), "2026-10-14");
1019 assert!(!cycle.is_new());
1020 assert_eq!(cycle.editing.as_deref(), Some("Sprint 3"));
1021
1022 let milestone = PlanningForm::from_milestone(&Milestone {
1023 name: "GA".into(),
1024 target_date: "2026-12-31".into(),
1025 description: "ship it".into(),
1026 created: "2026-01-01".into(),
1027 updated: "2026-01-02".into(),
1028 file_name: "2026-01-01-ga.md".into(),
1029 color: None,
1030 });
1031 assert_eq!(milestone.name(), "GA");
1032 assert_eq!(milestone.description(), "ship it");
1033 assert_eq!(milestone.date_a(), "2026-12-31");
1034 assert_eq!(milestone.date_b(), "", "milestones have no due-date field");
1035 assert_eq!(milestone.editing.as_deref(), Some("GA"));
1036 }
1037
1038 #[test]
1039 fn form_cursor_and_typing() {
1040 let mut form = PlanningForm::new(PlanningKind::Cycle);
1041 assert_eq!(form.focused_label(), "Name");
1042 form.insert_char('S');
1043 form.insert_char('3');
1044 assert_eq!(form.name(), "S3");
1045 form.focus_next();
1046 assert_eq!(form.focused_label(), "Description");
1047 form.insert_char('k');
1048 assert_eq!(form.description(), "k");
1049 form.delete_char();
1050 assert_eq!(form.description(), "");
1051 form.focus_prev();
1052 assert_eq!(form.focused_label(), "Name");
1053
1054 form.focus_next();
1055 form.focus_next();
1056 form.focus_next();
1057 form.focus_next();
1058 assert_eq!(form.focused_label(), "Name", "Tab wraps to the first field");
1059 form.focus_prev();
1060 assert_eq!(
1061 form.focused_label(),
1062 "Due date",
1063 "Shift+Tab wraps to the last field"
1064 );
1065 }
1066
1067 #[test]
1068 fn form_render_smokes_at_80x24_and_tiny() {
1069 for (w, h) in [(80, 24), (20, 5)] {
1070 let form = PlanningForm::from_milestone(&Milestone {
1071 name: "GA".into(),
1072 target_date: "2026-12-31".into(),
1073 description: "ship it".into(),
1074 created: "2026-01-01".into(),
1075 updated: "2026-01-02".into(),
1076 file_name: "2026-01-01-ga.md".into(),
1077 color: None,
1078 });
1079 let backend = TestBackend::new(w, h);
1080 let mut terminal = Terminal::new(backend).unwrap();
1081 terminal.draw(|f| form.render(f, f.area())).unwrap();
1082 }
1083 }
1084
1085 #[test]
1086 fn edit_form_renders_edit_title() {
1087 let form = PlanningForm::from_cycle(
1088 "Sprint 3",
1089 &PlanningCycle {
1090 description: None,
1091 start_date: None,
1092 due_date: None,
1093 },
1094 );
1095 let backend = TestBackend::new(80, 24);
1096 let mut terminal = Terminal::new(backend).unwrap();
1097 terminal.draw(|f| form.render(f, f.area())).unwrap();
1098 let buffer = terminal.backend().buffer();
1099 let mut rows: Vec<String> = Vec::new();
1100 for y in 0..buffer.area().height {
1101 let mut line = String::new();
1102 for x in 0..buffer.area().width {
1103 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
1104 }
1105 rows.push(line);
1106 }
1107 assert!(
1108 rows.iter().any(|r| r.contains("Edit cycle")),
1109 "edit form titles itself: {rows:?}"
1110 );
1111 }
1112
1113 fn epic_entry(
1117 name: &str,
1118 id: &str,
1119 progress: &str,
1120 members: &[u32],
1121 ) -> crate::commands::view::EpicViewEntry {
1122 crate::commands::view::EpicViewEntry {
1123 epic: crate::commands::view::EpicBlockEntry {
1124 heading: format!("{name} ({id})"),
1125 detail: vec![
1126 format!("progress: {progress}"),
1127 "weighted progress: none (no weighted children)".to_string(),
1128 "weight: unset".to_string(),
1129 "children: 0".to_string(),
1130 "parent: (none)".to_string(),
1131 "depth: 0".to_string(),
1132 "blocked: 0".to_string(),
1133 ],
1134 },
1135 members: members
1136 .iter()
1137 .map(|number| crate::commands::view::EpicMemberRef {
1138 number: *number,
1139 display: format!("#{number} Issue {number}"),
1140 })
1141 .collect(),
1142 }
1143 }
1144
1145 fn render_epic(
1147 entries: &[crate::commands::view::EpicViewEntry],
1148 state: &EpicViewState,
1149 ) -> Vec<String> {
1150 let backend = TestBackend::new(90, 30);
1151 let mut terminal = Terminal::new(backend).unwrap();
1152 terminal
1153 .draw(|f| state.render(f, f.area(), entries))
1154 .unwrap();
1155 let buffer = terminal.backend().buffer();
1156 (0..buffer.area().height)
1157 .map(|y| {
1158 (0..buffer.area().width)
1159 .map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
1160 .collect()
1161 })
1162 .collect()
1163 }
1164
1165 #[test]
1166 fn t020_the_epic_view_lists_epics_in_declaration_order_with_context_and_members() {
1167 let entries = vec![
1168 epic_entry("Alpha", "e-aaa111", "1/2 done", &[5, 6]),
1169 epic_entry("Beta", "e-bbb222", "0/1 done", &[7]),
1170 ];
1171 let rows = render_epic(&entries, &EpicViewState::new());
1172 let text = rows.join("\n");
1173 assert!(text.contains("Epics — 2 in declaration order"), "{text}");
1174 assert!(text.contains("Alpha (e-aaa111)"), "{text}");
1175 assert!(text.contains("progress: 1/2 done"), "{text}");
1176 assert!(text.contains("members:"), "{text}");
1177 assert!(
1178 text.contains("#5 Issue 5") && text.contains("#6 Issue 6"),
1179 "{text}"
1180 );
1181 let alpha = rows
1182 .iter()
1183 .position(|row| row.contains("Alpha (e-aaa111)"))
1184 .unwrap();
1185 let beta = rows
1186 .iter()
1187 .position(|row| row.contains("Beta (e-bbb222)"))
1188 .unwrap();
1189 assert!(alpha < beta, "the service's declaration order is kept");
1190 assert!(
1191 text.contains("Enter: open the member issue directly"),
1192 "the direct issue navigation affordance is explicit (FR-004): {text}"
1193 );
1194 }
1195
1196 #[test]
1197 fn t020_the_cursor_targets_the_selected_member_issue_directly() {
1198 let entries = vec![
1199 epic_entry("Alpha", "e-aaa111", "1/2 done", &[5, 6]),
1200 epic_entry("Beta", "e-bbb222", "0/1 done", &[7]),
1201 ];
1202 let mut state = EpicViewState::new();
1203 assert_eq!(state.target_member(&entries), Some(5));
1204 state.move_member(1, 2);
1205 assert_eq!(
1206 state.target_member(&entries),
1207 Some(6),
1208 "the member cursor selects the member `Enter` opens"
1209 );
1210 state.move_selected(1, 2);
1211 assert_eq!(state.target_member(&entries), Some(7));
1212 assert_eq!(EpicViewState::new().target_member(&[]), None);
1214 assert_eq!(
1215 EpicViewState::new().target_member(&[epic_entry("Alpha", "e-aaa111", "0/0 done", &[])]),
1216 None
1217 );
1218 }
1219
1220 #[test]
1221 fn t020_a_task_labelled_member_is_an_ordinary_member() {
1222 let entries = vec![epic_entry("Alpha", "e-aaa111", "0/1 done", &[9])];
1225 let text = epic_lines(&entries, &EpicViewState::new(), 40)
1226 .iter()
1227 .map(|line| {
1228 line.spans
1229 .iter()
1230 .map(|span| span.content.as_ref())
1231 .collect::<String>()
1232 })
1233 .collect::<Vec<_>>()
1234 .join("\n");
1235 assert!(text.contains("#9 Issue 9"), "{text}");
1236 assert!(!text.contains("task"), "no task section exists: {text}");
1237 }
1238
1239 #[test]
1240 fn t020_an_empty_registry_draws_an_empty_state_not_a_blank() {
1241 let text = render_epic(&[], &EpicViewState::new()).join("\n");
1242 assert!(text.contains("No epics yet"), "{text}");
1243 }
1244
1245 fn row_text(line: &Line<'_>) -> String {
1247 line.spans
1248 .iter()
1249 .map(|span| span.content.as_ref())
1250 .collect()
1251 }
1252
1253 #[test]
1254 fn t020_the_selected_epic_stays_in_the_frame_when_the_window_is_small() {
1255 let entries: Vec<crate::commands::view::EpicViewEntry> = (0..3)
1260 .map(|index| {
1261 epic_entry(
1262 &format!("E{index}"),
1263 &format!("e-aaa{index}{index}{index}"),
1264 "0/1 done",
1265 &[index + 1],
1266 )
1267 })
1268 .collect();
1269
1270 let mut state = EpicViewState::new();
1271 state.move_selected(2, 3);
1272 let lines = epic_lines(&entries, &state, 10);
1273 let text = lines.iter().map(row_text).collect::<Vec<_>>().join("\n");
1274 assert!(
1275 text.contains("E2 (e-aaa222)"),
1276 "the selected epic must be inside the frame: {text}"
1277 );
1278 assert!(
1279 lines
1280 .iter()
1281 .any(|line| row_text(line).contains("> E2 (e-aaa222)")),
1282 "and it must carry the cursor, because `Enter` acts on it: {text}"
1283 );
1284
1285 let mut state = EpicViewState::new();
1288 state.move_selected(1, 2);
1289 let text = epic_lines(&entries[..2], &state, 10)
1290 .iter()
1291 .map(row_text)
1292 .collect::<Vec<_>>()
1293 .join("\n");
1294 assert!(text.contains("> E1 (e-aaa111)"), "{text}");
1295 }
1296}