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::*;

/// Board view: a persistent tabs bar at the top (omitted when only one board
/// is configured), the hovered-issue title line beneath it, then the board
/// (with its own status line), and the hint bar at the very bottom
/// (FR-009/FR-010).
pub(in crate::app) fn render_board(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
    let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
    let view_h: u16 = u16::from(app.view_indicator().is_some());
    let band_h = filter_band_height(app);
    let filter_h = band_h - view_h;
    let hint_h: u16 = if app.one_shot { 0 } else { HINT_HEIGHT };
    let mention_h = mention_row_height(app);
    if area.height <= hint_h + mention_h + tab_h + HOVER_HEIGHT + band_h {
        return;
    }
    let hint_area = Rect {
        y: area.y + area.height - hint_h,
        height: hint_h,
        ..area
    };
    // The indicator sits immediately above the hint bar, so the two bottom rows read
    // together: what is waiting for you, and how to act on it.
    let mention_area = Rect {
        y: area.y + area.height - hint_h - mention_h,
        height: mention_h,
        ..area
    };
    let tabs_area = Rect {
        y: area.y,
        height: tab_h,
        ..area
    };
    let view_area = Rect {
        y: area.y + tab_h + HOVER_HEIGHT,
        height: view_h,
        ..area
    };
    let filter_area = Rect {
        y: area.y + tab_h + HOVER_HEIGHT + view_h,
        height: filter_h,
        ..area
    };
    let content = Rect {
        y: area.y + tab_h + HOVER_HEIGHT + band_h,
        height: area.height - tab_h - HOVER_HEIGHT - band_h - hint_h - mention_h,
        ..area
    };
    if tab_h > 0 {
        app.tabs.render(f, tabs_area);
        for index in 0..app.tabs.len() {
            if let Some(rect) = app.tabs.tab_rect(index, tabs_area) {
                app.record_region(rect, Action::SwitchBoard(index));
            }
        }
    }
    render_hover_line(app, f, area);
    if view_h > 0 {
        render_view_row(app, f, view_area);
    }
    if filter_h > 0 {
        filter_row(app).render(f, filter_area);
    }
    let page_cards = board::render(
        &app.board,
        f,
        content,
        &app.status_msg,
        app.drag_preview_col,
        board::BoardDisplay::from(app.active_board_config()),
        &app.theme,
        Some(&app.wip),
        app.capacity_summaries(),
        app.marked_numbers(),
    );
    // The renderer reports what one screen of the active column holds; the page keys
    // read it back. Set through `&App` because the field is a `Cell` — the same reason
    // the help overlay's geometry is one (tix #1442 follow-up).
    app.board_page.set(page_cards);
    if mention_h > 0 {
        crate::ui::mentions::render_indicator(app, f, mention_area);
    }
    if hint_h > 0 {
        render_hint(f, hint_area, app, tag);
    }
}

/// Detail view: detail content, optional status line, then the hint bar.
pub(in crate::app) fn render_detail(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
    let status_h: u16 = if app.status_msg.is_some() { 1 } else { 0 };
    if area.height <= HINT_HEIGHT + status_h {
        return;
    }
    let hint_area = Rect {
        y: area.y + area.height - HINT_HEIGHT,
        height: HINT_HEIGHT,
        ..area
    };
    let content = Rect {
        height: area.height - status_h - HINT_HEIGHT,
        ..area
    };
    if let Some(ref d) = app.detail {
        detail::render(d, f, content, &app.theme);
    }
    if let Some(msg) = &app.status_msg {
        let status_area = Rect {
            y: area.y + area.height - status_h - HINT_HEIGHT,
            height: status_h,
            ..area
        };
        let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
            .style(Style::default().fg(Color::Yellow));
        f.render_widget(para, status_area);
    }
    if let Some(picker) = &app.related_picker {
        render_related_picker(app, picker, f, area);
    }
    render_hint(f, hint_area, app, tag);
}

/// The related-issues picker, drawn over the detail pane (tix #1513).
///
/// A centered modal like the form's picker; `related_picker` is the state, so the
/// rows come straight from it. There is no filter line: `InputContext::RelatedPicker`
/// binds only navigation, select, and cancel, so a filter the user could not type
/// would be a lie.
pub(in crate::app) fn render_related_picker(
    app: &App,
    picker: &DropdownState,
    f: &mut ratatui::Frame,
    area: Rect,
) {
    let rect = crate::ui::modal::modal_rect(area);
    let picker_rect = Rect::new(
        rect.x.saturating_add(2),
        rect.y.saturating_add(2),
        rect.width.saturating_sub(4).max(10),
        rect.height.saturating_sub(4).max(5),
    );
    f.render_widget(Clear, picker_rect);

    const WINDOW: usize = 10;
    let visible = picker.visible_indices();
    let start = picker.cursor.saturating_sub(WINDOW.saturating_sub(1));
    let mut lines: Vec<Line<'static>> = Vec::new();
    for (pos, idx) in visible.iter().enumerate().skip(start).take(WINDOW) {
        let Some(item) = picker.options.get(*idx) else {
            continue;
        };
        let marker = if pos == picker.cursor { "> " } else { "  " };
        lines.push(Line::from(format!("{marker}{}", item.label)));
    }
    if lines.is_empty() {
        lines.push(Line::from("(no related issues)"));
    }
    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        format!(
            "{} select | {} close",
            app.bindings
                .label(InputContext::RelatedPicker, TuiAction::Submit),
            app.bindings
                .label(InputContext::RelatedPicker, TuiAction::Cancel),
        ),
        Style::default().fg(Color::DarkGray),
    )));

    let para = Paragraph::new(lines).block(
        Block::default()
            .borders(Borders::ALL)
            .title("Related issues"),
    );
    f.render_widget(para, picker_rect);
}

/// Draw the filter modal's open criterion picker (that criterion's known values)
/// over the modal, recording one region per visible option row and one for the
/// surrounding area so a click outside closes it without choosing.
pub(in crate::app) fn render_filter_picker(
    app: &App,
    f: &mut ratatui::Frame,
    area: Rect,
    picker: &FilterPicker,
) {
    let modal = crate::ui::modal::modal_rect(area);
    let Some((rect, rows)) = crate::ui::filter_modal::picker_layout(modal, &picker.state) else {
        return;
    };
    // Recorded first, so the option rows recorded below win on overlap.
    app.record_region(area, Action::FilterPickerClose);
    f.render_widget(Clear, rect);
    let block = Block::default()
        .borders(Borders::ALL)
        .title(format!(" {} ", picker.field.label()));
    let inner = block.inner(rect);
    f.render_widget(block, rect);

    let lines: Vec<Line<'static>> = picker
        .state
        .visible_indices()
        .iter()
        .enumerate()
        .map(|(row, index)| {
            let label = &picker.state.options[*index].label;
            let marker = if row == picker.state.cursor {
                "> "
            } else {
                "  "
            };
            Line::from(format!("{marker}{label}"))
        })
        .collect();
    f.render_widget(Paragraph::new(lines), inner);
    for (row, row_rect) in rows.iter().enumerate() {
        app.record_region(*row_rect, Action::FilterPickerOption(row));
    }
}