返回 CodeWhale
feedback_picker.rs
根目录 / crates / tui / src / tui / feedback_picker.rs
1 //! `/feedback` picker for GitHub feedback destinations.
2
3 use std::cell::RefCell;
4
5 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, 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
14 use crate::tui::menu_style;
15 use crate::tui::views::{
16 ActionHint, CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent,
17 centered_modal_area, render_modal_footer, render_modal_surface,
18 };
19 use codewhale_palette as palette;
20
21 #[derive(Debug, Clone, Copy)]
22 struct FeedbackOption {
23 number: char,
24 label: &'static str,
25 description: &'static str,
26 command: &'static str,
27 }
28
29 const OPTIONS: &[FeedbackOption] = &[
30 FeedbackOption {
31 number: '1',
32 label: "Bug report",
33 description: "Report a problem or regression",
34 command: "/feedback bug",
35 },
36 FeedbackOption {
37 number: '2',
38 label: "Feature request",
39 description: "Suggest an idea or improvement",
40 command: "/feedback feature",
41 },
42 FeedbackOption {
43 number: '3',
44 label: "Security vulnerability",
45 description: "Review the security policy before reporting",
46 command: "/feedback security",
47 },
48 ];
49
50 pub struct FeedbackPickerView {
51 selected: usize,
52 /// Screen row of each option row, recorded as it is painted. Keyboard and
53 /// mouse must reach the same rows; without this the view inherits the
54 /// no-op `handle_mouse` and silently swallows every click.
55 row_hitboxes: RefCell<Vec<Rect>>,
56 }
57
58 impl FeedbackPickerView {
59 #[must_use]
60 pub fn new() -> Self {
61 Self {
62 selected: 0,
63 row_hitboxes: RefCell::new(Vec::new()),
64 }
65 }
66
67 fn move_up(&mut self) {
68 self.selected = crate::tui::list_nav::wrap_index(self.selected, OPTIONS.len(), -1);
69 }
70
71 fn move_down(&mut self) {
72 self.selected = crate::tui::list_nav::wrap_index(self.selected, OPTIONS.len(), 1);
73 }
74
75 fn select_number(&mut self, number: char) -> Option<ViewAction> {
76 let idx = OPTIONS.iter().position(|option| option.number == number)?;
77 self.selected = idx;
78 Some(self.selected_action())
79 }
80
81 fn selected_action(&self) -> ViewAction {
82 let command = OPTIONS
83 .get(self.selected)
84 .map(|option| option.command)
85 .unwrap_or(OPTIONS[0].command)
86 .to_string();
87 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
88 action: CommandPaletteAction::ExecuteCommand { command },
89 })
90 }
91 }
92
93 impl Default for FeedbackPickerView {
94 fn default() -> Self {
95 Self::new()
96 }
97 }
98
99 impl ModalView for FeedbackPickerView {
100 fn kind(&self) -> ModalKind {
101 ModalKind::FeedbackPicker
102 }
103
104 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
105 self
106 }
107
108 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
109 match key.code {
110 KeyCode::Esc => ViewAction::Close,
111 KeyCode::Enter => self.selected_action(),
112 KeyCode::Up | KeyCode::Char('k') => {
113 self.move_up();
114 ViewAction::None
115 }
116 KeyCode::Down | KeyCode::Char('j') => {
117 self.move_down();
118 ViewAction::None
119 }
120 KeyCode::Char(number)
121 if !key.modifiers.contains(KeyModifiers::CONTROL)
122 && OPTIONS.iter().any(|option| option.number == number) =>
123 {
124 self.select_number(number).unwrap_or(ViewAction::None)
125 }
126 _ => ViewAction::None,
127 }
128 }
129
130 fn render(&self, area: Rect, buf: &mut Buffer) {
131 let popup_area = centered_modal_area(area, 78, (OPTIONS.len() as u16) + 7, 44, 8);
132
133 render_modal_surface(area, popup_area, buf);
134
135 let block = Block::default()
136 .title(Line::from(Span::styled(
137 " Feedback ",
138 Style::default()
139 .fg(palette::WHALE_ACTION)
140 .add_modifier(Modifier::BOLD),
141 )))
142 .borders(Borders::ALL)
143 .border_style(Style::default().fg(palette::BORDER_COLOR))
144 .style(Style::default().bg(palette::WHALE_BG))
145 .padding(Padding::uniform(1));
146
147 let inner = block.inner(popup_area);
148 block.render(popup_area, buf);
149
150 let content = render_modal_footer(
151 inner,
152 buf,
153 &[
154 ActionHint::new("↑/↓", "move"),
155 ActionHint::new("Enter", "open"),
156 ActionHint::new("Esc", "cancel"),
157 ],
158 );
159
160 let mut lines = Vec::with_capacity(OPTIONS.len() + 2);
161 lines.push(Line::from(""));
162 // `lines` opens with a blank row, so option `idx` paints one row lower.
163 let mut hitboxes = self.row_hitboxes.borrow_mut();
164 hitboxes.clear();
165
166 for (idx, option) in OPTIONS.iter().enumerate() {
167 let row = content
168 .y
169 .saturating_add(u16::try_from(idx).unwrap_or(u16::MAX))
170 + 1;
171 if row < content.bottom() {
172 hitboxes.push(Rect::new(content.x, row, content.width, 1));
173 }
174 let is_selected = idx == self.selected;
175 let row_style = if is_selected {
176 menu_style::selected_row_style()
177 } else {
178 Style::default().fg(palette::TEXT_PRIMARY)
179 };
180 let desc_style = if is_selected {
181 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
182 } else {
183 Style::default().fg(palette::TEXT_MUTED)
184 };
185 let pointer = crate::tui::glyphs::selection_marker(is_selected);
186
187 lines.push(Line::from(vec![
188 Span::styled(format!("{pointer} {}. ", option.number), row_style),
189 Span::styled(option.label, row_style),
190 Span::raw(" "),
191 Span::styled(option.description, desc_style),
192 ]));
193 }
194
195 drop(hitboxes);
196 Paragraph::new(lines).render(content, buf);
197 }
198
199 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
200 match mouse.kind {
201 MouseEventKind::ScrollUp => {
202 self.move_up();
203 ViewAction::None
204 }
205 MouseEventKind::ScrollDown => {
206 self.move_down();
207 ViewAction::None
208 }
209 MouseEventKind::Moved => {
210 let hovered = self
211 .row_hitboxes
212 .borrow()
213 .iter()
214 .position(|rect| rect.y == mouse.row);
215 if let Some(index) = hovered {
216 self.selected = index;
217 }
218 ViewAction::None
219 }
220 MouseEventKind::Down(MouseButton::Left) => {
221 let clicked = self
222 .row_hitboxes
223 .borrow()
224 .iter()
225 .position(|rect| rect.y == mouse.row);
226 match clicked {
227 // Click to focus, click again to open — the same two-step
228 // the session picker uses, so a stray click never opens a
229 // browser tab the user did not choose.
230 Some(index) if index == self.selected => self.selected_action(),
231 Some(index) => {
232 self.selected = index;
233 ViewAction::None
234 }
235 None => ViewAction::None,
236 }
237 }
238 _ => ViewAction::None,
239 }
240 }
241 }
242
243 #[cfg(test)]
244 mod tests {
245 use super::*;
246
247 fn mouse(kind: MouseEventKind, row: u16) -> MouseEvent {
248 MouseEvent {
249 kind,
250 column: 2,
251 row,
252 modifiers: KeyModifiers::NONE,
253 }
254 }
255
256 /// Selectable rows need keyboard and mouse parity. This view overrode no
257 /// `handle_mouse`, so it inherited the no-op default and the view stack
258 /// swallowed every click on it — the only list in the picker family that
259 /// could not be used with a pointer.
260 #[test]
261 fn feedback_rows_are_clickable_and_land_on_the_row_under_the_pointer() {
262 use ratatui::{Terminal, backend::TestBackend};
263 let mut view = FeedbackPickerView::new();
264 let mut terminal = Terminal::new(TestBackend::new(100, 24)).expect("terminal");
265 terminal
266 .draw(|frame| view.render(frame.area(), frame.buffer_mut()))
267 .expect("draw");
268
269 let rows: Vec<u16> = view.row_hitboxes.borrow().iter().map(|r| r.y).collect();
270 assert_eq!(rows.len(), OPTIONS.len(), "every option needs a hitbox");
271 assert!(
272 rows.windows(2).all(|w| w[1] == w[0] + 1),
273 "option hitboxes must be consecutive rows, got {rows:?}"
274 );
275
276 // A click on the last option must select that option — an off-by-one
277 // against the leading blank row would put it on its neighbour or drop it.
278 let last = *rows.last().expect("a row");
279 assert!(matches!(
280 view.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), last)),
281 ViewAction::None
282 ));
283 assert_eq!(view.selected, OPTIONS.len() - 1);
284
285 // Clicking the already-selected row opens it; a single stray click
286 // never does, so a misplaced pointer cannot open a browser tab.
287 assert_eq!(
288 emitted_command(
289 view.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), last))
290 ),
291 OPTIONS[OPTIONS.len() - 1].command
292 );
293
294 assert!(matches!(
295 view.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0)),
296 ViewAction::None
297 ));
298 }
299
300 fn emitted_command(action: ViewAction) -> String {
301 match action {
302 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
303 action: CommandPaletteAction::ExecuteCommand { command },
304 }) => command,
305 other => panic!("expected feedback command emit, got {other:?}"),
306 }
307 }
308
309 #[test]
310 fn enter_emits_selected_feedback_command() {
311 let mut view = FeedbackPickerView::new();
312 let command =
313 emitted_command(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
314 assert_eq!(command, "/feedback bug");
315 }
316
317 #[test]
318 fn arrow_down_selects_feature_command() {
319 let mut view = FeedbackPickerView::new();
320 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
321 let command =
322 emitted_command(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
323 assert_eq!(command, "/feedback feature");
324 }
325
326 #[test]
327 fn digit_selects_security_command() {
328 let mut view = FeedbackPickerView::new();
329 let command =
330 emitted_command(view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)));
331 assert_eq!(command, "/feedback security");
332 }
333
334 #[test]
335 fn esc_closes_picker() {
336 let mut view = FeedbackPickerView::new();
337 assert!(matches!(
338 view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
339 ViewAction::Close
340 ));
341 }
342
343 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
344 /// every overlay to remain readable and fully operable at.
345 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
346
347 #[test]
348 fn feedback_is_usable_and_opaque_at_blocker_sizes() {
349 use crate::tui::views::ViewStack;
350 use ratatui::{buffer::Buffer, layout::Rect};
351 use unicode_width::UnicodeWidthStr;
352
353 for (w, h) in BLOCKER_SIZES {
354 let area = Rect::new(0, 0, w, h);
355 let mut buf = Buffer::empty(area);
356 for y in 0..h {
357 for x in 0..w {
358 buf[(x, y)].set_symbol("X");
359 }
360 }
361 let mut stack = ViewStack::new();
362 stack.push(FeedbackPickerView::new());
363 stack.render(area, &mut buf);
364
365 let rows: Vec<String> = (0..h)
366 .map(|y| {
367 (0..w)
368 .map(|x| buf[(x, y)].symbol().to_string())
369 .collect::<String>()
370 })
371 .collect();
372 let text = rows.join("\n");
373
374 for label in ["move", "open", "cancel"] {
375 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
376 }
377 assert!(
378 text.contains(crate::tui::glyphs::SELECTION),
379 "{w}x{h}: missing charter selection pointer"
380 );
381 assert!(
382 !text.contains('X'),
383 "{w}x{h}: background bleed-through into modal surface"
384 );
385 assert_eq!(
386 buf[(w / 2, h / 2)].bg,
387 palette::WHALE_BG,
388 "{w}x{h}: modal interior must be opaque"
389 );
390 for (y, row) in rows.iter().enumerate() {
391 assert!(
392 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
393 "{w}x{h}: row {y} overflows width: {row:?}"
394 );
395 }
396 }
397 }
398 }
399
399 lines RUST