kimun_notes/components/dialogs/
sort_dialog.rs1use 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#[derive(Clone, Copy, PartialEq)]
14enum Row {
15 Field,
16 Order,
17 GroupDirs,
18}
19
20pub 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 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 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 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}