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
//! The "this buffer has unsaved changes" confirm overlay: a small centered modal
//! with Save / Discard / Cancel buttons. Driven by `App::close_prompt_info()`;
//! key + mouse handling lives in `tui.rs` (it records button hitboxes here).
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use crate::app::App;
use crate::ui::theme;
pub fn draw(frame: &mut Frame, app: &mut App, screen: Rect) {
let Some((name, has_path)) = app.close_prompt_info() else {
return;
};
app.rects.close_prompt_buttons.clear();
// Buttons: Save (only if the buffer has a path), Discard, Cancel.
// Bracket-mnemonic form so keyboard-only users see the hotkey
// without having to read the underline (some terminals /
// recording pipelines strip UNDERLINED). R10 vscode-keyboard
// F7 (2026-08-22): tester saw no advertised keyboard
// affordance and had to try random letters to find s/d/esc.
// The bracket looks like text but paired with the bg-color
// box treatment below it reads as "S/D/C are the shortcuts".
// The underline is still applied to the letter INSIDE the
// brackets — belt-and-braces on terminals that DO render
// underline.
let mut buttons: Vec<(&str, u8)> = Vec::new();
if has_path {
buttons.push((" [S]ave ", 0));
}
buttons.push((" [D]iscard ", 1));
buttons.push((" [C]ancel ", 2));
let msg = format!(" {name} has unsaved changes.");
let buttons_w: usize = buttons.iter().map(|(t, _)| t.chars().count() + 2).sum();
let inner_w = msg.chars().count().max(buttons_w + 2).max(28);
let w = (inner_w as u16 + 2).min(screen.width.saturating_sub(2));
let h = 6u16.min(screen.height.saturating_sub(2));
let area = Rect {
x: screen.x + (screen.width.saturating_sub(w)) / 2,
y: screen.y + (screen.height.saturating_sub(h)) / 3,
width: w,
height: h,
};
frame.render_widget(Clear, area);
let block = crate::ui::design_tokens::popup_menu("Unsaved changes");
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height < 3 {
return;
}
// Row 0: the message. Row 1: blank. Last row: the buttons.
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
msg,
Style::default().fg(theme::cur().fg).bg(theme::cur().bg2),
))),
Rect::new(inner.x, inner.y, inner.width, 1),
);
let by = inner.y + inner.height - 1;
let mut bx = inner.x + 1;
for (i, (label, choice)) in buttons.iter().enumerate() {
// Default (first) button uses the shared menu-family highlight
// so this "commit action" reads as the same primitive as the
// quit dialog's default focus, menu-bar selection, etc.
let style = if i == 0 {
crate::ui::design_tokens::row_highlight_menu()
} else {
crate::ui::design_tokens::row_plain_menu()
};
let bw = label.chars().count() as u16;
if bx + bw > inner.x + inner.width {
break;
}
// Bracket-mnemonic layout is ` [X]abel ` — the hotkey
// letter is at index 3 (after the two padding spaces and
// the `[`). Underline it too so terminals that render
// UNDERLINED get double-signaling.
let mut spans: Vec<Span> = Vec::new();
let chars: Vec<char> = label.chars().collect();
// Padding + opening bracket (` [`).
spans.push(Span::styled(chars[..3].iter().collect::<String>(), style));
// The hotkey letter, underlined.
if let Some(&hk) = chars.get(3) {
spans.push(Span::styled(
hk.to_string(),
style.add_modifier(Modifier::UNDERLINED),
));
}
// Closing bracket + rest of the label.
spans.push(Span::styled(chars[4..].iter().collect::<String>(), style));
frame.render_widget(Paragraph::new(Line::from(spans)), Rect::new(bx, by, bw, 1));
app.rects
.close_prompt_buttons
.push((Rect::new(bx, by, bw, 1), *choice));
bx += bw + 2;
}
}