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

/// The height of the row band between the hover line and the board: the
/// saved-view indicator row when a view is applied (WP05, T024), plus the filter
/// row when a filter is active.
///
/// One helper for all three consumers — the two board renderers and the
/// hit-test geometry — so they cannot disagree about where the board starts (D3).
pub(in crate::app) fn filter_band_height(app: &App) -> u16 {
    let filter = if app.filter.is_some() || app.structured.has_active_criteria() {
        FILTER_HEIGHT
    } else {
        0
    };
    filter + u16::from(app.view_indicator().is_some())
}

/// The saved-view indicator row (WP05, T024), rendered in the band above the
/// board. The text is the same one the filter modal shows, so the board and the
/// modal cannot describe one state two ways; it turns yellow once the live state
/// diverges from the saved plan (T026).
pub(in crate::app) fn render_view_row(app: &App, f: &mut ratatui::Frame, area: Rect) {
    let Some(indicator) = app.view_indicator() else {
        return;
    };
    let style = if app
        .view_divergence()
        .is_some_and(|report| !report.in_sync())
    {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::Green)
    };
    let para = Paragraph::new(Line::from(Span::styled(indicator, style)));
    f.render_widget(para, area);
}

/// Full-width row that always shows the title of the currently hovered
/// (selected) issue — `#<number> <title>` — or a dim placeholder when no card
/// is selected. Renders regardless of filter state. Sits directly beneath the
/// tab bar, or at the very top when a single board makes the bar redundant.
pub(in crate::app) fn render_hover_line(app: &App, f: &mut ratatui::Frame, area: Rect) {
    let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
    let hover_area = Rect {
        y: area.y + tab_h,
        height: HOVER_HEIGHT,
        ..area
    };
    let line = match app.board.get_selected_card() {
        Some(card) => Line::from(vec![
            Span::styled(
                format!("#{}", card.number),
                Style::default().fg(Color::Cyan),
            ),
            Span::raw(" "),
            Span::styled(card.title.clone(), Style::default()),
        ]),
        None => Line::from(Span::styled("—", Style::default().fg(Color::DarkGray))),
    };
    f.render_widget(Paragraph::new(line), hover_area);
}

/// The surface beneath a planning form or date picker: the Config mode's
/// sectioned view when the sub-surface was opened from it — the Config mode
/// owns the cycles/milestones sections, so its surface must stay visible — and
/// the board otherwise.
pub(in crate::app) fn render_config_or_board(app: &App, f: &mut ratatui::Frame, area: Rect) {
    if app.settings.is_some() {
        let (cycles, milestones) = app.planning_rows();
        let viewer = app.viewer_label();
        crate::ui::settings::render_config(
            app,
            f,
            area,
            crate::ui::settings::DashboardView {
                name: app.active_board_name(),
                board: app.active_board_config(),
                editor: app.store.config.editor.as_deref(),
                viewer: &viewer,
            },
            &cycles,
            &milestones,
        );
    } else {
        render_board(app, f, area, commandbar::MODE_BOARD);
    }
}

/// Launch the system browser for `url` (tix #1513).
///
/// Spawned and **not waited on**: a browser is a long-lived process, and a TUI
/// that blocked on it would freeze until the browser closed. `stdout`/`stderr` are
/// nulled so a launcher's chatter cannot corrupt the alternate screen. `xdg-open`
/// first (the Linux convention), then `open` (macOS, and a fallback wherever a
/// user has it), which also covers the macOS case without a compile-time `cfg`.
pub(in crate::app) fn open_in_browser(url: &str) -> std::io::Result<()> {
    let mut last = None;
    for opener in ["xdg-open", "open"] {
        match std::process::Command::new(opener)
            .arg(url)
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn()
        {
            Ok(_child) => return Ok(()),
            Err(e) => last = Some(e),
        }
    }
    Err(last.unwrap_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "no browser launcher found")
    }))
}