use super::filter::{epic_bucket_label, nest_children, project_bucket_label, sort_cards};
#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone)]
pub(super) struct GroupSection {
pub(super) name: String,
pub(super) start: usize,
pub(super) end: usize,
pub(super) done: usize,
pub(super) total: usize,
}
pub(super) const GROUP_OPEN_AFFORDANCE: &str = "Enter: open issue";
pub(super) fn health_badges(issues: &[Issue]) -> BTreeMap<u32, String> {
let policy = crate::health::HealthPolicy::v1();
let index = crate::health::HealthIndex::build(issues, &[], &[], &policy);
let mut out = BTreeMap::new();
for issue in issues {
let scope = crate::health::HealthScope::of_issue(issue.number);
let Some((state, recorded)) = index.state_of(issue.number) else {
continue;
};
let derived = crate::health::rollup(&index, &scope, &policy);
if recorded.is_none() && derived.contributions.is_empty() {
continue;
}
let current = state.as_str();
let badge = if derived.is_derived() && derived.state.as_str() != current {
format!("{current}\u{2192}{}", derived.state.as_str())
} else {
current.to_string()
};
out.insert(issue.number, badge);
}
out
}
#[derive(Debug, Clone)]
pub struct Card {
pub reference: crate::id::IssueRef,
pub number: u32,
pub title: String,
pub priority: Priority,
pub labels: Vec<String>,
pub assignee: Option<String>,
pub filename: String,
pub commit: Option<String>,
pub created: String,
pub due_date: Option<String>,
pub status: String,
pub category: Option<String>,
pub category_color: Option<crate::workflow::ColorHint>,
pub done: bool,
pub cycle: Option<String>,
pub milestone: Option<String>,
pub blocked: Option<String>,
pub children: Option<String>,
pub parent: Option<crate::id::IssueRef>,
pub depth: u8,
pub archived: bool,
pub health: Option<String>,
pub related: usize,
pub projects: Vec<String>,
pub epics: Vec<String>,
}
pub(super) struct ProjectPartition {
pub(super) memberships: HashMap<u32, Vec<String>>,
pub(super) order: HashMap<String, usize>,
}
#[derive(Debug, Clone)]
pub struct BoardColumn {
pub name: String,
pub cards: Vec<Card>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HitTarget {
Card { col: usize, row: usize },
Column { col: usize },
}
pub type CapacitySummaries = std::collections::BTreeMap<String, String>;
#[derive(Debug, Clone)]
pub struct BoardState {
pub columns: Vec<BoardColumn>,
pub hidden: Vec<BoardColumn>,
pub mismatch: BoardColumn,
pub show_mismatch: bool,
pub active_col: usize,
pub selected: usize,
pub hover: Option<(usize, usize)>,
pub hover_col: Option<usize>,
pub filter: Option<String>,
}
pub fn truncate_to_cells(msg: &str, budget: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const ELLIPSIS: char = '\u{2026}';
if budget == 0 {
return String::new();
}
if UnicodeWidthStr::width(msg) <= budget {
return msg.to_string();
}
let marker_width = UnicodeWidthChar::width(ELLIPSIS).unwrap_or(1);
let mut out = String::new();
let mut used = 0usize;
for ch in msg.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w + marker_width > budget {
break;
}
out.push(ch);
used += w;
}
out.push(ELLIPSIS);
out
}
pub(super) fn blocked_reasons(
issues: &[crate::model::Issue],
done_column: &str,
) -> std::collections::BTreeMap<u32, String> {
let views: Vec<crate::relationships::IssueView<'_>> =
issues.iter().map(crate::model::Issue::issue_view).collect();
let graph = crate::relationships::RelationshipGraph::new(&views, done_column);
issues
.iter()
.filter_map(|issue| {
crate::commands::blocked_note(&graph.blocked_state(issue.number))
.map(|note| (issue.number, note))
})
.collect()
}
pub(super) fn child_markers(
issues: &[crate::model::Issue],
done_column: &str,
) -> std::collections::BTreeMap<u32, String> {
let views: Vec<crate::relationships::IssueView<'_>> =
issues.iter().map(crate::model::Issue::issue_view).collect();
let index = crate::hierarchy::HierarchyIndex::build(&views);
issues
.iter()
.filter_map(|issue| {
let facts =
crate::commands::parent::facts_for(&index, issues, issue.number, done_column);
facts.has_children().then(|| {
(
issue.number,
format!(
"{} {}/{}",
facts.rollup.direct_children,
facts.rollup.completed_children,
facts.rollup.progress.total
),
)
})
})
.collect()
}
impl BoardState {
pub fn resolution_for(
store: &Store,
board_config: &BoardConfiguration,
) -> crate::workflow::CategoryResolution {
let name = store
.config
.configs()
.find(|(_, board)| std::ptr::eq(*board, board_config))
.map(|(name, _)| name)
.unwrap_or("default");
crate::workflow::resolution(&store.config, board_config, name)
}
pub fn build(store: &Store, board_config: &BoardConfiguration) -> Self {
Self::build_with(store, board_config, false, false)
}
pub fn build_with_archived(
store: &Store,
board_config: &BoardConfiguration,
include_archived: bool,
) -> Self {
Self::build_with(store, board_config, include_archived, false)
}
pub fn build_with(
store: &Store,
board_config: &BoardConfiguration,
include_archived: bool,
show_children: bool,
) -> Self {
let issues = if include_archived {
store.list_all()
} else {
store.list()
}
.unwrap_or_default();
let resolution = Self::resolution_for(store, board_config);
Self::build_from_issues(
&issues,
board_config,
&store.config,
&resolution,
show_children,
)
}
pub fn build_from_issues(
issues: &[Issue],
board_config: &BoardConfiguration,
config: &crate::config::Config,
resolution: &crate::workflow::CategoryResolution,
show_children: bool,
) -> Self {
let done_column = crate::workflow::completion_column_or_default(board_config, config);
let done_column = done_column.as_str();
let blocked = blocked_reasons(issues, done_column);
let children = child_markers(issues, done_column);
let show_mismatch = board_config.show_mismatch;
let hidden_names: Vec<&str> = board_config
.hidden_columns
.iter()
.map(String::as_str)
.collect();
let mut column_map: Vec<Vec<Card>> =
board_config.columns.iter().map(|_| Vec::new()).collect();
let mut mismatch_cards = Vec::new();
let health_badges = health_badges(issues);
let projects = ProjectPartition::build(config, issues);
let epics = EpicPartition::build(config, issues);
for issue in issues {
let card = Card {
reference: crate::id::canonical_ref(issue),
number: issue.number,
title: issue.title.clone(),
priority: issue.priority.clone(),
labels: issue.labels.clone(),
assignee: issue.assignee.clone(),
filename: issue.filename.clone(),
commit: issue.commit.clone(),
created: issue.created.clone(),
due_date: issue.due_date.clone(),
status: issue.status.display().to_string(),
category: crate::workflow::category_of(issue, resolution)
.map(|c| c.label().to_string()),
category_color: crate::workflow::category_of(issue, resolution)
.and_then(|c| c.color_hint()),
done: crate::workflow::is_completed(issue, resolution),
cycle: issue.cycles.as_ref().and_then(|v| v.first().cloned()),
milestone: issue.milestones.as_ref().and_then(|v| v.first().cloned()),
blocked: blocked.get(&issue.number).cloned(),
children: children.get(&issue.number).cloned(),
parent: issue.parent,
depth: 0,
archived: issue.archived,
related: issue.related_issues.len(),
health: health_badges.get(&issue.number).cloned(),
projects: projects.memberships(issue.number),
epics: epics.memberships(issue.number),
};
if let Some(idx) = board_config
.columns
.iter()
.position(|c| c == issue.status.display())
{
column_map[idx].push(card);
} else {
mismatch_cards.push(card);
}
}
for cards in column_map.iter_mut() {
sort_cards(board_config, cards);
}
sort_cards(board_config, &mut mismatch_cards);
let mut columns: Vec<BoardColumn> = Vec::new();
let mut hidden: Vec<BoardColumn> = Vec::new();
for (cards, name) in column_map.into_iter().zip(board_config.columns.iter()) {
let col = BoardColumn {
name: name.clone(),
cards,
};
if hidden_names.contains(&name.as_str()) {
hidden.push(col);
} else {
columns.push(col);
}
}
let mismatch = BoardColumn {
name: "Mismatched".to_string(),
cards: mismatch_cards,
};
let mut state = BoardState {
columns,
hidden,
mismatch,
show_mismatch,
active_col: 0,
selected: 0,
filter: None,
hover: None,
hover_col: None,
};
state.apply_child_order(show_children);
state
}
pub(super) fn apply_child_order(&mut self, show_children: bool) {
if !show_children {
return;
}
for column in &mut self.columns {
nest_children(&mut column.cards);
}
nest_children(&mut self.mismatch.cards);
}
pub fn is_empty(&self) -> bool {
self.columns.iter().all(|c| c.cards.is_empty())
&& (!self.show_mismatch || self.mismatch.cards.is_empty())
}
pub fn total_cols(&self) -> usize {
if self.show_mismatch {
self.columns.len() + 1
} else {
self.columns.len()
}
}
pub fn real_cols(&self) -> usize {
self.columns.len()
}
pub fn column_name(&self, idx: usize) -> &str {
if idx < self.columns.len() {
&self.columns[idx].name
} else if idx == self.columns.len() && self.show_mismatch {
&self.mismatch.name
} else {
"Unknown"
}
}
pub fn active_column_name(&self) -> &str {
let visible = self.columns.len();
if self.active_col < visible {
&self.columns[self.active_col].name
} else {
let hidden_idx = self.active_col - visible;
if hidden_idx < self.hidden.len() {
&self.hidden[hidden_idx].name
} else {
"Unknown"
}
}
}
pub fn column_cards(&self, idx: usize) -> &[Card] {
if idx < self.columns.len() {
&self.columns[idx].cards
} else if idx == self.columns.len() && self.show_mismatch {
&self.mismatch.cards
} else {
&[]
}
}
pub fn get_selected_card(&self) -> Option<&Card> {
self.column_cards(self.active_col).get(self.selected)
}
pub fn sole_visible_card(&self) -> Option<(usize, usize)> {
let mut found = None;
for (col, column) in self.columns.iter().enumerate() {
for row in 0..column.cards.len() {
if found.is_some() {
return None;
}
found = Some((col, row));
}
}
if self.show_mismatch {
for row in 0..self.mismatch.cards.len() {
if found.is_some() {
return None;
}
found = Some((self.columns.len(), row));
}
}
found
}
pub fn move_left(&mut self) {
let total = self.real_cols();
self.active_col = if total == 0 {
0
} else if self.active_col >= total {
total - 1
} else {
(self.active_col + total - 1) % total
};
self.clamp_selection();
}
pub fn move_right(&mut self) {
let total = self.real_cols();
self.active_col = if total == 0 || self.active_col >= total {
0
} else {
(self.active_col + 1) % total
};
self.clamp_selection();
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
let cards = self.column_cards(self.active_col);
if self.selected + 1 < cards.len() {
self.selected += 1;
}
}
pub fn move_selection_by(&mut self, cards: i32) {
let len = self.column_cards(self.active_col).len();
if len == 0 {
self.selected = 0;
return;
}
let next = i32::try_from(self.selected)
.unwrap_or(i32::MAX)
.saturating_add(cards);
self.selected = next.clamp(0, i32::try_from(len).unwrap_or(i32::MAX) - 1) as usize;
}
pub fn clamp_selection(&mut self) {
let cards = self.column_cards(self.active_col);
if cards.is_empty() {
self.selected = 0;
} else if self.selected >= cards.len() {
self.selected = cards.len() - 1;
}
}
pub fn select_column(&mut self, col: usize) {
let total = self.real_cols();
if total > 0 {
self.active_col = col % total;
}
self.clamp_selection();
}
pub(super) fn group_footer(cards: &[Card], mode: Option<&str>) -> Option<String> {
let sections = Self::group_sections(cards, mode)?;
let text = sections
.into_iter()
.map(|section| {
let pct = (section.done * 100).checked_div(section.total).unwrap_or(0);
format!("{} {}/{} {pct}%", section.name, section.done, section.total)
})
.collect::<Vec<_>>()
.join(" | ");
Some(format!("{text} · {GROUP_OPEN_AFFORDANCE}"))
}
pub(super) fn card_group(card: &Card, key: &str) -> String {
match key {
"cycle" => card.cycle.clone().unwrap_or_else(|| "(none)".to_string()),
"milestone" => card
.milestone
.clone()
.unwrap_or_else(|| "(none)".to_string()),
"project" => project_bucket_label(&card.projects),
"epic" => epic_bucket_label(&card.epics),
_ => "(none)".to_string(),
}
}
pub(super) fn group_sections(cards: &[Card], mode: Option<&str>) -> Option<Vec<GroupSection>> {
let key =
mode.filter(|m| *m == "cycle" || *m == "milestone" || *m == "project" || *m == "epic")?;
if cards.is_empty() {
return None;
}
let mut sections = Vec::new();
let mut current = String::new();
let mut start = 0usize;
let mut done = 0usize;
let mut total = 0usize;
for (idx, card) in cards.iter().enumerate() {
let group = Self::card_group(card, key);
if idx == 0 {
current = group;
} else if group != current {
sections.push(GroupSection {
name: std::mem::take(&mut current),
start,
end: idx,
done,
total,
});
current = group;
start = idx;
done = 0;
total = 0;
}
total += 1;
if card.done {
done += 1;
}
}
sections.push(GroupSection {
name: current,
start,
end: cards.len(),
done,
total,
});
Some(sections)
}
}
#[derive(Debug, Clone, Copy)]
pub struct BoardDisplay {
pub wrap_titles: bool,
pub title_lines: usize,
}
impl Default for BoardDisplay {
fn default() -> Self {
BoardDisplay {
wrap_titles: true,
title_lines: 3,
}
}
}
impl From<&crate::config::BoardConfiguration> for BoardDisplay {
fn from(config: &crate::config::BoardConfiguration) -> Self {
BoardDisplay {
wrap_titles: config.wrap_titles,
title_lines: config.title_lines,
}
}
}