返回 CodeWhale
feedback_picker.rs
根目录 / crates / tui / src / tui / feedback_picker.rs
1 //! `/feedback` picker for GitHub feedback destinations.
2
3 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4 use ratatui::{
5 buffer::Buffer,
6 layout::Rect,
7 style::{Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, Padding, Paragraph, Widget},
10 };
11
12 use crate::palette;
13 use crate::tui::menu_style;
14 use crate::tui::views::{
15 ActionHint, CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent,
16 centered_modal_area, render_modal_footer, render_modal_surface,
17 };
18
19 #[derive(Debug, Clone, Copy)]
20 struct FeedbackOption {
21 number: char,
22 label: &'static str,
23 description: &'static str,
24 command: &'static str,
25 }
26
27 const OPTIONS: &[FeedbackOption] = &[
28 FeedbackOption {
29 number: '1',
30 label: "Bug report",
31 description: "Report a problem or regression",
32 command: "/feedback bug",
33 },
34 FeedbackOption {
35 number: '2',
36 label: "Feature request",
37 description: "Suggest an idea or improvement",
38 command: "/feedback feature",
39 },
40 FeedbackOption {
41 number: '3',
42 label: "Security vulnerability",
43 description: "Review the security policy before reporting",
44 command: "/feedback security",
45 },
46 ];
47
48 pub struct FeedbackPickerView {
49 selected: usize,
50 }
51
52 impl FeedbackPickerView {
53 #[must_use]
54 pub fn new() -> Self {
55 Self { selected: 0 }
56 }
57
58 fn move_up(&mut self) {
59 self.selected = crate::tui::list_nav::wrap_index(self.selected, OPTIONS.len(), -1);
60 }
61
62 fn move_down(&mut self) {
63 self.selected = crate::tui::list_nav::wrap_index(self.selected, OPTIONS.len(), 1);
64 }
65
66 fn select_number(&mut self, number: char) -> Option<ViewAction> {
67 let idx = OPTIONS.iter().position(|option| option.number == number)?;
68 self.selected = idx;
69 Some(self.selected_action())
70 }
71
72 fn selected_action(&self) -> ViewAction {
73 let command = OPTIONS
74 .get(self.selected)
75 .map(|option| option.command)
76 .unwrap_or(OPTIONS[0].command)
77 .to_string();
78 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
79 action: CommandPaletteAction::ExecuteCommand { command },
80 })
81 }
82 }
83
84 impl Default for FeedbackPickerView {
85 fn default() -> Self {
86 Self::new()
87 }
88 }
89
90 impl ModalView for FeedbackPickerView {
91 fn kind(&self) -> ModalKind {
92 ModalKind::FeedbackPicker
93 }
94
95 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
96 self
97 }
98
99 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
100 match key.code {
101 KeyCode::Esc => ViewAction::Close,
102 KeyCode::Enter => self.selected_action(),
103 KeyCode::Up | KeyCode::Char('k') => {
104 self.move_up();
105 ViewAction::None
106 }
107 KeyCode::Down | KeyCode::Char('j') => {
108 self.move_down();
109 ViewAction::None
110 }
111 KeyCode::Char(number)
112 if !key.modifiers.contains(KeyModifiers::CONTROL)
113 && OPTIONS.iter().any(|option| option.number == number) =>
114 {
115 self.select_number(number).unwrap_or(ViewAction::None)
116 }
117 _ => ViewAction::None,
118 }
119 }
120
121 fn render(&self, area: Rect, buf: &mut Buffer) {
122 let popup_area = centered_modal_area(area, 78, (OPTIONS.len() as u16) + 7, 44, 8);
123
124 render_modal_surface(area, popup_area, buf);
125
126 let block = Block::default()
127 .title(Line::from(Span::styled(
128 " Feedback ",
129 Style::default()
130 .fg(palette::WHALE_INFO)
131 .add_modifier(Modifier::BOLD),
132 )))
133 .borders(Borders::ALL)
134 .border_style(Style::default().fg(palette::BORDER_COLOR))
135 .style(Style::default().bg(palette::WHALE_BG))
136 .padding(Padding::uniform(1));
137
138 let inner = block.inner(popup_area);
139 block.render(popup_area, buf);
140
141 let content = render_modal_footer(
142 inner,
143 buf,
144 &[
145 ActionHint::new("↑/↓", "move"),
146 ActionHint::new("Enter", "open"),
147 ActionHint::new("Esc", "cancel"),
148 ],
149 );
150
151 let mut lines = Vec::with_capacity(OPTIONS.len() + 2);
152 lines.push(Line::from(""));
153
154 for (idx, option) in OPTIONS.iter().enumerate() {
155 let is_selected = idx == self.selected;
156 let row_style = if is_selected {
157 menu_style::selected_row_style()
158 } else {
159 Style::default().fg(palette::TEXT_PRIMARY)
160 };
161 let desc_style = if is_selected {
162 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
163 } else {
164 Style::default().fg(palette::TEXT_MUTED)
165 };
166 let pointer = crate::tui::glyphs::selection_marker(is_selected);
167
168 lines.push(Line::from(vec![
169 Span::styled(format!("{pointer} {}. ", option.number), row_style),
170 Span::styled(option.label, row_style),
171 Span::raw(" "),
172 Span::styled(option.description, desc_style),
173 ]));
174 }
175
176 Paragraph::new(lines).render(content, buf);
177 }
178 }
179
180 #[cfg(test)]
181 mod tests {
182 use super::*;
183
184 fn emitted_command(action: ViewAction) -> String {
185 match action {
186 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
187 action: CommandPaletteAction::ExecuteCommand { command },
188 }) => command,
189 other => panic!("expected feedback command emit, got {other:?}"),
190 }
191 }
192
193 #[test]
194 fn enter_emits_selected_feedback_command() {
195 let mut view = FeedbackPickerView::new();
196 let command =
197 emitted_command(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
198 assert_eq!(command, "/feedback bug");
199 }
200
201 #[test]
202 fn arrow_down_selects_feature_command() {
203 let mut view = FeedbackPickerView::new();
204 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
205 let command =
206 emitted_command(view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
207 assert_eq!(command, "/feedback feature");
208 }
209
210 #[test]
211 fn digit_selects_security_command() {
212 let mut view = FeedbackPickerView::new();
213 let command =
214 emitted_command(view.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)));
215 assert_eq!(command, "/feedback security");
216 }
217
218 #[test]
219 fn esc_closes_picker() {
220 let mut view = FeedbackPickerView::new();
221 assert!(matches!(
222 view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
223 ViewAction::Close
224 ));
225 }
226
227 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires
228 /// every overlay to remain readable and fully operable at.
229 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
230
231 #[test]
232 fn feedback_is_usable_and_opaque_at_blocker_sizes() {
233 use crate::tui::views::ViewStack;
234 use ratatui::{buffer::Buffer, layout::Rect};
235 use unicode_width::UnicodeWidthStr;
236
237 for (w, h) in BLOCKER_SIZES {
238 let area = Rect::new(0, 0, w, h);
239 let mut buf = Buffer::empty(area);
240 for y in 0..h {
241 for x in 0..w {
242 buf[(x, y)].set_symbol("X");
243 }
244 }
245 let mut stack = ViewStack::new();
246 stack.push(FeedbackPickerView::new());
247 stack.render(area, &mut buf);
248
249 let rows: Vec<String> = (0..h)
250 .map(|y| {
251 (0..w)
252 .map(|x| buf[(x, y)].symbol().to_string())
253 .collect::<String>()
254 })
255 .collect();
256 let text = rows.join("\n");
257
258 for label in ["move", "open", "cancel"] {
259 assert!(text.contains(label), "{w}x{h}: missing footer '{label}'");
260 }
261 assert!(
262 text.contains(crate::tui::glyphs::SELECTION),
263 "{w}x{h}: missing charter selection pointer"
264 );
265 assert!(
266 !text.contains('X'),
267 "{w}x{h}: background bleed-through into modal surface"
268 );
269 assert_eq!(
270 buf[(w / 2, h / 2)].bg,
271 palette::WHALE_BG,
272 "{w}x{h}: modal interior must be opaque"
273 );
274 for (y, row) in rows.iter().enumerate() {
275 assert!(
276 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
277 "{w}x{h}: row {y} overflows width: {row:?}"
278 );
279 }
280 }
281 }
282 }
283
283 lines RUST