Skip to main content

tix/ui/
planning.rs

1//! Planning-management widgets (cycles + milestones modal and its form).
2//!
3//! A pure view + model like `settings` and `filter_modal`: the widgets never
4//! read keys or mutate the registry — the app routes keys and applies planner
5//! mutations (`App::handle_planning_key` / `App::handle_planning_form_key`).
6
7use 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/// The list segment currently in view.
19#[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/// The kind of item a [`PlanningForm`] creates or edits.
36#[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/// A rendered planning row: the fields shown for one cycle or milestone.
52/// `date_a` is the anchor (cycle start date / milestone target date); `date_b`
53/// is the cycle due date — `None` for milestones (field does not apply) and
54/// `Some("")` for a cycle carrying no due date (field present, value blank).
55#[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
68/// The flat row text the modal renders for a planning item. Missing fields
69/// render blank (spec: "blank for fields the item does not carry").
70pub 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
80/// The scroll window of row lines a planning surface draws around `selected`.
81///
82/// Extracted from [`PlanningModal::render_with_bindings`] so the Config mode's
83/// cycle and milestone sections show the *same* rows with the same cursor and
84/// the same counts — a second renderer is how the two surfaces would drift.
85/// Returns an empty vector for an empty list; the caller draws its own
86/// "nothing here yet" hint (the modal suppresses it under a delete prompt, the
87/// Config mode does not).
88pub 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
118/// The confirmation line for an active planning prompt, naming the reference
119/// count. Shared with the Config mode for the same reason as [`rows_lines`].
120pub 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/// An active inline prompt inside the planning modal.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum PlanningPrompt {
135    /// Confirming deletion of `name`; `referencing` issues reference it.
136    ConfirmDelete { name: String, referencing: usize },
137}
138
139/// The planning modal: a segment switch (cycles/milestones) over a row list
140/// with a clamped cursor, a render-time vertical scroll window, and an
141/// optional inline confirmation prompt.
142#[derive(Debug, Clone, PartialEq, Eq, Default)]
143pub struct PlanningModal {
144    /// Active segment (cycles or milestones).
145    pub segment: PlanningSegment,
146    /// Row cursor within the active segment.
147    pub selected: usize,
148    /// Active inline prompt (None = browsing the list).
149    pub prompt: Option<PlanningPrompt>,
150}
151
152impl PlanningModal {
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Move the row cursor, clamped to `len` (the active segment's rows).
158    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    /// Jump the row cursor straight to `row` (a row click), clamped to the same
176    /// range the arrow keys navigate.
177    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    /// Render the modal over `area` for the given cycle and milestone rows.
186    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/// The create/edit form for a cycle or milestone: a list of labelled field
273/// rows with a focused cursor. `editing` is `Some(old name)` when the form
274/// edits an existing item (a changed name triggers the rename cascade).
275#[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    /// A blank create form for `kind`.
285    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    /// An edit form for a cycle, prefilled from `name` + `cycle`.
302    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    /// An edit form for a milestone, prefilled from `m`.
326    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    /// The focused field's typed value.
344    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    /// Cycle start date / milestone target date.
353    pub fn date_a(&self) -> &str {
354        self.field_value(2)
355    }
356
357    /// Cycle due date (empty for milestones).
358    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    /// The focused field's typed value.
377    pub fn focused_value(&self) -> &str {
378        self.field_value(self.cursor)
379    }
380
381    /// Whether the focused field is a picker-only date field. Date fields are
382    /// always the rows after `Name` and `Description`.
383    pub fn focused_is_date(&self) -> bool {
384        self.cursor >= 2
385    }
386
387    /// Whether the focused date field already holds a complete, valid date.
388    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    /// Overwrite the focused date field (no-op when it isn't a date field).
393    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    /// Render the form over `area`.
437    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        // The editor fills the whole content area rather than a centered dialog
457        // (tui-config-mode / planning-management): the record's fields get the
458        // surface, and the Config mode's drill-down reads as "detail = full
459        // screen".
460        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// ---------------------------------------------------------------------------
518// WP04 T020 — the epic view
519// ---------------------------------------------------------------------------
520
521/// The TUI epic view's cursor state (WP04 T020, FR-003/FR-004/FR-005).
522///
523/// A cursor over the epics, and a second into the selected epic's member list. The
524/// member list is the **direct issue navigation** surface: [`Self::target_member`]
525/// names the issue `Enter` opens, so a user reaches an issue's own detail pane from
526/// here rather than only through the epic layer (FR-004).
527#[derive(Debug, Clone, PartialEq, Eq, Default)]
528pub struct EpicViewState {
529    /// Cursor into the epic list, in the service's declaration order.
530    pub selected: usize,
531    /// Cursor into the selected epic's member list.
532    pub member: usize,
533}
534
535impl EpicViewState {
536    #[must_use]
537    pub fn new() -> Self {
538        Self::default()
539    }
540
541    /// Move the epic cursor, clamped to `len`; the member cursor resets, because it
542    /// indexes the newly selected epic's own list.
543    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    /// Move the member cursor, clamped to the selected epic's member `len`.
549    pub fn move_member(&mut self, delta: isize, len: usize) {
550        self.member = clamp_cursor(self.member, delta, len);
551    }
552
553    /// The member issue `Enter` opens, when the selected epic has one (FR-004).
554    ///
555    /// `None` for an empty view or an epic with no members, so the key is inert rather
556    /// than opening an arbitrary issue.
557    #[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    /// Render the epic view over `area`.
564    ///
565    /// The body is the pure [`epic_lines`], so the content is pinned by a unit test
566    /// rather than by a TTY-bound draw; this method only adds the frame and the
567    /// navigation affordance.
568    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        // Reserve the header, the blank separator and the hint line, so a long list
576        // cannot push the direct-navigation affordance out of the frame.
577        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            // The body is truncated to the reserved height so the footer below always
589            // fits; a window that overshot its own frame would clip the affordance.
590            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        // The direct-navigation affordance is stated, not implied (FR-004).
596        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/// The epic view's body lines, anchored at the selected epic (WP04 T020).
619///
620/// Pure and extracted, like [`rows_lines`]. The lines are the **same** canonical rows
621/// the issue detail block and `tix show` render ([`crate::commands::view::EpicViewEntry`]),
622/// so the three surfaces agree on every figure (C-008); the member list below them is
623/// the direct issue navigation surface (FR-004). A member renders as an ordinary issue:
624/// there is no task section, because there is no task entity (C-001).
625#[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    // The window is measured in **rendered lines**, not entries: an epic occupies a
636    // heading, its context lines and its member list, so stepping back one entry per
637    // visible line pushed the selected epic (and its cursor) out of the frame — with
638    // three epics at `view_h` 20 the loop broke before the selected one was drawn.
639    // Reserving the selected entry's own height first keeps the cursor inside the
640    // frame, which is the whole point of a selection.
641    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    /// Render the modal into a buffer and return the terminal rows as strings.
746    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    /// Render a planning form into a buffer and return its rows as strings.
767    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    /// The editor is the record's full-screen detail (tui-config-mode /
782    /// planning-management), not a centered dialog: its border and title sit on
783    /// the content area's own edges.
784    #[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    // ---- WP04 T020: the epic view ------------------------------------------
1114
1115    /// One epic view entry, shaped exactly as `view::epic_view` builds it.
1116    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    /// Render the epic view into a buffer and return its rows as strings.
1146    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        // An empty view and an epic with no members are inert, not arbitrary.
1213        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        // There is no task section because there is no task entity (C-001): a member is
1223        // rendered by its service display label like any other.
1224        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    /// Flatten a rendered line's spans, for a content assertion.
1246    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        // The reviewer's trigger: the window stepped back one **entry** per visible
1256        // line while bounding the loop by **rendered lines**, and an epic is ~10 lines,
1257        // so with the third epic selected the loop broke before drawing it — the cursor
1258        // (and `target_member`'s row) was still live but invisible.
1259        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        // The minimal two-entry case the old arithmetic also broke: one entry drew,
1286        // the selected one did not.
1287        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}