use crate::app::{App, BoardFocus};
use crate::components::*;
use crate::theme::*;
use kanban_core::pagination::scroll_offset_to_keep_visible;
use kanban_domain::{Sprint, SprintStatus};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
pub(super) fn render_board_detail_view(app: &App, frame: &mut Frame, area: Rect) {
if let Some(board_idx) = app.selection.board.get() {
if let Some(board) = app.model.boards().get(board_idx) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(8),
Constraint::Percentage(30),
Constraint::Percentage(35),
Constraint::Percentage(35),
])
.split(area);
render_board_name_field(app, board, frame, chunks[0]);
render_board_description_field(app, board, frame, chunks[1]);
render_board_settings_section(app, board, frame, chunks[2]);
render_board_sprints_list(app, board, frame, chunks[3]);
render_board_columns_list(app, board, frame, chunks[4]);
}
}
}
fn render_board_name_field(app: &App, board: &kanban_domain::Board, frame: &mut Frame, area: Rect) {
let name_config = FieldSectionConfig::new("Project Name")
.with_focus_indicator("Project Name [1]")
.focused(app.focus.board_focus == BoardFocus::Name);
let name = Paragraph::new(board.name.clone())
.style(bold_highlight())
.block(name_config.block());
frame.render_widget(name, area);
}
fn render_board_description_field(
app: &App,
board: &kanban_domain::Board,
frame: &mut Frame,
area: Rect,
) {
let desc_config = FieldSectionConfig::new("Description")
.with_focus_indicator("Description [2]")
.focused(app.focus.board_focus == BoardFocus::Description);
let desc_lines = if let Some(desc_text) = &board.description {
crate::markdown_renderer::render_markdown(desc_text)
} else {
vec![Line::from(Span::styled("No description", label_text()))]
};
let desc = Paragraph::new(desc_lines).block(desc_config.block());
frame.render_widget(desc, area);
}
fn render_board_settings_section(
app: &App,
board: &kanban_domain::Board,
frame: &mut Frame,
area: Rect,
) {
let settings_config = FieldSectionConfig::new("Settings")
.with_focus_indicator("Settings [3]")
.focused(app.focus.board_focus == BoardFocus::Settings);
let mut settings_lines = vec![
if let Some(prefix) = &board.sprint_prefix {
metadata_line_styled("Sprint Prefix", prefix, active_item())
} else {
Line::from(vec![
Span::styled("Sprint Prefix: ", label_text()),
Span::styled(
app.app_config.effective_default_sprint_prefix().to_string(),
normal_text(),
),
Span::styled(" (default)", label_text()),
])
},
if let Some(prefix) = &board.card_prefix {
metadata_line_styled("Card Prefix", prefix, active_item())
} else {
Line::from(vec![
Span::styled("Card Prefix: ", label_text()),
Span::styled(
app.app_config.effective_default_card_prefix().to_string(),
normal_text(),
),
Span::styled(" (default)", label_text()),
])
},
];
if let Some(sprint_prefix) =
kanban_domain::get_active_sprint_card_prefix_override(board, app.model.sprints())
{
settings_lines.push(metadata_line_styled(
"Active Sprint Card Prefix",
sprint_prefix,
Style::default().fg(Color::Cyan),
));
}
settings_lines.push(Line::from(""));
settings_lines.push(metadata_line(
"Sprint Duration",
board
.sprint_duration_days
.map(|d| format!("{} days", d))
.unwrap_or_else(|| "(not set)".to_string()),
));
let available_names: Vec<&str> = board
.sprint_names
.iter()
.skip(board.sprint_name_used_count)
.map(|s| s.as_str())
.collect();
if !available_names.is_empty() {
settings_lines.push(metadata_line("Sprint Names", available_names.join(", ")));
}
let settings = Paragraph::new(settings_lines).block(settings_config.block());
frame.render_widget(settings, area);
}
fn render_board_sprints_list(
app: &App,
board: &kanban_domain::Board,
frame: &mut Frame,
area: Rect,
) {
use crate::theme::colors::SELECTED_BG;
let sprints_config = FieldSectionConfig::new("Sprints")
.with_focus_indicator("Sprints [4]")
.focused(app.focus.board_focus == BoardFocus::Sprints);
let board_sprints: Vec<&Sprint> = app
.model
.sprints()
.iter()
.filter(|s| s.board_id == board.id)
.collect();
let mut sprint_lines = vec![];
if board_sprints.is_empty() {
sprint_lines.push(Line::from(Span::styled(
" No sprints yet. Press 'n' to create one!",
label_text(),
)));
} else {
let all_cards: Vec<&kanban_domain::Card> = app.model.cards().iter().collect();
for (sprint_idx, sprint) in board_sprints.iter().enumerate() {
let is_selected = app.selection.sprint.get() == Some(sprint_idx);
let is_focused = app.focus.board_focus == BoardFocus::Sprints;
let status_symbol = match sprint.status {
SprintStatus::Planning => "○",
SprintStatus::Active => "●",
SprintStatus::Completed => "✓",
SprintStatus::Cancelled => "✗",
};
let sprint_name = sprint.formatted_name(board, "sprint");
let card_count = all_cards
.iter()
.filter(|c| c.sprint_id == Some(sprint.id))
.count();
let is_active_sprint = board.active_sprint_id == Some(sprint.id);
let is_ended = sprint.is_ended(chrono::Utc::now());
let mut base_style = normal_text();
if is_selected && is_focused {
base_style = base_style.bg(SELECTED_BG);
}
let mut spans = vec![
Span::styled(
format!("{} ", status_symbol),
sprint_status_style(sprint.status),
),
Span::styled(sprint_name, base_style),
Span::styled(format!(" ({})", card_count), label_text()),
];
if is_active_sprint {
let mut active_style = active_item();
if is_selected && is_focused {
active_style = active_style.bg(SELECTED_BG);
}
spans.push(Span::styled(" Active", active_style));
}
if is_ended {
let mut ended_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
if is_selected && is_focused {
ended_style = ended_style.bg(SELECTED_BG);
}
spans.push(Span::styled(" Ended", ended_style));
}
sprint_lines.push(Line::from(spans));
}
}
let selected_idx = app.selection.sprint.get().unwrap_or(0);
let viewport_height = area.height.saturating_sub(2) as usize;
let scroll = scroll_offset_to_keep_visible(
app.selection.sprint_scroll.get(),
selected_idx,
viewport_height,
);
app.selection.sprint_scroll.set(scroll);
let sprints = Paragraph::new(sprint_lines)
.block(sprints_config.block())
.scroll((scroll as u16, 0));
frame.render_widget(sprints, area);
}
fn render_board_columns_list(
app: &App,
board: &kanban_domain::Board,
frame: &mut Frame,
area: Rect,
) {
use crate::theme::colors::SELECTED_BG;
let columns_config = FieldSectionConfig::new("Columns")
.with_focus_indicator("Columns [5]")
.focused(app.focus.board_focus == BoardFocus::Columns);
let mut board_columns: Vec<_> = app
.model
.columns()
.iter()
.filter(|col| col.board_id == board.id)
.collect();
board_columns.sort_by_key(|col| col.position);
let mut column_lines = vec![];
if board_columns.is_empty() {
column_lines.push(Line::from(Span::styled(
" No columns yet. Press 'n' to create one!",
label_text(),
)));
} else {
let all_cards: Vec<&kanban_domain::Card> = app.model.cards().iter().collect();
for (column_idx, column) in board_columns.iter().enumerate() {
let is_selected = app.dialog_input.column_selection.get() == Some(column_idx);
let is_focused = app.focus.board_focus == BoardFocus::Columns;
let card_count = all_cards
.iter()
.filter(|c| c.column_id == column.id)
.count();
let mut base_style = normal_text();
if is_selected && is_focused {
base_style = base_style.bg(SELECTED_BG);
}
let spans = vec![
Span::styled(format!("{}. ", column.position + 1), label_text()),
Span::styled(&column.name, base_style),
Span::styled(format!(" ({})", card_count), label_text()),
];
column_lines.push(Line::from(spans));
}
}
let selected_idx = app.dialog_input.column_selection.get().unwrap_or(0);
let viewport_height = area.height.saturating_sub(2) as usize;
let scroll = scroll_offset_to_keep_visible(
app.dialog_input.column_scroll.get(),
selected_idx,
viewport_height,
);
app.dialog_input.column_scroll.set(scroll);
let columns = Paragraph::new(column_lines)
.block(columns_config.block())
.scroll((scroll as u16, 0));
frame.render_widget(columns, area);
}