From f4a0c484ffc0775ad773123bb13bd47f43e302e1 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 24 Jul 2026 02:36:05 +0900 Subject: [PATCH] Release v0.2.7 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 +- src/viewer.rs | 301 +++++++++++++++--- src/window.rs | 24 +- 6 files changed, 273 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0088458..0021796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.6" +version = "0.2.7" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b37e02f..5312a7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.6" +version = "0.2.7" edition = "2024" description = "A comic archive viewer for local and remote locations" license = "GPL-3.0-or-later" diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml index 1025231..a5d9062 100644 --- a/data/io.github.comicviewer.Comicviewer.metainfo.xml +++ b/data/io.github.comicviewer.Comicviewer.metainfo.xml @@ -26,6 +26,7 @@ + diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index 15dedde..5a2f002 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.6-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.7-x86_64.AppImage.sha256` - `dist/Comicviewer-latest-x86_64.tar.gz` - `dist/Comicviewer-latest-x86_64.tar.gz.sha256` @@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256 ```sh git add Cargo.toml Cargo.lock data packaging README.md docs -git commit -m "Release v0.2.6" -git tag -a v0.2.6 -m "Comicviewer v0.2.6" +git commit -m "Release v0.2.7" +git tag -a v0.2.7 -m "Comicviewer v0.2.7" git push origin main -git push origin v0.2.6 +git push origin v0.2.7 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 29d68ad..6cf15f5 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -25,6 +25,7 @@ const IMAGE_ZOOM_MIN: f64 = 0.1; const IMAGE_ZOOM_MAX: f64 = 4.0; const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller"; const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-viewer-controller"; +const PRESERVE_SEEK_CLASS: &str = "viewer-preserve-seek"; fn clear_viewer_controller(window: >k::ApplicationWindow) { unsafe { @@ -110,10 +111,10 @@ pub(crate) fn set_viewer_header( pub struct ViewerWindow; pub struct SiblingNavigator { - position: Rc Option<(usize, usize)>>, navigate: Rc bool>, files: Rc Vec>, actions: Rc, + recursive: bool, } type SiblingActionResolver = dyn Fn(&str) -> Option; @@ -133,16 +134,16 @@ pub struct SiblingFileActions { impl SiblingNavigator { pub fn new( - position: Rc Option<(usize, usize)>>, navigate: Rc bool>, files: Rc Vec>, actions: Rc, + recursive: bool, ) -> Self { Self { - position, navigate, files, actions, + recursive, } } @@ -165,14 +166,14 @@ impl SiblingNavigator { ) } - fn position(&self) -> Option<(usize, usize)> { - (self.position)() - } - fn files(&self) -> Vec { (self.files)() } + fn seek_files(&self, current_kind: EntryKind) -> Vec { + seek_sibling_files(self.files(), current_kind, self.recursive) + } + fn open_uri(&self, uri: &str, window: gtk::ApplicationWindow) -> bool { let files = self.files(); let Some(index) = files.iter().position(|file| file.uri == uri) else { @@ -445,6 +446,7 @@ impl ViewerWindow { file_download: download_control.download, view_mode_change: view_mode_control.change, seek_hide_timer: RefCell::new(None), + seek_interacting: Cell::new(false), last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), @@ -477,6 +479,14 @@ impl ViewerWindow { }); controller.connect_signals(); controller.fullscreen_changed(); + if controller.window.has_css_class(PRESERVE_SEEK_CLASS) { + controller.window.remove_css_class(PRESERVE_SEEK_CLASS); + if controller.window.is_fullscreen() { + controller.show_fullscreen_ui_temporarily(); + } else { + controller.show_seek_temporarily(); + } + } if controller.file_list_button.is_active() { controller.refresh_file_list(); } @@ -604,6 +614,7 @@ struct ViewerController { file_download: Rc, view_mode_change: Rc, seek_hide_timer: RefCell>, + seek_interacting: Cell, last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, @@ -629,13 +640,13 @@ impl ViewerController { let controller = Rc::downgrade(self); self.previous_button.connect_clicked(move |_| { if let Some(controller) = controller.upgrade() { - controller.navigate(false); + controller.navigate_from_button(false); } }); let controller = Rc::downgrade(self); self.next_button.connect_clicked(move |_| { if let Some(controller) = controller.upgrade() { - controller.navigate(true); + controller.navigate_from_button(true); } }); let controller = Rc::downgrade(self); @@ -740,6 +751,29 @@ impl ViewerController { controller.seek_page(index); } }); + let seek_click = gtk::GestureClick::builder() + .button(gdk::BUTTON_PRIMARY) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + let controller = Rc::downgrade(self); + seek_click.connect_pressed(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() { + controller.begin_seek_interaction(); + } + }); + let controller = Rc::downgrade(self); + seek_click.connect_released(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() { + controller.end_seek_interaction(); + } + }); + let controller = Rc::downgrade(self); + seek_click.connect_cancel(move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.end_seek_interaction(); + } + }); + self.page_seek.add_controller(seek_click); let adjustment = self.continuous_scrolled.vadjustment(); let controller = Rc::downgrade(self); @@ -1103,12 +1137,36 @@ impl ViewerController { let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { if let Some(controller) = controller.upgrade() { controller.seek_hide_timer.borrow_mut().take(); - controller.set_seek_visible(false); + if !controller.seek_interacting.get() { + controller.set_seek_visible(false); + } } }); self.seek_hide_timer.replace(Some(timer)); } + fn begin_seek_interaction(self: &Rc) { + self.seek_interacting.set(true); + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.cancel_fullscreen_ui_timer(); + if self.window.is_fullscreen() { + self.set_fullscreen_ui_visible(true); + } else { + self.set_seek_visible(true); + } + } + + fn end_seek_interaction(self: &Rc) { + self.seek_interacting.set(false); + if self.window.is_fullscreen() { + self.show_fullscreen_ui_temporarily(); + } else { + self.show_seek_temporarily(); + } + } + fn pointer_moved(self: &Rc, x: f64, y: f64) { if self.last_pointer_position.get() == Some((x, y)) { return; @@ -1128,7 +1186,9 @@ impl ViewerController { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } - self.set_seek_visible(false); + if !self.seek_interacting.get() { + self.set_seek_visible(false); + } } fn set_seek_visible(self: &Rc, visible: bool) { @@ -1187,7 +1247,7 @@ impl ViewerController { } fn hide_fullscreen_ui_now(self: &Rc) { - if self.window.is_fullscreen() { + if self.window.is_fullscreen() && !self.seek_interacting.get() { self.cancel_fullscreen_ui_timer(); self.set_fullscreen_ui_visible(false); } @@ -1442,7 +1502,13 @@ impl ViewerController { }); } - fn navigate(self: &Rc, forward: bool) { + fn navigate_from_button(self: &Rc, forward: bool) { + if self.navigate(forward) && (self.image_zoom.replace(1.0) - 1.0).abs() >= f64::EPSILON { + self.apply_view_mode(); + } + } + + fn navigate(self: &Rc, forward: bool) -> bool { let step = if self.view_mode() == ViewMode::TwoPage { 2 } else { @@ -1450,7 +1516,7 @@ impl ViewerController { }; let mut state = self.state.borrow_mut(); if state.pages.is_empty() { - return; + return false; } let next = if forward { state.anchor.saturating_add(step).min(state.pages.len() - 1) @@ -1458,7 +1524,7 @@ impl ViewerController { state.anchor.saturating_sub(step) }; if next == state.anchor { - return; + return false; } state.anchor = next; drop(state); @@ -1467,6 +1533,7 @@ impl ViewerController { if self.view_mode() == ViewMode::Continuous { self.schedule_scroll_to_anchor(); } + true } fn seek_edge(self: &Rc, end: bool) { @@ -2175,10 +2242,12 @@ impl MediaViewerWindow { .build(); let media_seek = navigation_scale(); let media_seek_row = navigation_scale_row(&media_seek); - if let Some((current, count)) = sibling_navigator + let seek_files = sibling_navigator .as_ref() - .and_then(SiblingNavigator::position) - { + .map(|navigator| navigator.seek_files(kind)) + .unwrap_or_default(); + if let Some(current) = seek_position(&seek_files, &source.source_uri) { + let count = seek_files.len(); media_seek.set_range(0.0, navigation_scale_upper(count)); media_seek.set_value(current as f64); media_seek.set_sensitive(count > 1); @@ -2219,6 +2288,7 @@ impl MediaViewerWindow { status_label, media_seek, media_seek_row, + seek_files: RefCell::new(seek_files), sibling_navigator, file_list_button, file_list_panel: file_list.panel, @@ -2231,6 +2301,8 @@ impl MediaViewerWindow { seek_generation: Cell::new(0), seek_opening: Cell::new(false), seek_hide_timer: RefCell::new(None), + seek_interacting: Cell::new(false), + pending_seek_index: Cell::new(None), last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), @@ -2253,6 +2325,14 @@ impl MediaViewerWindow { } controller.connect_signals(); controller.fullscreen_changed(); + if controller.window.has_css_class(PRESERVE_SEEK_CLASS) { + controller.window.remove_css_class(PRESERVE_SEEK_CLASS); + if controller.window.is_fullscreen() { + controller.show_fullscreen_ui_temporarily(); + } else { + controller.show_seek_temporarily(); + } + } if controller.file_list_button.is_active() { controller.refresh_file_list(); } @@ -2294,6 +2374,7 @@ struct MediaViewerController { status_label: gtk::Label, media_seek: gtk::Scale, media_seek_row: gtk::Box, + seek_files: RefCell>, sibling_navigator: Option, file_list_button: gtk::ToggleButton, file_list_panel: gtk::ScrolledWindow, @@ -2306,6 +2387,8 @@ struct MediaViewerController { seek_generation: Cell, seek_opening: Cell, seek_hide_timer: RefCell>, + seek_interacting: Cell, + pending_seek_index: Cell>, last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, @@ -2589,17 +2672,39 @@ impl MediaViewerController { let Some(controller) = controller.upgrade() else { return; }; - let Some(navigator) = &controller.sibling_navigator else { - return; - }; - let Some((_, count)) = navigator.position() else { - return; - }; + let count = controller.seek_files.borrow().len(); let Some(index) = page_seek_index(scale.value(), count) else { return; }; - controller.schedule_seek(index); + if controller.seek_interacting.get() { + controller.pending_seek_index.set(Some(index)); + } else { + controller.schedule_seek(index); + } }); + let seek_click = gtk::GestureClick::builder() + .button(gdk::BUTTON_PRIMARY) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + let controller = Rc::downgrade(self); + seek_click.connect_pressed(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() { + controller.begin_seek_interaction(); + } + }); + let controller = Rc::downgrade(self); + seek_click.connect_released(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() { + controller.end_seek_interaction(); + } + }); + let controller = Rc::downgrade(self); + seek_click.connect_cancel(move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.cancel_seek_interaction(); + } + }); + self.media_seek.add_controller(seek_click); } fn refresh_file_list(&self) { @@ -2849,11 +2954,9 @@ impl MediaViewerController { 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) - { + let seek_files = self.seek_files.borrow(); + if let Some(current) = seek_position(&seek_files, &self.source.source_uri) { + let count = seek_files.len(); self.media_seek .set_range(0.0, navigation_scale_upper(count)); self.media_seek.set_sensitive(count > 1); @@ -2864,12 +2967,55 @@ impl MediaViewerController { let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { if let Some(controller) = controller.upgrade() { controller.seek_hide_timer.borrow_mut().take(); - controller.set_seek_visible(false); + if !controller.seek_interacting.get() { + controller.set_seek_visible(false); + } } }); self.seek_hide_timer.replace(Some(timer)); } + fn begin_seek_interaction(self: &Rc) { + self.seek_interacting.set(true); + self.pending_seek_index.set(None); + self.seek_generation + .set(self.seek_generation.get().wrapping_add(1)); + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.cancel_fullscreen_ui_timer(); + if self.window.is_fullscreen() { + self.set_fullscreen_ui_visible(true); + } else { + self.set_seek_visible(true); + } + } + + fn end_seek_interaction(self: &Rc) { + if !self.seek_interacting.replace(false) { + return; + } + if self.window.is_fullscreen() { + self.show_fullscreen_ui_temporarily(); + } else { + self.show_seek_temporarily(); + } + if let Some(index) = self.pending_seek_index.take() { + self.schedule_seek(index); + } + } + + fn cancel_seek_interaction(self: &Rc) { + self.pending_seek_index.set(None); + if self.seek_interacting.replace(false) { + if self.window.is_fullscreen() { + self.show_fullscreen_ui_temporarily(); + } else { + self.show_seek_temporarily(); + } + } + } + fn pointer_moved(self: &Rc, x: f64, y: f64) { if self.last_pointer_position.get() == Some((x, y)) { return; @@ -2889,7 +3035,9 @@ impl MediaViewerController { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } - self.set_seek_visible(false); + if !self.seek_interacting.get() { + self.set_seek_visible(false); + } } fn set_seek_visible(self: &Rc, visible: bool) { @@ -2909,14 +3057,13 @@ impl MediaViewerController { } fn schedule_seek(self: &Rc, index: usize) { - let Some(navigator) = &self.sibling_navigator else { + if self.sibling_navigator.is_none() { + return; + } + let Some(file) = self.seek_files.borrow().get(index).cloned() else { return; }; - if navigator - .position() - .is_none_or(|(current, _)| index == current) - || self.seek_opening.get() - { + if file.uri == self.source.source_uri || self.seek_opening.get() { return; } let generation = self.seek_generation.get().wrapping_add(1); @@ -2932,14 +3079,28 @@ impl MediaViewerController { let Some(navigator) = &controller.sibling_navigator else { return; }; - if navigator.open_index(index, controller.window.clone()) { - controller.seek_opening.set(true); + let Some(uri) = controller + .seek_files + .borrow() + .get(index) + .map(|file| file.uri.clone()) + else { + return; + }; + controller.seek_opening.set(true); + controller.window.add_css_class(PRESERVE_SEEK_CLASS); + if navigator.open_uri(&uri, controller.window.clone()) { controller.prepare_for_navigation(); + } else { + controller.seek_opening.set(false); + controller.window.remove_css_class(PRESERVE_SEEK_CLASS); } }); } fn prepare_for_navigation(&self) { + self.seek_generation + .set(self.seek_generation.get().wrapping_add(1)); self.stop_playback(); if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); @@ -3008,7 +3169,7 @@ impl MediaViewerController { } fn hide_fullscreen_ui_now(self: &Rc) { - if self.window.is_fullscreen() { + if self.window.is_fullscreen() && !self.seek_interacting.get() { self.cancel_fullscreen_ui_timer(); self.set_fullscreen_ui_visible(false); } @@ -3404,9 +3565,12 @@ fn single_picture_request( } fn next_image_zoom(current: f64, zoom_in: bool) -> f64 { - let direction = if zoom_in { 1.0 } else { -1.0 }; - ((((current + direction * IMAGE_ZOOM_STEP) * 10.0).round()) / 10.0) - .clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX) + let multiplier = if zoom_in { + 1.0 + IMAGE_ZOOM_STEP + } else { + 1.0 - IMAGE_ZOOM_STEP + }; + ((current * multiplier * 10_000.0).round() / 10_000.0).clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX) } fn scaled_dimension(value: i32, zoom: f64) -> i32 { @@ -3599,6 +3763,21 @@ fn sibling_index(current: usize, count: usize, forward: bool) -> Option { } } +fn seek_sibling_files( + mut files: Vec, + current_kind: EntryKind, + recursive: bool, +) -> Vec { + if current_kind == EntryKind::Image && !recursive { + files.retain(|file| file.kind != EntryKind::ZipArchive); + } + files +} + +fn seek_position(files: &[SiblingFile], current_uri: &str) -> Option { + files.iter().position(|file| file.uri == current_uri) +} + fn video_seek_target(timestamp: i64, duration: i64, delta: i64) -> i64 { let target = timestamp.saturating_add(delta).max(0); if duration > 0 { @@ -3764,10 +3943,44 @@ mod tests { fn image_zoom_changes_by_ten_percent_and_clamps() { assert_eq!(next_image_zoom(1.0, true), 1.1); assert_eq!(next_image_zoom(1.0, false), 0.9); + assert_eq!(next_image_zoom(1.1, true), 1.21); + assert_eq!(next_image_zoom(1.1, false), 0.99); assert_eq!(next_image_zoom(IMAGE_ZOOM_MAX, true), IMAGE_ZOOM_MAX); assert_eq!(next_image_zoom(IMAGE_ZOOM_MIN, false), IMAGE_ZOOM_MIN); } + #[test] + fn ordinary_image_seek_excludes_zip_but_recursive_seek_keeps_it() { + let files = vec![ + SiblingFile { + uri: "file:///book.zip".into(), + name: "book.zip".into(), + kind: EntryKind::ZipArchive, + }, + SiblingFile { + uri: "file:///page.jpg".into(), + name: "page.jpg".into(), + kind: EntryKind::Image, + }, + SiblingFile { + uri: "file:///clip.mp4".into(), + name: "clip.mp4".into(), + kind: EntryKind::Video, + }, + ]; + + let ordinary = seek_sibling_files(files.clone(), EntryKind::Image, false); + assert_eq!(ordinary.len(), 2); + assert!( + !ordinary + .iter() + .any(|file| file.kind == EntryKind::ZipArchive) + ); + assert_eq!(seek_position(&ordinary, "file:///page.jpg"), Some(0)); + assert_eq!(seek_position(&ordinary, "file:///clip.mp4"), Some(1)); + assert_eq!(seek_sibling_files(files, EntryKind::Image, true).len(), 3); + } + #[test] fn file_list_shortcut_rejects_command_modifiers() { assert!(!shortcut_modifiers_blocked(gdk::ModifierType::SHIFT_MASK)); diff --git a/src/window.rs b/src/window.rs index e22dafc..8df633e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1024,21 +1024,9 @@ impl BrowserController { siblings: Rc>, profile: LocationProfile, ) -> SiblingNavigator { + let recursive = siblings.iter().any(|entry| entry.list_name.is_some()); 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())) - }); - let controller = Rc::downgrade(self); let navigate_siblings = Rc::clone(&siblings); let navigate_current_uri = current_uri.clone(); @@ -1052,6 +1040,7 @@ impl BrowserController { &navigate_current_uri, current_kind, &navigate_siblings, + recursive, ); let Some(current) = siblings .iter() @@ -1079,7 +1068,7 @@ impl BrowserController { return Vec::new(); }; controller - .navigation_siblings(&files_current_uri, current_kind, &files_fallback) + .navigation_siblings(&files_current_uri, current_kind, &files_fallback, recursive) .iter() .map(|entry| SiblingFile { uri: entry.uri.clone(), @@ -1098,6 +1087,7 @@ impl BrowserController { &actions_current_uri, current_kind, &actions_fallback, + recursive, ); let entry = entries.iter().find(|entry| entry.uri == uri)?; Some(SiblingFileActions { @@ -1105,7 +1095,7 @@ impl BrowserController { download: controller.download_control(entry), }) }); - SiblingNavigator::new(position, navigate, files, actions) + SiblingNavigator::new(navigate, files, actions, recursive) } fn navigation_siblings( @@ -1113,7 +1103,11 @@ impl BrowserController { current_uri: &str, current_kind: EntryKind, fallback: &Rc>, + fixed: bool, ) -> Rc> { + if fixed { + return Rc::clone(fallback); + } let case_insensitive = self.config.borrow().case_insensitive_sort; let state = self.state.borrow(); let entries = sorted_navigable_entries(