From ef2a0a7915c02fc03b2fde27c08aa42bd4960153 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 01:41:01 +0900 Subject: [PATCH] Add viewer navigation and file downloads --- src/browser.rs | 8 +++ src/viewer.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++++--- src/window.rs | 108 ++++++++++++++++++++++++------------ 3 files changed, 221 insertions(+), 43 deletions(-) diff --git a/src/browser.rs b/src/browser.rs index d70ef56..1d5eb2f 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -15,6 +15,10 @@ impl EntryKind { pub fn is_viewable(self) -> bool { matches!(self, Self::ZipArchive | Self::Image | Self::Video) } + + pub fn is_media(self) -> bool { + matches!(self, Self::Image | Self::Video) + } } #[derive(Debug, Clone)] @@ -194,6 +198,10 @@ mod tests { assert_eq!(classify_file(Path::new("movie.MP4")), EntryKind::Video); assert_eq!(classify_file(Path::new("movie.mkv")), EntryKind::Video); assert_eq!(classify_file(Path::new("notes.txt")), EntryKind::OtherFile); + assert!(EntryKind::Image.is_media()); + assert!(EntryKind::Video.is_media()); + assert!(!EntryKind::ZipArchive.is_media()); + assert!(!EntryKind::OtherFile.is_media()); } #[test] diff --git a/src/viewer.rs b/src/viewer.rs index 9e85b6a..9fc5108 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use std::sync::mpsc::{self, TryRecvError}; @@ -21,13 +21,42 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64; pub struct ViewerWindow; -pub type SiblingNavigator = Rc bool>; +pub struct SiblingNavigator { + current: usize, + count: usize, + open: Rc bool>, +} + +impl SiblingNavigator { + pub fn new(current: usize, count: usize, open: Rc bool>) -> Self { + Self { + current, + count, + open, + } + } + + fn open_relative(&self, forward: bool) -> bool { + let Some(index) = sibling_index(self.current, self.count, forward) else { + return false; + }; + (self.open)(index) + } + + fn open_index(&self, index: usize) -> bool { + index < self.count && index != self.current && (self.open)(index) + } +} pub struct FavoriteControl { pub active: bool, pub toggle: Rc bool>, } +pub struct FileDownloadControl { + pub download: Rc, +} + impl ViewerWindow { pub fn open( application: >k::Application, @@ -36,6 +65,7 @@ impl ViewerWindow { fast_image_loading: bool, sibling_navigator: Option, favorite_control: FavoriteControl, + download_control: FileDownloadControl, ) { let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() @@ -47,6 +77,8 @@ impl ViewerWindow { let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); + let download_button = gtk::Button::with_label("파일 다운로드"); + header.pack_end(&download_button); header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); @@ -162,6 +194,8 @@ impl ViewerWindow { sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, + download_button, + file_download: download_control.download, state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -240,6 +274,8 @@ struct ViewerController { sibling_navigator: Option, favorite_button: gtk::Button, favorite_toggle: Rc bool>, + download_button: gtk::Button, + file_download: Rc, state: RefCell, } @@ -269,6 +305,13 @@ impl ViewerController { controller.favorite_button.set_label(favorite_label(active)); }); let controller = Rc::clone(self); + self.download_button.connect_clicked(move |_| { + (controller.file_download)( + controller.window.clone().upcast(), + controller.status_label.clone(), + ); + }); + let controller = Rc::clone(self); self.page_seek.connect_value_changed(move |scale| { if let Some(index) = page_seek_index(scale.value(), controller.state.borrow().pages.len()) @@ -339,7 +382,7 @@ impl ViewerController { fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator - && navigate(forward) + && navigate.open_relative(forward) { self.window.close(); } @@ -945,6 +988,7 @@ impl MediaViewerWindow { kind: EntryKind, sibling_navigator: Option, favorite_control: FavoriteControl, + download_control: FileDownloadControl, ) { let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() @@ -956,6 +1000,8 @@ impl MediaViewerWindow { let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); + let download_button = gtk::Button::with_label("파일 다운로드"); + header.pack_end(&download_button); header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); @@ -982,7 +1028,19 @@ impl MediaViewerWindow { .margin_start(8) .margin_end(8) .build(); + let media_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); + media_seek.set_draw_value(false); + media_seek.set_margin_start(8); + media_seek.set_margin_end(8); + if let Some(navigator) = &sibling_navigator { + media_seek.set_range(0.0, navigator.count.saturating_sub(1) as f64); + media_seek.set_value(navigator.current as f64); + media_seek.set_sensitive(navigator.count > 1); + } else { + media_seek.set_sensitive(false); + } root.append(&stack); + root.append(&media_seek); root.append(&status_label); window.set_child(Some(&root)); @@ -993,9 +1051,14 @@ impl MediaViewerWindow { video, stack, status_label, + media_seek, sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, + download_button, + file_download: download_control.download, + seek_generation: Cell::new(0), + seek_opening: Cell::new(false), }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); @@ -1027,9 +1090,14 @@ struct MediaViewerController { video: gtk::Video, stack: gtk::Stack, status_label: gtk::Label, + media_seek: gtk::Scale, sibling_navigator: Option, favorite_button: gtk::Button, favorite_toggle: Rc bool>, + download_button: gtk::Button, + file_download: Rc, + seek_generation: Cell, + seek_opening: Cell, } impl MediaViewerController { @@ -1061,19 +1129,67 @@ impl MediaViewerController { let active = (controller.favorite_toggle)(); controller.favorite_button.set_label(favorite_label(active)); }); + let controller = Rc::clone(self); + self.download_button.connect_clicked(move |_| { + (controller.file_download)( + controller.window.clone().upcast(), + controller.status_label.clone(), + ); + }); + let controller = Rc::clone(self); + self.media_seek.connect_value_changed(move |scale| { + let Some(navigator) = &controller.sibling_navigator else { + return; + }; + let Some(index) = page_seek_index(scale.value(), navigator.count) else { + return; + }; + controller.schedule_seek(index); + }); } fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator - && navigate(forward) + && navigate.open_relative(forward) { - if let Some(stream) = self.video.media_stream() { - stream.pause(); - } - self.window.close(); + self.close_for_navigation(); } } + fn schedule_seek(self: &Rc, index: usize) { + let Some(navigator) = &self.sibling_navigator else { + return; + }; + if index == navigator.current || self.seek_opening.get() { + return; + } + let generation = self.seek_generation.get().wrapping_add(1); + self.seek_generation.set(generation); + let controller = Rc::downgrade(self); + glib::timeout_add_local_once(Duration::from_millis(180), move || { + let Some(controller) = controller.upgrade() else { + return; + }; + if controller.seek_generation.get() != generation || controller.seek_opening.get() { + return; + } + let Some(navigator) = &controller.sibling_navigator else { + return; + }; + if navigator.open_index(index) { + controller.seek_opening.set(true); + controller.close_for_navigation(); + } + }); + } + + fn close_for_navigation(&self) { + if let Some(stream) = self.video.media_stream() { + stream.pause(); + } + self.window.close(); + } + fn toggle_fullscreen(&self) { if self.window.is_fullscreen() { self.window.unfullscreen(); @@ -1196,6 +1312,14 @@ fn page_seek_index(value: f64, count: usize) -> Option { (count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1)) } +fn sibling_index(current: usize, count: usize, forward: bool) -> Option { + if forward { + (current + 1 < count).then_some(current + 1) + } else { + current.checked_sub(1) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1245,4 +1369,12 @@ mod tests { assert_eq!(page_seek_index(20.0, 10), Some(9)); assert_eq!(page_seek_index(0.0, 0), None); } + + #[test] + fn sibling_navigation_stops_at_boundaries() { + assert_eq!(sibling_index(1, 3, false), Some(0)); + assert_eq!(sibling_index(1, 3, true), Some(2)); + assert_eq!(sibling_index(0, 3, false), None); + assert_eq!(sibling_index(2, 3, true), None); + } } diff --git a/src/window.rs b/src/window.rs index 95c4229..d95852a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -20,7 +20,9 @@ use crate::source::{ display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root, mount_operation, parent_within_root, root_file, validate_profile, }; -use crate::viewer::{FavoriteControl, MediaViewerWindow, SiblingNavigator, ViewerWindow}; +use crate::viewer::{ + FavoriteControl, FileDownloadControl, MediaViewerWindow, SiblingNavigator, ViewerWindow, +}; use crate::zip_cache::ZipCache; pub struct ComicWindow { @@ -607,12 +609,13 @@ impl BrowserController { self.refresh(); } EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { + let current_kind = entry.kind; let siblings = { let state = self.state.borrow(); let mut entries = state .entries .iter() - .filter(|entry| entry.kind.is_viewable()) + .filter(|entry| navigable_sibling(current_kind, entry.kind)) .cloned() .collect::>(); sort_entries(&mut entries, state.sort_field, state.sort_direction); @@ -637,7 +640,19 @@ impl BrowserController { match entry.kind { EntryKind::ZipArchive => self.open_archive(entry, siblings, current, profile), EntryKind::Image | EntryKind::Video => { - self.open_media(entry, siblings, current, profile) + let media_siblings = Rc::new( + siblings + .iter() + .filter(|sibling| sibling.kind.is_media()) + .cloned() + .collect::>(), + ); + if let Some(current) = media_siblings + .iter() + .position(|sibling| sibling.uri == entry.uri) + { + self.open_media(entry, media_siblings, current, profile); + } } _ => {} } @@ -650,16 +665,17 @@ impl BrowserController { profile: LocationProfile, ) -> SiblingNavigator { let controller = Rc::downgrade(self); - Rc::new(move |forward| { + let count = siblings.len(); + let open = Rc::new(move |next: usize| { let Some(controller) = controller.upgrade() else { return false; }; - let Some(next) = sibling_index(current, siblings.len(), forward) else { + if next >= siblings.len() { controller .status_label .set_text("더 이상 이동할 파일이 없습니다."); return false; - }; + } controller.open_viewable( siblings[next].clone(), Rc::clone(&siblings), @@ -667,7 +683,8 @@ impl BrowserController { profile.clone(), ); true - }) + }); + SiblingNavigator::new(current, count, open) } fn open_archive( @@ -682,6 +699,7 @@ impl BrowserController { let controller = Rc::clone(self); let navigator = self.sibling_navigator(siblings, current, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); + let download_control = self.download_control(&entry); self.zip_cache.prepare( entry, move |current, total| { @@ -701,6 +719,7 @@ impl BrowserController { controller.config.borrow().fast_image_loading, Some(navigator), favorite_control, + download_control, ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), @@ -722,6 +741,7 @@ impl BrowserController { let controller = Rc::clone(self); let navigator = self.sibling_navigator(siblings, current, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); + let download_control = self.download_control(&entry); self.zip_cache.prepare_media( entry, move |current, total| { @@ -740,6 +760,7 @@ impl BrowserController { kind, Some(navigator), favorite_control, + download_control, ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), @@ -761,8 +782,7 @@ impl BrowserController { return; }; let can_favorite = entry.kind == EntryKind::Directory || entry.kind.is_viewable(); - let can_download = - entry.kind != EntryKind::Directory && !gio::File::for_uri(&entry.uri).is_native(); + let can_download = entry.kind != EntryKind::Directory; if !can_favorite && !can_download { return; } @@ -792,13 +812,17 @@ impl BrowserController { }); } if can_download { - let download_button = gtk::Button::with_label("다운로드..."); + let download_button = gtk::Button::with_label("파일 다운로드..."); actions.append(&download_button); let controller = Rc::clone(self); let popover_to_close = popover.clone(); download_button.connect_clicked(move |_| { popover_to_close.popdown(); - controller.choose_download_destination(entry.clone()); + controller.choose_download_destination( + entry.clone(), + controller.window.clone().upcast(), + controller.status_label.clone(), + ); }); } popover.set_child(Some(&actions)); @@ -832,6 +856,19 @@ impl BrowserController { } } + fn download_control(self: &Rc, entry: &BrowserEntry) -> FileDownloadControl { + let entry = entry.clone(); + let controller = Rc::downgrade(self); + FileDownloadControl { + download: Rc::new(move |parent, status_label| { + let Some(controller) = controller.upgrade() else { + return; + }; + controller.choose_download_destination(entry.clone(), parent, status_label); + }), + } + } + fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { let mut updated = self.config.borrow().clone(); let was_active = self.is_favorite(&entry.uri); @@ -851,13 +888,17 @@ impl BrowserController { active } - fn choose_download_destination(self: &Rc, entry: BrowserEntry) { + fn choose_download_destination( + self: &Rc, + entry: BrowserEntry, + parent: gtk::Window, + status_label: gtk::Label, + ) { let dialog = gtk::FileDialog::builder() .title("다운로드 위치 선택") .initial_name(&entry.name) .build(); let controller = Rc::downgrade(self); - let parent = self.window.clone(); glib::MainContext::default().spawn_local(async move { let result = dialog.save_future(Some(&parent)).await; let Some(controller) = controller.upgrade() else { @@ -865,30 +906,27 @@ impl BrowserController { }; match result { Ok(destination) if destination.is_native() => { - controller.download_remote_file(entry, destination); + controller.download_file(entry, destination, status_label); } - Ok(_) => controller - .status_label - .set_text("로컬 파일 시스템 위치를 선택해 주세요."), + Ok(_) => status_label.set_text("로컬 파일 시스템 위치를 선택해 주세요."), Err(error) if error.matches(gio::IOErrorEnum::Cancelled) => {} - Err(error) => controller - .status_label - .set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")), + Err(error) => { + status_label.set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")); + } } }); } - fn download_remote_file(&self, entry: BrowserEntry, destination: gio::File) { + fn download_file(&self, entry: BrowserEntry, destination: gio::File, status_label: gtk::Label) { let Some(final_path) = destination.path() else { - self.status_label - .set_text("로컬 파일 시스템 위치를 선택해 주세요."); + status_label.set_text("로컬 파일 시스템 위치를 선택해 주세요."); return; }; let part_path = download_part_path(&final_path); let source = gio::File::for_uri(&entry.uri); let part_file = gio::File::for_path(&part_path); - let progress_label = self.status_label.clone(); - let result_label = self.status_label.clone(); + let progress_label = status_label.clone(); + let result_label = status_label; let expected_size = entry.size; source.copy_async( &part_file, @@ -1199,11 +1237,11 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt .find(|index| entries[*index].name.to_lowercase().starts_with(&query)) } -fn sibling_index(current: usize, count: usize, forward: bool) -> Option { - if forward { - (current + 1 < count).then_some(current + 1) - } else { - current.checked_sub(1) +fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { + match current { + EntryKind::ZipArchive => candidate.is_viewable(), + EntryKind::Image | EntryKind::Video => candidate.is_media(), + _ => false, } } @@ -1270,11 +1308,11 @@ mod tests { } #[test] - fn sibling_navigation_stops_at_boundaries() { - assert_eq!(sibling_index(1, 3, false), Some(0)); - assert_eq!(sibling_index(1, 3, true), Some(2)); - assert_eq!(sibling_index(0, 3, false), None); - assert_eq!(sibling_index(2, 3, true), None); + fn media_navigation_excludes_zip_archives() { + assert!(navigable_sibling(EntryKind::Image, EntryKind::Video)); + assert!(navigable_sibling(EntryKind::Video, EntryKind::Image)); + assert!(!navigable_sibling(EntryKind::Image, EntryKind::ZipArchive)); + assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); } #[test]