返回 DeepSeek-TUI-2026
status_picker.rs
根目录 / crates / tui / src / tui / views / status_picker.rs
1 //! `/statusline` multi-select picker.
2 //!
3 //! Mirrors codex-rs's `bottom_pane::status_line_setup` ergonomically: a
4 //! checklist of footer items the user can toggle on/off with Space (or
5 //! Enter), reordered by ↑/↓, applied immediately so the live footer
6 //! reflects every change. Enter saves to `~/.deepseek/config.toml` under
7 //! `tui.status_items`; Esc reverts to the snapshot taken on open.
8 //!
9 //! The picker enumerates [`StatusItem::all`] so adding a new variant in
10 //! `crates/tui/src/config.rs` automatically surfaces a new row here.
11
12 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13 use ratatui::{
14 buffer::Buffer,
15 layout::Rect,
16 style::{Modifier, Style},
17 text::{Line, Span},
18 widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
19 };
20
21 use crate::config::StatusItem;
22 use crate::palette;
23 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
24
25 /// Picker state. We hold both the user's working selection AND the original
26 /// snapshot so Esc can perfectly revert the live preview.
27 pub struct StatusPickerView {
28 /// Every available item, in the order shown to the user. We keep this
29 /// list ordered so toggles produce a stable on-screen layout that
30 /// doesn't shuffle as items flip.
31 rows: Vec<StatusItem>,
32 /// Indices in `rows` currently checked on (the user's working set).
33 selected: Vec<bool>,
34 /// Highlighted row.
35 cursor: usize,
36 /// Snapshot of `app.status_items` at open time so Esc reverts cleanly.
37 original: Vec<StatusItem>,
38 }
39
40 impl StatusPickerView {
41 #[must_use]
42 pub fn new(active: &[StatusItem]) -> Self {
43 let rows: Vec<StatusItem> = StatusItem::all().to_vec();
44 let selected: Vec<bool> = rows.iter().map(|item| active.contains(item)).collect();
45 Self {
46 rows,
47 selected,
48 cursor: 0,
49 original: active.to_vec(),
50 }
51 }
52
53 /// Build the current selection in the same order the user sees it.
54 /// Preserves `StatusItem::all()` order so toggling produces deterministic
55 /// `tui.status_items` output (no churn-induced diffs in config.toml).
56 fn current_selection(&self) -> Vec<StatusItem> {
57 self.rows
58 .iter()
59 .zip(self.selected.iter())
60 .filter_map(|(item, on)| if *on { Some(*item) } else { None })
61 .collect()
62 }
63
64 fn move_up(&mut self) {
65 if self.cursor > 0 {
66 self.cursor -= 1;
67 }
68 }
69
70 fn move_down(&mut self) {
71 let max = self.rows.len().saturating_sub(1);
72 if self.cursor < max {
73 self.cursor += 1;
74 }
75 }
76
77 fn toggle_current(&mut self) {
78 if let Some(slot) = self.selected.get_mut(self.cursor) {
79 *slot = !*slot;
80 }
81 }
82
83 fn live_preview_event(&self) -> ViewEvent {
84 ViewEvent::StatusItemsUpdated {
85 items: self.current_selection(),
86 final_save: false,
87 }
88 }
89
90 fn final_event(&self) -> ViewEvent {
91 ViewEvent::StatusItemsUpdated {
92 items: self.current_selection(),
93 final_save: true,
94 }
95 }
96
97 fn revert_event(&self) -> ViewEvent {
98 ViewEvent::StatusItemsUpdated {
99 items: self.original.clone(),
100 final_save: false,
101 }
102 }
103 }
104
105 impl ModalView for StatusPickerView {
106 fn kind(&self) -> ModalKind {
107 ModalKind::StatusPicker
108 }
109
110 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
111 self
112 }
113
114 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
115 match key.code {
116 KeyCode::Esc => {
117 // Roll the live preview back to the snapshot so Esc means
118 // "take me back to where I was."
119 ViewAction::EmitAndClose(self.revert_event())
120 }
121 KeyCode::Enter => ViewAction::EmitAndClose(self.final_event()),
122 KeyCode::Up | KeyCode::Char('k') => {
123 self.move_up();
124 ViewAction::None
125 }
126 KeyCode::Down | KeyCode::Char('j') => {
127 self.move_down();
128 ViewAction::None
129 }
130 KeyCode::Char(' ') | KeyCode::Char('x') | KeyCode::Char('X') => {
131 self.toggle_current();
132 ViewAction::Emit(self.live_preview_event())
133 }
134 KeyCode::Char('a') | KeyCode::Char('A')
135 if !key.modifiers.contains(KeyModifiers::CONTROL) =>
136 {
137 // Quality-of-life: 'a' selects all so the user can quickly
138 // see every chip available before paring back.
139 for slot in &mut self.selected {
140 *slot = true;
141 }
142 ViewAction::Emit(self.live_preview_event())
143 }
144 KeyCode::Char('n') | KeyCode::Char('N') => {
145 // 'n' clears all so the user can build up from scratch.
146 for slot in &mut self.selected {
147 *slot = false;
148 }
149 ViewAction::Emit(self.live_preview_event())
150 }
151 _ => ViewAction::None,
152 }
153 }
154
155 fn render(&self, area: Rect, buf: &mut Buffer) {
156 let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
157 // Two header lines + one row per StatusItem + one footer hint line.
158 let needed_height = (self.rows.len() as u16).saturating_add(4);
159 let popup_height = needed_height.min(area.height.saturating_sub(4)).max(8);
160
161 let popup_area = Rect {
162 x: area.x + (area.width.saturating_sub(popup_width)) / 2,
163 y: area.y + (area.height.saturating_sub(popup_height)) / 2,
164 width: popup_width,
165 height: popup_height,
166 };
167
168 Clear.render(popup_area, buf);
169
170 let block = Block::default()
171 .title(Line::from(Span::styled(
172 " Status line ",
173 Style::default()
174 .fg(palette::DEEPSEEK_SKY)
175 .add_modifier(Modifier::BOLD),
176 )))
177 .title_bottom(Line::from(vec![
178 Span::styled(" Space ", Style::default().fg(palette::TEXT_MUTED)),
179 Span::raw("toggle "),
180 Span::styled(" a ", Style::default().fg(palette::TEXT_MUTED)),
181 Span::raw("all "),
182 Span::styled(" n ", Style::default().fg(palette::TEXT_MUTED)),
183 Span::raw("none "),
184 Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
185 Span::raw("save "),
186 Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
187 Span::raw("cancel "),
188 ]))
189 .borders(Borders::ALL)
190 .border_style(Style::default().fg(palette::BORDER_COLOR))
191 .style(Style::default().bg(palette::DEEPSEEK_INK))
192 .padding(Padding::uniform(1));
193
194 let inner = block.inner(popup_area);
195 block.render(popup_area, buf);
196
197 let mut lines: Vec<Line> = Vec::with_capacity(self.rows.len() + 2);
198 lines.push(Line::from(Span::styled(
199 "Pick the chips you want in the footer:",
200 Style::default().fg(palette::TEXT_MUTED),
201 )));
202 lines.push(Line::from(""));
203
204 for (idx, item) in self.rows.iter().enumerate() {
205 let checked = *self.selected.get(idx).unwrap_or(&false);
206 let is_cursor = idx == self.cursor;
207 let mark = if checked { "[x]" } else { "[ ]" };
208
209 let row_style = if is_cursor {
210 Style::default()
211 .fg(palette::SELECTION_TEXT)
212 .bg(palette::SELECTION_BG)
213 .add_modifier(Modifier::BOLD)
214 } else if checked {
215 Style::default().fg(palette::TEXT_PRIMARY)
216 } else {
217 Style::default().fg(palette::TEXT_MUTED)
218 };
219 let hint_style = if is_cursor {
220 Style::default()
221 .fg(palette::SELECTION_TEXT)
222 .bg(palette::SELECTION_BG)
223 } else {
224 Style::default().fg(palette::TEXT_DIM)
225 };
226 let pointer = if is_cursor { "▸" } else { " " };
227
228 lines.push(Line::from(vec![
229 Span::styled(format!(" {pointer} "), row_style),
230 Span::styled(mark.to_string(), row_style),
231 Span::raw(" "),
232 Span::styled(item.label().to_string(), row_style),
233 Span::raw(" "),
234 Span::styled(format!("({})", item.hint()), hint_style),
235 ]));
236 }
237
238 Paragraph::new(lines).render(inner, buf);
239 }
240 }
241
242 #[cfg(test)]
243 mod tests {
244 use super::*;
245
246 #[test]
247 fn opens_with_active_items_pre_selected() {
248 let active = StatusItem::default_footer();
249 let view = StatusPickerView::new(&active);
250 assert_eq!(view.current_selection(), active);
251 }
252
253 #[test]
254 fn space_toggles_current_row_and_emits_live_preview() {
255 let active = StatusItem::default_footer();
256 let mut view = StatusPickerView::new(&active);
257 // Cursor starts at row 0 = StatusItem::Mode (currently checked).
258 let action = view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
259 match action {
260 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, final_save }) => {
261 assert!(!final_save);
262 assert!(!items.contains(&StatusItem::Mode));
263 }
264 other => panic!("expected live preview emit, got {other:?}"),
265 }
266 }
267
268 #[test]
269 fn enter_emits_final_save() {
270 let active = StatusItem::default_footer();
271 let mut view = StatusPickerView::new(&active);
272 let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
273 match action {
274 ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { final_save, .. }) => {
275 assert!(final_save);
276 }
277 other => panic!("expected final save EmitAndClose, got {other:?}"),
278 }
279 }
280
281 #[test]
282 fn esc_reverts_to_snapshot() {
283 let active = StatusItem::default_footer();
284 let mut view = StatusPickerView::new(&active);
285 // Toggle a few items off so the working set diverges from snapshot.
286 view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
287 view.move_down();
288 view.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
289 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
290 match action {
291 ViewAction::EmitAndClose(ViewEvent::StatusItemsUpdated { items, final_save }) => {
292 assert!(!final_save);
293 assert_eq!(items, active);
294 }
295 other => panic!("expected revert EmitAndClose, got {other:?}"),
296 }
297 }
298
299 #[test]
300 fn select_all_and_select_none_keys_work() {
301 let active: Vec<StatusItem> = Vec::new();
302 let mut view = StatusPickerView::new(&active);
303 let action = view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
304 match action {
305 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => {
306 assert_eq!(items.len(), StatusItem::all().len());
307 }
308 other => panic!("expected select-all emit, got {other:?}"),
309 }
310 let action = view.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
311 match action {
312 ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) => {
313 assert!(items.is_empty());
314 }
315 other => panic!("expected select-none emit, got {other:?}"),
316 }
317 }
318
319 #[test]
320 fn arrow_keys_move_cursor_within_bounds() {
321 let active = StatusItem::default_footer();
322 let mut view = StatusPickerView::new(&active);
323 assert_eq!(view.cursor, 0);
324 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
325 assert_eq!(view.cursor, 1);
326 view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
327 assert_eq!(view.cursor, 0);
328 // Move past the bottom shouldn't wrap.
329 for _ in 0..StatusItem::all().len() + 5 {
330 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
331 }
332 assert_eq!(view.cursor, StatusItem::all().len() - 1);
333 }
334 }
335
335 lines RUST