Skip to main content

fc_dioxus/behavior/
sort.rs

1//! Table sorting, ported from upstream's `table.ts`.
2//!
3//! Numbers compare numerically when both values parse, after removing grouping
4//! separators, so "9, 10, 100" and "12,480" sort as numbers. Other text compares
5//! naturally (digit runs by value) and without regard to case. Upstream uses the
6//! browser's `Intl.Collator` in the table's language for text; this port has no locale
7//! tailoring, so give accented or locale-sensitive columns an explicit sort key.
8
9use std::cmp::Ordering;
10
11/// Which way a column is sorted.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum SortDirection {
14    /// Smallest first.
15    Ascending,
16    /// Largest first.
17    Descending,
18}
19impl SortDirection {
20    /// The `aria-sort` value.
21    #[must_use]
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::Ascending => "ascending",
25            Self::Descending => "descending",
26        }
27    }
28}
29
30/// The direction a header press picks: a column sorted ascending flips to descending,
31/// anything else starts ascending.
32#[must_use]
33pub fn next_direction(current: Option<(usize, SortDirection)>, column: usize) -> SortDirection {
34    match current {
35        Some((sorted, SortDirection::Ascending)) if sorted == column => SortDirection::Descending,
36        _ => SortDirection::Ascending,
37    }
38}
39
40/// A value as a number, ignoring spaces and commas used for grouping.
41#[must_use]
42pub fn as_number(value: &str) -> Option<f64> {
43    let cleaned: String = value
44        .chars()
45        .filter(|c| !matches!(c, ',' | ' ' | '\u{a0}' | '\u{202f}') && !c.is_whitespace())
46        .collect();
47    if cleaned.is_empty() {
48        return None;
49    }
50    cleaned.parse::<f64>().ok().filter(|n| n.is_finite())
51}
52
53/// Compare two sort keys as `table.ts` does: numerically when both are numbers.
54///
55/// ```
56/// use fc_dioxus::behavior::sort::compare;
57/// use std::cmp::Ordering;
58/// assert_eq!(compare("9", "10"), Ordering::Less);
59/// assert_eq!(compare("12,480", "9"), Ordering::Greater);
60/// assert_eq!(compare("item 2", "item 10"), Ordering::Less);
61/// assert_eq!(compare("apple", "Banana"), Ordering::Less);
62/// ```
63#[must_use]
64pub fn compare(a: &str, b: &str) -> Ordering {
65    match (as_number(a), as_number(b)) {
66        (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
67        _ => natural(a, b),
68    }
69}
70
71fn natural(a: &str, b: &str) -> Ordering {
72    let mut left = a.chars().peekable();
73    let mut right = b.chars().peekable();
74    loop {
75        match (left.peek().copied(), right.peek().copied()) {
76            (None, None) => return Ordering::Equal,
77            (None, Some(_)) => return Ordering::Less,
78            (Some(_), None) => return Ordering::Greater,
79            (Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => {
80                let take = |it: &mut std::iter::Peekable<std::str::Chars<'_>>| {
81                    let mut digits = String::new();
82                    while let Some(c) = it.peek().copied().filter(char::is_ascii_digit) {
83                        digits.push(c);
84                        it.next();
85                    }
86                    digits
87                };
88                let (x, y) = (take(&mut left), take(&mut right));
89                let (xs, ys) = (x.trim_start_matches('0'), y.trim_start_matches('0'));
90                let order = xs.len().cmp(&ys.len()).then_with(|| xs.cmp(ys));
91                if order != Ordering::Equal {
92                    return order;
93                }
94            }
95            (Some(x), Some(y)) => {
96                let order = x.to_lowercase().cmp(y.to_lowercase());
97                if order != Ordering::Equal {
98                    return order;
99                }
100                left.next();
101                right.next();
102            }
103        }
104    }
105}
106
107/// Indices of `keys` in sorted order. Stable: equal keys keep their order, so sorting by
108/// one column after another groups within the earlier order.
109#[must_use]
110pub fn order(keys: &[&str], direction: SortDirection) -> Vec<usize> {
111    let mut indices: Vec<usize> = (0..keys.len()).collect();
112    indices.sort_by(|&a, &b| {
113        let ordering = compare(
114            keys.get(a).copied().unwrap_or(""),
115            keys.get(b).copied().unwrap_or(""),
116        );
117        match direction {
118            SortDirection::Ascending => ordering,
119            SortDirection::Descending => ordering.reverse(),
120        }
121    });
122    indices
123}