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