tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The WIP override confirmation (mission `configurable-wip-limits-01M3C6WX`, WP05
//! T026).
//!
//! A blocking limit refused a move, so the modal states **why** the limit refused
//! it — the policy's own rendered decision, not a re-worded summary — and asks for
//! the reason that authorizes going ahead. Nothing is written while it is open, and
//! `esc` leaves the move exactly where it was.
//!
//! The prompt shows the decision rather than a message it composed, for the reason
//! the C-006 rule exists: a surface that paraphrases a policy decision is a second
//! place the policy is written down.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};

use crate::app::{App, Mode};

/// One blocking refusal waiting for a reason.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WipOverridePrompt {
    /// The issue that would move.
    pub number: u32,
    /// How many selected issues the move covers. `1` for a single-card move; a
    /// batch asks once because every item shares the target column's limit.
    pub count: usize,
    /// The target column, so confirming re-runs the same move (including a
    /// configured column that is currently hidden).
    pub target_move: crate::app::MoveTarget,
    /// The target column's name, for the sentence.
    pub target: String,
    /// The refusal itself, from the shared policy.
    pub decision: crate::wip::WipDecision,
}

/// How many rows the modal wants.
pub fn height() -> u16 {
    8
}

pub fn render(app: &App, f: &mut Frame, area: Rect) {
    let Some(prompt) = app.wip_override.as_ref() else {
        return;
    };
    let reason = match &app.mode {
        Mode::WipOverride { buffer } => buffer.clone(),
        _ => String::new(),
    };
    // Centred, and wide enough for the decision's sentence without wrapping it into
    // an unreadable column. The frame is cleared first: the modal is an overlay, and
    // the board underneath must not show through its text.
    // `clamp` needs its bounds ordered, and 40 <= 96 here; a tiny terminal still
    // gets the box squeezed into the area it has.
    let width = area.width.saturating_sub(4).clamp(40, 96);
    let height = height().min(area.height);
    let x = area.x + (area.width.saturating_sub(width)) / 2;
    let y = area.y + (area.height.saturating_sub(height)) / 2;
    let box_area = Rect {
        x,
        y,
        width,
        height,
    };
    f.render_widget(Clear, box_area);

    let mut lines: Vec<Line<'static>> = Vec::new();
    let heading = if prompt.count > 1 {
        format!(
            "{} selected issues would exceed the limit on `{}` (first: #{})",
            prompt.count, prompt.target, prompt.number
        )
    } else {
        format!(
            "#{} would exceed the limit on `{}`",
            prompt.number, prompt.target
        )
    };
    lines.push(Line::from(Span::styled(
        heading,
        Style::default().add_modifier(Modifier::BOLD),
    )));
    // The policy's own words, one line each, so the modal quotes the decision rather
    // than paraphrasing it.
    for line in crate::wip::render_decision(&prompt.decision).lines() {
        lines.push(Line::from(line.to_string()));
    }
    lines.push(Line::from(""));
    lines.push(Line::from(vec![
        Span::styled("reason: ", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw(reason.clone()),
        Span::styled("_", Style::default().add_modifier(Modifier::SLOW_BLINK)),
    ]));
    lines.push(Line::from(Span::styled(
        "enter: override and move  ·  esc: cancel (nothing is written)",
        Style::default().add_modifier(Modifier::DIM),
    )));

    f.render_widget(
        Paragraph::new(lines)
            .block(Block::default().borders(Borders::ALL).title("WIP override"))
            .wrap(Wrap { trim: false }),
        box_area,
    );
}