tix-tracker 0.11.0

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

pub(in crate::app) fn render_form_picker(
    app: &App,
    picker: &FormPicker,
    f: &mut ratatui::Frame,
    area: Rect,
    bindings: &EffectiveBindings,
) {
    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);

    let mut lines = Vec::new();
    lines.push(Line::from(format!("Filter: {}", picker.state.filter)));
    lines.push(Line::from(""));

    if picker.state.mode == PickerMode::InlineCreate {
        lines.push(Line::from(Span::styled(
            format!("Create: {}_", picker.state.create_buffer),
            Style::default().fg(Color::Yellow),
        )));
    } else {
        let visible = picker.state.visible_indices();
        for (pos, idx) in visible.into_iter().take(8).enumerate() {
            let item = &picker.state.options[idx];
            let selected = match &picker.state.selection {
                SelectionModel::Single(current) => current.as_deref() == Some(item.id.as_str()),
                SelectionModel::Multi(current) => current.contains(&item.id),
            };
            let cursor = if pos == picker.state.cursor { ">" } else { " " };
            let mark = if selected { "[x]" } else { "[ ]" };
            lines.push(Line::from(format!("{cursor} {mark} {}", item.label)));
        }
    }

    lines.push(Line::from(""));
    // The create key is only advertised where it does something: the Status
    // dropdown refuses `+` (tix #1508), so its footer must not offer it.
    let create_hint = if picker.field.allows_inline_create() {
        format!(
            " | {} create",
            bindings.label(
                InputContext::FormPicker,
                crate::keybindings::TuiAction::Create
            )
        )
    } else {
        String::new()
    };
    lines.push(Line::from(Span::styled(
        format!(
            "{}/{} close | {} select{create_hint}",
            bindings.label(
                InputContext::FormPicker,
                crate::keybindings::TuiAction::OpenPicker
            ),
            bindings.label(
                InputContext::FormPicker,
                crate::keybindings::TuiAction::Cancel
            ),
            bindings.label(
                InputContext::FormPicker,
                crate::keybindings::TuiAction::Submit
            ),
        ),
        Style::default().fg(Color::DarkGray),
    )));

    let title = match picker.field {
        FormPickerField::Status => "Status picker",
        FormPickerField::Priority => "Priority picker",
        FormPickerField::Labels => "Labels picker",
        FormPickerField::Assignee => "Assignee picker",
        FormPickerField::Cycles => "Cycles picker",
        FormPickerField::Milestones => "Milestones picker",
        FormPickerField::Reviewer => "Reviewer picker",
        FormPickerField::Parent => "Parent picker",
        FormPickerField::Project => "Projects picker",
        FormPickerField::Epic => "Epics picker",
        FormPickerField::Template => "Template picker",
    };
    let para = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
    f.render_widget(para, picker_rect);

    // The template selector is clickable as well as keyboard-navigable
    // (tui-new-issue-mode, requirement 4). Only the template picker records
    // regions: the multi-select pickers stay selections the user closes
    // deliberately, and a click that silently closed one would drop the rest.
    if picker.field == FormPickerField::Template && picker.state.mode != PickerMode::InlineCreate {
        // Below the top border the panel is `Filter:` (row 0), a blank (row 1),
        // then the option rows — so the first option sits three rows down.
        for (pos, _idx) in picker
            .state
            .visible_indices()
            .into_iter()
            .take(8)
            .enumerate()
        {
            let row = Rect {
                x: picker_rect.x.saturating_add(1),
                y: picker_rect
                    .y
                    .saturating_add(3)
                    .saturating_add(u16::try_from(pos).unwrap_or(0)),
                width: picker_rect.width.saturating_sub(2),
                height: 1,
            };
            app.record_region(row, Action::TemplatePickOption(pos));
        }
    }
}

/// The repair prompt (tix #1506): the editor round trip left a file with one or
/// more problems, and this lists each with its diagnostic and a proposal.
///
/// Every key is named with the chord the effective keymap binds: `confirm`
/// applies the proposed fix, `declare_key` writes an undeclared key into
/// `tix.toml`, `reedit` reopens the editor on the retained copy, `ignore`
/// acknowledges and keeps it, and `discard` removes it behind a second press.
/// The retained copy's path is named so a problem with no automatic fix can be
/// fixed by hand even outside the editor the key offers.
///
/// Wrapped, not clipped: the diagnostics are long (this is the message tix #1506
/// was filed about), and an offer to fix a problem must not be the line that
/// falls off the right edge on an 80-column terminal.
#[allow(clippy::too_many_arguments)]
pub(in crate::app) fn render_edit_repair(
    app: &App,
    f: &mut ratatui::Frame,
    area: Rect,
    filename: &str,
    message: &str,
    key: Option<&str>,
    problems: &[EditProblem],
    temp: Option<&std::path::Path>,
    discard_armed: bool,
) {
    let label = |action| app.bindings.label(InputContext::EditRepair, action);
    render_board(app, f, area, commandbar::MODE_BOARD);
    let rect = crate::ui::modal::modal_rect(area);
    f.render_widget(Clear, rect);
    let mut lines = vec![
        Line::from(Span::styled("Edit Repair", Style::default().fg(Color::Red))),
        Line::from(Span::raw(filename.to_string())),
    ];
    if problems.is_empty() {
        lines.push(Line::from(Span::styled(
            message.to_string(),
            Style::default().fg(Color::Yellow),
        )));
    } else {
        for problem in problems {
            lines.push(Line::from(Span::styled(
                problem.message.clone(),
                Style::default().fg(Color::Yellow),
            )));
            lines.push(Line::from(Span::styled(
                format!("\u{2192} {}", problem.proposal_text()),
                Style::default().fg(Color::Green),
            )));
        }
    }
    // #1506. Offered only when the problem *is* an undeclared key — the one case
    // where accepting has something to declare. The chord is the context's own
    // `confirm` binding, so a customised keymap is what the hint shows.
    if let Some(key) = key {
        lines.push(Line::from(Span::styled(
            format!(
                "{}: declare `{key}` in [custom_attributes] in tix.toml",
                label(crate::keybindings::TuiAction::Confirm)
            ),
            Style::default().fg(Color::Green),
        )));
    }
    lines.push(Line::from(""));
    lines.push(Line::from(Span::styled(
        format!(
            "{}: apply the fix · {}: re-edit · {}: ignore",
            label(crate::keybindings::TuiAction::Confirm),
            label(crate::keybindings::TuiAction::Reedit),
            label(crate::keybindings::TuiAction::Ignore),
        ),
        Style::default().fg(Color::DarkGray),
    )));
    let discard = if discard_armed {
        format!(
            "{}: press again to discard the retained copy",
            label(crate::keybindings::TuiAction::Discard)
        )
    } else {
        format!(
            "{}: discard the edit (asks again)",
            label(crate::keybindings::TuiAction::Discard)
        )
    };
    let retained = match temp {
        Some(path) => format!("retained copy: {}", path.display()),
        None => "the edit is already on disk".to_string(),
    };
    lines.push(Line::from(Span::styled(
        format!("{discard} · {retained}"),
        Style::default().fg(Color::Red),
    )));
    let para = Paragraph::new(lines).wrap(Wrap { trim: true }).block(
        Block::default()
            .borders(Borders::ALL)
            .title("Edit Repair")
            .style(Style::default().fg(Color::Red)),
    );
    f.render_widget(para, rect);
}

/// The edit-conflict prompt (tix #1510): the issue file changed on disk while the
/// editor had it open, so nothing has been written and both versions still exist.
///
/// It states that fact, names **both** files — the edited temp copy and the newer
/// on-disk one — so the user can compare them by hand, and offers the three
/// decisions with the chords the effective keymap binds. Wrapped, not clipped: the
/// temp and store paths are long, and a prompt that hides where the two versions
/// are is a prompt that cannot be acted on at 80 columns.
pub(in crate::app) fn render_edit_conflict(
    app: &App,
    f: &mut ratatui::Frame,
    area: Rect,
    filename: &str,
    temp: &std::path::Path,
    disk_path: &std::path::Path,
) {
    render_board(app, f, area, commandbar::MODE_BOARD);
    let rect = crate::ui::modal::modal_rect(area);
    f.render_widget(Clear, rect);
    let mut lines = vec![
        Line::from(Span::styled(
            "The issue changed on disk while the editor was open",
            Style::default().fg(Color::Red),
        )),
        Line::from(Span::raw(filename.to_string())),
        Line::from(Span::styled(
            format!("your edit:     {}", temp.display()),
            Style::default().fg(Color::Yellow),
        )),
        Line::from(Span::styled(
            format!("on disk (new): {}", disk_path.display()),
            Style::default().fg(Color::Yellow),
        )),
        Line::from(Span::styled(
            "nothing has been written yet".to_string(),
            Style::default().fg(Color::DarkGray),
        )),
        Line::from(""),
    ];
    // The three decisions, each with the chord the effective keymap binds and the
    // wording the *action* carries (`description_in`), so the prompt, the help
    // overlay and the docs say the same thing and a rebound key reads correctly
    // here without a second place to keep in step.
    for action in [TuiAction::Confirm, TuiAction::Ignore, TuiAction::Reedit] {
        lines.push(Line::from(format!(
            "{}: {}",
            app.bindings.label(InputContext::EditConflict, action),
            action.description_in(InputContext::EditConflict)
        )));
    }
    let para = Paragraph::new(lines).wrap(Wrap { trim: true }).block(
        Block::default()
            .borders(Borders::ALL)
            .title("Edit Conflict")
            .style(Style::default().fg(Color::Red)),
    );
    f.render_widget(para, rect);
}