From 048df6355e16f7c36c90a3511f431bfb4fb386c3 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 14:42:23 +0900 Subject: [PATCH] Release v0.2.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 16 +- docs/RELEASING.ko.md | 8 +- src/archive.rs | 133 ++- src/browser.rs | 8 - src/viewer.rs | 1020 +++++++++++++++-- src/window.rs | 333 ++++-- src/zip_cache.rs | 198 +++- 10 files changed, 1467 insertions(+), 254 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28b8f59..8696120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index c648663..72c8f1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.0" +version = "0.2.1" 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 ad52674..84e954c 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/INSTALL.ko.md b/docs/INSTALL.ko.md index 5ebeffd..a116d4c 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -79,4 +79,18 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - SMB 접속에는 호스트 시스템의 `gvfs-smb`가 필요합니다. - FTP와 SFTP 접속에는 호스트 시스템의 GVfs 서비스가 필요합니다. - 원격 ZIP은 열기 전에 로컬 캐시로 완전히 다운로드됩니다. -- 기본 영구 ZIP 캐시 한도는 10GB이며 설정에서 변경할 수 있습니다. +- 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다. +- 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다. +- 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다. + +## 뷰어 조작 + +- `[`와 `]`: 폴더의 이전/다음 ZIP·이미지·동영상 열기 +- `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기 +- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동 +- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 +- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기 +- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기 +- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드 + +애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다. diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index befe386..8f8694b 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.0-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.1-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 "Prepare v0.2.0 release" -git tag -a v0.2.0 -m "Comicviewer v0.2.0" +git commit -m "Prepare v0.2.1 release" +git tag -a v0.2.1 -m "Comicviewer v0.2.1" git push origin main -git push origin v0.2.0 +git push origin v0.2.1 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/archive.rs b/src/archive.rs index a3e5db0..eb7f661 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -1,9 +1,13 @@ use std::fs::File; use std::io::{Cursor, Read}; use std::path::Path; +use std::time::Duration; use anyhow::{Context, Result, bail}; -use image::ImageReader; +use image::codecs::gif::GifDecoder; +use image::codecs::webp::WebPDecoder; +use image::metadata::LoopCount; +use image::{AnimationDecoder, ImageFormat, ImageReader}; use zip::ZipArchive; use crate::browser::natural_cmp_with_case; @@ -11,6 +15,9 @@ use crate::browser::natural_cmp_with_case; const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 30_000; const MAX_IMAGE_PIXELS: u64 = 100_000_000; +const MAX_ANIMATION_FRAMES: usize = 1_000; +const MAX_ANIMATION_BYTES: usize = 512 * 1024 * 1024; +const MIN_FRAME_DELAY: Duration = Duration::from_millis(10); #[derive(Debug, Clone)] pub struct ArchivePage { @@ -21,7 +28,13 @@ pub struct ArchivePage { pub struct DecodedPage { pub width: u32, pub height: u32, + pub frames: Vec, + pub loop_count: Option, +} + +pub struct DecodedFrame { pub rgba: Vec, + pub delay: Duration, } pub fn list_pages(path: &Path, case_insensitive: bool) -> Result> { @@ -90,11 +103,33 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result { let reader = ImageReader::new(Cursor::new(encoded)) .with_guessed_format() .with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?; + let format = reader.format(); let (width, height) = reader .into_dimensions() .with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?; validate_dimensions(width, height, name)?; + let animated = match format { + Some(ImageFormat::Gif) => { + let decoder = GifDecoder::new(Cursor::new(encoded)) + .with_context(|| format!("GIF를 디코딩할 수 없습니다: {name}"))?; + let loop_count = animation_loop_count(decoder.loop_count()); + Some((decoder.into_frames(), loop_count)) + } + Some(ImageFormat::WebP) => { + let decoder = WebPDecoder::new(Cursor::new(encoded)) + .with_context(|| format!("WebP를 디코딩할 수 없습니다: {name}"))?; + decoder.has_animation().then(|| { + let loop_count = animation_loop_count(decoder.loop_count()); + (decoder.into_frames(), loop_count) + }) + } + _ => None, + }; + if let Some((frames, loop_count)) = animated { + return decoded_animation(width, height, frames, loop_count, name); + } + let image = image::load_from_memory(encoded) .with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?; let rgba = image.into_rgba8(); @@ -102,10 +137,69 @@ fn decode_image_bytes(encoded: &[u8], name: &str) -> Result { Ok(DecodedPage { width, height, - rgba: rgba.into_raw(), + frames: vec![DecodedFrame { + rgba: rgba.into_raw(), + delay: Duration::ZERO, + }], + loop_count: Some(1), }) } +fn decoded_animation( + width: u32, + height: u32, + frames: impl Iterator>, + loop_count: Option, + name: &str, +) -> Result { + let expected_bytes = usize::try_from(width)? + .checked_mul(usize::try_from(height)?) + .and_then(|pixels| pixels.checked_mul(4)) + .context("애니메이션 프레임 크기를 처리할 수 없습니다")?; + let mut decoded = Vec::new(); + let mut total_bytes = 0_usize; + for (index, frame) in frames.enumerate() { + if index >= MAX_ANIMATION_FRAMES { + bail!( + "애니메이션 프레임 수가 제한을 초과합니다: {name} (최대 {MAX_ANIMATION_FRAMES}개)" + ); + } + let frame = + frame.with_context(|| format!("애니메이션 프레임을 디코딩할 수 없습니다: {name}"))?; + total_bytes = total_bytes + .checked_add(expected_bytes) + .context("애니메이션 전체 크기를 처리할 수 없습니다")?; + if total_bytes > MAX_ANIMATION_BYTES { + bail!("애니메이션 디코딩 크기가 제한을 초과합니다: {name} (최대 512MB)"); + } + let delay = Duration::from(frame.delay()).max(MIN_FRAME_DELAY); + let rgba = frame.into_buffer(); + if rgba.dimensions() != (width, height) || rgba.len() != expected_bytes { + bail!("애니메이션 프레임 크기가 일치하지 않습니다: {name}"); + } + decoded.push(DecodedFrame { + rgba: rgba.into_raw(), + delay, + }); + } + if decoded.is_empty() { + bail!("애니메이션에 표시할 프레임이 없습니다: {name}"); + } + Ok(DecodedPage { + width, + height, + frames: decoded, + loop_count, + }) +} + +fn animation_loop_count(loop_count: LoopCount) -> Option { + match loop_count { + LoopCount::Infinite => None, + LoopCount::Finite(count) => Some(count.get()), + } +} + fn validate_dimensions(width: u32, height: u32, name: &str) -> Result<()> { let pixels = u64::from(width) * u64::from(height); if width == 0 @@ -188,7 +282,7 @@ mod tests { ); let decoded = decode_page(&path, &pages[0]).unwrap(); assert_eq!((decoded.width, decoded.height), (2, 3)); - assert_eq!(decoded.rgba.len(), 2 * 3 * 4); + assert_eq!(decoded.frames[0].rgba.len(), 2 * 3 * 4); std::fs::remove_file(path).unwrap(); } @@ -202,6 +296,37 @@ mod tests { let decoded = decode_image_file(&path, "photo.png").unwrap(); assert_eq!((decoded.width, decoded.height), (4, 5)); - assert_eq!(decoded.rgba.len(), 4 * 5 * 4); + assert_eq!(decoded.frames[0].rgba.len(), 4 * 5 * 4); + } + + #[test] + fn decodes_all_animated_gif_frames() { + let mut encoded = Vec::new(); + let first = image::Frame::from_parts( + image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255])), + 0, + 0, + image::Delay::from_numer_denom_ms(20, 1), + ); + let second = image::Frame::from_parts( + image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 0, 255, 255])), + 0, + 0, + image::Delay::from_numer_denom_ms(30, 1), + ); + let mut encoder = image::codecs::gif::GifEncoder::new(&mut encoded); + encoder + .set_repeat(image::codecs::gif::Repeat::Finite(2)) + .unwrap(); + encoder.encode_frames([first, second]).unwrap(); + drop(encoder); + + let decoded = decode_image_bytes(&encoded, "animated.gif").unwrap(); + + assert_eq!(decoded.frames.len(), 2); + assert_eq!(decoded.frames[0].delay, Duration::from_millis(20)); + assert_eq!(decoded.frames[1].delay, Duration::from_millis(30)); + assert_eq!(decoded.loop_count, Some(2)); + assert_ne!(decoded.frames[0].rgba, decoded.frames[1].rgba); } } diff --git a/src/browser.rs b/src/browser.rs index defbd1d..d3f31e3 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -15,10 +15,6 @@ 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)] @@ -229,10 +225,6 @@ 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 ac33744..883ab74 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,6 +1,6 @@ use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet, VecDeque}; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use std::sync::mpsc::{self, TryRecvError}; use std::thread; use std::time::Duration; @@ -18,20 +18,89 @@ const CACHE_LIMIT_BYTES: usize = 256 * 1024 * 1024; const CACHE_LIMIT_PAGES: usize = 16; const FAST_CACHE_LIMIT_BYTES: usize = 1024 * 1024 * 1024; const FAST_CACHE_LIMIT_PAGES: usize = 64; +const VIDEO_SEEK_STEP_US: i64 = 5_000_000; +const VIDEO_VOLUME_STEP: f64 = 0.05; +const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller"; +const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-viewer-controller"; + +fn clear_viewer_controller(window: >k::ApplicationWindow) { + unsafe { + if let Some(controller) = window.steal_data::>(VIEWER_CONTROLLER_KEY) { + controller.stop_animations(); + } + if let Some(controller) = + window.steal_data::>(MEDIA_VIEWER_CONTROLLER_KEY) + { + controller.stop_playback(); + } + } +} + +fn ensure_viewer_close_handler(window: >k::ApplicationWindow) { + if window.has_css_class("viewer-close-handler") { + return; + } + window.add_css_class("viewer-close-handler"); + window.connect_close_request(|window| { + clear_viewer_controller(window); + glib::Propagation::Proceed + }); +} + +pub(crate) fn set_viewer_header(window: >k::ApplicationWindow, header: >k::HeaderBar) { + let host = window + .titlebar() + .and_downcast::() + .unwrap_or_else(|| { + let host = gtk::Box::new(gtk::Orientation::Horizontal, 0); + host.set_hexpand(true); + window.set_titlebar(Some(&host)); + host + }); + while let Some(child) = host.first_child() { + host.remove(&child); + } + header.set_hexpand(true); + host.append(header); +} pub struct ViewerWindow; pub struct SiblingNavigator { position: Rc Option<(usize, usize)>>, navigate: Rc bool>, + files: Rc Vec>, + actions: Rc, +} + +type SiblingActionResolver = dyn Fn(&str) -> Option; + +#[derive(Clone)] +pub struct SiblingFile { + pub uri: String, + pub name: String, + pub kind: EntryKind, +} + +#[derive(Clone)] +pub struct SiblingFileActions { + pub favorite: FavoriteControl, + pub download: FileDownloadControl, } impl SiblingNavigator { pub fn new( position: Rc Option<(usize, usize)>>, navigate: Rc bool>, + files: Rc Vec>, + actions: Rc, ) -> Self { - Self { position, navigate } + Self { + position, + navigate, + files, + actions, + } } fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool { @@ -56,6 +125,22 @@ impl SiblingNavigator { fn position(&self) -> Option<(usize, usize)> { (self.position)() } + + fn files(&self) -> Vec { + (self.files)() + } + + 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 { + return false; + }; + self.open_index(index, window) + } + + fn actions(&self, uri: &str) -> Option { + (self.actions)(uri) + } } #[derive(Clone, Copy)] @@ -77,11 +162,13 @@ impl SiblingTarget { } } +#[derive(Clone)] pub struct FavoriteControl { pub active: bool, pub toggle: Rc bool>, } +#[derive(Clone)] pub struct FileDownloadControl { pub download: Rc, } @@ -117,6 +204,9 @@ impl ViewerWindow { view_mode_control: _, } = controls; let title = source.display_name.clone(); + if let Some(window) = reuse_window.as_ref() { + clear_viewer_controller(window); + } let window = reuse_window.unwrap_or_else(|| { gtk::ApplicationWindow::builder() .application(application) @@ -125,14 +215,19 @@ impl ViewerWindow { .default_height(850) .build() }); + ensure_viewer_close_handler(&window); window.set_title(Some(&title)); 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("파일 다운로드"); + let file_list_button = gtk::ToggleButton::with_label("파일 목록"); + file_list_button.set_sensitive(sibling_navigator.is_some()); + file_list_button.set_active(window.has_css_class("viewer-file-list-visible")); header.pack_end(&download_button); header.pack_end(&favorite_button); - window.set_titlebar(Some(&header)); + header.pack_end(&file_list_button); + set_viewer_header(&window, &header); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6); @@ -224,7 +319,14 @@ impl ViewerWindow { 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)); + window_overlay.set_hexpand(true); + window_overlay.set_vexpand(true); + let file_list = viewer_file_list(); + file_list.panel.set_visible(file_list_button.is_active()); + let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0); + viewer_layout.append(&window_overlay); + viewer_layout.append(&file_list.panel); + window.set_child(Some(&viewer_layout)); let controller = Rc::new(ViewerController { window, @@ -252,6 +354,10 @@ impl ViewerWindow { fast_image_loading, case_insensitive_sort, sibling_navigator, + file_list_button, + file_list_panel: file_list.panel, + file_list: file_list.list, + file_list_files: RefCell::new(Vec::new()), favorite_button, favorite_toggle: favorite_control.toggle, download_button, @@ -266,6 +372,9 @@ impl ViewerWindow { navigation_message_generation: Rc::new(Cell::new(0)), continuous_programmatic_scroll: Cell::new(false), view_layout_generation: Cell::new(0), + single_animation_generation: Rc::new(AnimationState::default()), + left_animation_generation: Rc::new(AnimationState::default()), + right_animation_generation: Rc::new(AnimationState::default()), state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -280,6 +389,14 @@ impl ViewerWindow { }), }); controller.connect_signals(); + if controller.file_list_button.is_active() { + controller.refresh_file_list(); + } + unsafe { + controller + .window + .set_data(VIEWER_CONTROLLER_KEY, Rc::clone(&controller)); + } controller.apply_view_mode(); controller.window.present(); controller.load_archive(); @@ -295,16 +412,55 @@ enum ViewMode { Continuous, } -struct CachedPage { +#[derive(Clone)] +struct CachedFrame { texture: gdk::MemoryTexture, + delay: Duration, +} + +struct CachedAnimation { + frames: Vec, + loop_count: Option, +} + +impl std::ops::Deref for CachedAnimation { + type Target = [CachedFrame]; + + fn deref(&self) -> &Self::Target { + &self.frames + } +} + +struct CachedPage { + frames: Rc, cost: usize, last_used: u64, } +#[derive(Default)] +struct AnimationState { + generation: Cell, + timer: RefCell>, + frames: RefCell>>, +} + +impl AnimationState { + fn cancel(&self) -> u64 { + if let Some(timer) = self.timer.borrow_mut().take() { + timer.remove(); + } + self.frames.borrow_mut().take(); + let generation = self.generation.get().wrapping_add(1); + self.generation.set(generation); + generation + } +} + struct ContinuousRow { frame: gtk::AspectFrame, picture: gtk::Picture, ratio: f32, + animation_generation: Rc, } struct ViewerState { @@ -346,6 +502,10 @@ struct ViewerController { fast_image_loading: bool, case_insensitive_sort: bool, sibling_navigator: Option, + file_list_button: gtk::ToggleButton, + file_list_panel: gtk::ScrolledWindow, + file_list: gtk::ListBox, + file_list_files: RefCell>, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, @@ -360,45 +520,102 @@ struct ViewerController { navigation_message_generation: Rc>, continuous_programmatic_scroll: Cell, view_layout_generation: Cell, + single_animation_generation: Rc, + left_animation_generation: Rc, + right_animation_generation: Rc, state: RefCell, } impl ViewerController { fn connect_signals(self: &Rc) { - let controller = Rc::clone(self); - self.previous_button - .connect_clicked(move |_| controller.navigate(false)); - let controller = Rc::clone(self); - self.next_button - .connect_clicked(move |_| controller.navigate(true)); - let controller = Rc::clone(self); - self.shift_back_button - .connect_clicked(move |_| controller.shift_pairing(-1)); - let controller = Rc::clone(self); - self.shift_forward_button - .connect_clicked(move |_| controller.shift_pairing(1)); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); + self.previous_button.connect_clicked(move |_| { + if let Some(controller) = controller.upgrade() { + controller.navigate(false); + } + }); + let controller = Rc::downgrade(self); + self.next_button.connect_clicked(move |_| { + if let Some(controller) = controller.upgrade() { + controller.navigate(true); + } + }); + let controller = Rc::downgrade(self); + self.shift_back_button.connect_clicked(move |_| { + if let Some(controller) = controller.upgrade() { + controller.shift_pairing(-1); + } + }); + let controller = Rc::downgrade(self); + self.shift_forward_button.connect_clicked(move |_| { + if let Some(controller) = controller.upgrade() { + controller.shift_pairing(1); + } + }); + let controller = Rc::downgrade(self); self.mode_dropdown.connect_selected_notify(move |dropdown| { - (controller.view_mode_change)(dropdown.selected()); - controller.apply_view_mode(); + if let Some(controller) = controller.upgrade() { + (controller.view_mode_change)(dropdown.selected()); + controller.apply_view_mode(); + } }); - let controller = Rc::clone(self); - self.direction_dropdown - .connect_selected_notify(move |_| controller.render()); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); + self.direction_dropdown.connect_selected_notify(move |_| { + if let Some(controller) = controller.upgrade() { + controller.render(); + } + }); + let controller = Rc::downgrade(self); self.favorite_button.connect_clicked(move |_| { - let active = (controller.favorite_toggle)(); - controller.favorite_button.set_label(favorite_label(active)); + if let Some(controller) = controller.upgrade() { + let active = (controller.favorite_toggle)(); + controller.favorite_button.set_label(favorite_label(active)); + } }); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); self.download_button.connect_clicked(move |_| { - (controller.file_download)( - controller.window.clone().upcast(), - controller.status_label.clone(), - ); + if let Some(controller) = controller.upgrade() { + (controller.file_download)( + controller.window.clone().upcast(), + controller.status_label.clone(), + ); + } }); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); + self.file_list_button.connect_toggled(move |button| { + if let Some(controller) = controller.upgrade() { + controller.file_list_panel.set_visible(button.is_active()); + if button.is_active() { + controller.window.add_css_class("viewer-file-list-visible"); + } else { + controller + .window + .remove_css_class("viewer-file-list-visible"); + } + if button.is_active() { + controller.refresh_file_list(); + } + } + }); + let controller = Rc::downgrade(self); + self.file_list.connect_row_activated(move |_, row| { + if let Some(controller) = controller.upgrade() { + controller.open_file_list_row(row.index()); + } + }); + let file_list_click = gtk::GestureClick::builder().button(3).build(); + let controller = Rc::downgrade(self); + file_list_click.connect_pressed(move |_, _, x, y| { + if let Some(controller) = controller.upgrade() { + controller.show_file_list_context_menu(x, y); + } + }); + self.file_list.add_controller(file_list_click); + let controller = Rc::downgrade(self); self.page_seek.connect_value_changed(move |scale| { + let Some(controller) = controller.upgrade() else { + return; + }; let page_count = controller.state.borrow().pages.len(); if let Some(index) = page_seek_index(scale.value(), page_count) { controller.seek_page(index); @@ -406,32 +623,49 @@ impl ViewerController { }); let adjustment = self.continuous_scrolled.vadjustment(); - let controller = Rc::clone(self); - adjustment.connect_value_changed(move |_| controller.continuous_scrolled()); - let controller = Rc::clone(self); - adjustment.connect_changed(move |_| controller.continuous_scrolled()); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); + adjustment.connect_value_changed(move |_| { + if let Some(controller) = controller.upgrade() { + controller.continuous_scrolled(); + } + }); + let controller = Rc::downgrade(self); + adjustment.connect_changed(move |_| { + if let Some(controller) = controller.upgrade() { + controller.continuous_scrolled(); + } + }); + let controller = Rc::downgrade(self); self.continuous_scrolled .connect_notify_local(Some("width"), move |_, _| { - controller.resize_continuous_rows(); + if let Some(controller) = controller.upgrade() { + controller.resize_continuous_rows(); + } }); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); self.single_scrolled .connect_notify_local(Some("width"), move |_, _| { - controller.resize_single_picture(); + if let Some(controller) = controller.upgrade() { + controller.resize_single_picture(); + } }); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); self.single_picture .connect_notify_local(Some("width"), move |_, _| { - controller.resize_single_picture(); + if let Some(controller) = controller.upgrade() { + controller.resize_single_picture(); + } }); let scroll_controller = gtk::EventControllerScroll::new( gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, ); scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); scroll_controller.connect_scroll(move |_, _, delta_y| { + let Some(controller) = controller.upgrade() else { + return glib::Propagation::Proceed; + }; if matches!( controller.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous @@ -446,14 +680,21 @@ impl ViewerController { let motion_controller = gtk::EventControllerMotion::new(); motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); - let controller = Rc::clone(self); - motion_controller.connect_motion(move |_, x, y| controller.pointer_moved(x, y)); + let controller = Rc::downgrade(self); + motion_controller.connect_motion(move |_, x, y| { + if let Some(controller) = controller.upgrade() { + controller.pointer_moved(x, y); + } + }); self.window.add_controller(motion_controller.clone()); self.motion_controller.replace(Some(motion_controller)); let key_controller = gtk::EventControllerKey::new(); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); key_controller.connect_key_pressed(move |_, key, _, _| { + let Some(controller) = controller.upgrade() else { + return glib::Propagation::Proceed; + }; controller.hide_seek(); match key { gdk::Key::Home => { @@ -526,6 +767,70 @@ impl ViewerController { } } + fn refresh_file_list(&self) { + while let Some(child) = self.file_list.first_child() { + self.file_list.remove(&child); + } + let files = self + .sibling_navigator + .as_ref() + .map(SiblingNavigator::files) + .unwrap_or_default(); + let current = files + .iter() + .position(|file| file.uri == self.source.source_uri); + for file in &files { + self.file_list.append(&viewer_file_row(file)); + } + *self.file_list_files.borrow_mut() = files; + if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) { + self.file_list.select_row(Some(&row)); + } + } + + fn open_file_list_row(&self, index: i32) { + let Some(uri) = self + .file_list_files + .borrow() + .get(index as usize) + .map(|file| file.uri.clone()) + else { + return; + }; + if uri == self.source.source_uri { + return; + } + if let Some(navigator) = &self.sibling_navigator + && navigator.open_uri(&uri, self.window.clone()) + { + self.detach_window_controllers(); + } + } + + fn show_file_list_context_menu(&self, x: f64, y: f64) { + let Some((row, file)) = selected_viewer_file(&self.file_list, &self.file_list_files, x, y) + else { + return; + }; + self.file_list.select_row(Some(&row)); + let Some(actions) = self + .sibling_navigator + .as_ref() + .and_then(|navigator| navigator.actions(&file.uri)) + else { + return; + }; + show_viewer_file_context_menu( + &row, + &file, + actions, + &self.source.source_uri, + &self.favorite_button, + &self.window, + &self.status_label, + ); + } + fn show_navigation_error(&self, forward: bool) { self.navigation_message_label .set_text(navigation_error_message(forward)); @@ -542,6 +847,7 @@ impl ViewerController { } fn detach_window_controllers(&self) { + self.stop_animations(); if let Some(controller) = self.key_controller.borrow_mut().take() { self.window.remove_controller(&controller); } @@ -550,6 +856,15 @@ impl ViewerController { } } + fn stop_animations(&self) { + self.single_animation_generation.cancel(); + self.left_animation_generation.cancel(); + self.right_animation_generation.cancel(); + for row in &self.state.borrow().continuous_rows { + row.animation_generation.cancel(); + } + } + fn show_seek_temporarily(self: &Rc) { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); @@ -686,6 +1001,7 @@ impl ViewerController { frame, picture, ratio: default_ratio, + animation_generation: Rc::new(AnimationState::default()), }); } let mut state = self.state.borrow_mut(); @@ -716,12 +1032,29 @@ impl ViewerController { ViewMode::Continuous => self.stack.set_visible_child_name("continuous"), _ => self.stack.set_visible_child_name("single"), } - if mode == ViewMode::Continuous { - self.single_picture.set_paintable(gdk::Paintable::NONE); - self.left_picture.set_paintable(gdk::Paintable::NONE); - self.right_picture.set_paintable(gdk::Paintable::NONE); - } else { - self.clear_continuous_paintables(); + match mode { + ViewMode::Continuous => { + set_picture_animation( + &self.single_picture, + None, + &self.single_animation_generation, + ); + set_picture_animation(&self.left_picture, None, &self.left_animation_generation); + set_picture_animation(&self.right_picture, None, &self.right_animation_generation); + } + ViewMode::TwoPage => { + set_picture_animation( + &self.single_picture, + None, + &self.single_animation_generation, + ); + self.clear_continuous_paintables(); + } + _ => { + set_picture_animation(&self.left_picture, None, &self.left_animation_generation); + set_picture_animation(&self.right_picture, None, &self.right_animation_generation); + self.clear_continuous_paintables(); + } } let actual = mode == ViewMode::ActualSize; self.single_box.set_hexpand(!actual); @@ -979,21 +1312,13 @@ impl ViewerController { Ok(page) => { let ratio = page.width as f32 / page.height as f32; decoded_ratio = Some(ratio); - let cost = page.rgba.len(); - let bytes = glib::Bytes::from_owned(page.rgba); - let texture = gdk::MemoryTexture::new( - page.width as i32, - page.height as i32, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - page.width as usize * 4, - ); + let (frames, cost) = texture_frames(page); state.access_clock = state.access_clock.wrapping_add(1); let last_used = state.access_clock; state.cache.insert( index, CachedPage { - texture, + frames, cost, last_used, }, @@ -1018,7 +1343,9 @@ impl ViewerController { } } self.evict_cache(); - self.render_loaded_pages(); + if self.visible_indices().contains(&index) { + self.render_loaded_pages(); + } self.update_spinner(); self.pump_requests(); } @@ -1035,10 +1362,14 @@ impl ViewerController { fn render_single(&self, index: usize) { let state = self.state.borrow(); - self.single_picture - .set_paintable(state.cache.get(&index).map(|page| &page.texture)); + let frames = state.cache.get(&index).map(|page| Rc::clone(&page.frames)); self.status_label.set_text(&state.pages[index].name); drop(state); + set_picture_animation( + &self.single_picture, + frames, + &self.single_animation_generation, + ); self.resize_single_picture(); } @@ -1048,7 +1379,8 @@ impl ViewerController { state .cache .get(&state.anchor) - .map(|page| (page.texture.width(), page.texture.height())) + .and_then(|page| page.frames.first()) + .map(|frame| (frame.texture.width(), frame.texture.height())) }; if let Some((texture_width, texture_height)) = dimensions && let Some((width, height)) = single_picture_request( @@ -1071,26 +1403,32 @@ impl ViewerController { fn render_spread(&self, indices: &[usize]) { let state = self.state.borrow(); - let first = indices.first().and_then(|index| state.cache.get(index)); - let second = indices.get(1).and_then(|index| state.cache.get(index)); + let first = indices + .first() + .and_then(|index| state.cache.get(index)) + .map(|page| Rc::clone(&page.frames)); + let second = indices + .get(1) + .and_then(|index| state.cache.get(index)) + .map(|page| Rc::clone(&page.frames)); + let status = indices + .iter() + .map(|index| state.pages[*index].name.as_str()) + .collect::>() + .join(" · "); + drop(state); if self.direction_dropdown.selected() == 1 { - self.left_picture - .set_paintable(second.map(|page| &page.texture)); - self.right_picture - .set_paintable(first.map(|page| &page.texture)); + set_picture_animation(&self.left_picture, second, &self.left_animation_generation); + set_picture_animation(&self.right_picture, first, &self.right_animation_generation); } else { - self.left_picture - .set_paintable(first.map(|page| &page.texture)); - self.right_picture - .set_paintable(second.map(|page| &page.texture)); + set_picture_animation(&self.left_picture, first, &self.left_animation_generation); + set_picture_animation( + &self.right_picture, + second, + &self.right_animation_generation, + ); } - self.status_label.set_text( - &indices - .iter() - .map(|index| state.pages[*index].name.as_str()) - .collect::>() - .join(" · "), - ); + self.status_label.set_text(&status); } fn render_continuous(&self, visible: &[usize]) { @@ -1099,24 +1437,36 @@ impl ViewerController { let old_bound = state.continuous_bound.clone(); let mut updates = Vec::new(); for index in old_bound.difference(&new_bound) { - updates.push((state.continuous_rows[*index].picture.clone(), None)); + let row = &state.continuous_rows[*index]; + updates.push(( + row.picture.clone(), + None, + Rc::clone(&row.animation_generation), + )); } for index in &new_bound { state.access_clock = state.access_clock.wrapping_add(1); let clock = state.access_clock; - let texture = state.cache.get_mut(index).map(|page| { + let frames = state.cache.get_mut(index).map(|page| { page.last_used = clock; - page.texture.clone() + Rc::clone(&page.frames) }); - updates.push((state.continuous_rows[*index].picture.clone(), texture)); + let row = &state.continuous_rows[*index]; + if !old_bound.contains(index) || row.picture.paintable().is_none() { + updates.push(( + row.picture.clone(), + frames, + Rc::clone(&row.animation_generation), + )); + } } state.continuous_bound = new_bound; let status = visible .first() .map(|index| state.pages[*index].name.clone()); drop(state); - for (picture, texture) in updates { - picture.set_paintable(texture.as_ref()); + for (picture, frames, generation) in updates { + set_picture_animation(&picture, frames, &generation); } if let Some(status) = status { self.status_label.set_text(&status); @@ -1222,11 +1572,14 @@ impl ViewerController { let indices: Vec<_> = state.continuous_bound.drain().collect(); let pictures: Vec<_> = indices .into_iter() - .map(|index| state.continuous_rows[index].picture.clone()) + .map(|index| { + let row = &state.continuous_rows[index]; + (row.picture.clone(), Rc::clone(&row.animation_generation)) + }) .collect(); drop(state); - for picture in pictures { - picture.set_paintable(gdk::Paintable::NONE); + for (picture, generation) in pictures { + set_picture_animation(&picture, None, &generation); } } @@ -1332,6 +1685,9 @@ impl MediaViewerWindow { view_mode_control, } = controls; let title = source.display_name.clone(); + if let Some(window) = reuse_window.as_ref() { + clear_viewer_controller(window); + } let window = reuse_window.unwrap_or_else(|| { gtk::ApplicationWindow::builder() .application(application) @@ -1340,11 +1696,15 @@ impl MediaViewerWindow { .default_height(850) .build() }); + ensure_viewer_close_handler(&window); window.set_title(Some(&title)); 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("파일 다운로드"); + let file_list_button = gtk::ToggleButton::with_label("파일 목록"); + file_list_button.set_sensitive(sibling_navigator.is_some()); + file_list_button.set_active(window.has_css_class("viewer-file-list-visible")); let loop_check = gtk::CheckButton::with_label("반복 재생"); loop_check.set_visible(kind == EntryKind::Video); let image_mode_dropdown = gtk::DropDown::from_strings(&["100%", "폭 맞춤", "높이 맞춤"]); @@ -1361,7 +1721,8 @@ impl MediaViewerWindow { header.pack_end(&loop_check); header.pack_end(&download_button); header.pack_end(&favorite_button); - window.set_titlebar(Some(&header)); + header.pack_end(&file_list_button); + set_viewer_header(&window, &header); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); let stack = gtk::Stack::new(); @@ -1414,7 +1775,14 @@ impl MediaViewerWindow { 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)); + window_overlay.set_hexpand(true); + window_overlay.set_vexpand(true); + let file_list = viewer_file_list(); + file_list.panel.set_visible(file_list_button.is_active()); + let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0); + viewer_layout.append(&window_overlay); + viewer_layout.append(&file_list.panel); + window.set_child(Some(&viewer_layout)); let controller = Rc::new(MediaViewerController { window, @@ -1426,12 +1794,17 @@ impl MediaViewerWindow { image_mode_dropdown, image_mode_change, image_dimensions: Cell::new(None), + image_animation_generation: Rc::new(AnimationState::default()), video, stack, status_label, media_seek, media_seek_row, sibling_navigator, + file_list_button, + file_list_panel: file_list.panel, + file_list: file_list.list, + file_list_files: RefCell::new(Vec::new()), favorite_button, favorite_toggle: favorite_control.toggle, download_button, @@ -1455,6 +1828,14 @@ impl MediaViewerWindow { }); } controller.connect_signals(); + if controller.file_list_button.is_active() { + controller.refresh_file_list(); + } + unsafe { + controller + .window + .set_data(MEDIA_VIEWER_CONTROLLER_KEY, Rc::clone(&controller)); + } controller.apply_image_view_mode(); controller.window.present(); match kind { @@ -1480,12 +1861,17 @@ struct MediaViewerController { image_mode_dropdown: gtk::DropDown, image_mode_change: Option>, image_dimensions: Cell>, + image_animation_generation: Rc, video: gtk::Video, stack: gtk::Stack, status_label: gtk::Label, media_seek: gtk::Scale, media_seek_row: gtk::Box, sibling_navigator: Option, + file_list_button: gtk::ToggleButton, + file_list_panel: gtk::ScrolledWindow, + file_list: gtk::ListBox, + file_list_files: RefCell>, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, @@ -1505,16 +1891,46 @@ impl MediaViewerController { fn connect_signals(self: &Rc) { let motion_controller = gtk::EventControllerMotion::new(); motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); - let controller = Rc::clone(self); - motion_controller.connect_motion(move |_, x, y| controller.pointer_moved(x, y)); + let controller = Rc::downgrade(self); + motion_controller.connect_motion(move |_, x, y| { + if let Some(controller) = controller.upgrade() { + controller.pointer_moved(x, y); + } + }); self.window.add_controller(motion_controller.clone()); self.motion_controller.replace(Some(motion_controller)); let keys = gtk::EventControllerKey::new(); - let controller = Rc::clone(self); - keys.connect_key_pressed(move |_, key, _, _| { + let controller = Rc::downgrade(self); + keys.connect_key_pressed(move |_, key, _, modifiers| { + let Some(controller) = controller.upgrade() else { + return glib::Propagation::Proceed; + }; + if modifiers.intersects( + gdk::ModifierType::CONTROL_MASK + | gdk::ModifierType::ALT_MASK + | gdk::ModifierType::SUPER_MASK, + ) { + return glib::Propagation::Proceed; + } controller.hide_seek(); match key { + gdk::Key::Left if controller.kind == EntryKind::Video => { + controller.seek_video(-VIDEO_SEEK_STEP_US); + glib::Propagation::Stop + } + gdk::Key::Right if controller.kind == EntryKind::Video => { + controller.seek_video(VIDEO_SEEK_STEP_US); + glib::Propagation::Stop + } + gdk::Key::Up if controller.kind == EntryKind::Video => { + controller.adjust_video_volume(VIDEO_VOLUME_STEP); + glib::Propagation::Stop + } + gdk::Key::Down if controller.kind == EntryKind::Video => { + controller.adjust_video_volume(-VIDEO_VOLUME_STEP); + glib::Propagation::Stop + } gdk::Key::Home => { controller.open_edge(false); glib::Propagation::Stop @@ -1567,33 +1983,74 @@ impl MediaViewerController { }); self.window.add_controller(keys.clone()); self.key_controller.replace(Some(keys)); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); self.favorite_button.connect_clicked(move |_| { - let active = (controller.favorite_toggle)(); - controller.favorite_button.set_label(favorite_label(active)); + if let Some(controller) = controller.upgrade() { + let active = (controller.favorite_toggle)(); + controller.favorite_button.set_label(favorite_label(active)); + } }); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); self.download_button.connect_clicked(move |_| { - (controller.file_download)( - controller.window.clone().upcast(), - controller.status_label.clone(), - ); + if let Some(controller) = controller.upgrade() { + (controller.file_download)( + controller.window.clone().upcast(), + controller.status_label.clone(), + ); + } }); + let controller = Rc::downgrade(self); + self.file_list_button.connect_toggled(move |button| { + if let Some(controller) = controller.upgrade() { + controller.file_list_panel.set_visible(button.is_active()); + if button.is_active() { + controller.window.add_css_class("viewer-file-list-visible"); + } else { + controller + .window + .remove_css_class("viewer-file-list-visible"); + } + if button.is_active() { + controller.refresh_file_list(); + } + } + }); + let controller = Rc::downgrade(self); + self.file_list.connect_row_activated(move |_, row| { + if let Some(controller) = controller.upgrade() { + controller.open_file_list_row(row.index()); + } + }); + let file_list_click = gtk::GestureClick::builder().button(3).build(); + let controller = Rc::downgrade(self); + file_list_click.connect_pressed(move |_, _, x, y| { + if let Some(controller) = controller.upgrade() { + controller.show_file_list_context_menu(x, y); + } + }); + self.file_list.add_controller(file_list_click); if let Some(change) = self.image_mode_change.clone() { - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); self.image_mode_dropdown .connect_selected_notify(move |dropdown| { - change(dropdown.selected()); - controller.apply_image_view_mode(); + if let Some(controller) = controller.upgrade() { + change(dropdown.selected()); + controller.apply_image_view_mode(); + } }); } - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); self.image_scrolled .connect_notify_local(Some("width"), move |_, _| { - controller.resize_image_picture(); + if let Some(controller) = controller.upgrade() { + controller.resize_image_picture(); + } }); - let controller = Rc::clone(self); + let controller = Rc::downgrade(self); self.media_seek.connect_value_changed(move |scale| { + let Some(controller) = controller.upgrade() else { + return; + }; let Some(navigator) = &controller.sibling_navigator else { return; }; @@ -1607,6 +2064,70 @@ impl MediaViewerController { }); } + fn refresh_file_list(&self) { + while let Some(child) = self.file_list.first_child() { + self.file_list.remove(&child); + } + let files = self + .sibling_navigator + .as_ref() + .map(SiblingNavigator::files) + .unwrap_or_default(); + let current = files + .iter() + .position(|file| file.uri == self.source.source_uri); + for file in &files { + self.file_list.append(&viewer_file_row(file)); + } + *self.file_list_files.borrow_mut() = files; + if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) { + self.file_list.select_row(Some(&row)); + } + } + + fn open_file_list_row(&self, index: i32) { + let Some(uri) = self + .file_list_files + .borrow() + .get(index as usize) + .map(|file| file.uri.clone()) + else { + return; + }; + if uri == self.source.source_uri { + return; + } + if let Some(navigator) = &self.sibling_navigator + && navigator.open_uri(&uri, self.window.clone()) + { + self.prepare_for_navigation(); + } + } + + fn show_file_list_context_menu(&self, x: f64, y: f64) { + let Some((row, file)) = selected_viewer_file(&self.file_list, &self.file_list_files, x, y) + else { + return; + }; + self.file_list.select_row(Some(&row)); + let Some(actions) = self + .sibling_navigator + .as_ref() + .and_then(|navigator| navigator.actions(&file.uri)) + else { + return; + }; + show_viewer_file_context_menu( + &row, + &file, + actions, + &self.source.source_uri, + &self.favorite_button, + &self.window, + &self.status_label, + ); + } + fn image_view_mode(&self) -> ViewMode { match self.image_mode_dropdown.selected() { 0 => ViewMode::ActualSize, @@ -1615,6 +2136,28 @@ impl MediaViewerController { } } + fn seek_video(&self, delta: i64) { + let Some(stream) = self + .video + .media_stream() + .filter(|stream| stream.is_seekable()) + else { + return; + }; + stream.seek(video_seek_target( + stream.timestamp(), + stream.duration(), + delta, + )); + } + + fn adjust_video_volume(&self, delta: f64) { + let Some(stream) = self.video.media_stream() else { + return; + }; + stream.set_volume(video_volume_target(stream.volume(), delta)); + } + fn apply_image_view_mode(&self) { if self.kind != EntryKind::Image { return; @@ -1803,9 +2346,7 @@ impl MediaViewerController { } fn prepare_for_navigation(&self) { - if let Some(stream) = self.video.media_stream() { - stream.pause(); - } + self.stop_playback(); if let Some(controller) = self.key_controller.borrow_mut().take() { self.window.remove_controller(&controller); } @@ -1814,6 +2355,13 @@ impl MediaViewerController { } } + fn stop_playback(&self) { + self.image_animation_generation.cancel(); + if let Some(stream) = self.video.media_stream() { + stream.pause(); + } + } + fn toggle_fullscreen(&self) { if self.window.is_fullscreen() { self.window.unfullscreen(); @@ -1853,15 +2401,12 @@ impl MediaViewerController { Ok(page) => { self.image_dimensions .set(Some((page.width as i32, page.height as i32))); - let bytes = glib::Bytes::from_owned(page.rgba); - let texture = gdk::MemoryTexture::new( - page.width as i32, - page.height as i32, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - page.width as usize * 4, + let (frames, _) = texture_frames(page); + set_picture_animation( + &self.picture, + Some(frames), + &self.image_animation_generation, ); - self.picture.set_paintable(Some(&texture)); self.apply_image_view_mode(); self.status_label.set_text(&self.source.display_name); } @@ -1878,6 +2423,115 @@ fn favorite_label(active: bool) -> &'static str { } } +struct ViewerFileList { + panel: gtk::ScrolledWindow, + list: gtk::ListBox, +} + +fn viewer_file_list() -> ViewerFileList { + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::Single); + list.set_activate_on_single_click(false); + let panel = gtk::ScrolledWindow::builder() + .width_request(280) + .vexpand(true) + .child(&list) + .build(); + panel.add_css_class("sidebar"); + panel.set_visible(false); + ViewerFileList { panel, list } +} + +fn viewer_file_row(file: &SiblingFile) -> gtk::ListBoxRow { + let row = gtk::ListBoxRow::new(); + let content = gtk::Box::new(gtk::Orientation::Horizontal, 8); + content.set_margin_top(7); + content.set_margin_bottom(7); + content.set_margin_start(8); + content.set_margin_end(8); + let icon_name = match file.kind { + EntryKind::ZipArchive => "package-x-generic-symbolic", + EntryKind::Video => "video-x-generic-symbolic", + _ => "image-x-generic-symbolic", + }; + let icon = gtk::Image::from_icon_name(icon_name); + let label = gtk::Label::builder() + .label(&file.name) + .xalign(0.0) + .hexpand(true) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .tooltip_text(&file.name) + .build(); + content.append(&icon); + content.append(&label); + row.set_child(Some(&content)); + row +} + +fn selected_viewer_file( + list: >k::ListBox, + files: &RefCell>, + x: f64, + y: f64, +) -> Option<(gtk::ListBoxRow, SiblingFile)> { + let widget = list.pick(x, y, gtk::PickFlags::DEFAULT)?; + let row = widget + .clone() + .downcast::() + .ok() + .or_else(|| { + widget + .ancestor(gtk::ListBoxRow::static_type()) + .and_then(|widget| widget.downcast::().ok()) + })?; + let file = files.borrow().get(row.index() as usize)?.clone(); + Some((row, file)) +} + +fn show_viewer_file_context_menu( + row: >k::ListBoxRow, + file: &SiblingFile, + actions: SiblingFileActions, + current_uri: &str, + current_favorite_button: >k::Button, + window: >k::ApplicationWindow, + status_label: >k::Label, +) { + let popover = gtk::Popover::new(); + popover.set_parent(row); + let buttons = gtk::Box::new(gtk::Orientation::Vertical, 4); + buttons.set_margin_top(6); + buttons.set_margin_bottom(6); + buttons.set_margin_start(6); + buttons.set_margin_end(6); + let favorite_button = gtk::Button::with_label(favorite_label(actions.favorite.active)); + let download_button = gtk::Button::with_label("파일 다운로드..."); + buttons.append(&favorite_button); + buttons.append(&download_button); + popover.set_child(Some(&buttons)); + + let toggle = actions.favorite.toggle; + let update_current = file.uri == current_uri; + let current_favorite_button = current_favorite_button.clone(); + let popover_to_close = popover.clone(); + favorite_button.connect_clicked(move |_| { + let active = toggle(); + if update_current { + current_favorite_button.set_label(favorite_label(active)); + } + popover_to_close.popdown(); + }); + let download = actions.download.download; + let window: gtk::Window = window.clone().upcast(); + let status_label = status_label.clone(); + let popover_to_close = popover.clone(); + download_button.connect_clicked(move |_| { + popover_to_close.popdown(); + download(window.clone(), status_label.clone()); + }); + popover.popup(); +} + fn standalone_mode_index(global_index: u32) -> u32 { match global_index { 0 | 2 => global_index, @@ -1977,6 +2631,100 @@ fn single_picture_request( } } +fn texture_frames(page: DecodedPage) -> (Rc, usize) { + let width = page.width; + let height = page.height; + let loop_count = page.loop_count; + let mut cost = 0; + let frames = page + .frames + .into_iter() + .map(|frame| { + cost += frame.rgba.len(); + let bytes = glib::Bytes::from_owned(frame.rgba); + CachedFrame { + texture: gdk::MemoryTexture::new( + width as i32, + height as i32, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + width as usize * 4, + ), + delay: frame.delay, + } + }) + .collect(); + (Rc::new(CachedAnimation { frames, loop_count }), cost) +} + +fn set_picture_animation( + picture: >k::Picture, + frames: Option>, + state: &Rc, +) { + if let Some(frames) = frames.as_ref() + && state + .frames + .borrow() + .as_ref() + .and_then(Weak::upgrade) + .is_some_and(|current| Rc::ptr_eq(¤t, frames)) + { + return; + } + let current = state.cancel(); + let Some(frames) = frames.filter(|frames| !frames.is_empty()) else { + picture.set_paintable(gdk::Paintable::NONE); + return; + }; + state.frames.replace(Some(Rc::downgrade(&frames))); + show_animation_frame(picture.downgrade(), frames, Rc::clone(state), current, 0, 0); +} + +fn show_animation_frame( + picture: glib::WeakRef, + frames: Rc, + state: Rc, + expected_generation: u64, + index: usize, + completed_loops: u32, +) { + if state.generation.get() != expected_generation { + return; + } + let Some(picture_widget) = picture.upgrade() else { + return; + }; + let frame = &frames[index]; + picture_widget.set_paintable(Some(&frame.texture)); + if frames.len() == 1 { + return; + } + let delay = frame.delay; + let next = (index + 1) % frames.len(); + let completed_loops = completed_loops + u32::from(next == 0); + if next == 0 + && frames + .loop_count + .is_some_and(|loop_count| completed_loops >= loop_count) + { + return; + } + let callback_state = Rc::clone(&state); + let timer = glib::timeout_add_local_once(delay, move || { + callback_state.timer.borrow_mut().take(); + show_animation_frame( + picture, + frames, + callback_state, + expected_generation, + next, + completed_loops, + ); + }); + state.timer.replace(Some(timer)); +} + fn continuous_content_width(stack_width: i32) -> i32 { (stack_width - 24).max(320) } @@ -2036,6 +2784,19 @@ fn sibling_index(current: usize, count: usize, forward: bool) -> Option { } } +fn video_seek_target(timestamp: i64, duration: i64, delta: i64) -> i64 { + let target = timestamp.saturating_add(delta).max(0); + if duration > 0 { + target.min(duration) + } else { + target + } +} + +fn video_volume_target(volume: f64, delta: f64) -> f64 { + (volume + delta).clamp(0.0, 1.0) +} + #[cfg(test)] mod tests { use super::*; @@ -2139,6 +2900,19 @@ mod tests { assert_eq!(SiblingTarget::Last.resolve(0, 0), None); } + #[test] + fn video_controls_clamp_seek_and_volume() { + assert_eq!(video_seek_target(3_000_000, 20_000_000, -5_000_000), 0); + assert_eq!( + video_seek_target(18_000_000, 20_000_000, 5_000_000), + 20_000_000 + ); + assert_eq!(video_seek_target(18_000_000, 0, 5_000_000), 23_000_000); + assert_eq!(video_volume_target(0.02, -0.05), 0.0); + assert_eq!(video_volume_target(0.98, 0.05), 1.0); + assert_eq!(video_volume_target(0.5, 0.05), 0.55); + } + #[test] fn navigation_boundary_messages_match_direction() { assert_eq!( diff --git a/src/window.rs b/src/window.rs index 476bfcf..e60c6d3 100644 --- a/src/window.rs +++ b/src/window.rs @@ -21,8 +21,9 @@ use crate::source::{ mount_operation, parent_within_root, root_file, validate_profile, }; use crate::viewer::{ - FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator, - SiblingTarget, ViewModeControl, ViewerWindow, + FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingFile, + SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow, + set_viewer_header, }; use crate::zip_cache::ZipCache; @@ -51,6 +52,8 @@ impl ComicWindow { header.set_title_widget(Some(>k::Label::new(Some("Comicviewer")))); let about_button = gtk::Button::with_label("정보"); header.pack_end(&about_button); + let clear_cache_button = gtk::Button::with_label("캐시 비우기"); + header.pack_end(&clear_cache_button); window.set_titlebar(Some(&header)); let root = gtk::Box::new(gtk::Orientation::Vertical, 8); @@ -128,6 +131,7 @@ impl ComicWindow { let controller = Rc::new(BrowserController { window, about_button, + clear_cache_button, location_dropdown, edit_location_button, delete_location_button, @@ -155,6 +159,7 @@ impl ComicWindow { config_path, database: Rc::new(database), zip_cache, + listing_task: RefCell::new(None), }); controller.rebuild_profiles(); @@ -193,6 +198,7 @@ struct BrowserState { struct BrowserController { window: gtk::ApplicationWindow, about_button: gtk::Button, + clear_cache_button: gtk::Button, location_dropdown: gtk::DropDown, edit_location_button: gtk::Button, delete_location_button: gtk::Button, @@ -207,6 +213,7 @@ struct BrowserController { config_path: PathBuf, database: Rc, zip_cache: Rc, + listing_task: RefCell>>, } impl BrowserController { @@ -326,6 +333,9 @@ impl BrowserController { fast_loading_switch.connect_active_notify(move |switch| { controller.set_fast_image_loading(switch); }); + let controller = Rc::clone(self); + self.clear_cache_button + .connect_clicked(move |_| controller.show_clear_cache_dialog()); } fn profiles(&self) -> Vec { @@ -388,6 +398,7 @@ impl BrowserController { } fn show_favorites(&self) { + self.cancel_listing(); self.edit_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false); self.path_entry.set_text("즐겨찾기"); @@ -439,12 +450,29 @@ impl BrowserController { return; } + self.cancel_listing(); self.status_label.set_text("즐겨찾기에 연결하는 중..."); let operation = mount_operation(&favorite.profile, &self.window); let controller = Rc::downgrade(self); - glib::MainContext::default().spawn_local(async move { + let task = glib::MainContext::default().spawn_local(async move { let file = gio::File::for_uri(&favorite.uri); let result = ensure_mounted(&file, &favorite.profile, &operation).await; + let folder_entries = if result.is_ok() { + if let Some(parent) = file.parent() { + enumerate_directory( + &favorite.profile, + parent.uri().as_str(), + &operation, + |_| {}, + ) + .await + .ok() + } else { + None + } + } else { + None + }; let Some(controller) = controller.upgrade() else { return; }; @@ -459,18 +487,36 @@ impl BrowserController { .set_text("이 즐겨찾기 파일 형식은 열 수 없습니다."); return; } - let entry = BrowserEntry { - uri: favorite.uri, - name: favorite.name, - kind, - size: None, - modified: None, - created: None, - etag: None, - }; - let siblings = Rc::new(vec![entry.clone()]); + let entry = folder_entries + .as_ref() + .and_then(|entries| entries.iter().find(|entry| entry.uri == favorite.uri)) + .cloned() + .unwrap_or_else(|| BrowserEntry { + uri: favorite.uri.clone(), + name: favorite.name.clone(), + kind, + size: None, + modified: None, + created: None, + etag: None, + }); + let siblings = folder_entries.map_or_else( + || vec![entry.clone()], + |entries| { + let state = controller.state.borrow(); + sorted_navigable_entries( + &entries, + kind, + state.sort_field, + state.sort_direction, + controller.config.borrow().case_insensitive_sort, + ) + }, + ); + let siblings = Rc::new(siblings); controller.open_viewable(entry, siblings, favorite.profile, None); }); + self.listing_task.replace(Some(task)); } fn open_entered_path(self: &Rc) { @@ -512,6 +558,7 @@ impl BrowserController { self.show_favorites(); return; } + self.cancel_listing(); let (profile, uri, generation) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); @@ -532,7 +579,7 @@ impl BrowserController { let operation = mount_operation(&profile, &self.window); let progress_controller = Rc::downgrade(self); let controller = Rc::downgrade(self); - glib::MainContext::default().spawn_local(async move { + let task = glib::MainContext::default().spawn_local(async move { let result = enumerate_directory(&profile, &uri, &operation, move |count| { let Some(controller) = progress_controller.upgrade() else { return; @@ -561,6 +608,13 @@ impl BrowserController { Err(error) => controller.status_label.set_text(&format!("{error:#}")), } }); + self.listing_task.replace(Some(task)); + } + + fn cancel_listing(&self) { + if let Some(task) = self.listing_task.borrow_mut().take() { + task.abort(); + } } fn render(&self) { @@ -733,19 +787,7 @@ impl BrowserController { match entry.kind { EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window), EntryKind::Image | EntryKind::Video => { - let media_siblings = Rc::new( - siblings - .iter() - .filter(|sibling| sibling.kind.is_media()) - .cloned() - .collect::>(), - ); - if media_siblings - .iter() - .any(|sibling| sibling.uri == entry.uri) - { - self.open_media(entry, media_siblings, profile, reuse_window); - } + self.open_media(entry, siblings, profile, reuse_window) } _ => {} } @@ -773,14 +815,22 @@ impl BrowserController { }); let controller = Rc::downgrade(self); + let navigate_siblings = Rc::clone(&siblings); + let navigate_current_uri = current_uri.clone(); + let navigate_profile = profile.clone(); 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) + let siblings = controller.navigation_siblings( + &navigate_current_uri, + current_kind, + &navigate_siblings, + ); + let Some(current) = siblings + .iter() + .position(|entry| entry.uri == navigate_current_uri) else { return false; }; @@ -790,13 +840,47 @@ impl BrowserController { controller.open_viewable( siblings[next].clone(), Rc::clone(&siblings), - profile.clone(), + navigate_profile.clone(), Some(reuse_window), ); true }, ); - SiblingNavigator::new(position, navigate) + let files_controller = Rc::downgrade(self); + let files_current_uri = current_uri.clone(); + let files_fallback = Rc::clone(&siblings); + let files = Rc::new(move || { + let Some(controller) = files_controller.upgrade() else { + return Vec::new(); + }; + controller + .navigation_siblings(&files_current_uri, current_kind, &files_fallback) + .iter() + .map(|entry| SiblingFile { + uri: entry.uri.clone(), + name: entry.name.clone(), + kind: entry.kind, + }) + .collect() + }); + let actions_controller = Rc::downgrade(self); + let actions_current_uri = current_uri; + let actions_fallback = Rc::clone(&siblings); + let actions_profile = profile; + let actions = Rc::new(move |uri: &str| { + let controller = actions_controller.upgrade()?; + let entries = controller.navigation_siblings( + &actions_current_uri, + current_kind, + &actions_fallback, + ); + let entry = entries.iter().find(|entry| entry.uri == uri)?; + Some(SiblingFileActions { + favorite: controller.favorite_control(entry, &actions_profile), + download: controller.download_control(entry), + }) + }); + SiblingNavigator::new(position, navigate, files, actions) } fn navigation_siblings( @@ -814,8 +898,9 @@ impl BrowserController { state.sort_direction, case_insensitive, ); + let browsing_favorites = state.browsing_favorites; drop(state); - if entries.iter().any(|entry| entry.uri == current_uri) { + if !browsing_favorites && entries.iter().any(|entry| entry.uri == current_uri) { Rc::new(entries) } else { Rc::clone(fallback) @@ -846,33 +931,41 @@ impl BrowserController { let percent = if total > 0 { current * 100 / total } else { 0 }; progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%")); }, - move |result| match result { - Ok(source) => { - controller.status_label.set_text("다운로드 완료"); - result_label.set_text("ZIP 파일을 여는 중..."); - let Some(application) = controller.window.application() else { - return; - }; - ViewerWindow::open( - &application, - 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), - favorite_control, - download_control, - reuse_window, - view_mode_control: None, - }, - ); + move |result| { + if reuse_window + .as_ref() + .is_some_and(|window| !window.is_visible()) + { + return; } - Err(error) => { - let message = format!("{error:#}"); - controller.status_label.set_text(&message); - result_label.set_text(&message); + match result { + Ok(source) => { + controller.status_label.set_text("다운로드 완료"); + result_label.set_text("ZIP 파일을 여는 중..."); + let Some(application) = controller.window.application() else { + return; + }; + ViewerWindow::open( + &application, + 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), + favorite_control, + download_control, + reuse_window, + view_mode_control: None, + }, + ); + } + Err(error) => { + let message = format!("{error:#}"); + controller.status_label.set_text(&message); + result_label.set_text(&message); + } } }, ); @@ -904,30 +997,38 @@ impl BrowserController { let percent = if total > 0 { current * 100 / total } else { 0 }; progress_label.set_text(&format!("미디어 다운로드 중... {percent}%")); }, - move |result| match result { - Ok(source) => { - controller.status_label.set_text("다운로드 완료"); - result_label.set_text("미디어 파일을 여는 중..."); - let Some(application) = controller.window.application() else { - return; - }; - MediaViewerWindow::open( - &application, - source, - kind, - FileViewerControls { - sibling_navigator: Some(navigator), - favorite_control, - download_control, - reuse_window, - view_mode_control, - }, - ); + move |result| { + if reuse_window + .as_ref() + .is_some_and(|window| !window.is_visible()) + { + return; } - Err(error) => { - let message = format!("{error:#}"); - controller.status_label.set_text(&message); - result_label.set_text(&message); + match result { + Ok(source) => { + controller.status_label.set_text("다운로드 완료"); + result_label.set_text("미디어 파일을 여는 중..."); + let Some(application) = controller.window.application() else { + return; + }; + MediaViewerWindow::open( + &application, + source, + kind, + FileViewerControls { + sibling_navigator: Some(navigator), + favorite_control, + download_control, + reuse_window, + view_mode_control, + }, + ); + } + Err(error) => { + let message = format!("{error:#}"); + controller.status_label.set_text(&message); + result_label.set_text(&message); + } } }, ); @@ -1252,6 +1353,60 @@ impl BrowserController { dialog.present(); } + fn show_clear_cache_dialog(self: &Rc) { + let dialog = gtk::Window::builder() + .title("캐시 비우기") + .transient_for(&self.window) + .modal(true) + .resizable(false) + .default_width(440) + .build(); + let content = gtk::Box::new(gtk::Orientation::Vertical, 16); + content.set_margin_top(18); + content.set_margin_bottom(12); + content.set_margin_start(18); + content.set_margin_end(18); + let message = gtk::Label::new(Some( + "다운로드한 ZIP 캐시를 삭제하시겠습니까?\n현재 열려 있는 ZIP과 읽기 기록은 유지됩니다.", + )); + message.set_wrap(true); + message.set_xalign(0.0); + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); + actions.set_halign(gtk::Align::End); + let cancel_button = gtk::Button::with_label("취소"); + let clear_button = gtk::Button::with_label("캐시 삭제"); + clear_button.add_css_class("destructive-action"); + actions.append(&cancel_button); + actions.append(&clear_button); + content.append(&message); + content.append(&actions); + dialog.set_child(Some(&content)); + + let dialog_to_close = dialog.clone(); + cancel_button.connect_clicked(move |_| dialog_to_close.close()); + let controller = Rc::clone(self); + let dialog_to_close = dialog.clone(); + clear_button.connect_clicked(move |_| match controller.zip_cache.clear_persistent() { + Ok(report) => { + let removed_mib = report.removed_bytes as f64 / (1024.0 * 1024.0); + let retained = if report.retained_open_files == 0 { + String::new() + } else { + format!(", 열려 있어 유지한 파일 {}개", report.retained_open_files) + }; + controller.status_label.set_text(&format!( + "ZIP 캐시 {}개({removed_mib:.1} MiB)를 삭제했습니다{retained}.", + report.removed_files + )); + dialog_to_close.close(); + } + Err(error) => controller + .status_label + .set_text(&format!("캐시를 삭제할 수 없습니다: {error:#}")), + }); + dialog.present(); + } + fn show_location_dialog(self: &Rc, edit_index: Option) { let existing = edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned()); @@ -1504,11 +1659,7 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt } fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { - match current { - EntryKind::ZipArchive => candidate.is_viewable(), - EntryKind::Image | EntryKind::Video => candidate.is_media(), - _ => false, - } + current.is_viewable() && candidate.is_viewable() } fn is_favorites_selection(selected: u32, profile_count: usize) -> bool { @@ -1555,7 +1706,7 @@ fn show_viewer_loading( window.set_title(Some(file_name)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(file_name)))); - window.set_titlebar(Some(&header)); + set_viewer_header(window, &header); let content = gtk::Box::new(gtk::Orientation::Vertical, 12); content.set_halign(gtk::Align::Center); @@ -1645,11 +1796,13 @@ mod tests { } #[test] - fn media_navigation_excludes_zip_archives() { + fn viewer_navigation_includes_all_viewable_files() { 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::Image, EntryKind::ZipArchive)); + assert!(navigable_sibling(EntryKind::ZipArchive, EntryKind::Video)); assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); + assert!(!navigable_sibling(EntryKind::Directory, EntryKind::Image)); } #[test] diff --git a/src/zip_cache.rs b/src/zip_cache.rs index 5cd3268..4c118f1 100644 --- a/src/zip_cache.rs +++ b/src/zip_cache.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::fs::{self, File}; use std::path::{Path, PathBuf}; @@ -24,10 +24,18 @@ pub struct ArchiveSource { pub struct ZipCache { directory: PathBuf, - quota_bytes: u64, + quota_bytes: Cell, pinned: RefCell>, } +#[derive(Debug, Default, PartialEq, Eq)] +pub struct CacheCleanupReport { + pub removed_files: usize, + pub removed_bytes: u64, + pub retained_open_files: usize, + pub remaining_bytes: u64, +} + struct CachePin { path: PathBuf, cache: Weak, @@ -37,12 +45,18 @@ impl Drop for CachePin { fn drop(&mut self) { if let Some(cache) = self.cache.upgrade() { let mut pinned = cache.pinned.borrow_mut(); + let mut released = false; if let Some(count) = pinned.get_mut(&self.path) { *count -= 1; if *count == 0 { pinned.remove(&self.path); + released = true; } } + drop(pinned); + if released { + let _ = cache.enforce_quota(None); + } } } } @@ -52,18 +66,24 @@ impl ZipCache { fs::create_dir_all(&directory)?; for entry in fs::read_dir(&directory)? { let path = entry?.path(); - if path + let incomplete_download = path .extension() - .is_some_and(|extension| extension == "part") - { + .is_some_and(|extension| extension == "part"); + let stale_temporary = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("temporary-")); + if incomplete_download || stale_temporary { let _ = fs::remove_file(path); } } - Ok(Rc::new(Self { + let cache = Rc::new(Self { directory, - quota_bytes: quota_gb.saturating_mul(1024 * 1024 * 1024), + quota_bytes: Cell::new(quota_gb.saturating_mul(1024 * 1024 * 1024)), pinned: RefCell::new(HashMap::new()), - })) + }); + cache.enforce_quota(None)?; + Ok(cache) } pub fn prepare(self: &Rc, entry: BrowserEntry, progress: P, callback: F) @@ -83,7 +103,8 @@ impl ZipCache { return; } - if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) { + let quota_bytes = self.quota_bytes.get(); + if quota_bytes == 0 || entry.size.is_some_and(|size| size > quota_bytes) { self.download_temporary(source, entry, true, progress, callback); } else { self.download_persistent(source, entry, progress, callback); @@ -127,7 +148,11 @@ impl ZipCache { || ".part".to_owned(), |extension| format!(".part.{extension}"), ); - let temporary = match tempfile::Builder::new().suffix(&suffix).tempfile() { + let temporary = match tempfile::Builder::new() + .prefix("temporary-") + .suffix(&suffix) + .tempfile_in(&self.directory) + { Ok(file) => Rc::new(file.into_temp_path()), Err(error) => { callback(Err(error.into())); @@ -183,6 +208,12 @@ impl ZipCache { } let part_path = self.directory.join(format!("{key}.part")); + if let Some(required_bytes) = entry.size + && let Err(error) = self.make_room_for(required_bytes) + { + callback(Err(error)); + return; + } let destination = gio::File::for_path(&part_path); let cache = Rc::clone(self); source.copy_async( @@ -200,7 +231,7 @@ impl ZipCache { final_path.display() ) })?; - cache.enforce_quota(&final_path)?; + cache.enforce_quota(Some(&final_path))?; Ok(cache.lease(final_path, entry.uri, entry.name)) }); if result.is_err() { @@ -211,11 +242,35 @@ impl ZipCache { ); } - fn enforce_quota(&self, protected: &Path) -> Result<()> { + pub fn clear_persistent(&self) -> Result { + self.cleanup_to_limit(0, None) + } + + fn make_room_for(&self, required_bytes: u64) -> Result<()> { + let limit = self.quota_bytes.get().saturating_sub(required_bytes); + let report = self.cleanup_to_limit(limit, None)?; + if report.remaining_bytes > limit { + bail!("열려 있는 ZIP 때문에 다운로드에 필요한 캐시 공간을 확보할 수 없습니다"); + } + Ok(()) + } + + fn enforce_quota(&self, protected: Option<&Path>) -> Result { + self.cleanup_to_limit(self.quota_bytes.get(), protected) + } + + fn cleanup_to_limit(&self, limit: u64, protected: Option<&Path>) -> Result { let mut files = Vec::new(); let mut total = 0_u64; for entry in fs::read_dir(&self.directory)? { let path = entry?.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("temporary-")) + { + continue; + } if path.extension().is_none_or(|extension| extension != "zip") { continue; } @@ -224,18 +279,26 @@ impl ZipCache { files.push((metadata.modified().ok(), metadata.len(), path)); } files.sort_by_key(|(modified, _, path)| (*modified, path.clone())); + let mut report = CacheCleanupReport::default(); for (_, size, path) in files { - if total <= self.quota_bytes { + if total <= limit { break; } - if path != protected - && !self.pinned.borrow().contains_key(&path) - && fs::remove_file(&path).is_ok() - { - total = total.saturating_sub(size); + if protected == Some(path.as_path()) { + continue; } + if self.pinned.borrow().contains_key(&path) { + report.retained_open_files += 1; + continue; + } + fs::remove_file(&path) + .with_context(|| format!("캐시 파일을 삭제할 수 없습니다: {}", path.display()))?; + total = total.saturating_sub(size); + report.removed_files += 1; + report.removed_bytes = report.removed_bytes.saturating_add(size); } - Ok(()) + report.remaining_bytes = total; + Ok(report) } fn lease( @@ -351,7 +414,7 @@ mod tests { fs::write(&second, [0_u8; 8]).unwrap(); let cache = Rc::new(ZipCache { directory: directory.path().to_path_buf(), - quota_bytes: 8, + quota_bytes: Cell::new(8), pinned: RefCell::new(HashMap::new()), }); @@ -360,13 +423,104 @@ mod tests { "file:///first.zip".into(), "first.zip".into(), ); - cache.enforce_quota(&second).unwrap(); + cache.enforce_quota(Some(&second)).unwrap(); assert!(first.exists()); assert!(second.exists()); drop(lease); - cache.enforce_quota(&second).unwrap(); + cache.enforce_quota(Some(&second)).unwrap(); assert!(!first.exists()); assert!(second.exists()); } + + #[test] + fn lru_removes_oldest_cache_file_first() { + let directory = tempfile::tempdir().unwrap(); + let oldest = directory.path().join("oldest.zip"); + let newest = directory.path().join("newest.zip"); + fs::write(&oldest, [0_u8; 8]).unwrap(); + fs::write(&newest, [0_u8; 8]).unwrap(); + filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap(); + filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap(); + let cache = ZipCache { + directory: directory.path().to_path_buf(), + quota_bytes: Cell::new(8), + pinned: RefCell::new(HashMap::new()), + }; + + let report = cache.enforce_quota(None).unwrap(); + + assert_eq!(report.removed_files, 1); + assert!(!oldest.exists()); + assert!(newest.exists()); + } + + #[test] + fn download_reserves_space_before_writing() { + let directory = tempfile::tempdir().unwrap(); + let oldest = directory.path().join("oldest.zip"); + let newest = directory.path().join("newest.zip"); + fs::write(&oldest, [0_u8; 8]).unwrap(); + fs::write(&newest, [0_u8; 8]).unwrap(); + filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap(); + filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap(); + let cache = ZipCache { + directory: directory.path().to_path_buf(), + quota_bytes: Cell::new(16), + pinned: RefCell::new(HashMap::new()), + }; + + cache.make_room_for(8).unwrap(); + + assert!(!oldest.exists()); + assert!(newest.exists()); + } + + #[test] + fn zero_quota_clears_existing_cache_on_startup() { + let directory = tempfile::tempdir().unwrap(); + let cached = directory.path().join("cached.zip"); + fs::write(&cached, [0_u8; 8]).unwrap(); + + let _cache = ZipCache::new(directory.path().to_path_buf(), 0).unwrap(); + + assert!(!cached.exists()); + } + + #[test] + fn startup_removes_stale_temporary_downloads() { + let directory = tempfile::tempdir().unwrap(); + let temporary = directory.path().join("temporary-abcd.part.mp4"); + fs::write(&temporary, [0_u8; 8]).unwrap(); + + let _cache = ZipCache::new(directory.path().to_path_buf(), 10).unwrap(); + + assert!(!temporary.exists()); + } + + #[test] + fn manual_clear_keeps_open_archives() { + let directory = tempfile::tempdir().unwrap(); + let open = directory.path().join("open.zip"); + let unused = directory.path().join("unused.zip"); + let temporary = directory.path().join("temporary-download.part.zip"); + fs::write(&open, [0_u8; 8]).unwrap(); + fs::write(&unused, [0_u8; 8]).unwrap(); + fs::write(&temporary, [0_u8; 8]).unwrap(); + let cache = Rc::new(ZipCache { + directory: directory.path().to_path_buf(), + quota_bytes: Cell::new(16), + pinned: RefCell::new(HashMap::new()), + }); + let lease = cache.lease(open.clone(), "file:///open.zip".into(), "open.zip".into()); + + let report = cache.clear_persistent().unwrap(); + + assert_eq!(report.removed_files, 1); + assert_eq!(report.retained_open_files, 1); + assert!(open.exists()); + assert!(!unused.exists()); + assert!(temporary.exists()); + drop(lease); + } }