tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// Equal-width column slices for a board of `total_cols` columns (the same
/// `Constraint::Ratio` layout `render` draws), shared so hit-testing can map a
/// pointer position back to a column without re-deriving geometry (D3).
pub fn compute_column_rects(area: Rect, total_cols: usize) -> Vec<Rect> {
    if total_cols == 0 {
        return Vec::new();
    }
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints(vec![Constraint::Ratio(1, total_cols as u32); total_cols])
        .split(area)
        .to_vec()
}

// `wip` is the one derived fact the board needs beyond its own state (WP05 T025):
// the columns' usage against their limits, from the shared snapshot. It is passed
// in rather than built here so every renderer in a frame reports the same figures.
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
pub fn render(
    board: &BoardState,
    f: &mut Frame,
    area: Rect,
    status_msg: &Option<String>,
    drag_preview_col: Option<usize>,
    display: BoardDisplay,
    theme: &crate::ui::color::Theme,
    wip: Option<&crate::wip::WipSnapshot>,
    capacity: Option<&CapacitySummaries>,
    // The batch selection, by issue number. Marked cards are tinted so the set
    // is visible on the board itself rather than only in the batch modal.
    marked: Option<&[u32]>,
) -> usize {
    // How many cards one screen of the **active** column holds (tix #1442 follow-up).
    //
    // Returned rather than re-derived by the caller: this is the only place that knows
    // the column's inner height *and* each card's rendered height, and a page key needs
    // both. `0` when nothing was drawn, so the caller cannot page a column that is not
    // on screen.
    let mut page_cards = 0usize;

    if area.height < 2 || area.width < 4 {
        return 0;
    }

    let status_height: u16 = if status_msg.is_some() { 1 } else { 0 };
    let board_height = area.height.saturating_sub(status_height);
    let board_area = Rect {
        height: board_height,
        ..area
    };
    let status_area = Rect {
        y: area.y + board_height,
        height: status_height,
        ..area
    };

    let total = board.total_cols();
    if total == 0 {
        return 0;
    }

    let chunks = compute_column_rects(board_area, total);

    for (i, chunk) in chunks.iter().enumerate() {
        let col = if i < board.columns.len() {
            &board.columns[i]
        } else {
            &board.mismatch
        };
        let group_mode = board
            .filter
            .as_deref()
            .and_then(|q| crate::search::control_token(q, "group"));

        let is_active = i == board.active_col;
        let is_mismatch_col = i == board.columns.len();
        let is_mismatch_highlight = is_mismatch_col && !col.cards.is_empty();
        // The column the pointer sits in — set for empty columns too, so hovering
        // anywhere in a column lights the whole column up (not just a card).
        let hovered_col = board.hover_col;

        let mut selected_row: Option<usize> = None;
        let items: Vec<ListItem> = if let Some(mode) = group_mode.as_deref() {
            if let Some(sections) = BoardState::group_sections(&col.cards, Some(mode)) {
                let mut items = Vec::new();
                for section in sections {
                    let pct = (section.done * 100).checked_div(section.total).unwrap_or(0);
                    // WP06 T032: a `[cycle]`-grouped board appends the scope's compact
                    // capacity cell to its own section header — the same line, one more
                    // fact, so grouping, filtering, card order and hit-testing are all
                    // untouched. A group whose name names no configured scope (a card in
                    // no cycle, or a cycle with no ceiling) keeps exactly the header it
                    // had, because a fabricated cell would be a TUI-only number.
                    let capacity_cell = (mode == "cycle")
                        .then(|| capacity.and_then(|cells| cells.get(&section.name)))
                        .flatten();
                    let header = match capacity_cell {
                        Some(cell) => format!(
                            "-- {mode}: {} ({}/{} done, {pct}%) · {cell} --",
                            section.name, section.done, section.total
                        ),
                        None => format!(
                            "-- {mode}: {} ({}/{} done, {pct}%) --",
                            section.name, section.done, section.total
                        ),
                    };
                    items.push(ListItem::new(Line::from(Span::styled(
                        header,
                        Style::default()
                            .fg(Color::Yellow)
                            .add_modifier(Modifier::BOLD),
                    ))));
                    for j in section.start..section.end {
                        let card = &col.cards[j];
                        let lines = widgets::card_lines_themed(
                            card,
                            chunk.width as usize,
                            display.wrap_titles,
                            display.title_lines,
                            theme,
                        );
                        let is_marked =
                            marked.is_some_and(|numbers| numbers.contains(&card.number));
                        let marked_style = Style::default()
                            .fg(Color::Cyan)
                            .bg(Color::DarkGray)
                            .add_modifier(Modifier::BOLD);
                        let style = if is_active && j == board.selected {
                            selected_row = Some(items.len());
                            if is_marked {
                                // The cursor and a mark are two facts, so the cursor
                                // keeps the marked background and adds an underline
                                // rather than overwriting the mark's colour.
                                marked_style.add_modifier(Modifier::UNDERLINED)
                            } else {
                                Style::default()
                                    .bg(Color::DarkGray)
                                    .add_modifier(Modifier::BOLD)
                            }
                        } else if is_marked {
                            marked_style
                        } else if board.hover == Some((i, j)) {
                            // Hover highlight only — selection is untouched (D8).
                            if drag_preview_col == Some(i) {
                                Style::default()
                                    .fg(Color::Yellow)
                                    .add_modifier(Modifier::BOLD)
                            } else {
                                Style::default().fg(Color::Cyan)
                            }
                        } else if is_mismatch_highlight {
                            Style::default().fg(Color::Red)
                        } else {
                            Style::default()
                        };
                        items.push(ListItem::new(lines).style(style));
                    }
                }
                items
            } else {
                Vec::new()
            }
        } else {
            col.cards
                .iter()
                .enumerate()
                .map(|(j, card)| {
                    let lines = widgets::card_lines_themed(
                        card,
                        chunk.width as usize,
                        display.wrap_titles,
                        display.title_lines,
                        theme,
                    );
                    let is_marked = marked.is_some_and(|numbers| numbers.contains(&card.number));
                    let marked_style = Style::default()
                        .fg(Color::Cyan)
                        .bg(Color::DarkGray)
                        .add_modifier(Modifier::BOLD);
                    let style = if is_active && j == board.selected {
                        selected_row = Some(j);
                        if is_marked {
                            marked_style.add_modifier(Modifier::UNDERLINED)
                        } else {
                            Style::default()
                                .bg(Color::DarkGray)
                                .add_modifier(Modifier::BOLD)
                        }
                    } else if is_marked {
                        marked_style
                    } else if board.hover == Some((i, j)) {
                        if drag_preview_col == Some(i) {
                            Style::default()
                                .fg(Color::Yellow)
                                .add_modifier(Modifier::BOLD)
                        } else {
                            Style::default().fg(Color::Cyan)
                        }
                    } else if is_mismatch_highlight {
                        Style::default().fg(Color::Red)
                    } else {
                        Style::default()
                    };
                    ListItem::new(lines).style(style)
                })
                .collect()
        };

        // The column's own count, replaced by the usage cell when the board
        // configures a limit for it: `in progress (4/3 8/10d!)` says what the header
        // said and what the limit is, and a board with no limits keeps the plain
        // `in progress (4)` it always had (WP05 T025).
        let title = match wip
            .and_then(|snapshot| snapshot.column(&col.name))
            .filter(|usage| usage.limit.is_some())
        {
            Some(usage) => format!("{} ({})", col.name, usage.cell()),
            None => format!("{} ({})", col.name, col.cards.len()),
        };
        let border_style = if drag_preview_col == Some(i) {
            Style::default().fg(Color::Yellow)
        } else if is_active {
            Style::default().fg(Color::Cyan)
        } else if is_mismatch_highlight {
            Style::default().fg(Color::Red)
        } else if hovered_col == Some(i) {
            // The hovered column gets its own accent so the pointer's column is
            // visible even when the card itself is small.
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default()
        };

        let block = Block::default()
            .borders(Borders::ALL)
            .title(title)
            .border_style(border_style);

        let col_chunks = if BoardState::group_footer(&col.cards, group_mode.as_deref()).is_some() {
            Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Min(1), Constraint::Length(1)])
                .split(*chunk)
        } else {
            Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Min(1), Constraint::Length(0)])
                .split(*chunk)
        };

        let list = List::new(items).block(block);

        if is_active {
            // A page is what the reader can see: the column's inner height against the
            // heights the cards actually render at.
            page_cards = cards_per_screen(
                &col.cards,
                chunk.width as usize,
                col_chunks[0].height.saturating_sub(2),
                display,
            );
            let mut state = ListState::default();
            if let Some(row) = selected_row {
                state.select(Some(row));
            }
            f.render_stateful_widget(list, col_chunks[0], &mut state);
        } else {
            f.render_widget(list, col_chunks[0]);
        }
        if let Some(footer) = BoardState::group_footer(&col.cards, group_mode.as_deref()) {
            let para = Paragraph::new(Line::from(Span::styled(
                footer,
                Style::default().fg(Color::DarkGray),
            )));
            f.render_widget(para, col_chunks[1]);
        }
    }

    if let Some(msg) = status_msg {
        // The status area is one row and does not wrap, so anything longer is cut
        // by the widget with no indication at all. Mark the cut instead: a
        // silently shortened warning reads as a complete one, and the
        // retired-`links:` disclosure runs to ~100 characters before the filename,
        // which is routinely longer still.
        //
        // `Paragraph` is handed the truncated string rather than the full one, so
        // the ellipsis cannot itself be pushed off the right edge.
        //
        // Truncation is measured in **display cells**, not scalar values. A CJK
        // character occupies two cells, so `chars().take(budget)` yields a string
        // up to twice as wide as the area; the widget then hard-cuts it at the
        // right edge and discards the marker appended at the end of the
        // overflowed string — silently reinstating the exact failure this
        // truncation exists to prevent. `unicode-width` is already a dependency,
        // so this costs no new one.
        let budget = (status_area.width as usize).saturating_sub(1);
        let text = truncate_to_cells(msg, budget);
        let para =
            Paragraph::new(Line::from(Span::raw(text))).style(Style::default().fg(Color::Yellow));
        f.render_widget(para, status_area);
    }

    page_cards
}

/// How many of `cards` fit into `rows` of a column `width` cells wide.
///
/// Measured with the same [`widgets::card_lines`] the renderer draws, so the answer is
/// the height the reader actually sees rather than an estimate from a constant. Group
/// headers and the group footer are **not** counted: the result is a step size for a
/// page key, and a step that overshoots by a header or two is friendlier than one that
/// undershoots and leaves the reader pressing the key again.
///
/// Zero for an empty column — "there is nothing to page" — and at least one otherwise,
/// so a page key in a column shorter than a single card still moves somewhere.
pub(super) fn cards_per_screen(
    cards: &[Card],
    width: usize,
    rows: u16,
    display: BoardDisplay,
) -> usize {
    if cards.is_empty() {
        return 0;
    }
    let mut used: u16 = 0;
    let mut fits = 0usize;
    for card in cards {
        let height = u16::try_from(
            widgets::card_lines(card, width, display.wrap_titles, display.title_lines).len(),
        )
        .unwrap_or(u16::MAX);
        if fits > 0 && used.saturating_add(height) > rows {
            break;
        }
        used = used.saturating_add(height);
        fits += 1;
        if used >= rows {
            break;
        }
    }
    fits.max(1)
}