From f0d5cf3496cc999d56b6f79d7393946fc51d6b34 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 03:01:12 +0900 Subject: [PATCH] Sync viewer navigation with browser sorting --- src/archive.rs | 8 +- src/browser.rs | 55 ++++++++++--- src/config.rs | 5 ++ src/viewer.rs | 177 ++++++++++++++++++++++++++++++++++------ src/window.rs | 217 ++++++++++++++++++++++++++++++++++++++----------- 5 files changed, 375 insertions(+), 87 deletions(-) diff --git a/src/archive.rs b/src/archive.rs index ec7ad0f..a3e5db0 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result, bail}; use image::ImageReader; use zip::ZipArchive; -use crate::browser::natural_cmp; +use crate::browser::natural_cmp_with_case; const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 30_000; @@ -24,7 +24,7 @@ pub struct DecodedPage { pub rgba: Vec, } -pub fn list_pages(path: &Path) -> Result> { +pub fn list_pages(path: &Path, case_insensitive: bool) -> Result> { let file = File::open(path) .with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?; let mut archive = ZipArchive::new(file) @@ -41,7 +41,7 @@ pub fn list_pages(path: &Path) -> Result> { } } - pages.sort_by(|left, right| natural_cmp(&left.name, &right.name)); + pages.sort_by(|left, right| natural_cmp_with_case(&left.name, &right.name, case_insensitive)); Ok(pages) } @@ -178,7 +178,7 @@ mod tests { archive.write_all(b"ignored").unwrap(); archive.finish().unwrap(); - let pages = list_pages(&path).unwrap(); + let pages = list_pages(&path, true).unwrap(); assert_eq!( pages .iter() diff --git a/src/browser.rs b/src/browser.rs index 1d5eb2f..defbd1d 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -45,7 +45,12 @@ pub enum SortDirection { Descending, } -pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) { +pub fn sort_entries( + entries: &mut [BrowserEntry], + field: SortField, + direction: SortDirection, + case_insensitive: bool, +) { entries.sort_by(|left, right| { let kind_order = match ( left.kind == EntryKind::Directory, @@ -60,11 +65,24 @@ pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: S } match field { - SortField::Name => apply_direction(natural_cmp(&left.name, &right.name), direction), + SortField::Name => apply_direction( + natural_cmp_with_case(&left.name, &right.name, case_insensitive), + direction, + ), SortField::Modified => compare_optional_time(left.modified, right.modified, direction) - .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)), + .then_with(|| { + apply_direction( + natural_cmp_with_case(&left.name, &right.name, case_insensitive), + direction, + ) + }), SortField::Created => compare_optional_time(left.created, right.created, direction) - .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)), + .then_with(|| { + apply_direction( + natural_cmp_with_case(&left.name, &right.name, case_insensitive), + direction, + ) + }), } }); } @@ -104,13 +122,13 @@ pub fn classify_file(path: &Path) -> EntryKind { } } -pub fn natural_cmp(left: &str, right: &str) -> Ordering { +pub fn natural_cmp_with_case(left: &str, right: &str, case_insensitive: bool) -> Ordering { let mut left_chars = left.chars().peekable(); let mut right_chars = right.chars().peekable(); loop { match (left_chars.peek(), right_chars.peek()) { - (None, None) => return Ordering::Equal, + (None, None) => return left.cmp(right), (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, (Some(left_char), Some(right_char)) @@ -143,10 +161,11 @@ pub fn natural_cmp(left: &str, right: &str) -> Ordering { (Some(_), Some(_)) => { let left_char = left_chars.next().unwrap(); let right_char = right_chars.next().unwrap(); - let order = left_char - .to_lowercase() - .cmp(right_char.to_lowercase()) - .then_with(|| left_char.cmp(&right_char)); + let order = if case_insensitive { + left_char.to_lowercase().cmp(right_char.to_lowercase()) + } else { + left_char.cmp(&right_char) + }; if order != Ordering::Equal { return order; } @@ -173,18 +192,30 @@ mod tests { #[test] fn natural_order_places_two_before_ten() { let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"]; - names.sort_by(|left, right| natural_cmp(left, right)); + names.sort_by(|left, right| natural_cmp_with_case(left, right, true)); assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]); } #[test] fn natural_order_handles_numbers_larger_than_integers() { assert_eq!( - natural_cmp("9.zip", "123456789012345678901234567890.zip"), + natural_cmp_with_case("9.zip", "123456789012345678901234567890.zip", true), Ordering::Less ); } + #[test] + fn natural_order_can_ignore_or_respect_case() { + assert_eq!( + natural_cmp_with_case("a2.jpg", "A10.jpg", true), + Ordering::Less + ); + assert_eq!( + natural_cmp_with_case("a2.jpg", "A10.jpg", false), + Ordering::Greater + ); + } + #[test] fn zip_extension_is_case_insensitive() { assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive); diff --git a/src/config.rs b/src/config.rs index d84f5de..9378f78 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; pub struct AppConfig { pub cache_quota_gb: u64, pub fast_image_loading: bool, + pub case_insensitive_sort: bool, pub viewer_mode: ViewerMode, pub locations: Vec, pub favorites: Vec, @@ -20,6 +21,7 @@ impl Default for AppConfig { Self { cache_quota_gb: 10, fast_image_loading: true, + case_insensitive_sort: true, viewer_mode: ViewerMode::FitWidth, locations: Vec::new(), favorites: Vec::new(), @@ -167,6 +169,7 @@ mod tests { #[test] fn default_cache_quota_is_ten_gigabytes() { assert_eq!(AppConfig::default().cache_quota_gb, 10); + assert!(AppConfig::default().case_insensitive_sort); } #[test] @@ -174,6 +177,7 @@ mod tests { let config = AppConfig { cache_quota_gb: 0, fast_image_loading: true, + case_insensitive_sort: true, viewer_mode: ViewerMode::FitWidth, locations: vec![LocationProfile { name: "테스트".into(), @@ -209,6 +213,7 @@ mod tests { fn fast_image_loading_is_enabled_for_existing_configs() { let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); assert!(decoded.fast_image_loading); + assert!(decoded.case_insensitive_sort); assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert!(decoded.favorites.is_empty()); } diff --git a/src/viewer.rs b/src/viewer.rs index 5217886..47904e8 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -22,33 +22,43 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64; pub struct ViewerWindow; pub struct SiblingNavigator { - current: usize, - count: usize, - open: Rc bool>, + position: Rc Option<(usize, usize)>>, + navigate: Rc bool>, } impl SiblingNavigator { pub fn new( - current: usize, - count: usize, - open: Rc bool>, + position: Rc Option<(usize, usize)>>, + navigate: Rc bool>, ) -> Self { - Self { - current, - count, - open, - } + Self { position, navigate } } fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool { - let Some(index) = sibling_index(self.current, self.count, forward) else { - return false; - }; - (self.open)(index, window) + (self.navigate)(SiblingTarget::Relative(forward), window) } fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool { - index < self.count && index != self.current && (self.open)(index, window) + (self.navigate)(SiblingTarget::Index(index), window) + } + + fn position(&self) -> Option<(usize, usize)> { + (self.position)() + } +} + +#[derive(Clone, Copy)] +pub enum SiblingTarget { + Relative(bool), + Index(usize), +} + +impl SiblingTarget { + pub fn resolve(self, current: usize, count: usize) -> Option { + match self { + Self::Relative(forward) => sibling_index(current, count, forward), + Self::Index(index) => (index < count && index != current).then_some(index), + } } } @@ -79,6 +89,7 @@ impl ViewerWindow { source: ArchiveSource, database: Rc, fast_image_loading: bool, + case_insensitive_sort: bool, view_mode_control: ViewModeControl, controls: FileViewerControls, ) { @@ -190,7 +201,11 @@ impl ViewerWindow { root.append(&overlay); root.append(&page_seek_row); root.append(&status_label); - window.set_child(Some(&root)); + let window_overlay = gtk::Overlay::new(); + window_overlay.set_child(Some(&root)); + let (navigation_message, navigation_message_label) = navigation_message(); + window_overlay.add_overlay(&navigation_message); + window.set_child(Some(&window_overlay)); let controller = Rc::new(ViewerController { window, @@ -214,6 +229,7 @@ impl ViewerWindow { mode_dropdown, direction_dropdown, fast_image_loading, + case_insensitive_sort, sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, @@ -224,6 +240,9 @@ impl ViewerWindow { last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), + navigation_message, + navigation_message_label, + navigation_message_generation: Rc::new(Cell::new(0)), state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -300,6 +319,7 @@ struct ViewerController { mode_dropdown: gtk::DropDown, direction_dropdown: gtk::DropDown, fast_image_loading: bool, + case_insensitive_sort: bool, sibling_navigator: Option, favorite_button: gtk::Button, favorite_toggle: Rc bool>, @@ -310,6 +330,9 @@ struct ViewerController { last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, + navigation_message: gtk::Box, + navigation_message_label: gtk::Label, + navigation_message_generation: Rc>, state: RefCell, } @@ -431,9 +454,26 @@ impl ViewerController { && navigate.open_relative(forward, self.window.clone()) { self.detach_window_controllers(); + } else { + self.show_navigation_error(forward); } } + fn show_navigation_error(&self, forward: bool) { + self.navigation_message_label + .set_text(navigation_error_message(forward)); + self.navigation_message.set_visible(true); + let message = self.navigation_message.clone(); + let generation = self.navigation_message_generation.get().wrapping_add(1); + self.navigation_message_generation.set(generation); + let current_generation = Rc::clone(&self.navigation_message_generation); + glib::timeout_add_local_once(Duration::from_secs(2), move || { + if current_generation.get() == generation { + message.set_visible(false); + } + }); + } + fn detach_window_controllers(&self) { if let Some(controller) = self.key_controller.borrow_mut().take() { self.window.remove_controller(&controller); @@ -500,9 +540,12 @@ impl ViewerController { fn load_archive(self: &Rc) { self.spinner.start(); let path = self.source.local_path.clone(); + let case_insensitive_sort = self.case_insensitive_sort; let (sender, receiver) = mpsc::channel(); thread::spawn(move || { - let _ = sender.send(list_pages(&path).map_err(|error| format!("{error:#}"))); + let _ = sender.send( + list_pages(&path, case_insensitive_sort).map_err(|error| format!("{error:#}")), + ); }); let controller = Rc::downgrade(self); glib::timeout_add_local(Duration::from_millis(20), move || { @@ -1139,17 +1182,24 @@ impl MediaViewerWindow { .build(); let media_seek = navigation_scale(); let media_seek_row = navigation_scale_row(&media_seek); - if let Some(navigator) = &sibling_navigator { - media_seek.set_range(0.0, navigation_scale_upper(navigator.count)); - media_seek.set_value(navigator.current as f64); - media_seek.set_sensitive(navigator.count > 1); + if let Some((current, count)) = sibling_navigator + .as_ref() + .and_then(SiblingNavigator::position) + { + media_seek.set_range(0.0, navigation_scale_upper(count)); + media_seek.set_value(current as f64); + media_seek.set_sensitive(count > 1); } else { media_seek.set_sensitive(false); } root.append(&stack); root.append(&media_seek_row); root.append(&status_label); - window.set_child(Some(&root)); + let window_overlay = gtk::Overlay::new(); + window_overlay.set_child(Some(&root)); + let (navigation_message, navigation_message_label) = navigation_message(); + window_overlay.add_overlay(&navigation_message); + window.set_child(Some(&window_overlay)); let controller = Rc::new(MediaViewerController { window, @@ -1171,6 +1221,9 @@ impl MediaViewerWindow { last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), + navigation_message, + navigation_message_label, + navigation_message_generation: Rc::new(Cell::new(0)), }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); @@ -1215,6 +1268,9 @@ struct MediaViewerController { last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, + navigation_message: gtk::Box, + navigation_message_label: gtk::Label, + navigation_message_generation: Rc>, } impl MediaViewerController { @@ -1269,7 +1325,10 @@ impl MediaViewerController { let Some(navigator) = &controller.sibling_navigator else { return; }; - let Some(index) = page_seek_index(scale.value(), navigator.count) else { + let Some((_, count)) = navigator.position() else { + return; + }; + let Some(index) = page_seek_index(scale.value(), count) else { return; }; controller.schedule_seek(index); @@ -1281,13 +1340,40 @@ impl MediaViewerController { && navigate.open_relative(forward, self.window.clone()) { self.prepare_for_navigation(); + } else { + self.show_navigation_error(forward); } } + fn show_navigation_error(&self, forward: bool) { + self.navigation_message_label + .set_text(navigation_error_message(forward)); + self.navigation_message.set_visible(true); + let message = self.navigation_message.clone(); + let generation = self.navigation_message_generation.get().wrapping_add(1); + self.navigation_message_generation.set(generation); + let current_generation = Rc::clone(&self.navigation_message_generation); + glib::timeout_add_local_once(Duration::from_secs(2), move || { + if current_generation.get() == generation { + message.set_visible(false); + } + }); + } + fn show_seek_temporarily(self: &Rc) { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } + if let Some((current, count)) = self + .sibling_navigator + .as_ref() + .and_then(SiblingNavigator::position) + { + self.media_seek + .set_range(0.0, navigation_scale_upper(count)); + self.media_seek.set_sensitive(count > 1); + self.media_seek.set_value(current as f64); + } self.set_seek_visible(true); let controller = Rc::downgrade(self); let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { @@ -1334,7 +1420,11 @@ impl MediaViewerController { let Some(navigator) = &self.sibling_navigator else { return; }; - if index == navigator.current || self.seek_opening.get() { + if navigator + .position() + .is_none_or(|(current, _)| index == current) + || self.seek_opening.get() + { return; } let generation = self.seek_generation.get().wrapping_add(1); @@ -1452,6 +1542,31 @@ fn navigation_scale() -> gtk::Scale { scale } +fn navigation_message() -> (gtk::Box, gtk::Label) { + let message = gtk::Box::new(gtk::Orientation::Horizontal, 0); + message.add_css_class("osd"); + message.set_halign(gtk::Align::Center); + message.set_valign(gtk::Align::Center); + message.set_can_target(false); + message.set_margin_top(12); + message.set_margin_bottom(12); + message.set_margin_start(18); + message.set_margin_end(18); + message.set_visible(false); + let label = gtk::Label::new(None); + label.add_css_class("title-2"); + message.append(&label); + (message, label) +} + +fn navigation_error_message(forward: bool) -> &'static str { + if forward { + "더 이상 다음 파일이 없습니다." + } else { + "더 이상 이전 파일이 없습니다." + } +} + fn navigation_scale_row(scale: >k::Scale) -> gtk::Box { let row = gtk::Box::new(gtk::Orientation::Horizontal, 0); row.set_height_request(42); @@ -1591,4 +1706,16 @@ mod tests { assert_eq!(sibling_index(0, 3, false), None); assert_eq!(sibling_index(2, 3, true), None); } + + #[test] + fn navigation_boundary_messages_match_direction() { + assert_eq!( + navigation_error_message(false), + "더 이상 이전 파일이 없습니다." + ); + assert_eq!( + navigation_error_message(true), + "더 이상 다음 파일이 없습니다." + ); + } } diff --git a/src/window.rs b/src/window.rs index 86730df..51159a1 100644 --- a/src/window.rs +++ b/src/window.rs @@ -22,7 +22,7 @@ use crate::source::{ }; use crate::viewer::{ FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator, - ViewModeControl, ViewerWindow, + SiblingTarget, ViewModeControl, ViewerWindow, }; use crate::zip_cache::ZipCache; @@ -94,6 +94,8 @@ impl ComicWindow { .build(); let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); let direction_button = gtk::ToggleButton::with_label("오름차순"); + let case_insensitive_check = gtk::CheckButton::with_label("대소문자 구분 안 함"); + case_insensitive_check.set_active(config.case_insensitive_sort); let fast_loading_switch = gtk::Switch::builder() .active(config.fast_image_loading) .tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지") @@ -101,6 +103,7 @@ impl ComicWindow { controls.append(&search_entry); controls.append(&sort_dropdown); controls.append(&direction_button); + controls.append(&case_insensitive_check); controls.append(>k::Label::new(Some("빠른 이미지 로딩"))); controls.append(&fast_loading_switch); @@ -157,6 +160,7 @@ impl ComicWindow { refresh_button, up_button, add_location_button, + case_insensitive_check, fast_loading_switch, ); controller.refresh(); @@ -208,6 +212,7 @@ impl BrowserController { refresh_button: gtk::Button, up_button: gtk::Button, add_location_button: gtk::Button, + case_insensitive_check: gtk::CheckButton, fast_loading_switch: gtk::Switch, ) { let controller = Rc::clone(self); @@ -246,6 +251,10 @@ impl BrowserController { self.direction_button .connect_toggled(move |_| controller.change_sort_direction()); let controller = Rc::clone(self); + case_insensitive_check.connect_toggled(move |check| { + controller.set_case_insensitive_sort(check); + }); + let controller = Rc::clone(self); self.list_box .connect_row_activated(move |_, row| controller.activate_row(row.index())); @@ -406,7 +415,7 @@ impl BrowserController { etag: None, }; let siblings = Rc::new(vec![entry.clone()]); - controller.open_viewable(entry, siblings, 0, favorite.profile, None); + controller.open_viewable(entry, siblings, favorite.profile, None); }); } @@ -502,6 +511,7 @@ impl BrowserController { self.list_box.remove(&child); } let query = self.search_entry.text().to_lowercase(); + let case_insensitive = self.config.borrow().case_insensitive_sort; let mut state = self.state.borrow_mut(); let mut visible_entries: Vec<_> = state .entries @@ -509,7 +519,12 @@ impl BrowserController { .filter(|entry| entry.name.to_lowercase().contains(query.as_str())) .cloned() .collect(); - sort_entries(&mut visible_entries, state.sort_field, state.sort_direction); + sort_entries( + &mut visible_entries, + state.sort_field, + state.sort_direction, + case_insensitive, + ); for entry in &visible_entries { self.list_box.append(&entry_row(entry)); } @@ -631,20 +646,20 @@ impl BrowserController { } EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { let current_kind = entry.kind; + let case_insensitive = self.config.borrow().case_insensitive_sort; let siblings = { let state = self.state.borrow(); - let mut entries = state - .entries - .iter() - .filter(|entry| navigable_sibling(current_kind, entry.kind)) - .cloned() - .collect::>(); - sort_entries(&mut entries, state.sort_field, state.sort_direction); - entries + sorted_navigable_entries( + &state.entries, + current_kind, + state.sort_field, + state.sort_direction, + case_insensitive, + ) }; let siblings = Rc::new(siblings); - if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { - self.open_viewable(entry, siblings, current, self.current_profile(), None); + if siblings.iter().any(|item| item.uri == entry.uri) { + self.open_viewable(entry, siblings, self.current_profile(), None); } } EntryKind::OtherFile => {} @@ -655,14 +670,11 @@ impl BrowserController { self: &Rc, entry: BrowserEntry, siblings: Rc>, - current: usize, profile: LocationProfile, reuse_window: Option, ) { match entry.kind { - EntryKind::ZipArchive => { - self.open_archive(entry, siblings, current, profile, reuse_window) - } + EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window), EntryKind::Image | EntryKind::Video => { let media_siblings = Rc::new( siblings @@ -671,11 +683,11 @@ impl BrowserController { .cloned() .collect::>(), ); - if let Some(current) = media_siblings + if media_siblings .iter() - .position(|sibling| sibling.uri == entry.uri) + .any(|sibling| sibling.uri == entry.uri) { - self.open_media(entry, media_siblings, current, profile, reuse_window); + self.open_media(entry, media_siblings, profile, reuse_window); } } _ => {} @@ -684,39 +696,79 @@ impl BrowserController { fn sibling_navigator( self: &Rc, + entry: &BrowserEntry, siblings: Rc>, - current: usize, profile: LocationProfile, ) -> SiblingNavigator { - let controller = Rc::downgrade(self); - let count = siblings.len(); - let open = Rc::new(move |next: usize, reuse_window: gtk::ApplicationWindow| { - let Some(controller) = controller.upgrade() else { - return false; - }; - if next >= siblings.len() { - controller - .status_label - .set_text("더 이상 이동할 파일이 없습니다."); - return false; - } - controller.open_viewable( - siblings[next].clone(), - Rc::clone(&siblings), - next, - profile.clone(), - Some(reuse_window), - ); - true + let current_uri = entry.uri.clone(); + let current_kind = entry.kind; + let position_controller = Rc::downgrade(self); + let position_uri = current_uri.clone(); + let position_fallback = Rc::clone(&siblings); + let position = Rc::new(move || { + let controller = position_controller.upgrade()?; + let siblings = + controller.navigation_siblings(&position_uri, current_kind, &position_fallback); + siblings + .iter() + .position(|entry| entry.uri == position_uri) + .map(|current| (current, siblings.len())) }); - SiblingNavigator::new(current, count, open) + + let controller = Rc::downgrade(self); + let navigate = Rc::new( + move |target: SiblingTarget, reuse_window: gtk::ApplicationWindow| { + let Some(controller) = controller.upgrade() else { + return false; + }; + let siblings = + controller.navigation_siblings(¤t_uri, current_kind, &siblings); + let Some(current) = siblings.iter().position(|entry| entry.uri == current_uri) + else { + return false; + }; + let Some(next) = target.resolve(current, siblings.len()) else { + return false; + }; + controller.open_viewable( + siblings[next].clone(), + Rc::clone(&siblings), + profile.clone(), + Some(reuse_window), + ); + true + }, + ); + SiblingNavigator::new(position, navigate) + } + + fn navigation_siblings( + &self, + current_uri: &str, + current_kind: EntryKind, + fallback: &Rc>, + ) -> Rc> { + let case_insensitive = self.config.borrow().case_insensitive_sort; + let state = self.state.borrow(); + let entries = sorted_navigable_entries( + &state.entries, + current_kind, + state.sort_field, + state.sort_direction, + case_insensitive, + ); + drop(state); + if entries.iter().any(|entry| entry.uri == current_uri) { + Rc::new(entries) + } else { + Rc::clone(fallback) + } } fn open_archive( self: &Rc, entry: BrowserEntry, siblings: Rc>, - current: usize, profile: LocationProfile, reuse_window: Option, ) { @@ -727,7 +779,7 @@ impl BrowserController { ); let result_label = progress_label.clone(); let controller = Rc::clone(self); - let navigator = self.sibling_navigator(siblings, current, profile.clone()); + let navigator = self.sibling_navigator(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); let view_mode_control = self.view_mode_control(); @@ -749,6 +801,7 @@ impl BrowserController { source, Rc::clone(&controller.database), controller.config.borrow().fast_image_loading, + controller.config.borrow().case_insensitive_sort, view_mode_control, FileViewerControls { sibling_navigator: Some(navigator), @@ -771,7 +824,6 @@ impl BrowserController { self: &Rc, entry: BrowserEntry, siblings: Rc>, - current: usize, profile: LocationProfile, reuse_window: Option, ) { @@ -784,7 +836,7 @@ impl BrowserController { ); let result_label = progress_label.clone(); let controller = Rc::clone(self); - let navigator = self.sibling_navigator(siblings, current, profile.clone()); + let navigator = self.sibling_navigator(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); self.zip_cache.prepare_media( @@ -1050,6 +1102,27 @@ impl BrowserController { ); } + fn set_case_insensitive_sort(&self, check: >k::CheckButton) { + let active = check.is_active(); + let previous = self.config.borrow().case_insensitive_sort; + if active == previous { + return; + } + let mut updated = self.config.borrow().clone(); + updated.case_insensitive_sort = active; + match updated.save(&self.config_path) { + Ok(()) => { + *self.config.borrow_mut() = updated; + self.render(); + } + Err(error) => { + self.status_label + .set_text(&format!("정렬 설정을 저장할 수 없습니다: {error:#}")); + check.set_active(previous); + } + } + } + fn set_fast_image_loading(&self, switch: >k::Switch) { let active = switch.is_active(); let previous = self.config.borrow().fast_image_loading; @@ -1339,6 +1412,22 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { } } +fn sorted_navigable_entries( + entries: &[BrowserEntry], + current_kind: EntryKind, + sort_field: SortField, + sort_direction: SortDirection, + case_insensitive: bool, +) -> Vec { + let mut siblings = entries + .iter() + .filter(|entry| navigable_sibling(current_kind, entry.kind)) + .cloned() + .collect::>(); + sort_entries(&mut siblings, sort_field, sort_direction, case_insensitive); + siblings +} + fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { BrowserEntry { uri: favorite.uri.clone(), @@ -1432,6 +1521,12 @@ mod tests { } } + fn media_entry(name: &str) -> BrowserEntry { + let mut entry = entry(name); + entry.kind = EntryKind::Image; + entry + } + #[test] fn typeahead_matches_case_insensitively() { let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; @@ -1454,6 +1549,36 @@ mod tests { assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); } + #[test] + fn media_navigation_recalculates_position_for_case_sorting() { + let entries = [media_entry("a2.jpg"), media_entry("A10.jpg")]; + let insensitive = sorted_navigable_entries( + &entries, + EntryKind::Image, + SortField::Name, + SortDirection::Ascending, + true, + ); + let sensitive = sorted_navigable_entries( + &entries, + EntryKind::Image, + SortField::Name, + SortDirection::Ascending, + false, + ); + + assert_eq!(insensitive[0].name, "a2.jpg"); + assert_eq!(sensitive[0].name, "A10.jpg"); + assert_eq!( + insensitive.iter().position(|entry| entry.name == "a2.jpg"), + Some(0) + ); + assert_eq!( + sensitive.iter().position(|entry| entry.name == "a2.jpg"), + Some(1) + ); + } + #[test] fn favorites_are_rendered_as_browser_paths_and_files() { let profile = home_profile();