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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
//! Quit confirmation dialog for the application.
//!
//! Shows a confirmation dialog when the user attempts to close the window
//! while there are active terminal sessions. Allows the user to either
//! quit the application or cancel the close operation.
/// Action returned by the quit confirmation dialog
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuitConfirmAction {
/// User confirmed - quit the application
Quit,
/// User cancelled - keep the window open
Cancel,
/// No action yet (dialog not showing or still showing)
None,
}
/// State for the quit confirmation dialog
pub struct QuitConfirmationUI {
/// Whether the dialog is visible
visible: bool,
/// Number of active sessions to display
session_count: usize,
/// The attached par-mux session, if any — its survival changes what
/// the dialog may truthfully claim (UX.md M9)
mux_session: Option<String>,
/// Shared ConfirmDialog state (UX.md OV3)
confirm: crate::app::overlay::confirm::ConfirmState,
}
impl Default for QuitConfirmationUI {
fn default() -> Self {
Self::new()
}
}
impl QuitConfirmationUI {
/// Create a new quit confirmation UI
pub fn new() -> Self {
Self {
visible: false,
session_count: 0,
mux_session: None,
confirm: Default::default(),
}
}
/// Check if the dialog is currently visible
pub fn is_visible(&self) -> bool {
self.visible
}
/// Show the confirmation dialog with the number of active sessions
/// and, when a par-mux session is attached, its name (UX.md M9: the
/// dialog must state that the daemon session survives the quit).
pub fn show_confirmation(&mut self, session_count: usize, mux_session: Option<&str>) {
self.visible = true;
self.session_count = session_count;
self.mux_session = mux_session.map(str::to_string);
}
/// Hide the dialog and clear state
pub(crate) fn hide(&mut self) {
self.visible = false;
self.session_count = 0;
self.mux_session = None;
self.confirm.reset();
}
/// Render the dialog and return any action.
///
/// Drawn by the shared `ConfirmDialog` (UX.md OV3): Enter and Escape
/// cancel (MD5 — B64 mapped Enter to Quit), and Quit needs its own
/// chord or its button.
pub fn show(&mut self, ctx: &egui::Context) -> QuitConfirmAction {
use crate::app::overlay::confirm::{self, ConfirmChoice, ConfirmSpec};
if !self.visible {
return QuitConfirmAction::None;
}
let (session_text, closing_text) =
summary_lines(self.session_count, self.mux_session.as_deref());
let body = [session_text, closing_text];
let spec = ConfirmSpec {
title: "Quit par-term?",
body: &body,
detail: None,
safe_label: "Cancel",
alternate_label: None,
destructive_label: "Quit",
offers_dont_ask_again: false,
};
let Some(answer) = confirm::show(ctx, &spec, &mut self.confirm) else {
return QuitConfirmAction::None;
};
self.hide();
match answer.choice {
ConfirmChoice::Destructive => QuitConfirmAction::Quit,
ConfirmChoice::Safe | ConfirmChoice::Alternate => QuitConfirmAction::Cancel,
}
}
}
impl crate::traits::OverlayComponent for QuitConfirmationUI {
type Action = QuitConfirmAction;
fn show(&mut self, ctx: &egui::Context) -> Self::Action {
QuitConfirmationUI::show(self, ctx)
}
fn is_visible(&self) -> bool {
self.is_visible()
}
fn set_visible(&mut self, visible: bool) {
if !visible {
self.hide();
}
// Note: setting visible=true requires session context.
// Use show_confirmation(session_count, mux_session) to open this dialog.
}
}
/// UX.md M9: the dialog's two body lines, matched to what quitting
/// actually does. A local-only quit terminates everything, but an
/// attached par-mux session survives the quit (the detach drops the
/// client, the daemon keeps running) — "All sessions will be
/// terminated" was false whenever one was attached.
fn summary_lines(tab_count: usize, mux_session: Option<&str>) -> (String, String) {
// UX.md T4: "session" means only a par-mux/tmux session, so the count
// is of tabs, and local shells are said to close.
let first = if tab_count == 1 {
"There is 1 open tab.".to_string()
} else {
format!("There are {tab_count} open tabs.")
};
let second = match mux_session {
Some(name) => format!("par-mux session '{name}' will keep running (it detaches)."),
None => "Their shells and running programs will be closed.".to_string(),
};
(first, second)
}
#[cfg(test)]
mod tests {
use super::{QuitConfirmAction, QuitConfirmationUI, summary_lines};
/// Render one frame with an Enter key press under a headless egui
/// context and return the action the dialog reported.
fn show_with_enter(ui: &mut QuitConfirmationUI) -> QuitConfirmAction {
let ctx = egui::Context::default();
ctx.begin_pass(egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1200.0, 800.0),
)),
events: vec![egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Default::default(),
}],
..Default::default()
});
let action = ui.show(&ctx);
ctx.end_pass().textures_delta.clear();
action
}
/// MD5: Enter is the safe choice everywhere — in the quit dialog it
/// must cancel, never quit. B64 mapped Enter to Quit.
#[test]
fn enter_cancels_never_quits() {
let mut ui = QuitConfirmationUI::new();
ui.show_confirmation(2, None);
assert_eq!(show_with_enter(&mut ui), QuitConfirmAction::Cancel);
assert!(!ui.is_visible());
}
/// UX.md M9: with a par-mux session attached, quitting detaches —
/// the dialog must say the session survives, never "terminated".
#[test]
fn mux_attach_summary_claims_survival_not_termination() {
let (count_line, closing_line) = summary_lines(3, Some("work"));
assert_eq!(count_line, "There are 3 open tabs.");
assert_eq!(
closing_line,
"par-mux session 'work' will keep running (it detaches)."
);
}
/// The local-only quit says its tabs' shells close, in T4 vocabulary:
/// it counts tabs and never calls them "sessions".
#[test]
fn local_only_summary_still_claims_termination() {
let (count_line, closing_line) = summary_lines(1, None);
assert_eq!(count_line, "There is 1 open tab.");
assert_eq!(
closing_line,
"Their shells and running programs will be closed."
);
let (count_line, _) = summary_lines(4, None);
assert_eq!(count_line, "There are 4 open tabs.");
}
}