Skip to main content

kimun_notes/components/dialogs/
sort_dialog.rs

1use ratatui::Frame;
2use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
3use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::widgets::Paragraph;
6
7use crate::components::event_state::EventState;
8use crate::components::events::{AppEvent, AppTx, InputEvent, SortTarget};
9use crate::components::file_list::{SortField, SortOrder};
10use crate::components::hint_row::HintRow;
11use crate::components::key_picker::{KeyPicker, PickerOutcome};
12use crate::components::panel::{ModalSpec, modal_chrome};
13use crate::components::sortable::{SortState, is_blank_property, property_key};
14use crate::settings::themes::Theme;
15
16/// The selectable rows, in display order.
17#[derive(Clone, Copy, PartialEq)]
18enum Row {
19    Field,
20    Order,
21    Key,
22    GroupDirs,
23}
24
25/// Modal that edits one list's [`SortState`]: field / order, plus a "group
26/// directories" toggle for lists that have one. Changes apply live: each
27/// toggle emits `AppEvent::SortChanged` (`persist = false`). `s` (sidebar
28/// only) emits the same event with `persist = true` (save as default);
29/// Enter/Esc emit `CloseOverlay`.
30pub struct SortDialog {
31    target: SortTarget,
32    pub(crate) field: SortField,
33    pub(crate) order: SortOrder,
34    /// `Some` drives the "Group directories" row.
35    group_dirs: Option<bool>,
36    /// The Field cycle reaches Property (query-backed lists only).
37    allows_property: bool,
38    /// The list is in its natural order: the Field row says "Unsorted"
39    /// until the first Field or Order toggle picks Name (with that order).
40    unsorted: bool,
41    rows: Vec<Row>,
42    selected: usize,
43    picker: KeyPicker,
44    /// Screen rect of each row from the last render, for clicks.
45    row_rects: Vec<Rect>,
46    /// Footer chips while the Key row is selected.
47    key_hints: HintRow,
48    /// Footer chips for every other row.
49    row_hints: HintRow,
50}
51
52impl SortDialog {
53    /// A dialog for `target`, opened on its current `state`.
54    /// `allows_property` comes from the list's `SortableList::allows_property`.
55    pub fn new(target: SortTarget, state: SortState, allows_property: bool) -> Self {
56        let SortState {
57            field,
58            order,
59            group_dirs,
60        } = state;
61        let key = property_key(&field).unwrap_or_default().to_string();
62        let mut d = Self {
63            target,
64            field,
65            order,
66            group_dirs,
67            allows_property,
68            unsorted: false,
69            rows: Vec::new(),
70            selected: 0,
71            picker: KeyPicker::new(&key),
72            row_rects: Vec::new(),
73            key_hints: HintRow::new(&[
74                (KeyCode::Enter, "Enter", "Apply"),
75                (KeyCode::Esc, "Esc", "Close"),
76            ])
77            .with_prefix("type a key · "),
78            row_hints: if target == SortTarget::Sidebar {
79                HintRow::new(&[
80                    (KeyCode::Null, "Space", "Toggle"),
81                    (KeyCode::Char('s'), "s", "Save default"),
82                    (KeyCode::Esc, "Esc", "Close"),
83                ])
84            } else {
85                HintRow::new(&[
86                    (KeyCode::Null, "↑↓", "Move"),
87                    (KeyCode::Null, "Space", "Toggle"),
88                    (KeyCode::Esc, "Enter/Esc", "Close"),
89                ])
90            },
91        };
92        d.rebuild_rows();
93        d
94    }
95
96    /// Rows depend on the field: the Key row only exists for a property sort.
97    fn rebuild_rows(&mut self) {
98        let mut rows = vec![Row::Field, Row::Order];
99        if matches!(self.field, SortField::Property(_)) {
100            rows.push(Row::Key);
101        }
102        if self.group_dirs.is_some() {
103            rows.push(Row::GroupDirs);
104        }
105        self.rows = rows;
106        self.selected = self.selected.min(self.rows.len() - 1);
107    }
108
109    /// Open on a list in its natural order (see `unsorted`).
110    pub fn unsorted(mut self, unsorted: bool) -> Self {
111        self.unsorted = unsorted;
112        self
113    }
114
115    pub(crate) fn set_keys(&mut self, keys: Vec<String>) {
116        self.picker.set_keys(keys);
117    }
118
119    #[cfg(test)]
120    pub(crate) fn key_value(&self) -> &str {
121        self.picker.value()
122    }
123
124    #[cfg(test)]
125    pub(crate) fn row_origin(&self, i: usize) -> Option<(u16, u16)> {
126        self.row_rects.get(i).map(|r| (r.x, r.y))
127    }
128
129    #[cfg(test)]
130    pub(crate) fn row_count(&self) -> usize {
131        self.rows.len()
132    }
133
134    /// Emit the current selection. `persist` requests saving it as the default
135    /// (sidebar's `s` key); a plain toggle sends `persist = false` for live apply.
136    fn emit(&self, tx: &AppTx, persist: bool) {
137        // A property sort with no key yet is not a usable order.
138        if is_blank_property(&self.field) {
139            return;
140        }
141        tx.send(AppEvent::SortChanged {
142            target: self.target,
143            state: SortState {
144                field: self.field.clone(),
145                order: self.order,
146                group_dirs: self.group_dirs,
147            },
148            persist,
149        })
150        .ok();
151    }
152
153    /// A property sort takes the key typed in the Key row, even one never
154    /// submitted with Enter (the user moved to another row instead).
155    fn commit_typed_key(&mut self) {
156        let typed = self.picker.value().trim();
157        if matches!(self.field, SortField::Property(_)) && !typed.is_empty() {
158            self.field = SortField::Property(typed.to_string());
159        }
160    }
161
162    fn toggle_selected(&mut self, tx: &AppTx) {
163        match self.rows[self.selected] {
164            // Leaving "Unsorted" picks Name (below), so Name ascending is
165            // one toggle away.
166            Row::Field if self.unsorted => {}
167            Row::Field => {
168                self.field = self.field.cycle(self.allows_property);
169                self.rebuild_rows();
170            }
171            Row::Order => self.order = self.order.toggle(),
172            Row::Key => {}
173            Row::GroupDirs => self.group_dirs = self.group_dirs.map(|g| !g),
174        }
175        // Any toggle leaves "Unsorted" on Name (an Order toggle keeps its
176        // new direction).
177        if self.unsorted {
178            self.unsorted = false;
179            self.field = SortField::Name;
180            self.rebuild_rows();
181        }
182        self.commit_typed_key();
183        self.emit(tx, false);
184    }
185
186    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
187        if self.rows[self.selected] == Row::Key {
188            let list_open = self.picker.is_list_open();
189            let passthrough =
190                !list_open && matches!(key.code, KeyCode::Up | KeyCode::Down | KeyCode::Esc);
191            if !passthrough {
192                match self.picker.handle_key(&key) {
193                    PickerOutcome::Accepted(k) => {
194                        self.field = SortField::Property(k);
195                        self.emit(tx, false);
196                    }
197                    PickerOutcome::Submit => {
198                        let typed = self.picker.value().trim().to_string();
199                        if !typed.is_empty() && self.field != SortField::Property(typed.clone()) {
200                            self.field = SortField::Property(typed);
201                            self.emit(tx, false);
202                        } else {
203                            tx.send(AppEvent::CloseOverlay).ok();
204                        }
205                    }
206                    PickerOutcome::Cancel => {
207                        tx.send(AppEvent::CloseOverlay).ok();
208                    }
209                    _ => {}
210                }
211                return EventState::Consumed;
212            }
213        }
214        match key.code {
215            KeyCode::Up => {
216                self.selected = self.selected.saturating_sub(1);
217            }
218            KeyCode::Down => {
219                self.selected = (self.selected + 1).min(self.rows.len() - 1);
220            }
221            KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right => {
222                self.toggle_selected(tx);
223            }
224            KeyCode::Char('s') if self.target == SortTarget::Sidebar => {
225                self.emit(tx, true);
226            }
227            KeyCode::Enter | KeyCode::Esc => {
228                tx.send(AppEvent::CloseOverlay).ok();
229            }
230            _ => {}
231        }
232        EventState::Consumed
233    }
234
235    pub fn handle_mouse(&mut self, ev: &MouseEvent, tx: &AppTx) -> EventState {
236        if !matches!(ev.kind, MouseEventKind::Down(MouseButton::Left)) {
237            return EventState::Consumed;
238        }
239
240        let key_row = self.rows.iter().position(|r| *r == Row::Key);
241        if key_row.is_some() {
242            match self.picker.handle_click(ev.column, ev.row) {
243                PickerOutcome::Accepted(k) => {
244                    self.field = SortField::Property(k);
245                    self.emit(tx, false);
246                    return EventState::Consumed;
247                }
248                PickerOutcome::Consumed => {
249                    if let Some(i) = key_row {
250                        self.selected = i;
251                    }
252                    return EventState::Consumed;
253                }
254                _ => {}
255            }
256        }
257        // After the picker: its suggestion list draws over the footer.
258        // Only the drawn footer has rects; the other is stale or empty.
259        let hints = if self.rows[self.selected] == Row::Key {
260            &self.key_hints
261        } else {
262            &self.row_hints
263        };
264        if let Some(key) = hints.hit(ev) {
265            return self.handle_key(key, tx);
266        }
267        let pos = Position {
268            x: ev.column,
269            y: ev.row,
270        };
271        if let Some(i) = self.row_rects.iter().position(|r| r.contains(pos)) {
272            self.selected = i;
273            if self.rows[i] != Row::Key {
274                self.toggle_selected(tx);
275            }
276        }
277        EventState::Consumed
278    }
279
280    fn row_label(&self, row: Row) -> (String, String) {
281        match row {
282            Row::Field => (
283                "Sort by".to_string(),
284                match &self.field {
285                    _ if self.unsorted => "Unsorted".to_string(),
286                    SortField::Name => "Name".to_string(),
287                    SortField::Title => "Title".to_string(),
288                    SortField::Property(_) => "Property".to_string(),
289                },
290            ),
291            Row::Key => ("Key".to_string(), String::new()),
292            Row::Order => (
293                "Order".to_string(),
294                match self.order {
295                    SortOrder::Ascending => "Ascending \u{2191}".to_string(),
296                    SortOrder::Descending => "Descending \u{2193}".to_string(),
297                },
298            ),
299            Row::GroupDirs => (
300                "Group directories".to_string(),
301                if self.group_dirs == Some(true) {
302                    "On"
303                } else {
304                    "Off"
305                }
306                .to_string(),
307            ),
308        }
309    }
310}
311
312/// Wide enough for the sidebar footer's chips (they used to clip).
313const OUTER_WIDTH: u16 = 50;
314
315impl crate::components::Component for SortDialog {
316    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
317        match event {
318            InputEvent::Key(key) => self.handle_key(*key, tx),
319            InputEvent::Mouse(m) => self.handle_mouse(m, tx),
320            _ => EventState::NotConsumed,
321        }
322    }
323
324    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
325        // rows + borders(2) + footer(1).
326        let list_open = self.rows[self.selected] == Row::Key && self.picker.is_list_open();
327        let outer_height = self.rows.len() as u16 + 3 + if list_open { 5 } else { 0 };
328        let popup = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
329        let inner = modal_chrome(
330            f,
331            popup,
332            theme,
333            ModalSpec {
334                title: Some(" Sort "),
335                border: Some(Style::default().fg(theme.fg.to_ratatui())),
336                ..Default::default()
337            },
338        );
339        if inner.height < 2 {
340            return;
341        }
342
343        // Split body (rows) from a fixed 1-line footer. `Min(1)` collapses the
344        // body before the footer disappears, so the footer is never overlapped
345        // on a short terminal (mirrors help_dialog).
346        let chunks = Layout::default()
347            .direction(Direction::Vertical)
348            .constraints([Constraint::Min(1), Constraint::Length(1)])
349            .split(inner);
350        let body = chunks[0];
351        let footer_area = chunks[1];
352
353        let bg = theme.bg_panel.to_ratatui();
354        let fg = theme.fg.to_ratatui();
355        let gray = theme.gray.to_ratatui();
356        let fg_sel = theme.selection_fg.to_ratatui();
357        let bg_sel = theme.selection_bg.to_ratatui();
358
359        self.row_rects.clear();
360        let mut key_field = None;
361        for (i, &row) in self.rows.iter().enumerate() {
362            let y = body.y + i as u16;
363            if y >= body.y + body.height {
364                break;
365            }
366            let (label, value) = self.row_label(row);
367            let selected = i == self.selected;
368            let style = if selected {
369                Style::default()
370                    .fg(fg_sel)
371                    .bg(bg_sel)
372                    .add_modifier(Modifier::BOLD)
373            } else {
374                Style::default().fg(fg).bg(bg)
375            };
376            let marker = if selected { ">" } else { " " };
377            let row_rect = Rect {
378                x: body.x,
379                y,
380                width: body.width,
381                height: 1,
382            };
383            f.render_widget(
384                Paragraph::new(format!(" {marker} {label:<20}{value}")).style(style),
385                row_rect,
386            );
387            self.row_rects.push(row_rect);
388            if row == Row::Key {
389                let off = 3 + 20;
390                key_field = Some((
391                    Rect {
392                        x: row_rect.x + off.min(row_rect.width),
393                        y,
394                        width: row_rect.width.saturating_sub(off),
395                        height: 1,
396                    },
397                    selected,
398                ));
399            }
400        }
401
402        let key_selected = self.rows[self.selected] == Row::Key;
403        let hints = if key_selected {
404            &mut self.key_hints
405        } else {
406            &mut self.row_hints
407        };
408        hints.render(f, footer_area, Style::default().fg(gray).bg(bg), theme);
409
410        // Last, so the suggestion list draws over the rows and footer below.
411        if let Some((field, focused)) = key_field {
412            self.picker.render(f, field, inner, theme, focused);
413        }
414    }
415}
416
417impl_dialog!(SortDialog);
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::components::events::SortTarget;
423    use crate::components::file_list::{SortField, SortOrder};
424    use crate::components::sortable::SortState;
425    use ratatui::crossterm::event::{KeyCode, KeyEvent};
426    use tokio::sync::mpsc::unbounded_channel;
427
428    fn key(code: KeyCode) -> KeyEvent {
429        KeyEvent::from(code)
430    }
431
432    fn state(field: SortField, group_dirs: Option<bool>) -> SortState {
433        SortState {
434            field,
435            order: SortOrder::Ascending,
436            group_dirs,
437        }
438    }
439
440    fn sidebar_dialog() -> SortDialog {
441        SortDialog::new(
442            SortTarget::Sidebar,
443            state(SortField::Name, Some(false)),
444            false,
445        )
446    }
447
448    #[test]
449    fn empty_property_field_emits_nothing() {
450        let mut d = SortDialog::new(SortTarget::Query, state(SortField::Name, None), true);
451        let (tx, mut rx) = unbounded_channel();
452        d.handle_key(key(KeyCode::Char(' ')), &tx); // Name -> Title
453        assert!(rx.try_recv().is_ok());
454        d.handle_key(key(KeyCode::Char(' ')), &tx); // Title -> Property("")
455        assert_eq!(d.field, SortField::Property(String::new()));
456        assert!(rx.try_recv().is_err(), "no event for an empty key");
457    }
458
459    #[test]
460    fn space_toggles_field_and_emits_change() {
461        let mut d = sidebar_dialog();
462        let (tx, mut rx) = unbounded_channel();
463        d.handle_key(key(KeyCode::Char(' ')), &tx);
464        assert_eq!(d.field, SortField::Title);
465        let evt = rx.try_recv().expect("a SortChanged event");
466        match evt {
467            AppEvent::SortChanged {
468                target,
469                state,
470                persist,
471            } => {
472                assert_eq!(target, SortTarget::Sidebar);
473                assert_eq!(state.field, SortField::Title);
474                assert_eq!(state.order, SortOrder::Ascending);
475                assert_eq!(state.group_dirs, Some(false));
476                assert!(!persist, "a plain toggle is not a save");
477            }
478            other => panic!("expected SortChanged, got {other:?}"),
479        }
480    }
481
482    #[test]
483    fn down_then_space_toggles_order() {
484        let mut d = sidebar_dialog();
485        let (tx, mut rx) = unbounded_channel();
486        d.handle_key(key(KeyCode::Down), &tx);
487        assert!(rx.try_recv().is_err(), "navigation alone emits nothing");
488        d.handle_key(key(KeyCode::Char(' ')), &tx);
489        assert_eq!(d.order, SortOrder::Descending);
490        assert!(matches!(rx.try_recv(), Ok(AppEvent::SortChanged { .. })));
491    }
492
493    #[test]
494    fn group_row_present_only_with_group_dirs() {
495        let sidebar = sidebar_dialog();
496        assert_eq!(sidebar.row_count(), 3);
497        let query = SortDialog::new(SortTarget::Query, state(SortField::Name, None), true);
498        assert_eq!(query.row_count(), 2);
499    }
500
501    #[test]
502    fn s_saves_default_for_sidebar_only() {
503        let mut d = sidebar_dialog();
504        let (tx, mut rx) = unbounded_channel();
505        d.handle_key(key(KeyCode::Char('s')), &tx);
506        assert!(
507            matches!(
508                rx.try_recv(),
509                Ok(AppEvent::SortChanged { persist: true, .. })
510            ),
511            "s on the sidebar emits a persisting SortChanged"
512        );
513
514        let mut q = SortDialog::new(SortTarget::Query, state(SortField::Name, None), true);
515        let (tx2, mut rx2) = unbounded_channel();
516        q.handle_key(key(KeyCode::Char('s')), &tx2);
517        assert!(rx2.try_recv().is_err(), "query target has no save-default");
518    }
519
520    #[test]
521    fn enter_and_esc_close_overlay() {
522        for code in [KeyCode::Enter, KeyCode::Esc] {
523            let mut d = sidebar_dialog();
524            let (tx, mut rx) = unbounded_channel();
525            d.handle_key(key(code), &tx);
526            assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
527        }
528    }
529
530    use crate::components::events::InputEvent;
531    use crate::settings::themes::Theme;
532    use ratatui::{Terminal, backend::TestBackend};
533
534    fn query_dialog(field: SortField) -> SortDialog {
535        SortDialog::new(SortTarget::Query, state(field, None), true)
536    }
537
538    fn draw(d: &mut SortDialog) {
539        use crate::components::Component;
540        let theme = Theme::gruvbox_dark();
541        let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
542        t.draw(|f| d.render(f, f.area(), &theme, true)).unwrap();
543    }
544
545    fn mouse(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
546        match crate::test_support::mouse_down_at(col, row) {
547            InputEvent::Mouse(m) => m,
548            _ => unreachable!(),
549        }
550    }
551
552    #[test]
553    fn query_cycle_reaches_property_and_waits_for_a_key() {
554        let mut d = query_dialog(SortField::Title);
555        let (tx, mut rx) = unbounded_channel();
556        d.handle_key(key(KeyCode::Char(' ')), &tx);
557        assert_eq!(d.field, SortField::Property(String::new()));
558        assert!(rx.try_recv().is_err(), "no emit before a key is chosen");
559        assert_eq!(d.row_count(), 3, "Key row appears");
560    }
561
562    #[test]
563    fn accepting_a_key_emits_property_sort() {
564        let mut d = query_dialog(SortField::Property(String::new()));
565        d.set_keys(vec!["due".into(), "status".into()]);
566        let (tx, mut rx) = unbounded_channel();
567        d.handle_key(key(KeyCode::Down), &tx); // Order
568        d.handle_key(key(KeyCode::Down), &tx); // Key
569        for c in "du".chars() {
570            d.handle_key(key(KeyCode::Char(c)), &tx);
571        }
572        d.handle_key(key(KeyCode::Enter), &tx); // accept "due" from the list
573        match rx.try_recv() {
574            Ok(AppEvent::SortChanged { state, .. }) => {
575                assert_eq!(state.field, SortField::Property("due".into()))
576            }
577            other => panic!("expected SortChanged, got {other:?}"),
578        }
579    }
580
581    /// A key typed but not submitted with Enter still applies once the user
582    /// moves to another row and toggles it.
583    #[test]
584    fn typed_key_applies_when_toggling_another_row() {
585        let mut d = query_dialog(SortField::Property(String::new()));
586        let (tx, mut rx) = unbounded_channel();
587        d.handle_key(key(KeyCode::Down), &tx); // Order
588        d.handle_key(key(KeyCode::Down), &tx); // Key
589        for c in "due".chars() {
590            d.handle_key(key(KeyCode::Char(c)), &tx);
591        }
592        d.handle_key(key(KeyCode::Up), &tx); // back to Order, list closed
593        d.handle_key(key(KeyCode::Char(' ')), &tx);
594        match rx.try_recv() {
595            Ok(AppEvent::SortChanged { state, .. }) => {
596                assert_eq!(state.field, SortField::Property("due".into()));
597                assert_eq!(state.order, SortOrder::Descending);
598            }
599            other => panic!("expected SortChanged, got {other:?}"),
600        }
601    }
602
603    #[test]
604    fn opening_with_a_property_sort_preselects_it() {
605        let d = query_dialog(SortField::Property("due".into()));
606        assert_eq!(d.row_count(), 3);
607        assert_eq!(d.key_value(), "due");
608    }
609
610    #[test]
611    fn sidebar_never_cycles_to_property() {
612        let mut d = sidebar_dialog();
613        let (tx, _rx) = unbounded_channel();
614        d.handle_key(key(KeyCode::Char(' ')), &tx); // Name -> Title
615        d.handle_key(key(KeyCode::Char(' ')), &tx); // Title -> Name
616        assert_eq!(d.field, SortField::Name);
617    }
618
619    #[test]
620    fn click_on_a_row_selects_and_toggles_it() {
621        let mut d = sidebar_dialog();
622        draw(&mut d);
623        let (tx, mut rx) = unbounded_channel();
624        let (x, y) = d.row_origin(1).expect("Order row rendered");
625        d.handle_mouse(&mouse(x + 2, y), &tx);
626        assert_eq!(d.order, SortOrder::Descending);
627        assert!(matches!(rx.try_recv(), Ok(AppEvent::SortChanged { .. })));
628    }
629
630    #[test]
631    fn click_outside_sort_modal_does_nothing() {
632        let mut d = sidebar_dialog();
633        draw(&mut d);
634        let (tx, mut rx) = unbounded_channel();
635        d.handle_mouse(&mouse(0, 0), &tx);
636        assert!(rx.try_recv().is_err());
637        assert_eq!(d.field, SortField::Name);
638    }
639
640    #[test]
641    fn enter_on_key_row_with_list_closed_closes_overlay() {
642        let mut d = query_dialog(SortField::Property("due".into()));
643        let (tx, mut rx) = unbounded_channel();
644        d.handle_key(key(KeyCode::Down), &tx);
645        d.handle_key(key(KeyCode::Down), &tx);
646        d.handle_key(key(KeyCode::Enter), &tx);
647        assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
648    }
649
650    /// The browser target: property sorts allowed, no group row, no `s`.
651    #[test]
652    fn browser_dialog_cycles_to_property_without_group_row() {
653        let mut d = SortDialog::new(SortTarget::Browser, state(SortField::Title, None), true);
654        assert_eq!(d.row_count(), 2, "no group row without group_dirs");
655        let (tx, mut rx) = unbounded_channel();
656        d.handle_key(key(KeyCode::Char(' ')), &tx);
657        assert_eq!(d.field, SortField::Property(String::new()));
658        d.handle_key(key(KeyCode::Char('s')), &tx);
659        assert!(rx.try_recv().is_err(), "browser has no save-default");
660    }
661
662    /// The sidebar sorts by property now: its dialog cycles to Property and
663    /// shows the Key row next to the group row.
664    #[test]
665    fn sidebar_dialog_cycles_to_property_with_group_row() {
666        let mut d = SortDialog::new(
667            SortTarget::Sidebar,
668            state(SortField::Title, Some(true)),
669            true,
670        );
671        assert_eq!(d.row_count(), 3);
672        let (tx, _rx) = unbounded_channel();
673        d.handle_key(key(KeyCode::Char(' ')), &tx);
674        assert_eq!(d.field, SortField::Property(String::new()));
675        assert_eq!(d.row_count(), 4, "field, order, key and group rows");
676    }
677
678    /// `allows_property = false` never reaches Property, whatever the target.
679    #[test]
680    fn disallowed_property_never_cycles_to_property() {
681        let mut d = SortDialog::new(SortTarget::Query, state(SortField::Name, None), false);
682        let (tx, _rx) = unbounded_channel();
683        d.handle_key(key(KeyCode::Char(' ')), &tx);
684        d.handle_key(key(KeyCode::Char(' ')), &tx);
685        assert_eq!(d.field, SortField::Name);
686    }
687
688    fn finder_dialog() -> SortDialog {
689        SortDialog::new(SortTarget::Browser, state(SortField::Name, None), true).unsorted(true)
690    }
691
692    fn emitted(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> SortState {
693        match rx.try_recv() {
694            Ok(AppEvent::SortChanged { state, .. }) => state,
695            other => panic!("expected SortChanged, got {other:?}"),
696        }
697    }
698
699    /// A list in its natural order (the Ctrl+O finder) shows "Unsorted"
700    /// until a sort is picked; the sidebar never does.
701    #[test]
702    fn unsorted_list_shows_unsorted_until_a_pick() {
703        let d = finder_dialog();
704        assert_eq!(d.row_label(Row::Field).1, "Unsorted");
705        assert_eq!(sidebar_dialog().row_label(Row::Field).1, "Name");
706    }
707
708    /// The first Sort-by toggle lands on Name ascending (not Title).
709    #[test]
710    fn first_field_toggle_while_unsorted_picks_name_ascending() {
711        let mut d = finder_dialog();
712        let (tx, mut rx) = unbounded_channel();
713        d.handle_key(key(KeyCode::Char(' ')), &tx);
714        let st = emitted(&mut rx);
715        assert_eq!(
716            (st.field, st.order),
717            (SortField::Name, SortOrder::Ascending)
718        );
719        assert_eq!(d.row_label(Row::Field).1, "Name");
720        d.handle_key(key(KeyCode::Char(' ')), &tx);
721        assert_eq!(emitted(&mut rx).field, SortField::Title, "then cycles");
722    }
723
724    /// Toggling Order while unsorted picks Name with the toggled order.
725    #[test]
726    fn order_toggle_while_unsorted_picks_name_descending() {
727        let mut d = finder_dialog();
728        let (tx, mut rx) = unbounded_channel();
729        d.handle_key(key(KeyCode::Down), &tx);
730        d.handle_key(key(KeyCode::Char(' ')), &tx);
731        let st = emitted(&mut rx);
732        assert_eq!(
733            (st.field, st.order),
734            (SortField::Name, SortOrder::Descending)
735        );
736        assert_eq!(d.row_label(Row::Field).1, "Name");
737    }
738}