返回 CodeWhale
launch_resume_confirm.rs
根目录 / crates / tui / src / tui / launch_resume_confirm.rs
1 //! Confirmation popup for resuming a session from the launch card.
2 //!
3 //! Resuming replaces the whole session context, and a single click used to do
4 //! it instantly — founder live-test: "you just click it and boom you're there
5 //! ... you don't realize it's happening". The first attempt at a fix put an
6 //! arming line over the composer dock, which read as one more piece of chrome
7 //! rather than as a question ("that's even more confusing tbh"). This is the
8 //! question, as a popup, with the session it is about named in it.
9
10 use crossterm::event::{
11 KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
12 };
13 use ratatui::layout::Rect;
14 use ratatui::prelude::*;
15 use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
16
17 use crate::tui::views::{
18 ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area, render_modal_surface,
19 };
20 use codewhale_localization::{Locale, MessageId, tr};
21 use codewhale_palette as palette;
22
23 pub struct LaunchResumeConfirmView {
24 session_id: String,
25 title: String,
26 detail: String,
27 locale: Locale,
28 /// The painted popup, so a click outside it can dismiss.
29 last_area: std::cell::Cell<Option<Rect>>,
30 buttons: std::cell::Cell<[Rect; 2]>,
31 selected: usize,
32 hovered: Option<usize>,
33 }
34
35 impl LaunchResumeConfirmView {
36 #[must_use]
37 pub fn new(session_id: String, title: String, detail: String, locale: Locale) -> Self {
38 Self {
39 session_id,
40 title,
41 detail,
42 locale,
43 last_area: std::cell::Cell::new(None),
44 buttons: std::cell::Cell::new([Rect::default(); 2]),
45 selected: 0,
46 hovered: None,
47 }
48 }
49
50 fn confirm(&self) -> ViewAction {
51 ViewAction::EmitAndClose(ViewEvent::LaunchResumeConfirmed {
52 session_id: self.session_id.clone(),
53 })
54 }
55 }
56
57 impl ModalView for LaunchResumeConfirmView {
58 fn kind(&self) -> ModalKind {
59 ModalKind::LaunchResumeConfirm
60 }
61
62 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
63 // Resuming replaces the whole session, so only a plain press answers:
64 // a release/repeat, or a Ctrl/Alt/Super chord that happens to end in
65 // Enter or `y`, is not a confirmation (U01-05). Shift stays allowed
66 // for `Y`.
67 if key.kind != KeyEventKind::Press {
68 return ViewAction::None;
69 }
70 let chord = key
71 .modifiers
72 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
73 match key.code {
74 KeyCode::Enter if !chord => {
75 if self.selected == 0 {
76 self.confirm()
77 } else {
78 ViewAction::Close
79 }
80 }
81 KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
82 self.selected = 1 - self.selected;
83 ViewAction::None
84 }
85 // `y` is the habit every terminal confirmation teaches; Esc and
86 // `n` both back out. Nothing else acts, so a stray keystroke
87 // cannot resume a session by accident.
88 KeyCode::Char('y') | KeyCode::Char('Y') if !chord => self.confirm(),
89 KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => ViewAction::Close,
90 _ => ViewAction::None,
91 }
92 }
93
94 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
95 let target = self
96 .buttons
97 .get()
98 .iter()
99 .position(|rect| crate::tui::mouse_ui::mouse_hits_rect(mouse, Some(*rect)));
100 if matches!(mouse.kind, MouseEventKind::Moved) {
101 self.hovered = target;
102 return ViewAction::None;
103 }
104 if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
105 return ViewAction::None;
106 }
107 if let Some(index) = target {
108 return if index == 0 {
109 self.confirm()
110 } else {
111 ViewAction::Close
112 };
113 }
114 // A click outside the popup is a dismissal, never a confirmation:
115 // the whole point is that resuming needs a deliberate act.
116 match self.last_area.get() {
117 Some(area) if crate::tui::mouse_ui::mouse_hits_rect(mouse, Some(area)) => {
118 ViewAction::None
119 }
120 Some(_) => ViewAction::Close,
121 None => ViewAction::None,
122 }
123 }
124
125 fn occupied_region(&self, area: Rect) -> Rect {
126 centered_modal_area(area, 64, 11, 32, 7)
127 }
128
129 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
130 self
131 }
132
133 fn render(&self, area: Rect, buf: &mut Buffer) {
134 let popup = self.occupied_region(area);
135 self.last_area.set(Some(popup));
136 render_modal_surface(area, popup, buf);
137
138 let block = Block::default()
139 .title(Line::from(Span::styled(
140 tr(self.locale, MessageId::LaunchResumeConfirmTitle).to_string(),
141 Style::default().fg(palette::TEXT_PRIMARY).bold(),
142 )))
143 .borders(Borders::ALL)
144 .border_style(Style::default().fg(palette::BORDER_COLOR))
145 .style(Style::default().bg(palette::WHALE_BG))
146 .padding(Padding::uniform(1));
147 let inner = block.inner(popup);
148 block.render(popup, buf);
149
150 // Reserve the consequence and controls first. A long session title
151 // is one clipped line, never a paragraph that can bury the warning.
152 let body_height = inner.height.saturating_sub(1);
153 let warning = Paragraph::new(tr(self.locale, MessageId::LaunchResumeConfirmBody))
154 .style(Style::default().fg(palette::TEXT_SOFT))
155 .wrap(Wrap { trim: true });
156 let warning_height = warning
157 .line_count(inner.width)
158 .min(usize::from(body_height)) as u16;
159 let spare = body_height.saturating_sub(warning_height);
160 let mut y = inner.y;
161 if spare > 0 {
162 Paragraph::new(crate::tui::ui_text::truncate_line_to_width(
163 &self.title,
164 usize::from(inner.width),
165 ))
166 .style(Style::default().fg(palette::TEXT_PRIMARY).bold())
167 .render(Rect::new(inner.x, y, inner.width, 1), buf);
168 y += 1;
169 }
170 if spare > 1 {
171 Paragraph::new(crate::tui::ui_text::truncate_line_to_width(
172 &self.detail,
173 usize::from(inner.width),
174 ))
175 .style(Style::default().fg(palette::TEXT_MUTED))
176 .render(Rect::new(inner.x, y, inner.width, 1), buf);
177 y += 1;
178 }
179 if spare > 2 {
180 y += 1;
181 }
182 warning.render(Rect::new(inner.x, y, inner.width, warning_height), buf);
183
184 // These are real controls, sharing the same measured rectangles for
185 // paint and pointer input. Keep both reachable in compact terminals.
186 let button_width = inner.width.saturating_sub(1) / 2;
187 let y = inner.bottom().saturating_sub(1);
188 let buttons = [
189 Rect::new(inner.x, y, button_width, u16::from(inner.height > 0)),
190 Rect::new(
191 inner.x + button_width + 1,
192 y,
193 button_width,
194 u16::from(inner.height > 0),
195 ),
196 ];
197 self.buttons.set(buttons);
198 for (index, id) in [
199 MessageId::LaunchResumeConfirmResume,
200 MessageId::LaunchResumeConfirmCancel,
201 ]
202 .into_iter()
203 .enumerate()
204 {
205 let label = tr(self.locale, id);
206 let key = if self.selected == index {
207 "Enter"
208 } else if index == 1 {
209 "Esc"
210 } else {
211 ""
212 };
213 let text = if !key.is_empty()
214 && unicode_width::UnicodeWidthStr::width(label.as_ref()) + key.len() + 3
215 <= usize::from(button_width)
216 {
217 format!("{label} {key}")
218 } else {
219 label.into_owned()
220 };
221 let style = if self.selected == index {
222 crate::tui::menu_style::selected_row_style()
223 } else if self.hovered == Some(index) {
224 crate::tui::menu_style::hovered_row_style().fg(palette::TEXT_PRIMARY)
225 } else {
226 Style::default()
227 .bg(palette::SURFACE_ELEVATED)
228 .fg(palette::TEXT_PRIMARY)
229 };
230 Paragraph::new(text)
231 .centered()
232 .style(style)
233 .render(buttons[index], buf);
234 }
235 }
236 }
237
238 #[cfg(test)]
239 mod tests {
240 use super::*;
241 use crossterm::event::KeyModifiers;
242
243 fn view() -> LaunchResumeConfirmView {
244 LaunchResumeConfirmView::new(
245 "sess-1".to_string(),
246 "refactor the parser".to_string(),
247 "3h ago · 12 msgs".to_string(),
248 Locale::En,
249 )
250 }
251
252 #[test]
253 fn enter_confirms_and_esc_walks_away() {
254 let mut confirm = view();
255 match confirm.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
256 ViewAction::EmitAndClose(ViewEvent::LaunchResumeConfirmed { session_id }) => {
257 assert_eq!(session_id, "sess-1");
258 }
259 other => panic!("Enter must confirm, got {other:?}"),
260 }
261
262 let mut confirm = view();
263 assert!(matches!(
264 confirm.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
265 ViewAction::Close
266 ));
267
268 // A stray keystroke resumes nothing: the whole point is that this
269 // takes a deliberate act.
270 let mut confirm = view();
271 assert!(matches!(
272 confirm.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)),
273 ViewAction::None
274 ));
275
276 // U01-05: a chord that ends in Enter or `y`, and a key release, are
277 // not a confirmation.
278 for key in [
279 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
280 KeyEvent::new(KeyCode::Char('y'), KeyModifiers::ALT),
281 KeyEvent::new(KeyCode::Char('y'), KeyModifiers::SUPER),
282 KeyEvent::new_with_kind(
283 KeyCode::Enter,
284 KeyModifiers::NONE,
285 crossterm::event::KeyEventKind::Release,
286 ),
287 ] {
288 let mut confirm = view();
289 assert!(
290 matches!(confirm.handle_key(key), ViewAction::None),
291 "{key:?} must not resume"
292 );
293 }
294 }
295
296 #[test]
297 fn the_popup_names_the_session_it_is_asking_about() {
298 let confirm = view();
299 let area = Rect::new(0, 0, 100, 30);
300 let mut buf = Buffer::empty(area);
301 confirm.render(area, &mut buf);
302 let painted: String = (0..area.height)
303 .map(|y| {
304 (0..area.width)
305 .map(|x| buf[(x, y)].symbol())
306 .collect::<String>()
307 })
308 .collect::<Vec<_>>()
309 .join("\n");
310 assert!(
311 painted.contains("refactor the parser"),
312 "the session title is in the popup:\n{painted}"
313 );
314 assert!(
315 painted.contains("12 msgs"),
316 "so is enough detail to recognise it:\n{painted}"
317 );
318 }
319 }
320
320 lines RUST