Krolyxon
Commit 92928dd

Branches

Tags

Refs →

remagesrcstatequeue.rs

362 lines · 10.9 KB · Rust

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