use std::collections::HashSet;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::app::App;
use crate::config::Config;
use crate::error::Result;
use crate::model::Issue;
use crate::roadmap::registry::{AssociationKind, InitiativeEdit, NewInitiative};
use crate::roadmap::{
RoadmapConsumed, RoadmapFilter, RoadmapProjection, RoadmapRow, RoadmapSources,
};
use crate::storage::Store;
pub const TAB_LABEL: &str = "Roadmap";
pub const TAB_SECTION: &str = "initiatives";
pub const TAB_NOUN: &str = "initiative";
pub const TAB_EMPTY_HINT: &str =
"No initiatives yet — run `tix initiative create <name>`, or press `c` to add one here.";
pub const TAB_HINT: &str =
"j/k row · c create · e rename · a associate · x dereference · f filter · Tab tabs · q close";
pub fn projection_over(
config: &Config,
store: &Store,
issues: &[Issue],
filter: &RoadmapFilter,
) -> Result<RoadmapProjection> {
filter.validate()?;
let policy = config.health.policy()?;
let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| store.issues_dir.clone());
let planner = crate::planning::Planner::from_config(config.clone(), root);
let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
let overdue: HashSet<u32> = planner
.overdue_batch(store, &crate::model::today())
.iter()
.map(|issue| issue.number)
.collect();
let assigned = crate::projects::assigned_keys(issues);
let (graph_issues, done_column) = store.graph_source()?;
let views: Vec<crate::relationships::IssueView<'_>> =
graph_issues.iter().map(Issue::issue_view).collect();
let graph = crate::relationships::RelationshipGraph::new(&views, &done_column);
let blocked: HashSet<u32> = graph_issues
.iter()
.filter(|issue| graph.blocked_state(issue.number).is_blocked)
.map(|issue| issue.number)
.collect();
let consumed = RoadmapConsumed {
targets,
done_column,
assigned,
overdue,
blocked,
};
let sources = RoadmapSources::new(config, issues, &policy, consumed);
crate::roadmap::projection::project(config, &sources, filter)
}
#[must_use]
pub fn headline(row: &RoadmapRow) -> String {
crate::commands::roadmap::roadmap_row_lines(row)
.into_iter()
.next()
.unwrap_or_default()
.replace('\t', " ")
}
#[must_use]
pub fn detail_lines(row: &RoadmapRow) -> Vec<String> {
crate::commands::roadmap::roadmap_row_lines(row)
}
#[must_use]
pub fn empty_state_lines(projection: &RoadmapProjection) -> Vec<String> {
crate::commands::roadmap::roadmap_list_lines(projection)
}
#[must_use]
pub fn filter_context(filter: &RoadmapFilter) -> Option<String> {
crate::commands::roadmap::roadmap_filter_line(filter)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssocVerb {
Associate,
Dereference,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoadmapOverlay {
Filter(FilterDraft),
Create(NameDraft),
Rename(NameDraft),
Associate(AssociationDraft),
}
impl RoadmapOverlay {
#[must_use]
pub fn title(&self) -> &'static str {
match self {
Self::Filter(_) => "Roadmap filter",
Self::Create(_) => "New initiative",
Self::Rename(_) => "Rename initiative",
Self::Associate(draft) => match draft.verb {
AssocVerb::Associate => "Associate reference",
AssocVerb::Dereference => "Dereference reference",
},
}
}
#[must_use]
pub fn hint(&self) -> &'static str {
match self {
Self::Filter(_) => "type to edit · tab/↑↓ field · enter apply · esc cancel",
Self::Create(_) => "type a name · enter create · esc cancel",
Self::Rename(_) => "type a name · enter rename · esc cancel",
Self::Associate(_) => "type a target · ←/→ kind · enter apply · esc cancel",
}
}
pub fn focus(&mut self, delta: isize) {
match self {
Self::Filter(draft) => draft.focus(delta),
Self::Associate(draft) => draft.focus(delta),
Self::Create(_) | Self::Rename(_) => {}
}
}
pub fn cycle_kind(&mut self, delta: isize) {
if let Self::Associate(draft) = self {
draft.cycle_kind(delta);
}
}
pub fn push(&mut self, ch: char) {
if let Some(field) = self.field_mut() {
field.push(ch);
}
}
pub fn backspace(&mut self) {
if let Some(field) = self.field_mut() {
field.pop();
}
}
fn field_mut(&mut self) -> Option<&mut String> {
match self {
Self::Filter(draft) => Some(draft.field_mut()),
Self::Create(draft) | Self::Rename(draft) => Some(&mut draft.value),
Self::Associate(draft) => draft.field_mut(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NameDraft {
pub value: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilterDraft {
pub from: String,
pub to: String,
pub owner: String,
pub status: String,
pub labels: String,
pub query: String,
pub field: usize,
}
impl FilterDraft {
pub const ROWS: [&'static str; 6] = ["from", "to", "owner", "status", "labels", "query"];
#[must_use]
pub fn from_filter(filter: &RoadmapFilter) -> Self {
Self {
from: filter.period_from.clone().unwrap_or_default(),
to: filter.period_to.clone().unwrap_or_default(),
owner: filter.owner.clone().unwrap_or_default(),
status: filter.status.clone().unwrap_or_default(),
labels: filter.labels.join(","),
query: filter.query.clone().unwrap_or_default(),
field: 0,
}
}
#[must_use]
pub fn to_filter(&self) -> RoadmapFilter {
let opt = |value: &str| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
};
RoadmapFilter {
period_from: opt(&self.from),
period_to: opt(&self.to),
owner: opt(&self.owner),
status: opt(&self.status),
labels: self
.labels
.split(',')
.map(str::trim)
.filter(|label| !label.is_empty())
.map(ToString::to_string)
.collect(),
query: opt(&self.query),
}
}
#[must_use]
pub fn value(&self) -> &str {
self.value_at(self.field)
}
#[must_use]
pub fn value_at(&self, index: usize) -> &str {
match index % Self::ROWS.len() {
0 => &self.from,
1 => &self.to,
2 => &self.owner,
3 => &self.status,
4 => &self.labels,
_ => &self.query,
}
}
pub fn focus(&mut self, delta: isize) {
let len = Self::ROWS.len() as isize;
self.field = (self.field as isize + delta).rem_euclid(len) as usize;
}
fn field_mut(&mut self) -> &mut String {
match self.field % Self::ROWS.len() {
0 => &mut self.from,
1 => &mut self.to,
2 => &mut self.owner,
3 => &mut self.status,
4 => &mut self.labels,
_ => &mut self.query,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssociationDraft {
pub verb: AssocVerb,
pub kind: AssociationKind,
pub target: String,
pub field: usize,
}
impl AssociationDraft {
pub const KINDS: [AssociationKind; 3] = [
AssociationKind::Project,
AssociationKind::Epic,
AssociationKind::Milestone,
];
#[must_use]
pub fn new(verb: AssocVerb) -> Self {
Self {
verb,
kind: AssociationKind::Project,
target: String::new(),
field: 1,
}
}
pub fn cycle_kind(&mut self, delta: isize) {
let current = Self::KINDS
.iter()
.position(|kind| *kind == self.kind)
.unwrap_or(0);
let len = Self::KINDS.len() as isize;
self.kind = Self::KINDS[(current as isize + delta).rem_euclid(len) as usize];
}
#[must_use]
pub fn kind_label(&self) -> &'static str {
self.kind.field()
}
fn focus(&mut self, delta: isize) {
let len = 2isize;
self.field = (self.field as isize + delta).rem_euclid(len) as usize;
}
fn field_mut(&mut self) -> Option<&mut String> {
(self.field % 2 == 1).then_some(&mut self.target)
}
}
#[must_use]
pub fn new_initiative(draft: &NameDraft) -> NewInitiative {
NewInitiative {
name: draft.value.trim().to_string(),
..NewInitiative::default()
}
}
#[must_use]
pub fn rename_edit(draft: &NameDraft) -> InitiativeEdit {
InitiativeEdit {
name: crate::projects::Patch::Set(draft.value.trim().to_string()),
..InitiativeEdit::default()
}
}
fn dim() -> Style {
Style::default().fg(Color::DarkGray)
}
pub fn render_tab(app: &App, f: &mut Frame, area: Rect, selected: usize) {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
let criteria = filter_context(app.roadmap_filter());
let line = match criteria {
Some(text) => Line::from(Span::styled(text, Style::default().fg(Color::Cyan))),
None => Line::from(Span::styled(
"filter: none (no criterion set) · f to edit",
dim(),
)),
};
f.render_widget(Paragraph::new(line), split[0]);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Min(24)])
.split(split[1]);
let projection = app.portfolio_roadmap();
let rows: &[RoadmapRow] =
projection.map_or(&[], |projection| projection.initiatives.as_slice());
let list_title = format!("{} ({})", TAB_LABEL, rows.len());
let list_block = Block::default().borders(Borders::ALL).title(list_title);
if rows.is_empty() {
let text = if app.roadmap_registry_is_empty() {
TAB_EMPTY_HINT.to_string()
} else {
match projection {
Some(projection) => empty_state_lines(projection).join("\n"),
None => "The roadmap could not be read — check the filter and the registries."
.to_string(),
}
};
f.render_widget(
Paragraph::new(text)
.block(list_block)
.wrap(Wrap { trim: true })
.style(dim()),
body[0],
);
} else {
let lines: Vec<Line<'static>> = rows
.iter()
.enumerate()
.map(|(index, row)| {
let selected = index == selected;
let style = if selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::from(vec![
Span::raw(if selected { "> " } else { " " }),
Span::styled(headline(row), style),
])
})
.collect();
f.render_widget(Paragraph::new(lines).block(list_block), body[0]);
}
let detail_block = Block::default()
.borders(Borders::ALL)
.title(format!("{} detail", TAB_NOUN));
match rows.get(selected) {
Some(row) => {
let lines: Vec<Line<'static>> = detail_lines(row)
.into_iter()
.map(|line| Line::from(line.replace('\t', " ")))
.collect();
f.render_widget(
Paragraph::new(lines)
.block(detail_block)
.wrap(Wrap { trim: false }),
body[1],
);
}
None => f.render_widget(
Paragraph::new(Span::styled(
"Nothing selected. An initiative has to exist before the page can describe it.",
dim(),
))
.block(detail_block)
.wrap(Wrap { trim: true }),
body[1],
),
}
render_overlay(app, f, area);
}
pub fn render_overlay(app: &App, f: &mut Frame, area: Rect) {
let Some(overlay) = app.roadmap_overlay() else {
return;
};
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
let mut lines: Vec<Line<'static>> = Vec::new();
let row = |focused: bool, key: &str, value: &str| {
let marker = if focused { "> " } else { " " };
let style = if focused {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::from(vec![
Span::styled(format!("{marker}{key}: "), dim()),
Span::styled(value.to_string(), style),
])
};
match overlay {
RoadmapOverlay::Filter(draft) => {
for (index, key) in FilterDraft::ROWS.iter().enumerate() {
let focused = index == draft.field % FilterDraft::ROWS.len();
let value = if focused {
format!("{}_", draft.value())
} else {
draft.value_at(index).to_string()
};
lines.push(row(focused, key, &value));
}
}
RoadmapOverlay::Create(draft) | RoadmapOverlay::Rename(draft) => {
lines.push(row(true, "name", &format!("{}_", draft.value)));
}
RoadmapOverlay::Associate(draft) => {
let kind = draft.kind_label().to_string();
lines.push(row(draft.field % 2 == 0, "kind", &kind));
let target = if draft.field % 2 == 1 {
format!("{}_", draft.target)
} else {
draft.target.clone()
};
lines.push(row(draft.field % 2 == 1, "target", &target));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(overlay.hint(), dim())));
f.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(overlay.title()),
)
.wrap(Wrap { trim: false }),
rect,
);
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn the_filter_draft_normalizes_into_the_one_shared_dto() {
let mut draft = FilterDraft::from_filter(&RoadmapFilter::default());
assert_eq!(draft.to_filter(), RoadmapFilter::default());
draft.from = " 2026-01-01 ".to_string();
draft.owner = "alice".to_string();
draft.labels = "one, two ,, three".to_string();
draft.query = " ".to_string();
let filter = draft.to_filter();
assert_eq!(filter.period_from.as_deref(), Some("2026-01-01"));
assert_eq!(filter.owner.as_deref(), Some("alice"));
assert_eq!(filter.labels, vec!["one", "two", "three"]);
assert_eq!(filter.query, None, "a blank row narrows nothing");
assert!(filter.validate().is_ok());
let mut bad = FilterDraft::from_filter(&RoadmapFilter::default());
bad.from = "not-a-date".to_string();
assert!(bad.to_filter().validate().is_err());
}
#[test]
fn the_filter_draft_seeds_from_an_installed_filter() {
let filter = RoadmapFilter {
period_to: Some("2026-12-31".to_string()),
status: Some("active".to_string()),
labels: vec!["a".to_string(), "b".to_string()],
..RoadmapFilter::default()
};
let draft = FilterDraft::from_filter(&filter);
assert_eq!(draft.to, "2026-12-31");
assert_eq!(draft.status, "active");
assert_eq!(draft.labels, "a,b");
assert_eq!(draft.to_filter(), filter, "a round trip through the editor");
assert_eq!(
FilterDraft::ROWS,
["from", "to", "owner", "status", "labels", "query"]
);
}
#[test]
fn focus_wraps_and_the_association_kind_cycles() {
let mut draft = FilterDraft::from_filter(&RoadmapFilter::default());
draft.focus(-1);
assert_eq!(draft.field, FilterDraft::ROWS.len() - 1, "backward wraps");
draft.focus(1);
assert_eq!(draft.field, 0, "forward wraps");
let mut association = AssociationDraft::new(AssocVerb::Associate);
assert_eq!(association.kind, AssociationKind::Project);
association.cycle_kind(1);
assert_eq!(association.kind, AssociationKind::Epic);
association.cycle_kind(1);
assert_eq!(association.kind, AssociationKind::Milestone);
association.cycle_kind(1);
assert_eq!(association.kind, AssociationKind::Project, "forward wraps");
association.cycle_kind(-1);
assert_eq!(
association.kind,
AssociationKind::Milestone,
"backward wraps"
);
let mut association = AssociationDraft::new(AssocVerb::Associate);
let mut overlay = RoadmapOverlay::Associate(association.clone());
overlay.push('p');
let RoadmapOverlay::Associate(focused_target) = &overlay else {
unreachable!("the overlay is an association");
};
assert_eq!(focused_target.target, "p");
overlay.backspace();
let RoadmapOverlay::Associate(cleared) = &overlay else {
unreachable!("the overlay is an association");
};
assert_eq!(cleared.target, "");
association.field = 0;
overlay = RoadmapOverlay::Associate(association);
overlay.push('p');
let RoadmapOverlay::Associate(kind_row) = &overlay else {
unreachable!("the overlay is an association");
};
assert!(kind_row.target.is_empty(), "the kind row takes no text");
}
#[test]
fn the_action_drafts_build_the_services_own_specs() {
let new = new_initiative(&NameDraft {
value: " Alpha ".to_string(),
});
assert_eq!(
new.name, "Alpha",
"the name is trimmed, nothing else is invented"
);
assert!(new.outcome.is_none() && new.projects.is_empty());
let edit = rename_edit(&NameDraft {
value: "Renamed".to_string(),
});
assert_eq!(
edit,
InitiativeEdit {
name: crate::projects::Patch::Set("Renamed".to_string()),
..InitiativeEdit::default()
},
"a rename touches the label only, never the stable id (C-002)"
);
}
#[test]
fn the_tab_registration_names_how_to_fill_an_empty_registry() {
assert_eq!(TAB_LABEL, "Roadmap");
assert_eq!(TAB_SECTION, "initiatives");
assert_eq!(TAB_NOUN, "initiative");
assert!(TAB_EMPTY_HINT.contains("tix initiative create"));
for key in [
"c create",
"e rename",
"a associate",
"x dereference",
"f filter",
] {
assert!(TAB_HINT.contains(key), "the hint advertises `{key}`");
}
}
#[test]
fn the_overlay_titles_and_hints_are_stable() {
assert_eq!(
RoadmapOverlay::Create(NameDraft::default()).title(),
"New initiative"
);
assert_eq!(
RoadmapOverlay::Associate(AssociationDraft::new(AssocVerb::Dereference)).title(),
"Dereference reference"
);
assert_eq!(
RoadmapOverlay::Filter(FilterDraft::default()).hint(),
"type to edit · tab/↑↓ field · enter apply · esc cancel"
);
}
}