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
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::components::events::SortTarget;
421    use crate::components::file_list::{SortField, SortOrder};
422    use crate::components::sortable::SortState;
423    use ratatui::crossterm::event::{KeyCode, KeyEvent};
424    use tokio::sync::mpsc::unbounded_channel;
425
426    fn key(code: KeyCode) -> KeyEvent {
427        KeyEvent::from(code)
428    }
429
430    fn state(field: SortField, group_dirs: Option<bool>) -> SortState {
431        SortState {
432            field,
433            order: SortOrder::Ascending,
434            group_dirs,
435        }
436    }
437
438    fn sidebar_dialog() -> SortDialog {
439        SortDialog::new(
440            SortTarget::Sidebar,
441            state(SortField::Name, Some(false)),
442            false,
443        )
444    }
445
446    #[test]
447    fn empty_property_field_emits_nothing() {
448        let mut d = SortDialog::new(SortTarget::Query, state(SortField::Name, None), true);
449        let (tx, mut rx) = unbounded_channel();
450        d.handle_key(key(KeyCode::Char(' ')), &tx); // Name -> Title
451        assert!(rx.try_recv().is_ok());
452        d.handle_key(key(KeyCode::Char(' ')), &tx); // Title -> Property("")
453        assert_eq!(d.field, SortField::Property(String::new()));
454        assert!(rx.try_recv().is_err(), "no event for an empty key");
455    }
456
457    #[test]
458    fn space_toggles_field_and_emits_change() {
459        let mut d = sidebar_dialog();
460        let (tx, mut rx) = unbounded_channel();
461        d.handle_key(key(KeyCode::Char(' ')), &tx);
462        assert_eq!(d.field, SortField::Title);
463        let evt = rx.try_recv().expect("a SortChanged event");
464        match evt {
465            AppEvent::SortChanged {
466                target,
467                state,
468                persist,
469            } => {
470                assert_eq!(target, SortTarget::Sidebar);
471                assert_eq!(state.field, SortField::Title);
472                assert_eq!(state.order, SortOrder::Ascending);
473                assert_eq!(state.group_dirs, Some(false));
474                assert!(!persist, "a plain toggle is not a save");
475            }
476            other => panic!("expected SortChanged, got {other:?}"),
477        }
478    }
479
480    #[test]
481    fn down_then_space_toggles_order() {
482        let mut d = sidebar_dialog();
483        let (tx, mut rx) = unbounded_channel();
484        d.handle_key(key(KeyCode::Down), &tx);
485        assert!(rx.try_recv().is_err(), "navigation alone emits nothing");
486        d.handle_key(key(KeyCode::Char(' ')), &tx);
487        assert_eq!(d.order, SortOrder::Descending);
488        assert!(matches!(rx.try_recv(), Ok(AppEvent::SortChanged { .. })));
489    }
490
491    #[test]
492    fn group_row_present_only_with_group_dirs() {
493        let sidebar = sidebar_dialog();
494        assert_eq!(sidebar.row_count(), 3);
495        let query = SortDialog::new(SortTarget::Query, state(SortField::Name, None), true);
496        assert_eq!(query.row_count(), 2);
497    }
498
499    #[test]
500    fn s_saves_default_for_sidebar_only() {
501        let mut d = sidebar_dialog();
502        let (tx, mut rx) = unbounded_channel();
503        d.handle_key(key(KeyCode::Char('s')), &tx);
504        assert!(
505            matches!(
506                rx.try_recv(),
507                Ok(AppEvent::SortChanged { persist: true, .. })
508            ),
509            "s on the sidebar emits a persisting SortChanged"
510        );
511
512        let mut q = SortDialog::new(SortTarget::Query, state(SortField::Name, None), true);
513        let (tx2, mut rx2) = unbounded_channel();
514        q.handle_key(key(KeyCode::Char('s')), &tx2);
515        assert!(rx2.try_recv().is_err(), "query target has no save-default");
516    }
517
518    #[test]
519    fn enter_and_esc_close_overlay() {
520        for code in [KeyCode::Enter, KeyCode::Esc] {
521            let mut d = sidebar_dialog();
522            let (tx, mut rx) = unbounded_channel();
523            d.handle_key(key(code), &tx);
524            assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
525        }
526    }
527
528    use crate::components::events::InputEvent;
529    use crate::settings::themes::Theme;
530    use ratatui::{Terminal, backend::TestBackend};
531
532    fn query_dialog(field: SortField) -> SortDialog {
533        SortDialog::new(SortTarget::Query, state(field, None), true)
534    }
535
536    fn draw(d: &mut SortDialog) {
537        use crate::components::Component;
538        let theme = Theme::gruvbox_dark();
539        let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
540        t.draw(|f| d.render(f, f.area(), &theme, true)).unwrap();
541    }
542
543    fn mouse(col: u16, row: u16) -> ratatui::crossterm::event::MouseEvent {
544        match crate::test_support::mouse_down_at(col, row) {
545            InputEvent::Mouse(m) => m,
546            _ => unreachable!(),
547        }
548    }
549
550    #[test]
551    fn query_cycle_reaches_property_and_waits_for_a_key() {
552        let mut d = query_dialog(SortField::Title);
553        let (tx, mut rx) = unbounded_channel();
554        d.handle_key(key(KeyCode::Char(' ')), &tx);
555        assert_eq!(d.field, SortField::Property(String::new()));
556        assert!(rx.try_recv().is_err(), "no emit before a key is chosen");
557        assert_eq!(d.row_count(), 3, "Key row appears");
558    }
559
560    #[test]
561    fn accepting_a_key_emits_property_sort() {
562        let mut d = query_dialog(SortField::Property(String::new()));
563        d.set_keys(vec!["due".into(), "status".into()]);
564        let (tx, mut rx) = unbounded_channel();
565        d.handle_key(key(KeyCode::Down), &tx); // Order
566        d.handle_key(key(KeyCode::Down), &tx); // Key
567        for c in "du".chars() {
568            d.handle_key(key(KeyCode::Char(c)), &tx);
569        }
570        d.handle_key(key(KeyCode::Enter), &tx); // accept "due" from the list
571        match rx.try_recv() {
572            Ok(AppEvent::SortChanged { state, .. }) => {
573                assert_eq!(state.field, SortField::Property("due".into()))
574            }
575            other => panic!("expected SortChanged, got {other:?}"),
576        }
577    }
578
579    /// A key typed but not submitted with Enter still applies once the user
580    /// moves to another row and toggles it.
581    #[test]
582    fn typed_key_applies_when_toggling_another_row() {
583        let mut d = query_dialog(SortField::Property(String::new()));
584        let (tx, mut rx) = unbounded_channel();
585        d.handle_key(key(KeyCode::Down), &tx); // Order
586        d.handle_key(key(KeyCode::Down), &tx); // Key
587        for c in "due".chars() {
588            d.handle_key(key(KeyCode::Char(c)), &tx);
589        }
590        d.handle_key(key(KeyCode::Up), &tx); // back to Order, list closed
591        d.handle_key(key(KeyCode::Char(' ')), &tx);
592        match rx.try_recv() {
593            Ok(AppEvent::SortChanged { state, .. }) => {
594                assert_eq!(state.field, SortField::Property("due".into()));
595                assert_eq!(state.order, SortOrder::Descending);
596            }
597            other => panic!("expected SortChanged, got {other:?}"),
598        }
599    }
600
601    #[test]
602    fn opening_with_a_property_sort_preselects_it() {
603        let d = query_dialog(SortField::Property("due".into()));
604        assert_eq!(d.row_count(), 3);
605        assert_eq!(d.key_value(), "due");
606    }
607
608    #[test]
609    fn sidebar_never_cycles_to_property() {
610        let mut d = sidebar_dialog();
611        let (tx, _rx) = unbounded_channel();
612        d.handle_key(key(KeyCode::Char(' ')), &tx); // Name -> Title
613        d.handle_key(key(KeyCode::Char(' ')), &tx); // Title -> Name
614        assert_eq!(d.field, SortField::Name);
615    }
616
617    #[test]
618    fn click_on_a_row_selects_and_toggles_it() {
619        let mut d = sidebar_dialog();
620        draw(&mut d);
621        let (tx, mut rx) = unbounded_channel();
622        let (x, y) = d.row_origin(1).expect("Order row rendered");
623        d.handle_mouse(&mouse(x + 2, y), &tx);
624        assert_eq!(d.order, SortOrder::Descending);
625        assert!(matches!(rx.try_recv(), Ok(AppEvent::SortChanged { .. })));
626    }
627
628    #[test]
629    fn click_outside_sort_modal_does_nothing() {
630        let mut d = sidebar_dialog();
631        draw(&mut d);
632        let (tx, mut rx) = unbounded_channel();
633        d.handle_mouse(&mouse(0, 0), &tx);
634        assert!(rx.try_recv().is_err());
635        assert_eq!(d.field, SortField::Name);
636    }
637
638    #[test]
639    fn enter_on_key_row_with_list_closed_closes_overlay() {
640        let mut d = query_dialog(SortField::Property("due".into()));
641        let (tx, mut rx) = unbounded_channel();
642        d.handle_key(key(KeyCode::Down), &tx);
643        d.handle_key(key(KeyCode::Down), &tx);
644        d.handle_key(key(KeyCode::Enter), &tx);
645        assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
646    }
647
648    /// The browser target: property sorts allowed, no group row, no `s`.
649    #[test]
650    fn browser_dialog_cycles_to_property_without_group_row() {
651        let mut d = SortDialog::new(SortTarget::Browser, state(SortField::Title, None), true);
652        assert_eq!(d.row_count(), 2, "no group row without group_dirs");
653        let (tx, mut rx) = unbounded_channel();
654        d.handle_key(key(KeyCode::Char(' ')), &tx);
655        assert_eq!(d.field, SortField::Property(String::new()));
656        d.handle_key(key(KeyCode::Char('s')), &tx);
657        assert!(rx.try_recv().is_err(), "browser has no save-default");
658    }
659
660    /// The sidebar sorts by property now: its dialog cycles to Property and
661    /// shows the Key row next to the group row.
662    #[test]
663    fn sidebar_dialog_cycles_to_property_with_group_row() {
664        let mut d = SortDialog::new(
665            SortTarget::Sidebar,
666            state(SortField::Title, Some(true)),
667            true,
668        );
669        assert_eq!(d.row_count(), 3);
670        let (tx, _rx) = unbounded_channel();
671        d.handle_key(key(KeyCode::Char(' ')), &tx);
672        assert_eq!(d.field, SortField::Property(String::new()));
673        assert_eq!(d.row_count(), 4, "field, order, key and group rows");
674    }
675
676    /// `allows_property = false` never reaches Property, whatever the target.
677    #[test]
678    fn disallowed_property_never_cycles_to_property() {
679        let mut d = SortDialog::new(SortTarget::Query, state(SortField::Name, None), false);
680        let (tx, _rx) = unbounded_channel();
681        d.handle_key(key(KeyCode::Char(' ')), &tx);
682        d.handle_key(key(KeyCode::Char(' ')), &tx);
683        assert_eq!(d.field, SortField::Name);
684    }
685
686    fn finder_dialog() -> SortDialog {
687        SortDialog::new(SortTarget::Browser, state(SortField::Name, None), true).unsorted(true)
688    }
689
690    fn emitted(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> SortState {
691        match rx.try_recv() {
692            Ok(AppEvent::SortChanged { state, .. }) => state,
693            other => panic!("expected SortChanged, got {other:?}"),
694        }
695    }
696
697    /// A list in its natural order (the Ctrl+O finder) shows "Unsorted"
698    /// until a sort is picked; the sidebar never does.
699    #[test]
700    fn unsorted_list_shows_unsorted_until_a_pick() {
701        let d = finder_dialog();
702        assert_eq!(d.row_label(Row::Field).1, "Unsorted");
703        assert_eq!(sidebar_dialog().row_label(Row::Field).1, "Name");
704    }
705
706    /// The first Sort-by toggle lands on Name ascending (not Title).
707    #[test]
708    fn first_field_toggle_while_unsorted_picks_name_ascending() {
709        let mut d = finder_dialog();
710        let (tx, mut rx) = unbounded_channel();
711        d.handle_key(key(KeyCode::Char(' ')), &tx);
712        let st = emitted(&mut rx);
713        assert_eq!(
714            (st.field, st.order),
715            (SortField::Name, SortOrder::Ascending)
716        );
717        assert_eq!(d.row_label(Row::Field).1, "Name");
718        d.handle_key(key(KeyCode::Char(' ')), &tx);
719        assert_eq!(emitted(&mut rx).field, SortField::Title, "then cycles");
720    }
721
722    /// Toggling Order while unsorted picks Name with the toggled order.
723    #[test]
724    fn order_toggle_while_unsorted_picks_name_descending() {
725        let mut d = finder_dialog();
726        let (tx, mut rx) = unbounded_channel();
727        d.handle_key(key(KeyCode::Down), &tx);
728        d.handle_key(key(KeyCode::Char(' ')), &tx);
729        let st = emitted(&mut rx);
730        assert_eq!(
731            (st.field, st.order),
732            (SortField::Name, SortOrder::Descending)
733        );
734        assert_eq!(d.row_label(Row::Field).1, "Name");
735    }
736}