kanban-tui 0.9.0

Terminal user interface for the kanban project management tool
Documentation
use crate::app::App;
use crate::components::centered_rect;
use crate::theme::*;
use kanban_core::viewport::scroll_offset_to_keep_visible;
use kanban_view::filters::FilterDialogState;
use ratatui::{
    layout::{Constraint, Direction, Layout},
    style::Style,
    text::{Line, Span},
    widgets::{Block, Borders, Clear, Paragraph},
    Frame,
};

pub fn render_filter_options_popup(app: &App, frame: &mut Frame) {
    let area = centered_rect(70, 75, frame.area());
    frame.render_widget(Clear, area);

    let block = Block::default()
        .title("Filter Options")
        .borders(Borders::ALL)
        .border_style(focused_border());

    let inner = block.inner(area);
    frame.render_widget(block, area);

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .margin(2)
        .constraints([
            Constraint::Min(8),
            Constraint::Length(3),
            Constraint::Length(3),
        ])
        .split(inner);

    if let Some(ref dialog_state) = app.filter.dialog_state {
        render_filter_sprints_section(app, frame, chunks[0], dialog_state);
        render_filter_date_range_section(frame, chunks[1], dialog_state.section_index);
        render_filter_tags_section(frame, chunks[2], dialog_state.section_index);
    }
}

fn render_filter_sprints_section(
    app: &App,
    frame: &mut Frame,
    area: ratatui::layout::Rect,
    dialog_state: &FilterDialogState,
) {
    let section_index = dialog_state.section_index;

    let mut sprint_lines = vec![Line::from(Span::styled(
        "Sprints",
        if section_index == 0 {
            bold_highlight()
        } else {
            normal_text()
        },
    ))];

    let unassigned_cursor = if section_index == 0 && dialog_state.item_selection == 0 {
        "> "
    } else {
        "  "
    };

    sprint_lines.push(Line::from(vec![
        Span::raw(unassigned_cursor),
        Span::styled(
            if dialog_state.filters.show_unassigned_sprints {
                "[✓]"
            } else {
                "[ ]"
            },
            normal_text(),
        ),
        Span::raw(" "),
        Span::styled("Show cards with unassigned sprints", normal_text()),
    ]));

    sprint_lines.push(Line::from(Span::styled(
        "─────────────────────────",
        label_text(),
    )));

    if let Some(board) = app.active_board() {
        {
            let sprints = app.model.sprints();
            let board_sprints: Vec<_> = sprints.iter().filter(|s| s.board_id == board.id).collect();

            if board_sprints.is_empty() {
                sprint_lines.push(Line::from(Span::styled(
                    "  (no sprints available)",
                    label_text(),
                )));
            } else {
                for (idx, sprint) in board_sprints.iter().enumerate() {
                    let is_selected = dialog_state
                        .filters
                        .selected_sprint_ids
                        .contains(&sprint.id);
                    let cursor = if section_index == 0 && dialog_state.item_selection == idx + 1 {
                        "> "
                    } else {
                        "  "
                    };

                    sprint_lines.push(Line::from(vec![
                        Span::raw(cursor),
                        Span::styled(if is_selected { "[✓]" } else { "[ ]" }, normal_text()),
                        Span::raw(" "),
                        Span::styled(sprint.formatted_name(board, None), normal_text()),
                    ]));
                }
            }
        }
    }

    let selected_line_idx = if dialog_state.item_selection == 0 {
        1
    } else {
        dialog_state.item_selection + 2
    };
    let viewport_height = area.height.saturating_sub(2) as usize;
    let scroll = scroll_offset_to_keep_visible(
        dialog_state.item_scroll.get(),
        selected_line_idx,
        viewport_height,
    );
    dialog_state.item_scroll.set(scroll);
    let section = Paragraph::new(sprint_lines)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(if section_index == 0 {
                    focused_border()
                } else {
                    Style::default()
                }),
        )
        .scroll((scroll as u16, 0));
    frame.render_widget(section, area);
}

fn render_filter_date_range_section(
    frame: &mut Frame,
    area: ratatui::layout::Rect,
    section_index: usize,
) {
    let date_lines = vec![
        Line::from(Span::styled(
            "Date Range (Future)",
            if section_index == 1 {
                bold_highlight()
            } else {
                label_text()
            },
        )),
        Line::from(Span::styled(
            "  Filter by last updated or created date",
            label_text(),
        )),
    ];

    let section =
        Paragraph::new(date_lines).block(Block::default().borders(Borders::ALL).border_style(
            if section_index == 1 {
                focused_border()
            } else {
                Style::default()
            },
        ));
    frame.render_widget(section, area);
}

fn render_filter_tags_section(
    frame: &mut Frame,
    area: ratatui::layout::Rect,
    section_index: usize,
) {
    let tag_lines = vec![
        Line::from(Span::styled(
            "Tags (Future)",
            if section_index == 2 {
                bold_highlight()
            } else {
                label_text()
            },
        )),
        Line::from(Span::styled("  Filter cards by tags", label_text())),
    ];

    let section =
        Paragraph::new(tag_lines).block(Block::default().borders(Borders::ALL).border_style(
            if section_index == 2 {
                focused_border()
            } else {
                Style::default()
            },
        ));
    frame.render_widget(section, area);
}