1use std::collections::HashSet;
41
42use ratatui::Frame;
43use ratatui::layout::{Constraint, Direction, Layout, Rect};
44use ratatui::style::{Color, Modifier, Style};
45use ratatui::text::{Line, Span};
46use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
47
48use crate::app::App;
49use crate::config::Config;
50use crate::error::Result;
51use crate::model::Issue;
52use crate::roadmap::registry::{AssociationKind, InitiativeEdit, NewInitiative};
53use crate::roadmap::{
54 RoadmapConsumed, RoadmapFilter, RoadmapProjection, RoadmapRow, RoadmapSources,
55};
56use crate::storage::Store;
57
58pub const TAB_LABEL: &str = "Roadmap";
64
65pub const TAB_SECTION: &str = "initiatives";
70
71pub const TAB_NOUN: &str = "initiative";
73
74pub const TAB_EMPTY_HINT: &str =
76 "No initiatives yet — run `tix initiative create <name>`, or press `c` to add one here.";
77
78pub const TAB_HINT: &str =
81 "j/k row · c create · e rename · a associate · x dereference · f filter · Tab tabs · q close";
82
83pub fn projection_over(
96 config: &Config,
97 store: &Store,
98 issues: &[Issue],
99 filter: &RoadmapFilter,
100) -> Result<RoadmapProjection> {
101 filter.validate()?;
102 let policy = config.health.policy()?;
103 let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| store.issues_dir.clone());
104 let planner = crate::planning::Planner::from_config(config.clone(), root);
105 let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
106 let overdue: HashSet<u32> = planner
107 .overdue_batch(store, &crate::model::today())
108 .iter()
109 .map(|issue| issue.number)
110 .collect();
111 let assigned = crate::projects::assigned_keys(issues);
112 let (graph_issues, done_column) = store.graph_source()?;
113 let views: Vec<crate::relationships::IssueView<'_>> =
114 graph_issues.iter().map(Issue::issue_view).collect();
115 let graph = crate::relationships::RelationshipGraph::new(&views, &done_column);
116 let blocked: HashSet<u32> = graph_issues
117 .iter()
118 .filter(|issue| graph.blocked_state(issue.number).is_blocked)
119 .map(|issue| issue.number)
120 .collect();
121 let consumed = RoadmapConsumed {
122 targets,
123 done_column,
124 assigned,
125 overdue,
126 blocked,
127 };
128 let sources = RoadmapSources::new(config, issues, &policy, consumed);
129 crate::roadmap::projection::project(config, &sources, filter)
130}
131
132#[must_use]
143pub fn headline(row: &RoadmapRow) -> String {
144 crate::commands::roadmap::roadmap_row_lines(row)
145 .into_iter()
146 .next()
147 .unwrap_or_default()
148 .replace('\t', " ")
149}
150
151#[must_use]
157pub fn detail_lines(row: &RoadmapRow) -> Vec<String> {
158 crate::commands::roadmap::roadmap_row_lines(row)
159}
160
161#[must_use]
163pub fn empty_state_lines(projection: &RoadmapProjection) -> Vec<String> {
164 crate::commands::roadmap::roadmap_list_lines(projection)
165}
166
167#[must_use]
170pub fn filter_context(filter: &RoadmapFilter) -> Option<String> {
171 crate::commands::roadmap::roadmap_filter_line(filter)
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum AssocVerb {
181 Associate,
183 Dereference,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum RoadmapOverlay {
192 Filter(FilterDraft),
194 Create(NameDraft),
196 Rename(NameDraft),
198 Associate(AssociationDraft),
200}
201
202impl RoadmapOverlay {
203 #[must_use]
205 pub fn title(&self) -> &'static str {
206 match self {
207 Self::Filter(_) => "Roadmap filter",
208 Self::Create(_) => "New initiative",
209 Self::Rename(_) => "Rename initiative",
210 Self::Associate(draft) => match draft.verb {
211 AssocVerb::Associate => "Associate reference",
212 AssocVerb::Dereference => "Dereference reference",
213 },
214 }
215 }
216
217 #[must_use]
219 pub fn hint(&self) -> &'static str {
220 match self {
221 Self::Filter(_) => "type to edit · tab/↑↓ field · enter apply · esc cancel",
222 Self::Create(_) => "type a name · enter create · esc cancel",
223 Self::Rename(_) => "type a name · enter rename · esc cancel",
224 Self::Associate(_) => "type a target · ←/→ kind · enter apply · esc cancel",
225 }
226 }
227
228 pub fn focus(&mut self, delta: isize) {
230 match self {
231 Self::Filter(draft) => draft.focus(delta),
232 Self::Associate(draft) => draft.focus(delta),
233 Self::Create(_) | Self::Rename(_) => {}
234 }
235 }
236
237 pub fn cycle_kind(&mut self, delta: isize) {
239 if let Self::Associate(draft) = self {
240 draft.cycle_kind(delta);
241 }
242 }
243
244 pub fn push(&mut self, ch: char) {
246 if let Some(field) = self.field_mut() {
247 field.push(ch);
248 }
249 }
250
251 pub fn backspace(&mut self) {
253 if let Some(field) = self.field_mut() {
254 field.pop();
255 }
256 }
257
258 fn field_mut(&mut self) -> Option<&mut String> {
260 match self {
261 Self::Filter(draft) => Some(draft.field_mut()),
262 Self::Create(draft) | Self::Rename(draft) => Some(&mut draft.value),
263 Self::Associate(draft) => draft.field_mut(),
264 }
265 }
266}
267
268#[derive(Debug, Clone, Default, PartialEq, Eq)]
270pub struct NameDraft {
271 pub value: String,
273}
274
275#[derive(Debug, Clone, Default, PartialEq, Eq)]
282pub struct FilterDraft {
283 pub from: String,
285 pub to: String,
287 pub owner: String,
289 pub status: String,
291 pub labels: String,
293 pub query: String,
295 pub field: usize,
297}
298
299impl FilterDraft {
300 pub const ROWS: [&'static str; 6] = ["from", "to", "owner", "status", "labels", "query"];
302
303 #[must_use]
305 pub fn from_filter(filter: &RoadmapFilter) -> Self {
306 Self {
307 from: filter.period_from.clone().unwrap_or_default(),
308 to: filter.period_to.clone().unwrap_or_default(),
309 owner: filter.owner.clone().unwrap_or_default(),
310 status: filter.status.clone().unwrap_or_default(),
311 labels: filter.labels.join(","),
312 query: filter.query.clone().unwrap_or_default(),
313 field: 0,
314 }
315 }
316
317 #[must_use]
320 pub fn to_filter(&self) -> RoadmapFilter {
321 let opt = |value: &str| {
322 let trimmed = value.trim();
323 (!trimmed.is_empty()).then(|| trimmed.to_string())
324 };
325 RoadmapFilter {
326 period_from: opt(&self.from),
327 period_to: opt(&self.to),
328 owner: opt(&self.owner),
329 status: opt(&self.status),
330 labels: self
331 .labels
332 .split(',')
333 .map(str::trim)
334 .filter(|label| !label.is_empty())
335 .map(ToString::to_string)
336 .collect(),
337 query: opt(&self.query),
338 }
339 }
340
341 #[must_use]
343 pub fn value(&self) -> &str {
344 self.value_at(self.field)
345 }
346
347 #[must_use]
349 pub fn value_at(&self, index: usize) -> &str {
350 match index % Self::ROWS.len() {
351 0 => &self.from,
352 1 => &self.to,
353 2 => &self.owner,
354 3 => &self.status,
355 4 => &self.labels,
356 _ => &self.query,
357 }
358 }
359
360 pub fn focus(&mut self, delta: isize) {
362 let len = Self::ROWS.len() as isize;
363 self.field = (self.field as isize + delta).rem_euclid(len) as usize;
364 }
365
366 fn field_mut(&mut self) -> &mut String {
367 match self.field % Self::ROWS.len() {
368 0 => &mut self.from,
369 1 => &mut self.to,
370 2 => &mut self.owner,
371 3 => &mut self.status,
372 4 => &mut self.labels,
373 _ => &mut self.query,
374 }
375 }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct AssociationDraft {
381 pub verb: AssocVerb,
383 pub kind: AssociationKind,
385 pub target: String,
387 pub field: usize,
389}
390
391impl AssociationDraft {
392 pub const KINDS: [AssociationKind; 3] = [
394 AssociationKind::Project,
395 AssociationKind::Epic,
396 AssociationKind::Milestone,
397 ];
398
399 #[must_use]
401 pub fn new(verb: AssocVerb) -> Self {
402 Self {
403 verb,
404 kind: AssociationKind::Project,
405 target: String::new(),
406 field: 1,
407 }
408 }
409
410 pub fn cycle_kind(&mut self, delta: isize) {
412 let current = Self::KINDS
413 .iter()
414 .position(|kind| *kind == self.kind)
415 .unwrap_or(0);
416 let len = Self::KINDS.len() as isize;
417 self.kind = Self::KINDS[(current as isize + delta).rem_euclid(len) as usize];
418 }
419
420 #[must_use]
422 pub fn kind_label(&self) -> &'static str {
423 self.kind.field()
424 }
425
426 fn focus(&mut self, delta: isize) {
427 let len = 2isize;
428 self.field = (self.field as isize + delta).rem_euclid(len) as usize;
429 }
430
431 fn field_mut(&mut self) -> Option<&mut String> {
432 (self.field % 2 == 1).then_some(&mut self.target)
433 }
434}
435
436#[must_use]
439pub fn new_initiative(draft: &NameDraft) -> NewInitiative {
440 NewInitiative {
441 name: draft.value.trim().to_string(),
442 ..NewInitiative::default()
443 }
444}
445
446#[must_use]
449pub fn rename_edit(draft: &NameDraft) -> InitiativeEdit {
450 InitiativeEdit {
451 name: crate::projects::Patch::Set(draft.value.trim().to_string()),
452 ..InitiativeEdit::default()
453 }
454}
455
456fn dim() -> Style {
462 Style::default().fg(Color::DarkGray)
463}
464
465pub fn render_tab(app: &App, f: &mut Frame, area: Rect, selected: usize) {
472 let split = Layout::default()
473 .direction(Direction::Vertical)
474 .constraints([Constraint::Length(1), Constraint::Min(1)])
475 .split(area);
476
477 let criteria = filter_context(app.roadmap_filter());
478 let line = match criteria {
479 Some(text) => Line::from(Span::styled(text, Style::default().fg(Color::Cyan))),
480 None => Line::from(Span::styled(
481 "filter: none (no criterion set) · f to edit",
482 dim(),
483 )),
484 };
485 f.render_widget(Paragraph::new(line), split[0]);
486
487 let body = Layout::default()
488 .direction(Direction::Horizontal)
489 .constraints([Constraint::Percentage(55), Constraint::Min(24)])
490 .split(split[1]);
491
492 let projection = app.portfolio_roadmap();
493 let rows: &[RoadmapRow] =
494 projection.map_or(&[], |projection| projection.initiatives.as_slice());
495
496 let list_title = format!("{} ({})", TAB_LABEL, rows.len());
497 let list_block = Block::default().borders(Borders::ALL).title(list_title);
498 if rows.is_empty() {
499 let text = if app.roadmap_registry_is_empty() {
504 TAB_EMPTY_HINT.to_string()
505 } else {
506 match projection {
507 Some(projection) => empty_state_lines(projection).join("\n"),
508 None => "The roadmap could not be read — check the filter and the registries."
509 .to_string(),
510 }
511 };
512 f.render_widget(
513 Paragraph::new(text)
514 .block(list_block)
515 .wrap(Wrap { trim: true })
516 .style(dim()),
517 body[0],
518 );
519 } else {
520 let lines: Vec<Line<'static>> = rows
521 .iter()
522 .enumerate()
523 .map(|(index, row)| {
524 let selected = index == selected;
525 let style = if selected {
526 Style::default().add_modifier(Modifier::BOLD)
527 } else {
528 Style::default()
529 };
530 Line::from(vec![
531 Span::raw(if selected { "> " } else { " " }),
532 Span::styled(headline(row), style),
533 ])
534 })
535 .collect();
536 f.render_widget(Paragraph::new(lines).block(list_block), body[0]);
537 }
538
539 let detail_block = Block::default()
540 .borders(Borders::ALL)
541 .title(format!("{} detail", TAB_NOUN));
542 match rows.get(selected) {
543 Some(row) => {
544 let lines: Vec<Line<'static>> = detail_lines(row)
545 .into_iter()
546 .map(|line| Line::from(line.replace('\t', " ")))
547 .collect();
548 f.render_widget(
549 Paragraph::new(lines)
550 .block(detail_block)
551 .wrap(Wrap { trim: false }),
552 body[1],
553 );
554 }
555 None => f.render_widget(
556 Paragraph::new(Span::styled(
557 "Nothing selected. An initiative has to exist before the page can describe it.",
558 dim(),
559 ))
560 .block(detail_block)
561 .wrap(Wrap { trim: true }),
562 body[1],
563 ),
564 }
565
566 render_overlay(app, f, area);
567}
568
569pub fn render_overlay(app: &App, f: &mut Frame, area: Rect) {
571 let Some(overlay) = app.roadmap_overlay() else {
572 return;
573 };
574 let rect = crate::ui::modal::modal_rect(area);
575 f.render_widget(Clear, rect);
576
577 let mut lines: Vec<Line<'static>> = Vec::new();
578 let row = |focused: bool, key: &str, value: &str| {
579 let marker = if focused { "> " } else { " " };
580 let style = if focused {
581 Style::default().add_modifier(Modifier::BOLD)
582 } else {
583 Style::default()
584 };
585 Line::from(vec![
586 Span::styled(format!("{marker}{key}: "), dim()),
587 Span::styled(value.to_string(), style),
588 ])
589 };
590 match overlay {
591 RoadmapOverlay::Filter(draft) => {
592 for (index, key) in FilterDraft::ROWS.iter().enumerate() {
593 let focused = index == draft.field % FilterDraft::ROWS.len();
594 let value = if focused {
595 format!("{}_", draft.value())
596 } else {
597 draft.value_at(index).to_string()
598 };
599 lines.push(row(focused, key, &value));
600 }
601 }
602 RoadmapOverlay::Create(draft) | RoadmapOverlay::Rename(draft) => {
603 lines.push(row(true, "name", &format!("{}_", draft.value)));
604 }
605 RoadmapOverlay::Associate(draft) => {
606 let kind = draft.kind_label().to_string();
607 lines.push(row(draft.field % 2 == 0, "kind", &kind));
608 let target = if draft.field % 2 == 1 {
609 format!("{}_", draft.target)
610 } else {
611 draft.target.clone()
612 };
613 lines.push(row(draft.field % 2 == 1, "target", &target));
614 }
615 }
616 lines.push(Line::from(""));
617 lines.push(Line::from(Span::styled(overlay.hint(), dim())));
618
619 f.render_widget(
620 Paragraph::new(lines)
621 .block(
622 Block::default()
623 .borders(Borders::ALL)
624 .title(overlay.title()),
625 )
626 .wrap(Wrap { trim: false }),
627 rect,
628 );
629}
630
631#[cfg(test)]
632mod tests {
633 #[allow(clippy::wildcard_imports)]
634 use super::*;
635
636 #[test]
637 fn the_filter_draft_normalizes_into_the_one_shared_dto() {
638 let mut draft = FilterDraft::from_filter(&RoadmapFilter::default());
639 assert_eq!(draft.to_filter(), RoadmapFilter::default());
640 draft.from = " 2026-01-01 ".to_string();
641 draft.owner = "alice".to_string();
642 draft.labels = "one, two ,, three".to_string();
643 draft.query = " ".to_string();
644 let filter = draft.to_filter();
645 assert_eq!(filter.period_from.as_deref(), Some("2026-01-01"));
646 assert_eq!(filter.owner.as_deref(), Some("alice"));
647 assert_eq!(filter.labels, vec!["one", "two", "three"]);
648 assert_eq!(filter.query, None, "a blank row narrows nothing");
649 assert!(filter.validate().is_ok());
651 let mut bad = FilterDraft::from_filter(&RoadmapFilter::default());
652 bad.from = "not-a-date".to_string();
653 assert!(bad.to_filter().validate().is_err());
654 }
655
656 #[test]
657 fn the_filter_draft_seeds_from_an_installed_filter() {
658 let filter = RoadmapFilter {
659 period_to: Some("2026-12-31".to_string()),
660 status: Some("active".to_string()),
661 labels: vec!["a".to_string(), "b".to_string()],
662 ..RoadmapFilter::default()
663 };
664 let draft = FilterDraft::from_filter(&filter);
665 assert_eq!(draft.to, "2026-12-31");
666 assert_eq!(draft.status, "active");
667 assert_eq!(draft.labels, "a,b");
668 assert_eq!(draft.to_filter(), filter, "a round trip through the editor");
669 assert_eq!(
671 FilterDraft::ROWS,
672 ["from", "to", "owner", "status", "labels", "query"]
673 );
674 }
675
676 #[test]
677 fn focus_wraps_and_the_association_kind_cycles() {
678 let mut draft = FilterDraft::from_filter(&RoadmapFilter::default());
679 draft.focus(-1);
680 assert_eq!(draft.field, FilterDraft::ROWS.len() - 1, "backward wraps");
681 draft.focus(1);
682 assert_eq!(draft.field, 0, "forward wraps");
683
684 let mut association = AssociationDraft::new(AssocVerb::Associate);
685 assert_eq!(association.kind, AssociationKind::Project);
686 association.cycle_kind(1);
687 assert_eq!(association.kind, AssociationKind::Epic);
688 association.cycle_kind(1);
689 assert_eq!(association.kind, AssociationKind::Milestone);
690 association.cycle_kind(1);
691 assert_eq!(association.kind, AssociationKind::Project, "forward wraps");
692 association.cycle_kind(-1);
693 assert_eq!(
694 association.kind,
695 AssociationKind::Milestone,
696 "backward wraps"
697 );
698 let mut association = AssociationDraft::new(AssocVerb::Associate);
700 let mut overlay = RoadmapOverlay::Associate(association.clone());
701 overlay.push('p');
702 let RoadmapOverlay::Associate(focused_target) = &overlay else {
703 unreachable!("the overlay is an association");
704 };
705 assert_eq!(focused_target.target, "p");
706 overlay.backspace();
707 let RoadmapOverlay::Associate(cleared) = &overlay else {
708 unreachable!("the overlay is an association");
709 };
710 assert_eq!(cleared.target, "");
711 association.field = 0;
713 overlay = RoadmapOverlay::Associate(association);
714 overlay.push('p');
715 let RoadmapOverlay::Associate(kind_row) = &overlay else {
716 unreachable!("the overlay is an association");
717 };
718 assert!(kind_row.target.is_empty(), "the kind row takes no text");
719 }
720
721 #[test]
722 fn the_action_drafts_build_the_services_own_specs() {
723 let new = new_initiative(&NameDraft {
724 value: " Alpha ".to_string(),
725 });
726 assert_eq!(
727 new.name, "Alpha",
728 "the name is trimmed, nothing else is invented"
729 );
730 assert!(new.outcome.is_none() && new.projects.is_empty());
731
732 let edit = rename_edit(&NameDraft {
733 value: "Renamed".to_string(),
734 });
735 assert_eq!(
736 edit,
737 InitiativeEdit {
738 name: crate::projects::Patch::Set("Renamed".to_string()),
739 ..InitiativeEdit::default()
740 },
741 "a rename touches the label only, never the stable id (C-002)"
742 );
743 }
744
745 #[test]
746 fn the_tab_registration_names_how_to_fill_an_empty_registry() {
747 assert_eq!(TAB_LABEL, "Roadmap");
748 assert_eq!(TAB_SECTION, "initiatives");
749 assert_eq!(TAB_NOUN, "initiative");
750 assert!(TAB_EMPTY_HINT.contains("tix initiative create"));
751 for key in [
752 "c create",
753 "e rename",
754 "a associate",
755 "x dereference",
756 "f filter",
757 ] {
758 assert!(TAB_HINT.contains(key), "the hint advertises `{key}`");
759 }
760 }
761
762 #[test]
763 fn the_overlay_titles_and_hints_are_stable() {
764 assert_eq!(
765 RoadmapOverlay::Create(NameDraft::default()).title(),
766 "New initiative"
767 );
768 assert_eq!(
769 RoadmapOverlay::Associate(AssociationDraft::new(AssocVerb::Dereference)).title(),
770 "Dereference reference"
771 );
772 assert_eq!(
773 RoadmapOverlay::Filter(FilterDraft::default()).hint(),
774 "type to edit · tab/↑↓ field · enter apply · esc cancel"
775 );
776 }
777}