返回 CodeWhale
mode_picker.rs
根目录 / crates / tui / src / tui / views / mode_picker.rs
1 //! `/mode` picker for the currently supported interactive modes.
2
3 use std::cell::RefCell;
4
5 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
6 use ratatui::{
7 buffer::Buffer,
8 layout::Rect,
9 style::{Modifier, Style},
10 text::{Line, Span},
11 widgets::{Block, Borders, Padding, Paragraph, Widget},
12 };
13 use unicode_width::UnicodeWidthStr;
14
15 use crate::localization::Locale;
16 use crate::palette;
17 use crate::tui::app::AppMode;
18 use crate::tui::menu_style;
19 use crate::tui::views::{
20 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
21 render_modal_footer, render_modal_surface,
22 };
23
24 // Operate is visible because the engine now enforces a coordinator/worker
25 // boundary while allowing ordinary conversation and asynchronous dispatch.
26 const VISIBLE_MODES: [AppMode; 3] = [AppMode::Agent, AppMode::Plan, AppMode::Operate];
27
28 pub struct ModePickerView {
29 cursor: usize,
30 locale: Locale,
31 row_hitboxes: RefCell<Vec<Rect>>,
32 }
33
34 impl ModePickerView {
35 #[must_use]
36 pub fn new(current: AppMode, locale: Locale) -> Self {
37 let cursor = VISIBLE_MODES
38 .iter()
39 .position(|mode| *mode == current)
40 .unwrap_or(0);
41 Self {
42 cursor,
43 locale,
44 row_hitboxes: RefCell::new(Vec::new()),
45 }
46 }
47
48 fn selected_mode(&self) -> AppMode {
49 VISIBLE_MODES
50 .get(self.cursor)
51 .copied()
52 .unwrap_or(AppMode::Agent)
53 }
54
55 fn move_up(&mut self) {
56 self.cursor = crate::tui::list_nav::wrap_index(self.cursor, VISIBLE_MODES.len(), -1);
57 }
58
59 fn move_down(&mut self) {
60 self.cursor = crate::tui::list_nav::wrap_index(self.cursor, VISIBLE_MODES.len(), 1);
61 }
62
63 fn select_by_number(&mut self, number: char) -> Option<ViewAction> {
64 let idx = VISIBLE_MODES
65 .iter()
66 .position(|mode| mode.number() == number)?;
67 self.cursor = idx;
68 Some(ViewAction::EmitAndClose(ViewEvent::ModeSelected {
69 mode: self.selected_mode(),
70 }))
71 }
72 }
73
74 impl ModalView for ModePickerView {
75 fn kind(&self) -> ModalKind {
76 ModalKind::ModePicker
77 }
78
79 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
80 self
81 }
82
83 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
84 match key.code {
85 KeyCode::Esc => ViewAction::Close,
86 KeyCode::Enter => ViewAction::EmitAndClose(ViewEvent::ModeSelected {
87 mode: self.selected_mode(),
88 }),
89 KeyCode::Up | KeyCode::Char('k') => {
90 self.move_up();
91 ViewAction::None
92 }
93 KeyCode::Down | KeyCode::Char('j') => {
94 self.move_down();
95 ViewAction::None
96 }
97 KeyCode::Char(number) => self.select_by_number(number).unwrap_or(ViewAction::None),
98 _ => ViewAction::None,
99 }
100 }
101
102 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
103 match mouse.kind {
104 MouseEventKind::ScrollUp => {
105 self.move_up();
106 ViewAction::None
107 }
108 MouseEventKind::ScrollDown => {
109 self.move_down();
110 ViewAction::None
111 }
112 MouseEventKind::Down(MouseButton::Left) => {
113 let clicked = self.row_hitboxes.borrow().iter().position(|rect| {
114 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
115 });
116 if let Some(index) = clicked {
117 self.cursor = index;
118 return self.handle_key(KeyEvent::new(KeyCode::Enter, mouse.modifiers));
119 }
120 ViewAction::None
121 }
122 _ => ViewAction::None,
123 }
124 }
125
126 fn render(&self, area: Rect, buf: &mut Buffer) {
127 let popup_height = u16::try_from(VISIBLE_MODES.len()).unwrap_or(2) + 7;
128 let popup_area = centered_modal_area(area, 68, popup_height, 44, 8);
129
130 render_modal_surface(area, popup_area, buf);
131
132 let block = Block::default()
133 .title(Line::from(Span::styled(
134 " Mode ",
135 Style::default()
136 .fg(palette::WHALE_INFO)
137 .add_modifier(Modifier::BOLD),
138 )))
139 .borders(Borders::ALL)
140 .border_style(Style::default().fg(palette::BORDER_COLOR))
141 .style(Style::default().bg(palette::WHALE_BG))
142 .padding(Padding::uniform(1));
143
144 let inner = block.inner(popup_area);
145 block.render(popup_area, buf);
146
147 let content = render_modal_footer(
148 inner,
149 buf,
150 &[
151 ActionHint::new("↑/↓", "move"),
152 ActionHint::new("Enter", "select"),
153 ActionHint::new("Esc", "cancel"),
154 ],
155 );
156
157 self.row_hitboxes.borrow_mut().clear();
158
159 let mut lines = Vec::with_capacity(VISIBLE_MODES.len());
160
161 for (idx, mode) in VISIBLE_MODES.iter().copied().enumerate() {
162 let is_cursor = idx == self.cursor;
163 let row_style = if is_cursor {
164 menu_style::selected_row_style()
165 } else {
166 Style::default().fg(palette::TEXT_PRIMARY)
167 };
168 let hint_style = if is_cursor {
169 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
170 } else {
171 Style::default().fg(palette::TEXT_MUTED)
172 };
173 let pointer = crate::tui::glyphs::selection_marker(is_cursor);
174 let name = mode.display_name_localized(self.locale);
175 let hint = mode.picker_hint_localized(self.locale);
176 // Pad by terminal columns, not scalar count, so wide (CJK) mode
177 // names keep the hint column aligned.
178 let pad = " ".repeat(8usize.saturating_sub(UnicodeWidthStr::width(&*name)));
179
180 lines.push(Line::from(vec![
181 Span::styled(
182 format!("{pointer} {}. {name}{pad}", mode.number()),
183 row_style,
184 ),
185 Span::styled(hint, hint_style),
186 ]));
187 self.row_hitboxes.borrow_mut().push(Rect::new(
188 content.x,
189 content
190 .y
191 .saturating_add(u16::try_from(idx).unwrap_or(u16::MAX)),
192 content.width,
193 1,
194 ));
195 }
196
197 Paragraph::new(lines).render(content, buf);
198 }
199 }
200
201 #[cfg(test)]
202 mod tests {
203 use super::*;
204 use crossterm::event::KeyModifiers;
205 use ratatui::{Terminal, backend::TestBackend};
206
207 #[test]
208 fn opens_on_current_mode() {
209 let view = ModePickerView::new(AppMode::Plan, Locale::En);
210 assert_eq!(view.selected_mode(), AppMode::Plan);
211 }
212
213 #[test]
214 fn enter_emits_selected_mode() {
215 let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
216 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
217 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
218 match action {
219 ViewAction::EmitAndClose(ViewEvent::ModeSelected { mode }) => {
220 assert_eq!(mode, AppMode::Plan);
221 }
222 other => panic!("expected ModeSelected, got {other:?}"),
223 }
224 }
225
226 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
227 /// every overlay to remain readable and fully operable at.
228 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
229
230 fn render_at(width: u16, height: u16) -> (Buffer, Rect) {
231 use crate::tui::views::ViewStack;
232 let area = Rect::new(0, 0, width, height);
233 let mut buf = Buffer::empty(area);
234 // Pre-fill with a sentinel so any cell the composited modal fails to
235 // paint (bleed-through) is detectable as a surviving 'X'.
236 for y in 0..height {
237 for x in 0..width {
238 buf[(x, y)].set_symbol("X");
239 }
240 }
241 // Render through the ViewStack so the shared opaque backdrop is painted
242 // exactly as it is in production.
243 let mut stack = ViewStack::new();
244 stack.push(ModePickerView::new(AppMode::Agent, Locale::En));
245 stack.render(area, &mut buf);
246 (buf, area)
247 }
248
249 fn rows(buf: &Buffer, area: Rect) -> Vec<String> {
250 (0..area.height)
251 .map(|y| {
252 (0..area.width)
253 .map(|x| buf[(x, y)].symbol().to_string())
254 .collect::<String>()
255 })
256 .collect()
257 }
258
259 #[test]
260 fn mode_picker_is_usable_and_opaque_at_blocker_sizes() {
261 for (w, h) in BLOCKER_SIZES {
262 let (buf, area) = render_at(w, h);
263 let text = rows(&buf, area).join("\n");
264
265 // Action labels are present (footer never drops an action).
266 assert!(text.contains("move"), "{w}x{h}: missing 'move' hint");
267 assert!(text.contains("select"), "{w}x{h}: missing 'select' hint");
268 assert!(text.contains("cancel"), "{w}x{h}: missing 'cancel' hint");
269
270 // The cursor row carries the charter selection pointer.
271 assert!(
272 text.contains(crate::tui::glyphs::SELECTION),
273 "{w}x{h}: missing charter selection pointer"
274 );
275
276 // Composited frame is fully opaque: no sentinel survives and every
277 // cell carries the modal/backdrop ink background.
278 assert!(
279 !text.contains('X'),
280 "{w}x{h}: background bleed-through into modal surface"
281 );
282 let center = &buf[(w / 2, h / 2)];
283 assert_eq!(
284 center.bg,
285 palette::WHALE_BG,
286 "{w}x{h}: modal interior must be opaque"
287 );
288
289 // No row exceeds the frame width (no horizontal overflow).
290 for (y, row) in rows(&buf, area).iter().enumerate() {
291 assert!(
292 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
293 "{w}x{h}: row {y} overflows width: {row:?}"
294 );
295 }
296 }
297 }
298
299 #[test]
300 fn operate_is_advertised_as_a_visible_mode() {
301 let (buf, area) = render_at(80, 24);
302 let text = rows(&buf, area).join("\n");
303 assert!(text.contains("Operate"), "{text}");
304 }
305
306 #[test]
307 fn number_keys_select_modes() {
308 // Visible roster: 1 Act, 2 Plan, 3 Operate.
309 let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
310 let action = view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
311 assert!(matches!(
312 action,
313 ViewAction::EmitAndClose(ViewEvent::ModeSelected {
314 mode: AppMode::Operate
315 })
316 ));
317
318 // Legacy YOLO shorthand (4) is not offered by the picker.
319 let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
320 let action = view.handle_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE));
321 assert!(matches!(action, ViewAction::None));
322
323 // Old Operate number (5) is gone — no numeric gaps.
324 let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
325 let action = view.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE));
326 assert!(matches!(action, ViewAction::None));
327 }
328
329 #[test]
330 fn mouse_click_renders_and_selects_mode_row() {
331 let mut view = ModePickerView::new(AppMode::Agent, Locale::En);
332 let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("test terminal");
333 terminal
334 .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
335 .expect("render mode picker");
336 let rect = view.row_hitboxes.borrow()[1];
337 let action = view.handle_mouse(MouseEvent {
338 kind: MouseEventKind::Down(MouseButton::Left),
339 column: rect.x,
340 row: rect.y,
341 modifiers: KeyModifiers::NONE,
342 });
343 assert!(matches!(
344 action,
345 ViewAction::EmitAndClose(ViewEvent::ModeSelected {
346 mode: AppMode::Plan
347 })
348 ));
349 }
350 }
351
351 lines RUST