1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//! 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,
);
}