Krolyxon
Commit f2bd31a

Branches

Tags

Refs →

remagesrcstatequeue.rs

299 lines · 8.9 KB · Rust

1//! The file list: current index, ordering and navigation.
2
3use std::cmp::Ordering;
4use std::path::{Path, PathBuf};
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
7pub enum SortKey {
8 Natural,
9 Name,
10 Mtime,
11 Size,
12 Random,
13}
14
15#[derive(Debug, Default)]
16pub struct Queue {
17 files: Vec<PathBuf>,
18 index: usize,
19}
20
21impl Queue {
22 /// Build a queue positioned on `start` if present, else on `start_index` (clamped).
23 pub fn new(files: Vec<PathBuf>, start: Option<&Path>, start_index: usize) -> Self {
24 let index = start
25 .and_then(|s| files.iter().position(|f| f == s))
26 .unwrap_or(start_index);
27 let mut q = Self { files, index: 0 };
28 q.index = q.clamp(index);
29 q
30 }
31
32 pub fn len(&self) -> usize {
33 self.files.len()
34 }
35
36 pub fn is_empty(&self) -> bool {
37 self.files.is_empty()
38 }
39
40 pub fn index(&self) -> usize {
41 self.index
42 }
43
44 pub fn current(&self) -> Option<&Path> {
45 self.files.get(self.index).map(PathBuf::as_path)
46 }
47
48 pub fn get(&self, i: usize) -> Option<&Path> {
49 self.files.get(i).map(PathBuf::as_path)
50 }
51
52 /// Jump to `i` (clamped). Returns whether the current file changed.
53 pub fn goto(&mut self, i: usize) -> bool {
54 let i = self.clamp(i);
55 let changed = i != self.index;
56 self.index = i;
57 changed
58 }
59
60 /// Move by `delta`, wrapping around the ends when `wrap` is set.
61 pub fn step(&mut self, delta: isize, wrap: bool) -> bool {
62 if self.files.is_empty() {
63 return false;
64 }
65 let len = self.files.len() as isize;
66 let target = self.index as isize + delta;
67 let i = if wrap {
68 target.rem_euclid(len)
69 } else {
70 target.clamp(0, len - 1)
71 };
72 self.goto(i as usize)
73 }
74
75 /// Remove `path`; the current position moves to the following file.
76 pub fn remove(&mut self, path: &Path) -> bool {
77 let Some(i) = self.files.iter().position(|f| f == path) else {
78 return false;
79 };
80 self.files.remove(i);
81 if i < self.index {
82 self.index -= 1;
83 }
84 self.index = self.clamp(self.index);
85 true
86 }
87
88 /// Insert paths in natural order, keeping the current file selected.
89 pub fn insert_sorted(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
90 let current = self.current().map(Path::to_path_buf);
91 for p in paths {
92 if self.files.contains(&p) {
93 continue;
94 }
95 let at = self
96 .files
97 .partition_point(|f| natural_cmp_path(f, &p) != Ordering::Greater);
98 self.files.insert(at, p);
99 }
100 if let Some(cur) = current
101 && let Some(i) = self.files.iter().position(|f| *f == cur)
102 {
103 self.index = i;
104 }
105 }
106
107 /// Up to `n` neighbours on each side of the current file, nearest first.
108 pub fn neighbours(&self, n: usize, wrap: bool) -> Vec<usize> {
109 let len = self.files.len();
110 let mut out = Vec::with_capacity(n * 2);
111 for k in 1..=n {
112 for delta in [k as isize, -(k as isize)] {
113 let raw = self.index as isize + delta;
114 let i = if wrap {
115 raw.rem_euclid(len as isize) as usize
116 } else if (0..len as isize).contains(&raw) {
117 raw as usize
118 } else {
119 continue;
120 };
121 if i != self.index && !out.contains(&i) {
122 out.push(i);
123 }
124 }
125 }
126 out
127 }
128
129 fn clamp(&self, i: usize) -> usize {
130 i.min(self.files.len().saturating_sub(1))
131 }
132}
133
134/// Sort `files` in place. `meta` supplies (mtime seconds, size) for the stat-based keys.
135pub fn sort_files(
136 files: &mut [PathBuf],
137 key: SortKey,
138 reverse: bool,
139 meta: impl Fn(&Path) -> (i64, u64),
140 seed: u64,
141) {
142 match key {
143 SortKey::Natural => files.sort_by(|a, b| natural_cmp_path(a, b)),
144 SortKey::Name => files.sort(),
145 SortKey::Mtime => {
146 files.sort_by_cached_key(|p| meta(p).0);
147 }
148 SortKey::Size => {
149 files.sort_by_cached_key(|p| meta(p).1);
150 }
151 SortKey::Random => shuffle(files, seed),
152 }
153 if reverse {
154 files.reverse();
155 }
156}
157
158fn shuffle<T>(items: &mut [T], seed: u64) {
159 // xorshift64*: plenty for shuffling a file list.
160 let mut s = seed | 1;
161 for i in (1..items.len()).rev() {
162 s ^= s >> 12;
163 s ^= s << 25;
164 s ^= s >> 27;
165 let r = s.wrapping_mul(0x2545_F491_4F6C_DD1D);
166 items.swap(i, (r % (i as u64 + 1)) as usize);
167 }
168}
169
170pub fn natural_cmp_path(a: &Path, b: &Path) -> Ordering {
171 natural_cmp(&a.to_string_lossy(), &b.to_string_lossy())
172}
173
174/// Compare strings with digit runs ordered numerically and letters case-insensitively,
175/// so `img2` sorts before `img10` and `B` sits next to `b`.
176pub fn natural_cmp(a: &str, b: &str) -> Ordering {
177 let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());
178 loop {
179 match (x.peek().copied(), y.peek().copied()) {
180 (None, None) => return a.cmp(b),
181 (None, Some(_)) => return Ordering::Less,
182 (Some(_), None) => return Ordering::Greater,
183 (Some(c), Some(d)) if c.is_ascii_digit() && d.is_ascii_digit() => {
184 let take = |it: &mut std::iter::Peekable<std::str::Chars>| {
185 let mut s = String::new();
186 while let Some(&c) = it.peek().filter(|c| c.is_ascii_digit()) {
187 s.push(c);
188 it.next();
189 }
190 s
191 };
192 let (m, n) = (take(&mut x), take(&mut y));
193 let (mt, nt) = (m.trim_start_matches('0'), n.trim_start_matches('0'));
194 let ord = mt.len().cmp(&nt.len()).then_with(|| mt.cmp(nt));
195 if ord != Ordering::Equal {
196 return ord;
197 }
198 }
199 (Some(c), Some(d)) => {
200 let ord = c.to_lowercase().cmp(d.to_lowercase());
201 if ord != Ordering::Equal {
202 return ord;
203 }
204 x.next();
205 y.next();
206 }
207 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn paths(names: &[&str]) -> Vec<PathBuf> {
216 names.iter().map(PathBuf::from).collect()
217 }
218
219 #[test]
220 fn natural_order() {
221 let mut v = vec![
222 "img10.png",
223 "img2.png",
224 "Img1.png",
225 "img02.png",
226 "a.png",
227 "img1b.png",
228 ];
229 v.sort_by(|a, b| natural_cmp(a, b));
230 assert_eq!(
231 v,
232 [
233 "a.png",
234 "Img1.png",
235 "img1b.png",
236 "img02.png",
237 "img2.png",
238 "img10.png"
239 ]
240 );
241 }
242
243 #[test]
244 fn step_wraps_or_clamps() {
245 let mut q = Queue::new(paths(&["a", "b", "c"]), None, 0);
246 assert!(q.step(-1, true));
247 assert_eq!(q.index(), 2);
248 assert!(!q.step(1, false));
249 assert!(q.step(-5, false));
250 assert_eq!(q.index(), 0);
251 }
252
253 #[test]
254 fn starts_on_named_file() {
255 let q = Queue::new(paths(&["a", "b", "c"]), Some(Path::new("b")), 0);
256 assert_eq!(q.current(), Some(Path::new("b")));
257 let q = Queue::new(paths(&["a", "b"]), None, 9);
258 assert_eq!(q.index(), 1);
259 }
260
261 #[test]
262 fn remove_keeps_position() {
263 let mut q = Queue::new(paths(&["a", "b", "c"]), Some(Path::new("b")), 0);
264 q.remove(Path::new("a"));
265 assert_eq!(q.current(), Some(Path::new("b")));
266 q.remove(Path::new("b"));
267 assert_eq!(q.current(), Some(Path::new("c")));
268 q.remove(Path::new("c"));
269 assert!(q.current().is_none());
270 }
271
272 #[test]
273 fn insert_sorted_keeps_current() {
274 let mut q = Queue::new(paths(&["img1", "img3"]), Some(Path::new("img3")), 0);
275 q.insert_sorted(paths(&["img2", "img10", "img1"]));
276 assert_eq!(q.len(), 4);
277 assert_eq!(q.get(1), Some(Path::new("img2")));
278 assert_eq!(q.get(3), Some(Path::new("img10")));
279 assert_eq!(q.current(), Some(Path::new("img3")));
280 }
281
282 #[test]
283 fn neighbours_nearest_first() {
284 let q = Queue::new(paths(&["a", "b", "c", "d", "e"]), None, 0);
285 assert_eq!(q.neighbours(2, false), vec![1, 2]);
286 assert_eq!(q.neighbours(2, true), vec![1, 4, 2, 3]);
287 let q = Queue::new(paths(&["a", "b"]), None, 0);
288 assert_eq!(q.neighbours(2, true), vec![1]);
289 }
290
291 #[test]
292 fn random_sort_is_a_permutation() {
293 let mut v = paths(&["a", "b", "c", "d", "e"]);
294 sort_files(&mut v, SortKey::Random, false, |_| (0, 0), 42);
295 let mut s = v.clone();
296 s.sort();
297 assert_eq!(s, paths(&["a", "b", "c", "d", "e"]));
298 }
299}