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, Paragraph, Wrap};
use crate::app::App;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum PortfolioTab {
#[default]
Projects,
Epics,
Roadmap,
}
impl PortfolioTab {
pub const ALL: [Self; 3] = [Self::Projects, Self::Epics, Self::Roadmap];
pub const fn label(self) -> &'static str {
match self {
Self::Projects => "Projects",
Self::Epics => "Epics",
Self::Roadmap => crate::ui::roadmap::TAB_LABEL,
}
}
pub const fn section(self) -> &'static str {
match self {
Self::Projects => "projects",
Self::Epics => "epics",
Self::Roadmap => crate::ui::roadmap::TAB_SECTION,
}
}
pub const fn noun(self) -> &'static str {
match self {
Self::Projects => "project",
Self::Epics => "epic",
Self::Roadmap => crate::ui::roadmap::TAB_NOUN,
}
}
pub const fn empty_hint(self) -> &'static str {
match self {
Self::Projects => {
"No projects yet — run `tix project create <name>`, or associate one \
from an issue's form."
}
Self::Epics => {
"No epics yet — run `tix epic create <name>`, or reference one from \
an issue's form."
}
Self::Roadmap => crate::ui::roadmap::TAB_EMPTY_HINT,
}
}
pub const fn hint(self) -> &'static str {
match self {
Self::Projects => "j/k move · Tab switch tabs · q close",
Self::Epics => "j/k epic · h/l member · Enter open issue · Tab tabs · q close",
Self::Roadmap => crate::ui::roadmap::TAB_HINT,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PortfolioState {
pub tab: PortfolioTab,
pub selected: usize,
pub member: usize,
}
impl PortfolioState {
pub fn new() -> Self {
Self::default()
}
pub fn move_selected(&mut self, delta: isize, rows: usize) -> bool {
let last = rows.saturating_sub(1);
let before = self.selected;
let next = if delta.is_negative() {
self.selected.saturating_sub(delta.unsigned_abs())
} else {
self.selected.saturating_add(delta as usize)
};
self.selected = next.min(last);
let moved = self.selected != before;
if moved {
self.member = 0;
}
moved
}
pub fn move_member(&mut self, delta: isize, len: usize) -> bool {
let last = len.saturating_sub(1);
let before = self.member;
let next = if delta.is_negative() {
self.member.saturating_sub(delta.unsigned_abs())
} else {
self.member.saturating_add(delta as usize)
};
self.member = next.min(last);
self.member != before
}
pub fn clamp_selected(&mut self, rows: usize) {
self.selected = self.selected.min(rows.saturating_sub(1));
}
pub fn clamp_member(&mut self, members: usize) {
self.member = self.member.min(members.saturating_sub(1));
}
pub fn switch_tab(&mut self, delta: isize) -> bool {
let current = PortfolioTab::ALL
.iter()
.position(|tab| *tab == self.tab)
.unwrap_or(0);
let len = PortfolioTab::ALL.len();
let next = (current as isize + delta).rem_euclid(len as isize) as usize;
let tab = PortfolioTab::ALL[next];
if tab == self.tab {
return false;
}
self.tab = tab;
self.selected = 0;
self.member = 0;
true
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortfolioEntry {
pub id: crate::config::ProjectId,
pub name: String,
pub members: usize,
}
impl PortfolioEntry {
pub fn title(&self) -> String {
crate::projects::project_display(&self.id, &self.name)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PortfolioDetail {
pub heading: String,
pub lines: Vec<(String, String)>,
pub members: Vec<String>,
}
pub fn entries(projects: &[crate::projects::Project]) -> Vec<PortfolioEntry> {
projects
.iter()
.map(|project| PortfolioEntry {
id: project.id.clone(),
name: project.name.clone(),
members: project.member_count,
})
.collect()
}
pub fn detail(
projects: &[crate::projects::Project],
reports: &std::collections::BTreeMap<
crate::config::ProjectId,
crate::projects::ProjectHealthReport,
>,
index: usize,
) -> Option<PortfolioDetail> {
let project = projects.get(index)?;
Some(PortfolioDetail {
heading: "Project".to_string(),
lines: detail_lines(project, reports.get(&project.id)),
members: member_lines(project),
})
}
pub fn detail_lines(
project: &crate::projects::Project,
report: Option<&crate::projects::ProjectHealthReport>,
) -> Vec<(String, String)> {
let or_dash = |value: &Option<String>| value.clone().unwrap_or_else(|| "-".to_string());
let mut lines = vec![
("id".to_string(), project.id.to_string()),
("name".to_string(), project.name.clone()),
("description".to_string(), or_dash(&project.description)),
("status".to_string(), or_dash(&project.status)),
("lead".to_string(), or_dash(&project.lead)),
("start-date".to_string(), or_dash(&project.start_date)),
("due-date".to_string(), or_dash(&project.due_date)),
("members".to_string(), project.member_count.to_string()),
];
if let Some(report) = report {
lines.push((
"progress".to_string(),
format!("{}/{}", report.progress.done, report.progress.total),
));
lines.push(("overdue".to_string(), report.health.overdue.to_string()));
lines.push(("blocked".to_string(), report.health.blocked.to_string()));
lines.push((
"unassigned".to_string(),
report.health.unassigned.to_string(),
));
lines.push((
"missing-references".to_string(),
report.health.missing_references.len().to_string(),
));
}
lines
}
pub fn member_lines(project: &crate::projects::Project) -> Vec<String> {
project
.members
.iter()
.map(|member| member.display.clone())
.collect()
}
pub fn epic_detail(
entries: &[crate::commands::view::EpicViewEntry],
index: usize,
) -> Option<PortfolioDetail> {
let entry = entries.get(index)?;
let mut lines = vec![("heading".to_string(), entry.epic.heading.clone())];
for line in &entry.epic.detail {
match line.split_once(": ") {
Some((key, value)) => lines.push((key.to_string(), value.to_string())),
None => lines.push((String::new(), line.clone())),
}
}
Some(PortfolioDetail {
heading: "Epic".to_string(),
lines,
members: entry.members.iter().map(|m| m.display.clone()).collect(),
})
}
fn dim() -> Style {
Style::default().fg(Color::DarkGray)
}
fn tab_line(active: PortfolioTab) -> Line<'static> {
let mut spans = vec![
Span::styled(
"F10 Portfolio",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
for (index, tab) in PortfolioTab::ALL.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
let style = if *tab == active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
dim()
};
spans.push(Span::styled(format!("[{}]", tab.label()), style));
}
Line::from(spans)
}
#[allow(clippy::too_many_lines)]
pub fn render(app: &App, f: &mut Frame, area: Rect) {
let state = app.portfolio_state();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(area);
f.render_widget(Paragraph::new(tab_line(state.tab)), rows[0]);
f.render_widget(
Paragraph::new(Span::styled(state.tab.hint(), dim())),
rows[1],
);
if state.tab == PortfolioTab::Roadmap {
crate::ui::roadmap::render_tab(app, f, rows[2], state.selected);
return;
}
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(45), Constraint::Min(24)])
.split(rows[2]);
let (listed, detail_pane): (Vec<(String, usize)>, Option<PortfolioDetail>) = match state.tab {
PortfolioTab::Projects => {
let projects = app.projects_snapshot();
let reports = app.portfolio_reports();
let listed = entries(&projects)
.iter()
.map(|entry| (entry.title(), entry.members))
.collect();
(listed, detail(&projects, &reports, state.selected))
}
PortfolioTab::Epics => {
let view = app.portfolio_epics();
let listed = view
.iter()
.map(|entry| (entry.epic.heading.clone(), entry.members.len()))
.collect();
(listed, epic_detail(view, state.selected))
}
PortfolioTab::Roadmap => unreachable!("the Roadmap tab renders above"),
};
let title = format!("{} ({})", state.tab.label(), listed.len());
let list = Block::default().borders(Borders::ALL).title(title);
if listed.is_empty() {
f.render_widget(
Paragraph::new(state.tab.empty_hint())
.block(list)
.wrap(Wrap { trim: true })
.style(dim()),
body[0],
);
} else {
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, (heading, members)) in listed.iter().enumerate() {
let selected = index == state.selected;
let title_style = if selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::raw(if selected { "> " } else { " " }),
Span::styled(heading.clone(), title_style),
]));
lines.push(Line::from(Span::styled(
format!(
" {} {}",
members,
crate::ui::planning::issues_word(*members)
),
dim(),
)));
}
f.render_widget(Paragraph::new(lines).block(list), body[0]);
}
let detail_block = Block::default()
.borders(Borders::ALL)
.title(detail_title(state.tab, detail_pane.as_ref()));
match detail_pane.as_ref() {
Some(detail) => {
let mut lines: Vec<Line<'static>> = detail
.lines
.iter()
.map(|(key, value)| {
Line::from(vec![
Span::styled(format!("{key}: "), dim()),
Span::raw(value.clone()),
])
})
.collect();
let selected_member = (state.tab == PortfolioTab::Epics).then_some(state.member);
for (index, member) in detail.members.iter().enumerate() {
let is_selected = selected_member == Some(index);
let style = if is_selected {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::raw(if is_selected { "> " } else { " " }),
Span::styled(member.clone(), style),
]));
}
f.render_widget(
Paragraph::new(lines)
.block(detail_block)
.wrap(Wrap { trim: false }),
body[1],
);
}
None => f.render_widget(
Paragraph::new(Span::styled(
format!(
"Nothing selected. A {} has to exist before the page can describe it.",
state.tab.noun()
),
dim(),
))
.block(detail_block)
.wrap(Wrap { trim: true }),
body[1],
),
}
}
fn detail_title(tab: PortfolioTab, detail: Option<&PortfolioDetail>) -> String {
match detail {
Some(detail) => match detail.lines.first() {
Some((_, value)) if !value.is_empty() => format!("{} {value}", detail.heading),
_ => detail.heading.clone(),
},
None => format!("{} detail", tab.label()),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::ProjectId;
use crate::projects::{MemberRef, Project};
fn project(id: &str, name: &str, members: &[u32]) -> Project {
let members: Vec<MemberRef> = members
.iter()
.map(|number| MemberRef {
issue: crate::projects::IssueKey::new(*number),
display: format!("#{number} some title"),
})
.collect();
Project {
id: ProjectId::new(id).unwrap(),
name: name.to_string(),
description: None,
status: None,
lead: None,
start_date: None,
due_date: None,
member_count: members.len(),
members,
}
}
#[test]
fn every_tab_has_a_full_empty_state_and_names_its_command() {
assert_eq!(
PortfolioTab::ALL,
[
PortfolioTab::Projects,
PortfolioTab::Epics,
PortfolioTab::Roadmap
]
);
assert_eq!(PortfolioTab::default(), PortfolioTab::Projects);
assert_eq!(PortfolioTab::Projects.label(), "Projects");
assert_eq!(PortfolioTab::Projects.section(), "projects");
assert_eq!(PortfolioTab::Projects.noun(), "project");
assert_eq!(PortfolioTab::Epics.label(), "Epics");
assert_eq!(PortfolioTab::Epics.section(), "epics");
assert_eq!(PortfolioTab::Epics.noun(), "epic");
assert_eq!(PortfolioTab::Roadmap.label(), "Roadmap");
assert_eq!(PortfolioTab::Roadmap.section(), "initiatives");
assert_eq!(PortfolioTab::Roadmap.noun(), "initiative");
let empty = PortfolioTab::Projects.empty_hint();
assert!(empty.contains("tix project create"), "got {empty:?}");
let empty = PortfolioTab::Epics.empty_hint();
assert!(empty.contains("tix epic create"), "got {empty:?}");
let empty = PortfolioTab::Roadmap.empty_hint();
assert!(empty.contains("tix initiative create"), "got {empty:?}");
for tab in PortfolioTab::ALL {
assert!(!tab.hint().is_empty(), "{tab:?} needs a hint");
}
}
#[test]
fn tab_switching_wraps_and_resets_the_row_cursor() {
let mut state = PortfolioState::new();
state.selected = 3;
assert!(state.switch_tab(1));
assert_eq!(state.tab, PortfolioTab::Epics);
assert_eq!(
state.selected, 0,
"the cursor indexes the active tab's rows"
);
assert!(state.switch_tab(1));
assert_eq!(state.tab, PortfolioTab::Roadmap);
assert!(state.switch_tab(1));
assert_eq!(state.tab, PortfolioTab::Projects);
assert!(state.switch_tab(-1));
assert_eq!(state.tab, PortfolioTab::Roadmap);
}
#[test]
fn epic_detail_reads_the_services_heading_rows_and_members() {
use crate::commands::view::{EpicBlockEntry, EpicMemberRef, EpicViewEntry};
let entries = vec![EpicViewEntry {
epic: EpicBlockEntry {
heading: "Alpha (e-aaa111)".to_string(),
detail: vec!["progress: 1/2 done".to_string(), "weight: 5".to_string()],
},
members: vec![
EpicMemberRef {
number: 3,
display: "#3 Some title".to_string(),
},
EpicMemberRef {
number: 1,
display: "#1 Another".to_string(),
},
],
}];
let detail = epic_detail(&entries, 0).expect("a selected epic");
assert_eq!(detail.heading, "Epic");
assert_eq!(
detail.lines[0],
("heading".to_string(), "Alpha (e-aaa111)".to_string())
);
assert_eq!(
detail.lines[1],
("progress".to_string(), "1/2 done".to_string())
);
assert_eq!(
detail.members,
vec!["#3 Some title", "#1 Another"],
"the service's own derived order and labels"
);
assert!(epic_detail(&entries, 7).is_none(), "the registry shrank");
}
#[test]
fn the_cursor_clamps_in_both_directions_and_on_an_empty_registry() {
let mut state = PortfolioState::new();
assert_eq!(state.selected, 0);
assert!(state.move_selected(1, 3));
assert_eq!(state.selected, 1);
assert!(state.move_selected(5, 3));
assert_eq!(state.selected, 2, "clamped to the last row");
assert!(state.move_selected(-1, 3));
assert_eq!(state.selected, 1);
state.clamp_selected(1);
assert_eq!(state.selected, 0);
assert!(!state.move_selected(1, 0), "an empty list cannot move");
assert_eq!(state.selected, 0);
state.move_selected(-3, 0);
assert_eq!(state.selected, 0, "and cannot underflow");
}
#[test]
fn the_detail_lines_are_the_cli_records_keys_with_dashes_for_absent() {
let lines = detail_lines(&project("p-1", "Platform", &[3, 1]), None);
let keys: Vec<&str> = lines.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(
keys,
[
"id",
"name",
"description",
"status",
"lead",
"start-date",
"due-date",
"members"
],
"the same keys `tix project show` prints, in the same order — a new \
field is one row here and nothing else"
);
assert_eq!(lines[0].1, "p-1");
assert_eq!(lines[1].1, "Platform");
assert_eq!(lines[2].1, "-", "an absent field renders `-`, not empty");
assert_eq!(lines[7].1, "2", "the derived member count");
}
#[test]
fn the_entry_title_is_the_shared_display_form() {
let entry = PortfolioEntry {
id: ProjectId::new("p-3f9a2c").unwrap(),
name: "Platform hardening".to_string(),
members: 4,
};
assert_eq!(entry.title(), "Platform hardening (p-3f9a2c)");
}
#[test]
fn the_member_lines_use_the_services_derived_labels() {
let lines = member_lines(&project("p-1", "Platform", &[3, 1]));
assert_eq!(lines, vec!["#3 some title", "#1 some title"]);
}
}