Skip to main content

kimun_notes/components/dialogs/
sort_dialog.rs

1use ratatui::Frame;
2use ratatui::crossterm::event::{KeyCode, KeyEvent};
3use ratatui::layout::{Constraint, Direction, Layout, 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::panel::{ModalSpec, modal_chrome};
11use crate::settings::themes::Theme;
12
13/// The selectable rows, in display order.
14#[derive(Clone, Copy, PartialEq)]
15enum Row {
16    Field,
17    Order,
18    GroupDirs,
19}
20
21/// Modal that edits sort field / order (+ a sidebar-only "group directories"
22/// toggle). Changes apply live: each toggle emits `AppEvent::SortChanged`
23/// (`persist = false`). `s` (sidebar only) emits the same event with
24/// `persist = true` (save as default); Enter/Esc emit `CloseOverlay`.
25pub struct SortDialog {
26    target: SortTarget,
27    pub(crate) field: SortField,
28    pub(crate) order: SortOrder,
29    group_dirs: bool,
30    rows: Vec<Row>,
31    selected: usize,
32}
33
34impl SortDialog {
35    pub fn new(target: SortTarget, field: SortField, order: SortOrder, group_dirs: bool) -> Self {
36        let mut rows = vec![Row::Field, Row::Order];
37        if target == SortTarget::Sidebar {
38            rows.push(Row::GroupDirs);
39        }
40        Self {
41            target,
42            field,
43            order,
44            group_dirs,
45            rows,
46            selected: 0,
47        }
48    }
49
50    #[cfg(test)]
51    pub(crate) fn row_count(&self) -> usize {
52        self.rows.len()
53    }
54
55    /// Emit the current selection. `persist` requests saving it as the default
56    /// (sidebar's `s` key); a plain toggle sends `persist = false` for live apply.
57    fn emit(&self, tx: &AppTx, persist: bool) {
58        tx.send(AppEvent::SortChanged {
59            target: self.target,
60            field: self.field,
61            order: self.order,
62            group_directories: self.group_dirs,
63            persist,
64        })
65        .ok();
66    }
67
68    fn toggle_selected(&mut self, tx: &AppTx) {
69        match self.rows[self.selected] {
70            Row::Field => self.field = self.field.cycle(),
71            Row::Order => self.order = self.order.toggle(),
72            Row::GroupDirs => self.group_dirs = !self.group_dirs,
73        }
74        self.emit(tx, false);
75    }
76
77    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
78        match key.code {
79            KeyCode::Up => {
80                self.selected = self.selected.saturating_sub(1);
81            }
82            KeyCode::Down => {
83                self.selected = (self.selected + 1).min(self.rows.len() - 1);
84            }
85            KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right => {
86                self.toggle_selected(tx);
87            }
88            KeyCode::Char('s') if self.target == SortTarget::Sidebar => {
89                self.emit(tx, true);
90            }
91            KeyCode::Enter | KeyCode::Esc => {
92                tx.send(AppEvent::CloseOverlay).ok();
93            }
94            _ => {}
95        }
96        EventState::Consumed
97    }
98
99    fn row_label(&self, row: Row) -> (String, String) {
100        match row {
101            Row::Field => (
102                "Sort by".to_string(),
103                match self.field {
104                    SortField::Name => "Name".to_string(),
105                    SortField::Title => "Title".to_string(),
106                },
107            ),
108            Row::Order => (
109                "Order".to_string(),
110                match self.order {
111                    SortOrder::Ascending => "Ascending \u{2191}".to_string(),
112                    SortOrder::Descending => "Descending \u{2193}".to_string(),
113                },
114            ),
115            Row::GroupDirs => (
116                "Group directories".to_string(),
117                if self.group_dirs { "On" } else { "Off" }.to_string(),
118            ),
119        }
120    }
121}
122
123const OUTER_WIDTH: u16 = 44;
124
125impl crate::components::Component for SortDialog {
126    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
127        if let InputEvent::Key(key) = event {
128            self.handle_key(*key, tx)
129        } else {
130            EventState::NotConsumed
131        }
132    }
133
134    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
135        // rows + borders(2) + footer(1).
136        let outer_height = self.rows.len() as u16 + 3;
137        let popup = super::fixed_centered_rect(OUTER_WIDTH, outer_height, rect);
138        let inner = modal_chrome(
139            f,
140            popup,
141            theme,
142            ModalSpec {
143                title: Some(" Sort "),
144                border: Some(Style::default().fg(theme.fg.to_ratatui())),
145                ..Default::default()
146            },
147        );
148        if inner.height < 2 {
149            return;
150        }
151
152        // Split body (rows) from a fixed 1-line footer. `Min(1)` collapses the
153        // body before the footer disappears, so the footer is never overlapped
154        // on a short terminal (mirrors help_dialog).
155        let chunks = Layout::default()
156            .direction(Direction::Vertical)
157            .constraints([Constraint::Min(1), Constraint::Length(1)])
158            .split(inner);
159        let body = chunks[0];
160        let footer_area = chunks[1];
161
162        let bg = theme.bg_panel.to_ratatui();
163        let fg = theme.fg.to_ratatui();
164        let gray = theme.gray.to_ratatui();
165        let fg_sel = theme.selection_fg.to_ratatui();
166        let bg_sel = theme.selection_bg.to_ratatui();
167
168        for (i, &row) in self.rows.iter().enumerate() {
169            let y = body.y + i as u16;
170            if y >= body.y + body.height {
171                break;
172            }
173            let (label, value) = self.row_label(row);
174            let selected = i == self.selected;
175            let style = if selected {
176                Style::default()
177                    .fg(fg_sel)
178                    .bg(bg_sel)
179                    .add_modifier(Modifier::BOLD)
180            } else {
181                Style::default().fg(fg).bg(bg)
182            };
183            let marker = if selected { ">" } else { " " };
184            f.render_widget(
185                Paragraph::new(format!(" {marker} {label:<20}{value}")).style(style),
186                Rect {
187                    x: body.x,
188                    y,
189                    width: body.width,
190                    height: 1,
191                },
192            );
193        }
194
195        let footer = if self.target == SortTarget::Sidebar {
196            "  [↑↓] Move  [Space] Toggle  [s] Save default  [Enter/Esc] Close"
197        } else {
198            "  [↑↓] Move  [Space] Toggle  [Enter/Esc] Close"
199        };
200        f.render_widget(
201            Paragraph::new(footer).style(Style::default().fg(gray).bg(bg)),
202            footer_area,
203        );
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::components::events::SortTarget;
211    use crate::components::file_list::{SortField, SortOrder};
212    use ratatui::crossterm::event::{KeyCode, KeyEvent};
213    use tokio::sync::mpsc::unbounded_channel;
214
215    fn key(code: KeyCode) -> KeyEvent {
216        KeyEvent::from(code)
217    }
218
219    fn sidebar_dialog() -> SortDialog {
220        SortDialog::new(
221            SortTarget::Sidebar,
222            SortField::Name,
223            SortOrder::Ascending,
224            false,
225        )
226    }
227
228    #[test]
229    fn space_toggles_field_and_emits_change() {
230        let mut d = sidebar_dialog();
231        let (tx, mut rx) = unbounded_channel();
232        d.handle_key(key(KeyCode::Char(' ')), &tx);
233        assert_eq!(d.field, SortField::Title);
234        let evt = rx.try_recv().expect("a SortChanged event");
235        match evt {
236            AppEvent::SortChanged {
237                target,
238                field,
239                order,
240                group_directories,
241                persist,
242            } => {
243                assert_eq!(target, SortTarget::Sidebar);
244                assert_eq!(field, SortField::Title);
245                assert_eq!(order, SortOrder::Ascending);
246                assert!(!group_directories);
247                assert!(!persist, "a plain toggle is not a save");
248            }
249            other => panic!("expected SortChanged, got {other:?}"),
250        }
251    }
252
253    #[test]
254    fn down_then_space_toggles_order() {
255        let mut d = sidebar_dialog();
256        let (tx, mut rx) = unbounded_channel();
257        d.handle_key(key(KeyCode::Down), &tx);
258        assert!(rx.try_recv().is_err(), "navigation alone emits nothing");
259        d.handle_key(key(KeyCode::Char(' ')), &tx);
260        assert_eq!(d.order, SortOrder::Descending);
261        assert!(matches!(rx.try_recv(), Ok(AppEvent::SortChanged { .. })));
262    }
263
264    #[test]
265    fn group_row_present_only_for_sidebar() {
266        let sidebar = sidebar_dialog();
267        assert_eq!(sidebar.row_count(), 3);
268        let query = SortDialog::new(
269            SortTarget::Query,
270            SortField::Name,
271            SortOrder::Ascending,
272            false,
273        );
274        assert_eq!(query.row_count(), 2);
275    }
276
277    #[test]
278    fn s_saves_default_for_sidebar_only() {
279        let mut d = sidebar_dialog();
280        let (tx, mut rx) = unbounded_channel();
281        d.handle_key(key(KeyCode::Char('s')), &tx);
282        assert!(
283            matches!(
284                rx.try_recv(),
285                Ok(AppEvent::SortChanged { persist: true, .. })
286            ),
287            "s on the sidebar emits a persisting SortChanged"
288        );
289
290        let mut q = SortDialog::new(
291            SortTarget::Query,
292            SortField::Name,
293            SortOrder::Ascending,
294            false,
295        );
296        let (tx2, mut rx2) = unbounded_channel();
297        q.handle_key(key(KeyCode::Char('s')), &tx2);
298        assert!(rx2.try_recv().is_err(), "query target has no save-default");
299    }
300
301    #[test]
302    fn enter_and_esc_close_overlay() {
303        for code in [KeyCode::Enter, KeyCode::Esc] {
304            let mut d = sidebar_dialog();
305            let (tx, mut rx) = unbounded_channel();
306            d.handle_key(key(code), &tx);
307            assert!(matches!(rx.try_recv(), Ok(AppEvent::CloseOverlay)));
308        }
309    }
310}