Skip to main content

datui_lib/
sort_modal.rs

1use crate::widgets::column_widths::WidthChoice;
2use crate::widgets::text_input::TextInput;
3use ratatui::widgets::TableState;
4
5#[derive(Debug, Clone)]
6pub struct SortColumn {
7    pub name: String,
8    pub sort_order: Option<usize>, // For sorting (which columns to sort by and in what order)
9    /// This column's own direction, meaningful while `sort_order` is set. Each column
10    /// of a multi-sort runs its own way.
11    pub sort_descending: bool,
12    pub display_order: usize,  // For column display order
13    pub is_locked: bool,       // Whether this column is locked (and all columns before it)
14    pub is_to_be_locked: bool, // Whether this column is to-be-locked (pending, shown as dim lock)
15    pub is_visible: bool,      // Whether this column is visible in the table
16    /// How the column's width is chosen, as staged.
17    pub width: WidthChoice,
18    /// The width the table last drew the column at, where narrower and wider start.
19    pub shown_width: Option<u16>,
20}
21
22pub struct SortModal {
23    pub active: bool,
24    pub filter_input: TextInput,
25    pub columns: Vec<SortColumn>,
26    pub table_state: TableState,
27    pub has_unapplied_changes: bool,
28    pub history_limit: usize,
29    /// Why the last key did nothing, for the sidebar's status line; the next key
30    /// clears it.
31    pub status: Option<String>,
32    /// The column order last applied, hidden columns included. The table keeps only
33    /// the visible order, so this is where a hidden column is listed, and returns
34    /// to, when the sidebar reopens.
35    pub applied_order: Vec<String>,
36    /// How many leading columns of `applied_order` were frozen, hidden ones included:
37    /// the table's count leaves out a hidden column that ended the span.
38    pub applied_locked: usize,
39    /// Rows the Columns list showed when last drawn: what PgUp and PgDn move.
40    pub page_rows: usize,
41}
42
43impl Default for SortModal {
44    fn default() -> Self {
45        Self {
46            active: false,
47            filter_input: TextInput::new(),
48            columns: Vec::new(),
49            table_state: TableState::default(),
50            has_unapplied_changes: false,
51            history_limit: 1000,
52            status: None,
53            applied_order: Vec::new(),
54            applied_locked: 0,
55            page_rows: 10,
56        }
57    }
58}
59
60impl SortModal {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    pub fn filtered_columns(&self) -> Vec<(usize, &SortColumn)> {
66        let filter_text = self.filter_input.value().to_lowercase();
67        let mut filtered: Vec<_> = self
68            .columns
69            .iter()
70            .enumerate()
71            .filter(|(_, c)| c.name.to_lowercase().contains(&filter_text))
72            .collect();
73        // Sort by display_order to show columns in their current order
74        filtered.sort_by_key(|(_, c)| c.display_order);
75        filtered
76    }
77
78    pub fn get_column_order(&self) -> Vec<String> {
79        let mut cols: Vec<_> = self.columns.iter().filter(|c| c.is_visible).collect();
80        cols.sort_by_key(|c| c.display_order);
81        cols.into_iter().map(|c| c.name.clone()).collect()
82    }
83
84    /// How many visible columns the table freezes: those at or before the last locked
85    /// column in the order. A hidden column inside that span keeps its place but is
86    /// not counted, since the table never draws it.
87    pub fn get_locked_columns_count(&self) -> usize {
88        let Some(last_locked) = self
89            .columns
90            .iter()
91            .filter(|c| c.is_locked)
92            .map(|c| c.display_order)
93            .max()
94        else {
95            return 0;
96        };
97        self.columns
98            .iter()
99            .filter(|c| c.is_visible && c.display_order <= last_locked)
100            .count()
101    }
102
103    /// How many leading columns of the full order are frozen, hidden ones included.
104    pub fn get_locked_span(&self) -> usize {
105        self.columns
106            .iter()
107            .filter(|c| c.is_locked)
108            .map(|c| c.display_order + 1)
109            .max()
110            .unwrap_or(0)
111    }
112
113    /// Every column of the order: all columns, hidden included, sorted by place.
114    pub fn get_full_column_order(&self) -> Vec<String> {
115        let mut cols: Vec<_> = self.columns.iter().collect();
116        cols.sort_by_key(|c| c.display_order);
117        cols.into_iter().map(|c| c.name.clone()).collect()
118    }
119
120    pub fn get_sorted_columns(&self) -> Vec<String> {
121        self.sorted_columns_and_directions().0
122    }
123
124    /// The staged sort: column names in sort order, and per column whether it runs
125    /// descending.
126    pub fn sorted_columns_and_directions(&self) -> (Vec<String>, Vec<bool>) {
127        let mut sorted: Vec<_> = self
128            .columns
129            .iter()
130            .filter_map(|c| c.sort_order.map(|o| (o, c.name.clone(), c.sort_descending)))
131            .collect();
132        sorted.sort_by_key(|(order, _, _)| *order);
133        sorted
134            .into_iter()
135            .map(|(_, name, descending)| (name, descending))
136            .unzip()
137    }
138
139    /// Space on a column: none → ascending → descending → none. Joining the sort
140    /// appends the column at the end; leaving it renumbers the rest.
141    pub fn cycle_sort(&mut self) {
142        if let Some(idx) = self.table_state.selected() {
143            let filtered = self.filtered_columns();
144            if let Some((real_idx, _)) = filtered.get(idx) {
145                let real_idx = *real_idx;
146                match (
147                    self.columns[real_idx].sort_order,
148                    self.columns[real_idx].sort_descending,
149                ) {
150                    (None, _) => {
151                        let max_order = self
152                            .columns
153                            .iter()
154                            .filter_map(|c| c.sort_order)
155                            .max()
156                            .unwrap_or(0);
157                        self.columns[real_idx].sort_order = Some(max_order + 1);
158                        self.columns[real_idx].sort_descending = false;
159                    }
160                    (Some(_), false) => {
161                        self.columns[real_idx].sort_descending = true;
162                    }
163                    (Some(_), true) => self.unsort_index(real_idx),
164                }
165                self.has_unapplied_changes = true;
166            }
167        }
168    }
169
170    /// ← on a column: the cycle of Space backwards, none → descending → ascending →
171    /// none.
172    pub fn cycle_sort_back(&mut self) {
173        let Some(idx) = self.table_state.selected() else {
174            return;
175        };
176        let Some(&(real_idx, _)) = self.filtered_columns().get(idx) else {
177            return;
178        };
179        let col = &self.columns[real_idx];
180        match (col.sort_order, col.sort_descending) {
181            (None, _) => {
182                let max_order = self.columns.iter().filter_map(|c| c.sort_order).max();
183                self.columns[real_idx].sort_order = Some(max_order.unwrap_or(0) + 1);
184                self.columns[real_idx].sort_descending = true;
185            }
186            (Some(_), true) => self.columns[real_idx].sort_descending = false,
187            (Some(_), false) => self.unsort_index(real_idx),
188        }
189        self.has_unapplied_changes = true;
190    }
191
192    /// The sort, as indices into `columns`, first key first.
193    pub fn sort_entries(&self) -> Vec<usize> {
194        let mut entries: Vec<(usize, usize)> = self
195            .columns
196            .iter()
197            .enumerate()
198            .filter_map(|(i, c)| c.sort_order.map(|o| (o, i)))
199            .collect();
200        entries.sort_unstable();
201        entries.into_iter().map(|(_, i)| i).collect()
202    }
203
204    /// Flip the direction of the sort's `entry`th key.
205    pub fn flip_sort(&mut self, entry: usize) {
206        if let Some(&i) = self.sort_entries().get(entry) {
207            self.columns[i].sort_descending = !self.columns[i].sort_descending;
208            self.has_unapplied_changes = true;
209        }
210    }
211
212    /// Drop the sort's `entry`th key; the keys after it move up.
213    pub fn remove_sort_entry(&mut self, entry: usize) {
214        if let Some(&i) = self.sort_entries().get(entry) {
215            self.unsort_index(i);
216            self.has_unapplied_changes = true;
217        }
218    }
219
220    /// Move the sort's `entry`th key one place earlier or later. Returns where it is
221    /// now.
222    pub fn move_sort_entry(&mut self, entry: usize, earlier: bool) -> usize {
223        let entries = self.sort_entries();
224        let to = if earlier {
225            entry.checked_sub(1)
226        } else {
227            Some(entry + 1).filter(|to| *to < entries.len())
228        };
229        let (Some(to), Some(&from_i)) = (to, entries.get(entry)) else {
230            return entry;
231        };
232        let to_i = entries[to];
233        let a = self.columns[from_i].sort_order;
234        self.columns[from_i].sort_order = self.columns[to_i].sort_order;
235        self.columns[to_i].sort_order = a;
236        self.has_unapplied_changes = true;
237        to
238    }
239
240    /// Add the column named `name` as the sort's last key, ascending. Returns its
241    /// place in the sort; `None` when no column has that name. A column already
242    /// sorted keeps its place.
243    pub fn add_sort(&mut self, name: &str) -> Option<usize> {
244        let i = self.columns.iter().position(|c| c.name == name)?;
245        if self.columns[i].sort_order.is_none() {
246            let max_order = self.columns.iter().filter_map(|c| c.sort_order).max();
247            self.columns[i].sort_order = Some(max_order.unwrap_or(0) + 1);
248            self.columns[i].sort_descending = false;
249            self.has_unapplied_changes = true;
250        }
251        self.sort_entries().iter().position(|&e| e == i)
252    }
253
254    /// Del on a column: drop it from the sort outright, wherever in the cycle
255    /// it stands, and renumber the columns after it.
256    pub fn remove_sort(&mut self) {
257        if let Some(idx) = self.table_state.selected() {
258            let filtered = self.filtered_columns();
259            if let Some((real_idx, _)) = filtered.get(idx) {
260                let real_idx = *real_idx;
261                if self.columns[real_idx].sort_order.is_some() {
262                    self.unsort_index(real_idx);
263                    self.has_unapplied_changes = true;
264                }
265            }
266        }
267    }
268
269    fn unsort_index(&mut self, real_idx: usize) {
270        let Some(old_order) = self.columns[real_idx].sort_order else {
271            return;
272        };
273        self.columns[real_idx].sort_order = None;
274        self.columns[real_idx].sort_descending = false;
275        for col in &mut self.columns {
276            if let Some(order) = col.sort_order
277                && order > old_order
278            {
279                col.sort_order = Some(order - 1);
280            }
281        }
282    }
283
284    // Move column up in display order (left)
285    pub fn move_column_display_up(&mut self) {
286        if let Some(idx) = self.table_state.selected() {
287            let filtered = self.filtered_columns();
288            if let Some((real_idx, _)) = filtered.get(idx) {
289                let real_idx = *real_idx;
290                let current_display_order = self.columns[real_idx].display_order;
291                let was_locked = self.columns[real_idx].is_locked;
292                if current_display_order > 0 {
293                    // Find the column with display_order one less (the column we're moving above)
294                    let mut target_col_locked = false;
295                    let mut target_col_to_be_locked = false;
296                    for col in &self.columns {
297                        if col.display_order == current_display_order - 1 {
298                            target_col_locked = col.is_locked;
299                            target_col_to_be_locked = col.is_to_be_locked;
300                            break;
301                        }
302                    }
303
304                    // Swap display orders
305                    for col in &mut self.columns {
306                        if col.display_order == current_display_order - 1 {
307                            col.display_order = current_display_order;
308                            break;
309                        }
310                    }
311                    let new_display_order = current_display_order - 1;
312                    let was_to_be_locked = self.columns[real_idx].is_to_be_locked;
313
314                    // Find the last column in the lock/to-be-locked section BEFORE the move
315                    // (needed to check if this is the last one)
316                    let last_locked_or_to_be_order = self
317                        .columns
318                        .iter()
319                        .filter(|c| c.is_locked || c.is_to_be_locked)
320                        .map(|c| c.display_order)
321                        .max()
322                        .unwrap_or(0);
323
324                    // Swap display orders
325                    self.columns[real_idx].display_order = new_display_order;
326
327                    // If moving an unlocked column into a locked region, inherit the lock status
328                    if !was_locked && !was_to_be_locked {
329                        if target_col_locked || target_col_to_be_locked {
330                            // The column we moved above is locked or to-be-locked, so this column should match
331                            self.columns[real_idx].is_locked = target_col_locked;
332                            self.columns[real_idx].is_to_be_locked = target_col_to_be_locked;
333                        }
334                    } else {
335                        // When moving a locked or to-be-locked column up, check if it's the last one in the lock/to-be-locked section
336                        // Only clear to-be-locked if this is the last column in the lock/to-be-locked section
337                        if (was_locked || was_to_be_locked)
338                            && current_display_order == last_locked_or_to_be_order
339                        {
340                            // Clear to-be-locked for columns that are now at positions between new and old (exclusive of new, inclusive of old)
341                            // After swap: the column that was at new_display_order is now at current_display_order
342                            // We want to clear to-be-locked for columns at positions > new_display_order and <= current_display_order
343                            // but don't remove real locks
344                            for col in &mut self.columns {
345                                if col.display_order > new_display_order
346                                    && col.display_order <= current_display_order
347                                    && col.is_to_be_locked
348                                {
349                                    col.is_to_be_locked = false;
350                                }
351                            }
352                        }
353                    }
354
355                    self.has_unapplied_changes = true;
356                    // Update selection to follow the moved item
357                    if let Some(new_selected_idx) = self
358                        .filtered_columns()
359                        .iter()
360                        .position(|&(idx, _)| idx == real_idx)
361                    {
362                        self.table_state.select(Some(new_selected_idx));
363                    }
364                }
365            }
366        }
367    }
368
369    // Move column down in display order (right)
370    pub fn move_column_display_down(&mut self) {
371        if let Some(idx) = self.table_state.selected() {
372            let filtered = self.filtered_columns();
373            if let Some((real_idx, _)) = filtered.get(idx) {
374                let real_idx = *real_idx;
375                let max_display_order = self
376                    .columns
377                    .iter()
378                    .map(|c| c.display_order)
379                    .max()
380                    .unwrap_or(0);
381                let current_display_order = self.columns[real_idx].display_order;
382                let was_locked = self.columns[real_idx].is_locked;
383                if current_display_order < max_display_order {
384                    // Find the column with display_order one more
385                    for col in &mut self.columns {
386                        if col.display_order == current_display_order + 1 {
387                            col.display_order = current_display_order;
388                            break;
389                        }
390                    }
391                    let new_display_order = current_display_order + 1;
392                    let was_to_be_locked = self.columns[real_idx].is_to_be_locked;
393
394                    // Swap display orders first
395                    self.columns[real_idx].display_order = new_display_order;
396
397                    // If a locked or to-be-locked column is moved down, mark any unlocked columns it crosses as to-be-locked
398                    // After swap: the column that was at new_display_order is now at current_display_order
399                    // We need to mark columns that are now at positions from old position (inclusive) to new position (exclusive)
400                    // Excluding the moved column itself (which is now at new_display_order)
401                    if was_locked || was_to_be_locked {
402                        for (idx, col) in self.columns.iter_mut().enumerate() {
403                            // Mark columns that are now at positions from old position (inclusive) to new position (exclusive)
404                            // Exclude the moved column itself
405                            if idx != real_idx
406                                && col.display_order >= current_display_order
407                                && col.display_order < new_display_order
408                                && !col.is_locked
409                            {
410                                col.is_to_be_locked = true;
411                            }
412                        }
413                    }
414
415                    self.has_unapplied_changes = true;
416                    // Update selection to follow the moved item
417                    if let Some(new_selected_idx) = self
418                        .filtered_columns()
419                        .iter()
420                        .position(|&(idx, _)| idx == real_idx)
421                    {
422                        self.table_state.select(Some(new_selected_idx));
423                    }
424                }
425            }
426        }
427    }
428
429    // Toggle lock at this column (lock all columns up to and including this one)
430    pub fn toggle_lock_at_column(&mut self) {
431        if let Some(idx) = self.table_state.selected() {
432            let filtered = self.filtered_columns();
433            if let Some((real_idx, _)) = filtered.get(idx) {
434                let real_idx = *real_idx;
435                let target_display_order = self.columns[real_idx].display_order;
436
437                // Count how many columns are currently locked
438                let current_locked_count = self.columns.iter().filter(|c| c.is_locked).count();
439
440                // If clicking on a locked column or the first unlocked column, toggle lock boundary
441                if target_display_order < current_locked_count {
442                    // Unlock: set locked count to target_display_order
443                    for col in &mut self.columns {
444                        col.is_locked = col.display_order < target_display_order;
445                        col.is_to_be_locked = false; // Clear to-be-locked when unlocking
446                    }
447                } else {
448                    // Lock: set locked count to target_display_order + 1
449                    for col in &mut self.columns {
450                        col.is_locked = col.display_order <= target_display_order;
451                        col.is_to_be_locked = false; // Clear to-be-locked when applying locks
452                    }
453                }
454                self.has_unapplied_changes = true;
455            }
456        }
457    }
458
459    pub fn move_selection_up(&mut self) {
460        if let Some(idx) = self.table_state.selected() {
461            let filtered = self.filtered_columns();
462            if let Some((real_idx, _)) = filtered.get(idx) {
463                let real_idx = *real_idx;
464                if let Some(current_order) = self.columns[real_idx].sort_order
465                    && current_order > 1
466                {
467                    for col in &mut self.columns {
468                        if col.sort_order == Some(current_order - 1) {
469                            col.sort_order = Some(current_order);
470                            break;
471                        }
472                    }
473                    self.columns[real_idx].sort_order = Some(current_order - 1);
474                    self.has_unapplied_changes = true;
475                    // Update selection to follow the moved item
476                    if let Some(new_selected_idx) = self
477                        .filtered_columns()
478                        .iter()
479                        .position(|&(idx, _)| idx == real_idx)
480                    {
481                        self.table_state.select(Some(new_selected_idx));
482                    }
483                }
484            }
485        }
486    }
487
488    pub fn move_selection_down(&mut self) {
489        if let Some(idx) = self.table_state.selected() {
490            let filtered = self.filtered_columns();
491            if let Some((real_idx, _)) = filtered.get(idx) {
492                let real_idx = *real_idx;
493                let max_order = self
494                    .columns
495                    .iter()
496                    .filter_map(|c| c.sort_order)
497                    .max()
498                    .unwrap_or(0);
499                if let Some(current_order) = self.columns[real_idx].sort_order
500                    && current_order < max_order
501                {
502                    for col in &mut self.columns {
503                        if col.sort_order == Some(current_order + 1) {
504                            col.sort_order = Some(current_order);
505                            break;
506                        }
507                    }
508                    self.columns[real_idx].sort_order = Some(current_order + 1);
509                    // Update selection to follow the moved item
510                    if let Some(new_selected_idx) = self
511                        .filtered_columns()
512                        .iter()
513                        .position(|&(idx, _)| idx == real_idx)
514                    {
515                        self.table_state.select(Some(new_selected_idx));
516                    }
517                    self.has_unapplied_changes = true;
518                }
519            }
520        }
521    }
522
523    pub fn clear_selection(&mut self) {
524        // Reset all column state: clear sorting, unlock all, reset display order
525        for (idx, col) in self.columns.iter_mut().enumerate() {
526            col.sort_order = None;
527            col.sort_descending = false;
528            col.is_locked = false;
529            col.is_to_be_locked = false;
530            col.display_order = idx; // Reset to natural order (0, 1, 2, ...)
531            col.is_visible = true; // Make all columns visible
532            col.width = WidthChoice::Auto;
533        }
534        self.has_unapplied_changes = true;
535    }
536
537    /// Change how the width of the column under the cursor is chosen, from what is
538    /// staged: `<` and `>` step it, `f` fits it to the rows on screen, `w` returns
539    /// it to automatic.
540    pub fn change_width(&mut self, change: impl FnOnce(WidthChoice, Option<u16>) -> WidthChoice) {
541        let Some(idx) = self.table_state.selected() else {
542            return;
543        };
544        let Some(&(real_idx, _)) = self.filtered_columns().get(idx) else {
545            return;
546        };
547        let col = &mut self.columns[real_idx];
548        let width = change(col.width, col.shown_width);
549        if width != col.width {
550            col.width = width;
551            self.has_unapplied_changes = true;
552        }
553    }
554
555    /// Every column's width choice, as staged, for the table to apply.
556    pub fn width_choices(&self) -> Vec<(String, WidthChoice)> {
557        self.columns
558            .iter()
559            .map(|c| (c.name.clone(), c.width))
560            .collect()
561    }
562
563    /// Hide or show the column under the cursor. Visibility only: the column keeps
564    /// its place in the order and its lock, so showing it puts it back where it was.
565    pub fn toggle_visibility(&mut self) {
566        if let Some(idx) = self.table_state.selected() {
567            let filtered = self.filtered_columns();
568            if let Some(&(real_idx, _)) = filtered.get(idx) {
569                let col = &mut self.columns[real_idx];
570                col.is_visible = !col.is_visible;
571                self.has_unapplied_changes = true;
572            }
573        }
574    }
575
576    pub fn jump_selection_to_order(&mut self, new_order: usize) {
577        if let Some(idx) = self.table_state.selected() {
578            let filtered = self.filtered_columns();
579            if let Some((real_idx, _)) = filtered.get(idx) {
580                let real_idx = *real_idx;
581                let max_order = self
582                    .columns
583                    .iter()
584                    .filter_map(|c| c.sort_order)
585                    .max()
586                    .unwrap_or(0);
587                let old_order = self.columns[real_idx].sort_order;
588                // A sorted column moves among the places already taken; an
589                // unsorted one may also join at the end.
590                let last = if old_order.is_some() {
591                    max_order
592                } else {
593                    max_order + 1
594                };
595
596                if new_order > 0 && new_order <= last {
597                    let selected_column_name = self.columns[real_idx].name.clone();
598
599                    // Adjust existing orders
600                    for col in &mut self.columns {
601                        if col.name == selected_column_name {
602                            continue; // Skip the selected column for now
603                        }
604                        if let Some(order) = col.sort_order {
605                            if let Some(old) = old_order {
606                                if new_order < old && order >= new_order && order < old {
607                                    col.sort_order = Some(order + 1);
608                                } else if new_order > old && order <= new_order && order > old {
609                                    col.sort_order = Some(order - 1);
610                                }
611                            } else {
612                                // If the selected column was not sorted before
613                                if order >= new_order {
614                                    col.sort_order = Some(order + 1);
615                                }
616                            }
617                        }
618                    }
619                    self.columns[real_idx].sort_order = Some(new_order);
620
621                    // Re-number to ensure continuous sequence if a gap was created or an item was removed
622                    let mut current_sorted_cols: Vec<(&mut SortColumn, usize)> = self
623                        .columns
624                        .iter_mut()
625                        .filter_map(|c| c.sort_order.map(|o| (c, o)))
626                        .collect();
627                    current_sorted_cols.sort_by_key(|(_, o)| *o);
628
629                    for (i, (col, _)) in current_sorted_cols.into_iter().enumerate() {
630                        col.sort_order = Some(i + 1);
631                    }
632
633                    // Update selection to follow the moved item
634                    if let Some(new_selected_idx) = self
635                        .filtered_columns()
636                        .iter()
637                        .position(|&(r_idx, _)| r_idx == real_idx)
638                    {
639                        self.table_state.select(Some(new_selected_idx));
640                    }
641                    if old_order != Some(new_order) {
642                        self.has_unapplied_changes = true;
643                    }
644                } else if new_order == 0 {
645                    // User wants to unset sort order
646                    if self.columns[real_idx].sort_order.take().is_some() {
647                        self.has_unapplied_changes = true;
648                    }
649                    // Re-number to ensure continuous sequence
650                    let mut current_sorted_cols: Vec<(&mut SortColumn, usize)> = self
651                        .columns
652                        .iter_mut()
653                        .filter_map(|c| c.sort_order.map(|o| (c, o)))
654                        .collect();
655                    current_sorted_cols.sort_by_key(|(_, o)| *o);
656
657                    for (i, (col, _)) in current_sorted_cols.into_iter().enumerate() {
658                        col.sort_order = Some(i + 1);
659                    }
660                    // Selection should remain on the same column even if its sort order is removed
661                    if let Some(new_selected_idx) = self
662                        .filtered_columns()
663                        .iter()
664                        .position(|&(r_idx, _)| r_idx == real_idx)
665                    {
666                        self.table_state.select(Some(new_selected_idx));
667                    }
668                } else {
669                    // Past the end of the order: say so rather than doing nothing.
670                    let range = if last == 1 {
671                        "1".to_string()
672                    } else {
673                        format!("1-{last}")
674                    };
675                    self.status = Some(format!(
676                        "Position {new_order} is past the end; use {range}."
677                    ));
678                }
679            }
680        }
681    }
682}
683
684/// The sidebar's full order: `visible` as the table applies it, with each column of
685/// `all` it leaves out (a hidden one) put back right after the column it followed in
686/// `reference`, the order last applied, or in `all` when `reference` does not name
687/// it. A hidden column with nothing before it goes first.
688pub fn order_with_hidden(visible: &[String], all: &[String], reference: &[String]) -> Vec<String> {
689    use std::collections::HashSet;
690    let mut order = visible.to_vec();
691    let mut placed: HashSet<&str> = visible.iter().map(String::as_str).collect();
692    for name in all {
693        if placed.contains(name.as_str()) {
694            continue;
695        }
696        let earlier = match reference.iter().position(|r| r == name) {
697            Some(i) => &reference[..i],
698            None => &all[..all.iter().position(|a| a == name).unwrap_or(0)],
699        };
700        let at = earlier
701            .iter()
702            .rev()
703            .find(|e| placed.contains(e.as_str()))
704            .and_then(|e| order.iter().position(|o| o == e))
705            .map_or(0, |p| p + 1);
706        order.insert(at, name.clone());
707        placed.insert(name.as_str());
708    }
709    order
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    fn columns(names: &[&str]) -> Vec<SortColumn> {
717        names
718            .iter()
719            .enumerate()
720            .map(|(i, name)| SortColumn {
721                name: name.to_string(),
722                sort_order: None,
723                sort_descending: false,
724                display_order: i,
725                is_locked: false,
726                is_to_be_locked: false,
727                is_visible: true,
728                width: WidthChoice::Auto,
729                shown_width: Some(10),
730            })
731            .collect()
732    }
733
734    #[test]
735    fn test_sort_modal_new() {
736        let modal = SortModal::new();
737        assert!(!modal.active);
738        assert_eq!(modal.filter_input.value(), "");
739        assert!(modal.columns.is_empty());
740        assert!(modal.table_state.selected().is_none());
741    }
742
743    #[test]
744    fn test_filtered_columns() {
745        let mut modal = SortModal::new();
746        modal.columns = columns(&["Apple", "Banana", "Orange"]);
747        modal.filter_input.set_value("an");
748        let filtered = modal.filtered_columns();
749        assert_eq!(filtered.len(), 2);
750        assert_eq!(filtered[0].1.name, "Banana");
751        assert_eq!(filtered[1].1.name, "Orange");
752    }
753
754    /// Space walks one column through none → ascending → descending → none,
755    /// each column carrying its own direction.
756    #[test]
757    fn space_cycles_a_column_through_the_three_states() {
758        let mut modal = SortModal::new();
759        modal.columns = columns(&["A", "B"]);
760        modal.table_state.select(Some(0));
761
762        modal.cycle_sort();
763        assert_eq!(modal.columns[0].sort_order, Some(1));
764        assert!(!modal.columns[0].sort_descending, "first press: ascending");
765
766        modal.cycle_sort();
767        assert_eq!(modal.columns[0].sort_order, Some(1));
768        assert!(modal.columns[0].sort_descending, "second press: descending");
769
770        modal.cycle_sort();
771        assert_eq!(modal.columns[0].sort_order, None, "third press: out");
772        assert!(!modal.columns[0].sort_descending);
773    }
774
775    /// A column leaving the sort renumbers the ones after it, whatever their
776    /// directions, and the directions travel with their columns.
777    #[test]
778    fn leaving_the_sort_renumbers_and_keeps_directions() {
779        let mut modal = SortModal::new();
780        modal.columns = columns(&["A", "B", "C"]);
781        modal.table_state.select(Some(1)); // B ascending, order 1
782        modal.cycle_sort();
783        modal.table_state.select(Some(0)); // A order 2, then descending
784        modal.cycle_sort();
785        modal.cycle_sort();
786        modal.table_state.select(Some(2)); // C order 3
787        modal.cycle_sort();
788
789        let (names, directions) = modal.sorted_columns_and_directions();
790        assert_eq!(names, ["B", "A", "C"]);
791        assert_eq!(directions, [false, true, false]);
792
793        // B cycles out (asc → desc → none): A and C move up, directions intact.
794        modal.table_state.select(Some(1));
795        modal.cycle_sort();
796        modal.cycle_sort();
797        let (names, directions) = modal.sorted_columns_and_directions();
798        assert_eq!(names, ["A", "C"]);
799        assert_eq!(directions, [true, false]);
800    }
801
802    /// `0` takes a sorted column out and stages the change; on an unsorted
803    /// column it changes nothing and stages nothing.
804    #[test]
805    fn zero_removes_a_column_and_stages_the_change() {
806        let mut modal = SortModal::new();
807        modal.columns = columns(&["A", "B"]);
808        modal.columns[0].sort_order = Some(1);
809        modal.table_state.select(Some(1));
810        modal.jump_selection_to_order(0);
811        assert!(!modal.has_unapplied_changes, "B was not sorted");
812
813        modal.table_state.select(Some(0));
814        modal.jump_selection_to_order(0);
815        assert_eq!(modal.columns[0].sort_order, None);
816        assert!(modal.has_unapplied_changes);
817    }
818
819    /// A digit past the end of the order changes nothing and says which
820    /// positions exist.
821    #[test]
822    fn a_digit_past_the_end_says_why() {
823        let mut modal = SortModal::new();
824        modal.columns = columns(&["A", "B", "C"]);
825        modal.columns[0].sort_order = Some(1);
826        modal.table_state.select(Some(1));
827        modal.jump_selection_to_order(5);
828        assert_eq!(modal.columns[1].sort_order, None);
829        assert!(!modal.has_unapplied_changes);
830        assert_eq!(
831            modal.status.as_deref(),
832            Some("Position 5 is past the end; use 1-2.")
833        );
834
835        // The sorted column itself has only the positions already there, and
836        // its own position is no change.
837        modal.table_state.select(Some(0));
838        modal.jump_selection_to_order(2);
839        assert_eq!(
840            modal.status.as_deref(),
841            Some("Position 2 is past the end; use 1.")
842        );
843        modal.jump_selection_to_order(1);
844        assert_eq!(modal.columns[0].sort_order, Some(1));
845        assert!(!modal.has_unapplied_changes);
846    }
847
848    #[test]
849    fn test_move_selection_up() {
850        let mut modal = SortModal::new();
851        modal.columns = columns(&["A", "B"]);
852        modal.columns[0].sort_order = Some(2);
853        modal.columns[1].sort_order = Some(1);
854        modal.table_state.select(Some(0)); // Select "A"
855        modal.move_selection_up();
856        assert_eq!(modal.columns[0].sort_order, Some(1));
857        assert_eq!(modal.columns[1].sort_order, Some(2));
858    }
859
860    #[test]
861    fn test_move_selection_down() {
862        let mut modal = SortModal::new();
863        modal.columns = columns(&["A", "B"]);
864        modal.columns[0].sort_order = Some(2);
865        modal.columns[1].sort_order = Some(1);
866        modal.table_state.select(Some(1)); // Select "B"
867        modal.move_selection_down();
868        assert_eq!(modal.columns[0].sort_order, Some(1));
869        assert_eq!(modal.columns[1].sort_order, Some(2));
870    }
871
872    #[test]
873    fn the_sort_list_flips_moves_and_drops_entries() {
874        let mut modal = SortModal::new();
875        modal.columns = columns(&["A", "B", "C"]);
876        assert_eq!(modal.add_sort("C"), Some(0));
877        assert_eq!(modal.add_sort("A"), Some(1));
878        assert_eq!(
879            modal.add_sort("A"),
880            Some(1),
881            "a sorted column keeps its place"
882        );
883        assert_eq!(modal.get_sorted_columns(), ["C", "A"]);
884        modal.flip_sort(1);
885        assert_eq!(modal.sorted_columns_and_directions().1, [false, true]);
886        assert_eq!(modal.move_sort_entry(1, true), 0);
887        assert_eq!(modal.get_sorted_columns(), ["A", "C"]);
888        assert_eq!(modal.move_sort_entry(0, true), 0, "the first stays first");
889        modal.remove_sort_entry(0);
890        assert_eq!(modal.get_sorted_columns(), ["C"]);
891        assert_eq!(modal.columns[2].sort_order, Some(1), "renumbered");
892    }
893
894    #[test]
895    fn the_sort_cycles_both_ways() {
896        let mut modal = SortModal::new();
897        modal.columns = columns(&["A"]);
898        modal.table_state.select(Some(0));
899        modal.cycle_sort_back();
900        assert_eq!(modal.sorted_columns_and_directions().1, [true]);
901        modal.cycle_sort_back();
902        assert_eq!(modal.sorted_columns_and_directions().1, [false]);
903        modal.cycle_sort_back();
904        assert!(modal.get_sorted_columns().is_empty());
905    }
906
907    #[test]
908    fn test_clear_selection() {
909        let mut modal = SortModal::new();
910        modal.columns = columns(&["A", "B"]);
911        modal.columns[0].sort_order = Some(1);
912        modal.columns[0].sort_descending = true;
913        modal.columns[1].sort_order = Some(2);
914        modal.clear_selection();
915        assert!(modal.columns[0].sort_order.is_none());
916        assert!(!modal.columns[0].sort_descending);
917        assert!(modal.columns[1].sort_order.is_none());
918    }
919
920    /// Narrower and wider step from the width drawn, fit and automatic stage as
921    /// asked, and each change is staged rather than applied.
922    #[test]
923    fn width_changes_are_staged_on_the_column_under_the_cursor() {
924        let mut modal = SortModal::new();
925        modal.columns = columns(&["a", "b"]);
926        modal.table_state.select(Some(1));
927        modal.change_width(WidthChoice::wider);
928        assert_eq!(modal.columns[1].width, WidthChoice::Manual(14));
929        assert!(modal.has_unapplied_changes);
930        modal.change_width(WidthChoice::narrower);
931        modal.change_width(WidthChoice::narrower);
932        assert_eq!(modal.columns[1].width, WidthChoice::Manual(6));
933        modal.change_width(|_, _| WidthChoice::Fit);
934        assert_eq!(modal.columns[1].width, WidthChoice::Fit);
935        modal.change_width(|_, _| WidthChoice::Auto);
936        assert_eq!(modal.columns[0].width, WidthChoice::Auto);
937        assert_eq!(
938            modal.width_choices(),
939            vec![
940                ("a".to_string(), WidthChoice::Auto),
941                ("b".to_string(), WidthChoice::Auto)
942            ]
943        );
944        modal.change_width(WidthChoice::wider);
945        modal.clear_selection();
946        assert_eq!(modal.columns[1].width, WidthChoice::Auto);
947    }
948
949    fn names(order: &[&str]) -> Vec<String> {
950        order.iter().map(|s| s.to_string()).collect()
951    }
952
953    #[test]
954    fn hiding_and_showing_keeps_the_column_in_place() {
955        let mut modal = SortModal::new();
956        modal.columns = columns(&["A", "B", "C", "D"]);
957        modal.table_state.select(Some(1));
958        modal.toggle_visibility();
959        assert_eq!(modal.get_column_order(), names(&["A", "C", "D"]));
960        // The list does not move under the cursor: the next row is still C.
961        let listed: Vec<&str> = modal
962            .filtered_columns()
963            .iter()
964            .map(|(_, c)| c.name.as_str())
965            .collect();
966        assert_eq!(listed, ["A", "B", "C", "D"]);
967        modal.toggle_visibility();
968        assert_eq!(modal.get_column_order(), names(&["A", "B", "C", "D"]));
969    }
970
971    #[test]
972    fn a_hidden_column_keeps_its_lock_but_is_not_counted() {
973        let mut modal = SortModal::new();
974        modal.columns = columns(&["A", "B", "C", "D"]);
975        modal.table_state.select(Some(2));
976        modal.toggle_lock_at_column();
977        assert_eq!(modal.get_locked_columns_count(), 3);
978        modal.table_state.select(Some(1));
979        modal.toggle_visibility();
980        assert_eq!(modal.get_locked_columns_count(), 2, "A and C stay frozen");
981        modal.toggle_visibility();
982        assert_eq!(modal.get_locked_columns_count(), 3, "B is frozen again");
983    }
984
985    #[test]
986    fn hidden_columns_go_back_after_the_column_they_followed() {
987        let all = names(&["a", "b", "c", "d", "e"]);
988        // Last applied as c, a, b, d, e with b and d hidden.
989        let reference = names(&["c", "a", "b", "d", "e"]);
990        assert_eq!(
991            order_with_hidden(&names(&["c", "a", "e"]), &all, &reference),
992            names(&["c", "a", "b", "d", "e"])
993        );
994        // Nothing applied from the sidebar: schema order places them.
995        assert_eq!(
996            order_with_hidden(&names(&["c", "e"]), &all, &[]),
997            names(&["a", "b", "c", "d", "e"])
998        );
999    }
1000}