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::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#[derive(Clone, Copy, PartialEq)]
15enum Row {
16 Field,
17 Order,
18 GroupDirs,
19}
20
21pub 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 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 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 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}