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

@ -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(&gtk::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::<Vec<_>>();
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<Self>,
entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) {
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::<Vec<_>>(),
);
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<Self>,
entry: &BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>,
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(&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(
self: &Rc<Self>,
entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) {
@ -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<Self>,
entry: BrowserEntry,
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) {
@ -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: &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) {
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<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 {
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();