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