Sync viewer navigation with browser sorting

This commit is contained in:
burnintuna 2026-07-22 03:01:12 +09:00
parent bb73c1abea
commit f0d5cf3496
5 changed files with 375 additions and 87 deletions

View file

@ -6,7 +6,7 @@ use anyhow::{Context, Result, bail};
use image::ImageReader; use image::ImageReader;
use zip::ZipArchive; 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_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
const MAX_IMAGE_DIMENSION: u32 = 30_000; const MAX_IMAGE_DIMENSION: u32 = 30_000;
@ -24,7 +24,7 @@ pub struct DecodedPage {
pub rgba: Vec<u8>, pub rgba: Vec<u8>,
} }
pub fn list_pages(path: &Path) -> Result<Vec<ArchivePage>> { pub fn list_pages(path: &Path, case_insensitive: bool) -> Result<Vec<ArchivePage>> {
let file = File::open(path) let file = File::open(path)
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?; .with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
let mut archive = ZipArchive::new(file) let mut archive = ZipArchive::new(file)
@ -41,7 +41,7 @@ pub fn list_pages(path: &Path) -> Result<Vec<ArchivePage>> {
} }
} }
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) Ok(pages)
} }
@ -178,7 +178,7 @@ mod tests {
archive.write_all(b"ignored").unwrap(); archive.write_all(b"ignored").unwrap();
archive.finish().unwrap(); archive.finish().unwrap();
let pages = list_pages(&path).unwrap(); let pages = list_pages(&path, true).unwrap();
assert_eq!( assert_eq!(
pages pages
.iter() .iter()

View file

@ -45,7 +45,12 @@ pub enum SortDirection {
Descending, 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| { entries.sort_by(|left, right| {
let kind_order = match ( let kind_order = match (
left.kind == EntryKind::Directory, left.kind == EntryKind::Directory,
@ -60,11 +65,24 @@ pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: S
} }
match field { 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) 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) 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 left_chars = left.chars().peekable();
let mut right_chars = right.chars().peekable(); let mut right_chars = right.chars().peekable();
loop { loop {
match (left_chars.peek(), right_chars.peek()) { match (left_chars.peek(), right_chars.peek()) {
(None, None) => return Ordering::Equal, (None, None) => return left.cmp(right),
(None, Some(_)) => return Ordering::Less, (None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater, (Some(_), None) => return Ordering::Greater,
(Some(left_char), Some(right_char)) (Some(left_char), Some(right_char))
@ -143,10 +161,11 @@ pub fn natural_cmp(left: &str, right: &str) -> Ordering {
(Some(_), Some(_)) => { (Some(_), Some(_)) => {
let left_char = left_chars.next().unwrap(); let left_char = left_chars.next().unwrap();
let right_char = right_chars.next().unwrap(); let right_char = right_chars.next().unwrap();
let order = left_char let order = if case_insensitive {
.to_lowercase() left_char.to_lowercase().cmp(right_char.to_lowercase())
.cmp(right_char.to_lowercase()) } else {
.then_with(|| left_char.cmp(&right_char)); left_char.cmp(&right_char)
};
if order != Ordering::Equal { if order != Ordering::Equal {
return order; return order;
} }
@ -173,18 +192,30 @@ mod tests {
#[test] #[test]
fn natural_order_places_two_before_ten() { fn natural_order_places_two_before_ten() {
let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"]; 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"]); assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]);
} }
#[test] #[test]
fn natural_order_handles_numbers_larger_than_integers() { fn natural_order_handles_numbers_larger_than_integers() {
assert_eq!( assert_eq!(
natural_cmp("9.zip", "123456789012345678901234567890.zip"), natural_cmp_with_case("9.zip", "123456789012345678901234567890.zip", true),
Ordering::Less 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] #[test]
fn zip_extension_is_case_insensitive() { fn zip_extension_is_case_insensitive() {
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive); assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);

View file

@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
pub struct AppConfig { pub struct AppConfig {
pub cache_quota_gb: u64, pub cache_quota_gb: u64,
pub fast_image_loading: bool, pub fast_image_loading: bool,
pub case_insensitive_sort: bool,
pub viewer_mode: ViewerMode, pub viewer_mode: ViewerMode,
pub locations: Vec<LocationProfile>, pub locations: Vec<LocationProfile>,
pub favorites: Vec<FavoriteEntry>, pub favorites: Vec<FavoriteEntry>,
@ -20,6 +21,7 @@ impl Default for AppConfig {
Self { Self {
cache_quota_gb: 10, cache_quota_gb: 10,
fast_image_loading: true, fast_image_loading: true,
case_insensitive_sort: true,
viewer_mode: ViewerMode::FitWidth, viewer_mode: ViewerMode::FitWidth,
locations: Vec::new(), locations: Vec::new(),
favorites: Vec::new(), favorites: Vec::new(),
@ -167,6 +169,7 @@ mod tests {
#[test] #[test]
fn default_cache_quota_is_ten_gigabytes() { fn default_cache_quota_is_ten_gigabytes() {
assert_eq!(AppConfig::default().cache_quota_gb, 10); assert_eq!(AppConfig::default().cache_quota_gb, 10);
assert!(AppConfig::default().case_insensitive_sort);
} }
#[test] #[test]
@ -174,6 +177,7 @@ mod tests {
let config = AppConfig { let config = AppConfig {
cache_quota_gb: 0, cache_quota_gb: 0,
fast_image_loading: true, fast_image_loading: true,
case_insensitive_sort: true,
viewer_mode: ViewerMode::FitWidth, viewer_mode: ViewerMode::FitWidth,
locations: vec![LocationProfile { locations: vec![LocationProfile {
name: "테스트".into(), name: "테스트".into(),
@ -209,6 +213,7 @@ mod tests {
fn fast_image_loading_is_enabled_for_existing_configs() { fn fast_image_loading_is_enabled_for_existing_configs() {
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
assert!(decoded.fast_image_loading); assert!(decoded.fast_image_loading);
assert!(decoded.case_insensitive_sort);
assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth);
assert!(decoded.favorites.is_empty()); assert!(decoded.favorites.is_empty());
} }

View file

@ -22,33 +22,43 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64;
pub struct ViewerWindow; pub struct ViewerWindow;
pub struct SiblingNavigator { pub struct SiblingNavigator {
current: usize, position: Rc<dyn Fn() -> Option<(usize, usize)>>,
count: usize, navigate: Rc<dyn Fn(SiblingTarget, gtk::ApplicationWindow) -> bool>,
open: Rc<dyn Fn(usize, gtk::ApplicationWindow) -> bool>,
} }
impl SiblingNavigator { impl SiblingNavigator {
pub fn new( pub fn new(
current: usize, position: Rc<dyn Fn() -> Option<(usize, usize)>>,
count: usize, navigate: Rc<dyn Fn(SiblingTarget, gtk::ApplicationWindow) -> bool>,
open: Rc<dyn Fn(usize, gtk::ApplicationWindow) -> bool>,
) -> Self { ) -> Self {
Self { Self { position, navigate }
current,
count,
open,
}
} }
fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool { fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool {
let Some(index) = sibling_index(self.current, self.count, forward) else { (self.navigate)(SiblingTarget::Relative(forward), window)
return false;
};
(self.open)(index, window)
} }
fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool { 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<usize> {
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, source: ArchiveSource,
database: Rc<Database>, database: Rc<Database>,
fast_image_loading: bool, fast_image_loading: bool,
case_insensitive_sort: bool,
view_mode_control: ViewModeControl, view_mode_control: ViewModeControl,
controls: FileViewerControls, controls: FileViewerControls,
) { ) {
@ -190,7 +201,11 @@ impl ViewerWindow {
root.append(&overlay); root.append(&overlay);
root.append(&page_seek_row); root.append(&page_seek_row);
root.append(&status_label); 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 { let controller = Rc::new(ViewerController {
window, window,
@ -214,6 +229,7 @@ impl ViewerWindow {
mode_dropdown, mode_dropdown,
direction_dropdown, direction_dropdown,
fast_image_loading, fast_image_loading,
case_insensitive_sort,
sibling_navigator, sibling_navigator,
favorite_button, favorite_button,
favorite_toggle: favorite_control.toggle, favorite_toggle: favorite_control.toggle,
@ -224,6 +240,9 @@ impl ViewerWindow {
last_pointer_position: Cell::new(None), last_pointer_position: Cell::new(None),
key_controller: RefCell::new(None), key_controller: RefCell::new(None),
motion_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 { state: RefCell::new(ViewerState {
pages: Vec::new(), pages: Vec::new(),
anchor: 0, anchor: 0,
@ -300,6 +319,7 @@ struct ViewerController {
mode_dropdown: gtk::DropDown, mode_dropdown: gtk::DropDown,
direction_dropdown: gtk::DropDown, direction_dropdown: gtk::DropDown,
fast_image_loading: bool, fast_image_loading: bool,
case_insensitive_sort: bool,
sibling_navigator: Option<SiblingNavigator>, sibling_navigator: Option<SiblingNavigator>,
favorite_button: gtk::Button, favorite_button: gtk::Button,
favorite_toggle: Rc<dyn Fn() -> bool>, favorite_toggle: Rc<dyn Fn() -> bool>,
@ -310,6 +330,9 @@ struct ViewerController {
last_pointer_position: Cell<Option<(f64, f64)>>, last_pointer_position: Cell<Option<(f64, f64)>>,
key_controller: RefCell<Option<gtk::EventControllerKey>>, key_controller: RefCell<Option<gtk::EventControllerKey>>,
motion_controller: RefCell<Option<gtk::EventControllerMotion>>, motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
navigation_message: gtk::Box,
navigation_message_label: gtk::Label,
navigation_message_generation: Rc<Cell<u64>>,
state: RefCell<ViewerState>, state: RefCell<ViewerState>,
} }
@ -431,9 +454,26 @@ impl ViewerController {
&& navigate.open_relative(forward, self.window.clone()) && navigate.open_relative(forward, self.window.clone())
{ {
self.detach_window_controllers(); 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) { fn detach_window_controllers(&self) {
if let Some(controller) = self.key_controller.borrow_mut().take() { if let Some(controller) = self.key_controller.borrow_mut().take() {
self.window.remove_controller(&controller); self.window.remove_controller(&controller);
@ -500,9 +540,12 @@ impl ViewerController {
fn load_archive(self: &Rc<Self>) { fn load_archive(self: &Rc<Self>) {
self.spinner.start(); self.spinner.start();
let path = self.source.local_path.clone(); let path = self.source.local_path.clone();
let case_insensitive_sort = self.case_insensitive_sort;
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
thread::spawn(move || { 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); let controller = Rc::downgrade(self);
glib::timeout_add_local(Duration::from_millis(20), move || { glib::timeout_add_local(Duration::from_millis(20), move || {
@ -1139,17 +1182,24 @@ impl MediaViewerWindow {
.build(); .build();
let media_seek = navigation_scale(); let media_seek = navigation_scale();
let media_seek_row = navigation_scale_row(&media_seek); let media_seek_row = navigation_scale_row(&media_seek);
if let Some(navigator) = &sibling_navigator { if let Some((current, count)) = sibling_navigator
media_seek.set_range(0.0, navigation_scale_upper(navigator.count)); .as_ref()
media_seek.set_value(navigator.current as f64); .and_then(SiblingNavigator::position)
media_seek.set_sensitive(navigator.count > 1); {
media_seek.set_range(0.0, navigation_scale_upper(count));
media_seek.set_value(current as f64);
media_seek.set_sensitive(count > 1);
} else { } else {
media_seek.set_sensitive(false); media_seek.set_sensitive(false);
} }
root.append(&stack); root.append(&stack);
root.append(&media_seek_row); root.append(&media_seek_row);
root.append(&status_label); 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 { let controller = Rc::new(MediaViewerController {
window, window,
@ -1171,6 +1221,9 @@ impl MediaViewerWindow {
last_pointer_position: Cell::new(None), last_pointer_position: Cell::new(None),
key_controller: RefCell::new(None), key_controller: RefCell::new(None),
motion_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() { if let Some(stream) = controller.video.media_stream() {
let status_label = controller.status_label.clone(); let status_label = controller.status_label.clone();
@ -1215,6 +1268,9 @@ struct MediaViewerController {
last_pointer_position: Cell<Option<(f64, f64)>>, last_pointer_position: Cell<Option<(f64, f64)>>,
key_controller: RefCell<Option<gtk::EventControllerKey>>, key_controller: RefCell<Option<gtk::EventControllerKey>>,
motion_controller: RefCell<Option<gtk::EventControllerMotion>>, motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
navigation_message: gtk::Box,
navigation_message_label: gtk::Label,
navigation_message_generation: Rc<Cell<u64>>,
} }
impl MediaViewerController { impl MediaViewerController {
@ -1269,7 +1325,10 @@ impl MediaViewerController {
let Some(navigator) = &controller.sibling_navigator else { let Some(navigator) = &controller.sibling_navigator else {
return; 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; return;
}; };
controller.schedule_seek(index); controller.schedule_seek(index);
@ -1281,13 +1340,40 @@ impl MediaViewerController {
&& navigate.open_relative(forward, self.window.clone()) && navigate.open_relative(forward, self.window.clone())
{ {
self.prepare_for_navigation(); 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<Self>) { fn show_seek_temporarily(self: &Rc<Self>) {
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
timer.remove(); 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); self.set_seek_visible(true);
let controller = Rc::downgrade(self); let controller = Rc::downgrade(self);
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { 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 { let Some(navigator) = &self.sibling_navigator else {
return; return;
}; };
if index == navigator.current || self.seek_opening.get() { if navigator
.position()
.is_none_or(|(current, _)| index == current)
|| self.seek_opening.get()
{
return; return;
} }
let generation = self.seek_generation.get().wrapping_add(1); let generation = self.seek_generation.get().wrapping_add(1);
@ -1452,6 +1542,31 @@ fn navigation_scale() -> gtk::Scale {
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: &gtk::Scale) -> gtk::Box { fn navigation_scale_row(scale: &gtk::Scale) -> gtk::Box {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 0); let row = gtk::Box::new(gtk::Orientation::Horizontal, 0);
row.set_height_request(42); row.set_height_request(42);
@ -1591,4 +1706,16 @@ mod tests {
assert_eq!(sibling_index(0, 3, false), None); assert_eq!(sibling_index(0, 3, false), None);
assert_eq!(sibling_index(2, 3, true), 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),
"더 이상 다음 파일이 없습니다."
);
}
} }

View file

@ -22,7 +22,7 @@ use crate::source::{
}; };
use crate::viewer::{ use crate::viewer::{
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator, FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator,
ViewModeControl, ViewerWindow, SiblingTarget, ViewModeControl, ViewerWindow,
}; };
use crate::zip_cache::ZipCache; use crate::zip_cache::ZipCache;
@ -94,6 +94,8 @@ impl ComicWindow {
.build(); .build();
let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]);
let direction_button = gtk::ToggleButton::with_label("오름차순"); 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() let fast_loading_switch = gtk::Switch::builder()
.active(config.fast_image_loading) .active(config.fast_image_loading)
.tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지") .tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지")
@ -101,6 +103,7 @@ impl ComicWindow {
controls.append(&search_entry); controls.append(&search_entry);
controls.append(&sort_dropdown); controls.append(&sort_dropdown);
controls.append(&direction_button); controls.append(&direction_button);
controls.append(&case_insensitive_check);
controls.append(&gtk::Label::new(Some("빠른 이미지 로딩"))); controls.append(&gtk::Label::new(Some("빠른 이미지 로딩")));
controls.append(&fast_loading_switch); controls.append(&fast_loading_switch);
@ -157,6 +160,7 @@ impl ComicWindow {
refresh_button, refresh_button,
up_button, up_button,
add_location_button, add_location_button,
case_insensitive_check,
fast_loading_switch, fast_loading_switch,
); );
controller.refresh(); controller.refresh();
@ -208,6 +212,7 @@ impl BrowserController {
refresh_button: gtk::Button, refresh_button: gtk::Button,
up_button: gtk::Button, up_button: gtk::Button,
add_location_button: gtk::Button, add_location_button: gtk::Button,
case_insensitive_check: gtk::CheckButton,
fast_loading_switch: gtk::Switch, fast_loading_switch: gtk::Switch,
) { ) {
let controller = Rc::clone(self); let controller = Rc::clone(self);
@ -246,6 +251,10 @@ impl BrowserController {
self.direction_button self.direction_button
.connect_toggled(move |_| controller.change_sort_direction()); .connect_toggled(move |_| controller.change_sort_direction());
let controller = Rc::clone(self); 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 self.list_box
.connect_row_activated(move |_, row| controller.activate_row(row.index())); .connect_row_activated(move |_, row| controller.activate_row(row.index()));
@ -406,7 +415,7 @@ impl BrowserController {
etag: None, etag: None,
}; };
let siblings = Rc::new(vec![entry.clone()]); 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); self.list_box.remove(&child);
} }
let query = self.search_entry.text().to_lowercase(); 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 state = self.state.borrow_mut();
let mut visible_entries: Vec<_> = state let mut visible_entries: Vec<_> = state
.entries .entries
@ -509,7 +519,12 @@ impl BrowserController {
.filter(|entry| entry.name.to_lowercase().contains(query.as_str())) .filter(|entry| entry.name.to_lowercase().contains(query.as_str()))
.cloned() .cloned()
.collect(); .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 { for entry in &visible_entries {
self.list_box.append(&entry_row(entry)); self.list_box.append(&entry_row(entry));
} }
@ -631,20 +646,20 @@ impl BrowserController {
} }
EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => {
let current_kind = entry.kind; let current_kind = entry.kind;
let case_insensitive = self.config.borrow().case_insensitive_sort;
let siblings = { let siblings = {
let state = self.state.borrow(); let state = self.state.borrow();
let mut entries = state sorted_navigable_entries(
.entries &state.entries,
.iter() current_kind,
.filter(|entry| navigable_sibling(current_kind, entry.kind)) state.sort_field,
.cloned() state.sort_direction,
.collect::<Vec<_>>(); case_insensitive,
sort_entries(&mut entries, state.sort_field, state.sort_direction); )
entries
}; };
let siblings = Rc::new(siblings); let siblings = Rc::new(siblings);
if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { if siblings.iter().any(|item| item.uri == entry.uri) {
self.open_viewable(entry, siblings, current, self.current_profile(), None); self.open_viewable(entry, siblings, self.current_profile(), None);
} }
} }
EntryKind::OtherFile => {} EntryKind::OtherFile => {}
@ -655,14 +670,11 @@ impl BrowserController {
self: &Rc<Self>, self: &Rc<Self>,
entry: BrowserEntry, entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>, siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile, profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>, reuse_window: Option<gtk::ApplicationWindow>,
) { ) {
match entry.kind { match entry.kind {
EntryKind::ZipArchive => { EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window),
self.open_archive(entry, siblings, current, profile, reuse_window)
}
EntryKind::Image | EntryKind::Video => { EntryKind::Image | EntryKind::Video => {
let media_siblings = Rc::new( let media_siblings = Rc::new(
siblings siblings
@ -671,11 +683,11 @@ impl BrowserController {
.cloned() .cloned()
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
); );
if let Some(current) = media_siblings if media_siblings
.iter() .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( fn sibling_navigator(
self: &Rc<Self>, self: &Rc<Self>,
entry: &BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>, siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile, profile: LocationProfile,
) -> SiblingNavigator { ) -> SiblingNavigator {
let controller = Rc::downgrade(self); let current_uri = entry.uri.clone();
let count = siblings.len(); let current_kind = entry.kind;
let open = Rc::new(move |next: usize, reuse_window: gtk::ApplicationWindow| { let position_controller = Rc::downgrade(self);
let Some(controller) = controller.upgrade() else { let position_uri = current_uri.clone();
return false; let position_fallback = Rc::clone(&siblings);
}; let position = Rc::new(move || {
if next >= siblings.len() { let controller = position_controller.upgrade()?;
controller let siblings =
.status_label controller.navigation_siblings(&position_uri, current_kind, &position_fallback);
.set_text("더 이상 이동할 파일이 없습니다."); siblings
return false; .iter()
} .position(|entry| entry.uri == position_uri)
controller.open_viewable( .map(|current| (current, siblings.len()))
siblings[next].clone(),
Rc::clone(&siblings),
next,
profile.clone(),
Some(reuse_window),
);
true
}); });
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(&current_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<Vec<BrowserEntry>>,
) -> Rc<Vec<BrowserEntry>> {
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( fn open_archive(
self: &Rc<Self>, self: &Rc<Self>,
entry: BrowserEntry, entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>, siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile, profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>, reuse_window: Option<gtk::ApplicationWindow>,
) { ) {
@ -727,7 +779,7 @@ impl BrowserController {
); );
let result_label = progress_label.clone(); let result_label = progress_label.clone();
let controller = Rc::clone(self); 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 favorite_control = self.favorite_control(&entry, &profile);
let download_control = self.download_control(&entry); let download_control = self.download_control(&entry);
let view_mode_control = self.view_mode_control(); let view_mode_control = self.view_mode_control();
@ -749,6 +801,7 @@ impl BrowserController {
source, source,
Rc::clone(&controller.database), Rc::clone(&controller.database),
controller.config.borrow().fast_image_loading, controller.config.borrow().fast_image_loading,
controller.config.borrow().case_insensitive_sort,
view_mode_control, view_mode_control,
FileViewerControls { FileViewerControls {
sibling_navigator: Some(navigator), sibling_navigator: Some(navigator),
@ -771,7 +824,6 @@ impl BrowserController {
self: &Rc<Self>, self: &Rc<Self>,
entry: BrowserEntry, entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>, siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile, profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>, reuse_window: Option<gtk::ApplicationWindow>,
) { ) {
@ -784,7 +836,7 @@ impl BrowserController {
); );
let result_label = progress_label.clone(); let result_label = progress_label.clone();
let controller = Rc::clone(self); 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 favorite_control = self.favorite_control(&entry, &profile);
let download_control = self.download_control(&entry); let download_control = self.download_control(&entry);
self.zip_cache.prepare_media( self.zip_cache.prepare_media(
@ -1050,6 +1102,27 @@ impl BrowserController {
); );
} }
fn set_case_insensitive_sort(&self, check: &gtk::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: &gtk::Switch) { fn set_fast_image_loading(&self, switch: &gtk::Switch) {
let active = switch.is_active(); let active = switch.is_active();
let previous = self.config.borrow().fast_image_loading; 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<BrowserEntry> {
let mut siblings = entries
.iter()
.filter(|entry| navigable_sibling(current_kind, entry.kind))
.cloned()
.collect::<Vec<_>>();
sort_entries(&mut siblings, sort_field, sort_direction, case_insensitive);
siblings
}
fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry {
BrowserEntry { BrowserEntry {
uri: favorite.uri.clone(), 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] #[test]
fn typeahead_matches_case_insensitively() { fn typeahead_matches_case_insensitively() {
let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; let entries = [entry("Alpha"), entry("beta"), entry("Bravo")];
@ -1454,6 +1549,36 @@ mod tests {
assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); 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] #[test]
fn favorites_are_rendered_as_browser_paths_and_files() { fn favorites_are_rendered_as_browser_paths_and_files() {
let profile = home_profile(); let profile = home_profile();