kanban-tui 0.6.0

Terminal user interface for the kanban project management tool
Documentation
use crate::app::{App, AppMode, Focus};
use crate::components::*;
use crate::theme::*;
use crate::view_strategy::UnifiedViewStrategy;
use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    text::{Line, Span},
    widgets::Paragraph,
    Frame,
};

pub(super) fn render_main(app: &mut App, frame: &mut Frame, area: Rect) {
    let is_kanban_view = if let Some(idx) = app.selection.active_board_index {
        if let Some(board) = app.model.boards().get(idx) {
            board.task_list_view == kanban_domain::TaskListView::ColumnView
        } else {
            false
        }
    } else {
        false
    };

    if is_kanban_view {
        app.view.viewport_height = area.height.saturating_sub(2) as usize;
        render_tasks(app, frame, area);
    } else {
        let chunks = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
            .split(area);

        app.view.viewport_height = chunks[1].height.saturating_sub(2) as usize;
        render_projects_panel(app, frame, chunks[0]);
        render_tasks(app, frame, chunks[1]);
    }
}

pub(super) fn render_projects_panel(app: &App, frame: &mut Frame, area: Rect) {
    let mut lines = vec![];
    let boards = app.model.boards();

    if boards.is_empty() {
        lines.push(Line::from(Span::styled(
            "No projects yet. Press 'n' to create one!",
            label_text(),
        )));
    } else {
        for (idx, board) in boards.iter().enumerate() {
            let config = ListItemConfig::new()
                .selected(app.selection.board.get() == Some(idx))
                .focused(app.focus.active == Focus::Boards)
                .active(app.selection.active_board_index == Some(idx));

            lines.push(styled_list_item(&board.name, &config));
        }
    }

    let panel_config = PanelConfig::new("Projects")
        .with_focus_indicator("Projects [1]")
        .focused(app.focus.active == Focus::Boards);

    let content = Paragraph::new(lines);
    render_panel(frame, area, &panel_config, content);
}

pub fn build_filter_title_suffix(app: &App) -> Option<String> {
    let mut filters = vec![];

    if app.filter.hide_assigned_cards {
        filters.push("Unassigned Cards".to_string());
    }

    if !app.filter.active_sprint_filters.is_empty() {
        if let Some(board_idx) = app
            .selection
            .active_board_index
            .or(app.selection.board.get())
        {
            if let Some(board) = app.model.boards().get(board_idx) {
                let mut sprint_names: Vec<String> = app
                    .model
                    .sprints()
                    .iter()
                    .filter(|s| app.filter.active_sprint_filters.contains(&s.id))
                    .map(|s| s.formatted_name(board, "sprint"))
                    .collect();
                sprint_names.sort();
                filters.extend(sprint_names);
            }
        }
    }

    if filters.is_empty() {
        None
    } else {
        Some(format!(" - {}", filters.join(" + ")))
    }
}

pub fn build_tasks_panel_title(app: &App, with_filter_suffix: bool) -> String {
    let count = app
        .view
        .strategy
        .get_active_task_list()
        .map(|l| l.len())
        .unwrap_or(0);
    let mut title = if app.mode == AppMode::ArchivedCardsView {
        format!("Archive [{}]", count)
    } else if app.focus.active == Focus::Cards {
        format!("Tasks [2] ({})", count)
    } else {
        "Tasks".to_string()
    };

    if with_filter_suffix && app.mode != AppMode::ArchivedCardsView {
        if let Some(suffix) = build_filter_title_suffix(app) {
            title.push_str(&suffix);
        }
    }

    title
}

pub(super) fn render_tasks(app: &App, frame: &mut Frame, area: Rect) {
    if let Some(unified_strategy) = app
        .view
        .strategy
        .as_any()
        .downcast_ref::<UnifiedViewStrategy>()
    {
        unified_strategy
            .get_render_strategy()
            .render(app, frame, area);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_filter_title_suffix_no_filters_returns_none() {
        let app = App::test_default();
        assert_eq!(build_filter_title_suffix(&app), None);
    }

    #[test]
    fn test_build_filter_title_suffix_unassigned_cards_flag() {
        let mut app = App::test_default();
        app.filter.hide_assigned_cards = true;
        assert_eq!(
            build_filter_title_suffix(&app),
            Some(" - Unassigned Cards".to_string())
        );
    }

    #[test]
    fn test_build_filter_title_suffix_sprint_filter_formats_sprint_name() {
        use kanban_domain::KanbanOperations;
        let mut app = App::test_default();
        let board = app
            .ctx
            .inner_mut()
            .create_board("Test Board".to_string(), None)
            .unwrap();
        let sprint = app
            .ctx
            .inner_mut()
            .create_sprint(board.id, None, Some("Sprint".to_string()))
            .unwrap();
        let sprint_id = sprint.id;
        app.selection.active_board_index = Some(0);
        app.filter.active_sprint_filters.insert(sprint_id);
        app.prepare_frame();
        let suffix = build_filter_title_suffix(&app);
        assert!(
            suffix.is_some(),
            "Expected Some suffix with active sprint filter"
        );
        let suffix = suffix.unwrap();
        assert!(suffix.starts_with(" - "), "Suffix should start with ' - '");
        assert!(
            suffix.contains("Sprint"),
            "Suffix should contain sprint name"
        );
    }

    #[test]
    fn test_build_tasks_panel_title_default() {
        let app = App::test_default();
        assert_eq!(build_tasks_panel_title(&app, false), "Tasks");
    }

    #[test]
    fn test_build_tasks_panel_title_archived_view() {
        let mut app = App::test_default();
        app.mode = AppMode::ArchivedCardsView;
        assert_eq!(build_tasks_panel_title(&app, false), "Archive [0]");
    }

    #[test]
    fn test_build_tasks_panel_title_cards_focus() {
        let mut app = App::test_default();
        app.focus.active = Focus::Cards;
        assert_eq!(
            build_tasks_panel_title(&app, false),
            "Tasks [2] (0)",
            "empty board should show shortcut hint [2] and count (0)"
        );
    }

    #[test]
    fn test_build_tasks_panel_title_with_filter_suffix() {
        let mut app = App::test_default();
        app.filter.hide_assigned_cards = true;
        let title = build_tasks_panel_title(&app, true);
        assert!(
            title.ends_with(" - Unassigned Cards"),
            "Expected title to end with filter suffix, got: {}",
            title
        );
    }

    #[test]
    fn test_build_tasks_panel_title_archived_ignores_filter_suffix() {
        let mut app = App::test_default();
        app.mode = AppMode::ArchivedCardsView;
        app.filter.hide_assigned_cards = true;
        assert_eq!(build_tasks_panel_title(&app, true), "Archive [0]");
    }
}