Krolyxon
Commit 4654f72

Branches

Tags

Refs →

remagesrcstatequeue.rs

766 lines · 24.2 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
7use super::fuzzy::Fuzzy;
8
9#[derive(
10 Clone,
11 Copy,
12 Debug,
13 Default,
14 PartialEq,
15 Eq,
16 clap::ValueEnum,
17 serde::Deserialize,
18 serde::Serialize,
19)]
20#[serde(rename_all = "lowercase")]
21pub enum SortKey {
22 #[default]
23 Natural,
24 Name,
25 Mtime,
26 Size,
27 Random,
28}
29
30#[derive(Debug, Default)]
31pub struct Queue {
32 files: Vec<PathBuf>,
33 index: usize,
34 /// Marked files, by path so reordering keeps them.
35 marks: HashSet<PathBuf>,
36 /// Bumped on every change to the marks, so watchers can spot one.
37 marks_version: u64,
38 /// `filter <pattern>`: a glob or a plain substring of the file name.
39 filter: Option<String>,
40 /// Live `/` search: fuzzy over file names.
41 search: Option<String>,
42 /// Every file, in order, while a filter or search hides some (`files` is then the subset).
43 full: Option<Vec<PathBuf>>,
44 /// The current file when a filter left nothing shown, to return to afterwards.
45 lost: Option<PathBuf>,
46}
47
48/// Decides which files a filter and search let through.
49struct Matcher {
50 /// Lower-cased pattern and whether it is a glob.
51 pattern: Option<(String, bool)>,
52 fuzzy: Option<Fuzzy>,
53}
54
55impl Matcher {
56 fn is_match(&mut self, path: &Path) -> bool {
57 let name = path
58 .file_name()
59 .map_or_else(|| path.to_string_lossy(), |n| n.to_string_lossy());
60 if let Some((pat, glob)) = &self.pattern {
61 let lower = name.to_lowercase();
62 let ok = if *glob {
63 glob_match(pat, &lower)
64 } else {
65 lower.contains(pat.as_str())
66 };
67 if !ok {
68 return false;
69 }
70 }
71 self.fuzzy.as_mut().is_none_or(|f| f.score(&name).is_some())
72 }
73}
74
75impl Queue {
76 /// Build a queue positioned on `start` if present, else on `start_index` (clamped).
77 pub fn new(files: Vec<PathBuf>, start: Option<&Path>, start_index: usize) -> Self {
78 let index = start
79 .and_then(|s| files.iter().position(|f| f == s))
80 .unwrap_or(start_index);
81 let mut q = Self {
82 files,
83 index: 0,
84 marks: HashSet::new(),
85 marks_version: 0,
86 filter: None,
87 search: None,
88 full: None,
89 lost: None,
90 };
91 q.index = q.clamp(index);
92 q
93 }
94
95 pub fn len(&self) -> usize {
96 self.files.len()
97 }
98
99 pub fn is_empty(&self) -> bool {
100 self.files.is_empty()
101 }
102
103 pub fn index(&self) -> usize {
104 self.index
105 }
106
107 pub fn current(&self) -> Option<&Path> {
108 self.files.get(self.index).map(PathBuf::as_path)
109 }
110
111 pub fn get(&self, i: usize) -> Option<&Path> {
112 self.files.get(i).map(PathBuf::as_path)
113 }
114
115 /// Jump to `i` (clamped). Returns whether the current file changed.
116 pub fn goto(&mut self, i: usize) -> bool {
117 let i = self.clamp(i);
118 let changed = i != self.index;
119 self.index = i;
120 changed
121 }
122
123 /// Move by `delta`, wrapping around the ends when `wrap` is set.
124 pub fn step(&mut self, delta: isize, wrap: bool) -> bool {
125 if self.files.is_empty() {
126 return false;
127 }
128 let len = self.files.len() as isize;
129 let target = self.index as isize + delta;
130 let i = if wrap {
131 target.rem_euclid(len)
132 } else {
133 target.clamp(0, len - 1)
134 };
135 self.goto(i as usize)
136 }
137
138 /// Remove `path`; the current position moves to the following file.
139 pub fn remove(&mut self, path: &Path) -> bool {
140 let hidden = self.full.as_mut().is_some_and(|full| {
141 full.iter()
142 .position(|f| f == path)
143 .map(|i| full.remove(i))
144 .is_some()
145 });
146 let Some(i) = self.files.iter().position(|f| f == path) else {
147 if hidden && self.marks.remove(path) {
148 self.marks_version += 1;
149 }
150 return hidden;
151 };
152 let removed = self.files.remove(i);
153 if self.marks.remove(&removed) {
154 self.marks_version += 1;
155 }
156 if i < self.index {
157 self.index -= 1;
158 }
159 self.index = self.clamp(self.index);
160 true
161 }
162
163 /// Insert paths in natural order, keeping the current file selected.
164 pub fn insert_sorted(&mut self, paths: impl IntoIterator<Item = PathBuf>) {
165 let current = self.current().map(Path::to_path_buf);
166 let mut m = self.matcher();
167 let insert = |list: &mut Vec<PathBuf>, p: &PathBuf| {
168 if !list.contains(p) {
169 let at = list.partition_point(|f| natural_cmp_path(f, p) != Ordering::Greater);
170 list.insert(at, p.clone());
171 }
172 };
173 for p in paths {
174 if let Some(full) = &mut self.full {
175 insert(full, &p);
176 }
177 if m.as_mut().is_none_or(|m| m.is_match(&p)) {
178 insert(&mut self.files, &p);
179 }
180 }
181 if let Some(cur) = current
182 && let Some(i) = self.files.iter().position(|f| *f == cur)
183 {
184 self.index = i;
185 }
186 }
187
188 /// Up to `n` neighbours on each side of the current file, nearest first.
189 pub fn neighbours(&self, n: usize, wrap: bool) -> Vec<usize> {
190 let len = self.files.len();
191 let mut out = Vec::with_capacity(n * 2);
192 for k in 1..=n {
193 for delta in [k as isize, -(k as isize)] {
194 let raw = self.index as isize + delta;
195 let i = if wrap {
196 raw.rem_euclid(len as isize) as usize
197 } else if (0..len as isize).contains(&raw) {
198 raw as usize
199 } else {
200 continue;
201 };
202 if i != self.index && !out.contains(&i) {
203 out.push(i);
204 }
205 }
206 }
207 out
208 }
209
210 /// The files shown: all of them, or those a filter or search lets through.
211 pub fn files(&self) -> &[PathBuf] {
212 &self.files
213 }
214
215 /// Every file, including those a filter hides.
216 pub fn all_files(&self) -> &[PathBuf] {
217 self.full.as_deref().unwrap_or(&self.files)
218 }
219
220 pub fn filter(&self) -> Option<&str> {
221 self.filter.as_deref()
222 }
223
224 /// Show only files whose name matches `pattern`: a glob (`*.png`) when it has `*`, `?`
225 /// or `[`, otherwise a substring; case-insensitive either way. Returns whether the
226 /// current file changed.
227 pub fn set_filter(&mut self, pattern: Option<String>) -> bool {
228 self.filter = pattern.filter(|p| !p.is_empty());
229 self.refilter()
230 }
231
232 /// Narrow the files to fuzzy matches of `query` (the live `/` search).
233 pub fn set_search(&mut self, query: Option<String>) -> bool {
234 self.search = query.filter(|q| !q.is_empty());
235 self.refilter()
236 }
237
238 fn matcher(&self) -> Option<Matcher> {
239 if self.filter.is_none() && self.search.is_none() {
240 return None;
241 }
242 Some(Matcher {
243 pattern: self.filter.as_ref().map(|p| {
244 let glob = p.contains(['*', '?', '[']);
245 (p.to_lowercase(), glob)
246 }),
247 fuzzy: self.search.as_deref().map(Fuzzy::for_paths),
248 })
249 }
250
251 /// Recompute the shown files. The current file stays selected if it is still shown,
252 /// otherwise the next shown one after it. Returns whether the current file changed.
253 fn refilter(&mut self) -> bool {
254 let cur = self
255 .current()
256 .map(Path::to_path_buf)
257 .or_else(|| self.lost.take());
258 let Some(mut m) = self.matcher() else {
259 if let Some(full) = self.full.take() {
260 self.files = full;
261 }
262 return self.reselect(cur.as_deref());
263 };
264 let full = self.full.get_or_insert_with(|| self.files.clone());
265 self.files = full.iter().filter(|p| m.is_match(p)).cloned().collect();
266 self.reselect(cur.as_deref())
267 }
268
269 /// Select `cur`, or the first shown file after it in the full order.
270 fn reselect(&mut self, cur: Option<&Path>) -> bool {
271 let Some(cur) = cur else {
272 self.index = 0;
273 return !self.files.is_empty();
274 };
275 if self.files.is_empty() {
276 self.index = 0;
277 self.lost = Some(cur.to_path_buf());
278 return true;
279 }
280 let all = self.all_files();
281 let from = all.iter().position(|f| f == cur).unwrap_or(0);
282 let shown: HashSet<&PathBuf> = self.files.iter().collect();
283 let next = all[from..]
284 .iter()
285 .chain(all[..from].iter().rev())
286 .find(|f| shown.contains(f))
287 .cloned();
288 let i = next
289 .and_then(|n| self.files.iter().position(|f| *f == n))
290 .unwrap_or(0);
291 self.index = self.clamp(i);
292 self.current() != Some(cur)
293 }
294
295 /// Reverse the order, keeping the current file selected.
296 pub fn reverse(&mut self) {
297 if let Some(full) = &mut self.full {
298 full.reverse();
299 }
300 self.files.reverse();
301 self.index = self.clamp(self.files.len().saturating_sub(1) - self.index);
302 }
303
304 /// Adopt a new order of the same files (a finished re-sort), keeping the current file.
305 /// Files removed meanwhile stay removed; files added meanwhile go at the end.
306 pub fn reorder(&mut self, order: Vec<PathBuf>) {
307 let current = self.current().map(Path::to_path_buf);
308 if let Some(full) = &mut self.full {
309 *full = reordered(full, &order);
310 }
311 self.files = reordered(&self.files, &order);
312 if let Some(cur) = current
313 && let Some(i) = self.files.iter().position(|f| *f == cur)
314 {
315 self.index = i;
316 }
317 }
318
319 pub fn is_marked(&self, i: usize) -> bool {
320 self.files.get(i).is_some_and(|f| self.marks.contains(f))
321 }
322
323 pub fn marked_count(&self) -> usize {
324 self.marks.len()
325 }
326
327 pub fn marks_version(&self) -> u64 {
328 self.marks_version
329 }
330
331 /// Mark or unmark file `i`. Returns whether anything changed.
332 pub fn set_mark(&mut self, i: usize, on: bool) -> bool {
333 let Some(f) = self.files.get(i) else {
334 return false;
335 };
336 let changed = if on {
337 self.marks.insert(f.clone())
338 } else {
339 self.marks.remove(f)
340 };
341 self.marks_version += changed as u64;
342 changed
343 }
344
345 /// Mark every shown file.
346 pub fn mark_all(&mut self) {
347 self.marks.extend(self.files.iter().cloned());
348 self.marks_version += 1;
349 }
350
351 pub fn unmark_all(&mut self) {
352 self.marks.clear();
353 self.marks_version += 1;
354 }
355
356 /// Invert the marks of the shown files.
357 pub fn invert_marks(&mut self) {
358 self.marks_version += 1;
359 for f in &self.files {
360 if !self.marks.remove(f) {
361 self.marks.insert(f.clone());
362 }
363 }
364 }
365
366 /// Marked files in queue order, including any a filter hides.
367 pub fn marked(&self) -> Vec<&Path> {
368 self.all_files()
369 .iter()
370 .filter(|f| self.marks.contains(*f))
371 .map(PathBuf::as_path)
372 .collect()
373 }
374
375 /// The `n`th marked file after (`forward`) or before the current one.
376 pub fn find_mark(&self, forward: bool, n: usize, wrap: bool) -> Option<usize> {
377 let len = self.files.len();
378 if self.marks.is_empty() || len == 0 {
379 return None;
380 }
381 let mut found = None;
382 let mut left = n.max(1);
383 for k in 1..len {
384 let i = if forward {
385 let i = self.index + k;
386 if i >= len && !wrap {
387 break;
388 }
389 i % len
390 } else {
391 if k > self.index && !wrap {
392 break;
393 }
394 (self.index + len - k) % len
395 };
396 if self.is_marked(i) {
397 found = Some(i);
398 left -= 1;
399 if left == 0 {
400 break;
401 }
402 }
403 }
404 found
405 }
406
407 fn clamp(&self, i: usize) -> usize {
408 i.min(self.files.len().saturating_sub(1))
409 }
410}
411
412/// `list` in the order of `order`; files `order` lacks go at the end, files `list` lacks are
413/// dropped.
414fn reordered(list: &[PathBuf], order: &[PathBuf]) -> Vec<PathBuf> {
415 let have: HashSet<&PathBuf> = list.iter().collect();
416 let mut out: Vec<PathBuf> = order.iter().filter(|f| have.contains(f)).cloned().collect();
417 let placed: HashSet<&PathBuf> = out.iter().collect();
418 let missing: Vec<PathBuf> = list
419 .iter()
420 .filter(|f| !placed.contains(f))
421 .cloned()
422 .collect();
423 out.extend(missing);
424 out
425}
426
427/// Shell-style wildcard match: `*`, `?` and `[abc]` / `[a-z]` / `[!x]`.
428pub fn glob_match(pattern: &str, text: &str) -> bool {
429 let p: Vec<char> = pattern.chars().collect();
430 let t: Vec<char> = text.chars().collect();
431 // Iterative matching with backtracking to the last `*`.
432 let (mut pi, mut ti) = (0, 0);
433 let mut star: Option<(usize, usize)> = None;
434 while ti < t.len() {
435 let step = match p.get(pi) {
436 Some('*') => {
437 star = Some((pi, ti));
438 pi += 1;
439 continue;
440 }
441 Some('?') => Some(1),
442 Some('[') => class(&p[pi..], t[ti]),
443 Some(c) if *c == t[ti] => Some(1),
444 _ => None,
445 };
446 match step {
447 Some(n) if p[pi] == '[' => {
448 pi += n;
449 ti += 1;
450 }
451 Some(_) => {
452 pi += 1;
453 ti += 1;
454 }
455 None => match star {
456 Some((sp, st)) => {
457 pi = sp + 1;
458 ti = st + 1;
459 star = Some((sp, st + 1));
460 }
461 None => return false,
462 },
463 }
464 }
465 p[pi..].iter().all(|c| *c == '*')
466}
467
468/// Match `c` against the class at the start of `p` (`[...]`). Returns the class's length if
469/// it matches. An unclosed `[` matches itself literally.
470fn class(p: &[char], c: char) -> Option<usize> {
471 let Some(end) = p.iter().skip(2).position(|x| *x == ']').map(|i| i + 2) else {
472 return (c == '[').then_some(1);
473 };
474 let body = &p[1..end];
475 let (neg, body) = match body.first() {
476 Some('!' | '^') => (true, &body[1..]),
477 _ => (false, body),
478 };
479 let mut hit = false;
480 let mut i = 0;
481 while i < body.len() {
482 if i + 2 < body.len() && body[i + 1] == '-' {
483 hit |= (body[i]..=body[i + 2]).contains(&c);
484 i += 3;
485 } else {
486 hit |= body[i] == c;
487 i += 1;
488 }
489 }
490 (hit != neg).then_some(end + 1)
491}
492
493/// Sort `files` in place. `meta` supplies (mtime seconds, size) for the stat-based keys.
494pub fn sort_files(
495 files: &mut [PathBuf],
496 key: SortKey,
497 reverse: bool,
498 meta: impl Fn(&Path) -> (i64, u64),
499 seed: u64,
500) {
501 match key {
502 SortKey::Natural => files.sort_by(|a, b| natural_cmp_path(a, b)),
503 SortKey::Name => files.sort(),
504 SortKey::Mtime => {
505 files.sort_by_cached_key(|p| meta(p).0);
506 }
507 SortKey::Size => {
508 files.sort_by_cached_key(|p| meta(p).1);
509 }
510 SortKey::Random => shuffle(files, seed),
511 }
512 if reverse {
513 files.reverse();
514 }
515}
516
517fn shuffle<T>(items: &mut [T], seed: u64) {
518 // xorshift64*: plenty for shuffling a file list.
519 let mut s = seed | 1;
520 for i in (1..items.len()).rev() {
521 s ^= s >> 12;
522 s ^= s << 25;
523 s ^= s >> 27;
524 let r = s.wrapping_mul(0x2545_F491_4F6C_DD1D);
525 items.swap(i, (r % (i as u64 + 1)) as usize);
526 }
527}
528
529pub fn natural_cmp_path(a: &Path, b: &Path) -> Ordering {
530 natural_cmp(&a.to_string_lossy(), &b.to_string_lossy())
531}
532
533/// Compare strings with digit runs ordered numerically and letters case-insensitively,
534/// so `img2` sorts before `img10` and `B` sits next to `b`.
535pub fn natural_cmp(a: &str, b: &str) -> Ordering {
536 let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());
537 loop {
538 match (x.peek().copied(), y.peek().copied()) {
539 (None, None) => return a.cmp(b),
540 (None, Some(_)) => return Ordering::Less,
541 (Some(_), None) => return Ordering::Greater,
542 (Some(c), Some(d)) if c.is_ascii_digit() && d.is_ascii_digit() => {
543 let take = |it: &mut std::iter::Peekable<std::str::Chars>| {
544 let mut s = String::new();
545 while let Some(&c) = it.peek().filter(|c| c.is_ascii_digit()) {
546 s.push(c);
547 it.next();
548 }
549 s
550 };
551 let (m, n) = (take(&mut x), take(&mut y));
552 let (mt, nt) = (m.trim_start_matches('0'), n.trim_start_matches('0'));
553 let ord = mt.len().cmp(&nt.len()).then_with(|| mt.cmp(nt));
554 if ord != Ordering::Equal {
555 return ord;
556 }
557 }
558 (Some(c), Some(d)) => {
559 let ord = c.to_lowercase().cmp(d.to_lowercase());
560 if ord != Ordering::Equal {
561 return ord;
562 }
563 x.next();
564 y.next();
565 }
566 }
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 fn paths(names: &[&str]) -> Vec<PathBuf> {
575 names.iter().map(PathBuf::from).collect()
576 }
577
578 #[test]
579 fn natural_order() {
580 let mut v = vec![
581 "img10.png",
582 "img2.png",
583 "Img1.png",
584 "img02.png",
585 "a.png",
586 "img1b.png",
587 ];
588 v.sort_by(|a, b| natural_cmp(a, b));
589 assert_eq!(
590 v,
591 [
592 "a.png",
593 "Img1.png",
594 "img1b.png",
595 "img02.png",
596 "img2.png",
597 "img10.png"
598 ]
599 );
600 }
601
602 #[test]
603 fn step_wraps_or_clamps() {
604 let mut q = Queue::new(paths(&["a", "b", "c"]), None, 0);
605 assert!(q.step(-1, true));
606 assert_eq!(q.index(), 2);
607 assert!(!q.step(1, false));
608 assert!(q.step(-5, false));
609 assert_eq!(q.index(), 0);
610 }
611
612 #[test]
613 fn starts_on_named_file() {
614 let q = Queue::new(paths(&["a", "b", "c"]), Some(Path::new("b")), 0);
615 assert_eq!(q.current(), Some(Path::new("b")));
616 let q = Queue::new(paths(&["a", "b"]), None, 9);
617 assert_eq!(q.index(), 1);
618 }
619
620 #[test]
621 fn remove_keeps_position() {
622 let mut q = Queue::new(paths(&["a", "b", "c"]), Some(Path::new("b")), 0);
623 q.remove(Path::new("a"));
624 assert_eq!(q.current(), Some(Path::new("b")));
625 q.remove(Path::new("b"));
626 assert_eq!(q.current(), Some(Path::new("c")));
627 q.remove(Path::new("c"));
628 assert!(q.current().is_none());
629 }
630
631 #[test]
632 fn insert_sorted_keeps_current() {
633 let mut q = Queue::new(paths(&["img1", "img3"]), Some(Path::new("img3")), 0);
634 q.insert_sorted(paths(&["img2", "img10", "img1"]));
635 assert_eq!(q.len(), 4);
636 assert_eq!(q.get(1), Some(Path::new("img2")));
637 assert_eq!(q.get(3), Some(Path::new("img10")));
638 assert_eq!(q.current(), Some(Path::new("img3")));
639 }
640
641 #[test]
642 fn neighbours_nearest_first() {
643 let q = Queue::new(paths(&["a", "b", "c", "d", "e"]), None, 0);
644 assert_eq!(q.neighbours(2, false), vec![1, 2]);
645 assert_eq!(q.neighbours(2, true), vec![1, 4, 2, 3]);
646 let q = Queue::new(paths(&["a", "b"]), None, 0);
647 assert_eq!(q.neighbours(2, true), vec![1]);
648 }
649
650 #[test]
651 fn reverse_and_reorder_keep_current() {
652 let mut q = Queue::new(paths(&["a", "b", "c", "d"]), Some(Path::new("b")), 0);
653 q.reverse();
654 assert_eq!(q.files(), paths(&["d", "c", "b", "a"]));
655 assert_eq!(q.current(), Some(Path::new("b")));
656 q.remove(Path::new("d"));
657 q.reorder(paths(&["a", "b", "d", "c"]));
658 assert_eq!(q.files(), paths(&["a", "b", "c"]));
659 assert_eq!(q.current(), Some(Path::new("b")));
660 q.reorder(paths(&["c"]));
661 assert_eq!(q.files(), paths(&["c", "a", "b"]));
662 assert_eq!(q.current(), Some(Path::new("b")));
663 let mut empty = Queue::default();
664 empty.reverse();
665 assert!(empty.is_empty());
666 }
667
668 #[test]
669 fn marks_follow_files() {
670 let mut q = Queue::new(paths(&["a", "b", "c", "d"]), None, 0);
671 assert!(q.set_mark(1, true));
672 assert!(q.set_mark(3, true));
673 let v = q.marks_version();
674 assert!(!q.set_mark(3, true));
675 assert_eq!(q.marks_version(), v);
676 assert_eq!(q.marked_count(), 2);
677 q.reverse();
678 assert!(q.is_marked(0) && q.is_marked(2));
679 assert_eq!(q.marked(), [Path::new("d"), Path::new("b")]);
680 q.invert_marks();
681 assert_eq!(q.marked(), [Path::new("c"), Path::new("a")]);
682 q.remove(Path::new("c"));
683 assert_eq!(q.marked_count(), 1);
684 q.mark_all();
685 assert_eq!(q.marked_count(), 3);
686 q.unmark_all();
687 assert_eq!(q.marked_count(), 0);
688 }
689
690 #[test]
691 fn finding_marks() {
692 let mut q = Queue::new(paths(&["a", "b", "c", "d", "e"]), None, 2);
693 assert_eq!(q.find_mark(true, 1, true), None);
694 q.set_mark(0, true);
695 q.set_mark(4, true);
696 assert_eq!(q.find_mark(true, 1, true), Some(4));
697 assert_eq!(q.find_mark(true, 2, true), Some(0));
698 assert_eq!(q.find_mark(false, 1, true), Some(0));
699 assert_eq!(q.find_mark(false, 2, false), Some(0));
700 q.goto(4);
701 assert_eq!(q.find_mark(true, 1, false), None);
702 assert_eq!(q.find_mark(true, 1, true), Some(0));
703 // The current file alone is not a next mark.
704 q.unmark_all();
705 q.set_mark(4, true);
706 assert_eq!(q.find_mark(true, 1, true), None);
707 }
708
709 #[test]
710 fn globs() {
711 assert!(glob_match("*.png", "a.png"));
712 assert!(!glob_match("*.png", "a.png.jpg"));
713 assert!(glob_match("img?.*", "img1.jpg"));
714 assert!(glob_match("[a-c]*", "beach.jpg"));
715 assert!(!glob_match("[!a-c]*", "beach.jpg"));
716 assert!(glob_match("*a*b*", "xxaYYbzz"));
717 assert!(glob_match("[", "["));
718 assert!(glob_match("", ""));
719 assert!(!glob_match("", "a"));
720 }
721
722 #[test]
723 fn filters_hide_and_restore() {
724 let mut q = Queue::new(
725 paths(&["a.png", "b.jpg", "c.png", "d.jpg"]),
726 Some(Path::new("b.jpg")),
727 0,
728 );
729 q.set_mark(1, true);
730 // The current file is hidden: the next shown one after it is selected.
731 assert!(q.set_filter(Some("*.PNG".into())));
732 assert_eq!(q.files(), paths(&["a.png", "c.png"]));
733 assert_eq!(q.current(), Some(Path::new("c.png")));
734 assert_eq!(q.all_files().len(), 4);
735 // Hidden marks still count and print.
736 assert_eq!(q.marked(), [Path::new("b.jpg")]);
737 q.insert_sorted(paths(&["e.png", "f.jpg"]));
738 assert_eq!(q.files(), paths(&["a.png", "c.png", "e.png"]));
739 q.remove(Path::new("f.jpg"));
740 q.remove(Path::new("b.jpg"));
741 assert_eq!(q.marked_count(), 0);
742 // Search narrows within the filter; substring filters are plain text.
743 q.set_search(Some("ep".into()));
744 assert_eq!(q.files(), paths(&["e.png"]));
745 q.set_search(None);
746 q.set_filter(Some("c".into()));
747 assert_eq!(q.files(), paths(&["c.png"]));
748 assert!(!q.set_filter(None));
749 assert_eq!(q.files(), paths(&["a.png", "c.png", "d.jpg", "e.png"]));
750 assert_eq!(q.current(), Some(Path::new("c.png")));
751 // Nothing matches: an empty view, then everything back.
752 q.set_filter(Some("zzz".into()));
753 assert!(q.is_empty());
754 q.set_filter(None);
755 assert_eq!(q.current(), Some(Path::new("c.png")));
756 }
757
758 #[test]
759 fn random_sort_is_a_permutation() {
760 let mut v = paths(&["a", "b", "c", "d", "e"]);
761 sort_files(&mut v, SortKey::Random, false, |_| (0, 0), 42);
762 let mut s = v.clone();
763 s.sort();
764 assert_eq!(s, paths(&["a", "b", "c", "d", "e"]));
765 }
766}