From 8a213462e07b177c9494be8c17d5ca82dbfd7fbb Mon Sep 17 00:00:00 2001 From: burnintuna Date: Tue, 21 Jul 2026 04:12:59 +0900 Subject: [PATCH 01/30] Update installation guide for Cafe24 downloads --- docs/INSTALL.ko.md | 65 +++++++------------------------------------- docs/RELEASING.ko.md | 23 +++++++++------- 2 files changed, 23 insertions(+), 65 deletions(-) diff --git a/docs/INSTALL.ko.md b/docs/INSTALL.ko.md index 54756d7..10c8f08 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -19,10 +19,17 @@ sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb ## 2. 권장 방법: 통합 번들로 설치 또는 업데이트 -Release 페이지에서 다음 두 파일을 같은 폴더에 다운로드합니다. +다음 두 파일을 같은 폴더에 다운로드합니다. -- `Comicviewer-0.1.4-x86_64.tar.gz` -- `Comicviewer-0.1.4-x86_64.tar.gz.sha256` +- [Comicviewer-0.1.4-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz) +- [Comicviewer-0.1.4-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256) + +터미널에서 직접 다운로드할 수도 있습니다. + +```sh +curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz +curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256 +``` 체크섬을 확인하고 압축을 푼 뒤 설치 스크립트를 실행합니다. @@ -53,58 +60,6 @@ cd Comicviewer-0.1.4-x86_64 모두 삭제합니다. 삭제한 데이터는 복구할 수 없으므로 필요한 설정은 제거 전에 별도로 백업합니다. -## 3. AppImage만 다운로드하는 방법 - -Release 페이지에서 다음 두 파일을 같은 폴더에 다운로드합니다. - -- `Comicviewer-0.1.4-x86_64.AppImage` -- `Comicviewer-0.1.4-x86_64.AppImage.sha256` - -다운로드한 폴더에서 체크섬을 검증합니다. - -```sh -sha256sum -c Comicviewer-0.1.4-x86_64.AppImage.sha256 -``` - -`성공` 또는 `OK`가 표시되어야 합니다. 검증에 실패하면 파일을 실행하지 말고 -다시 다운로드합니다. - -## 4. 직접 실행 - -AppImage에 실행 권한을 부여한 뒤 실행합니다. - -```sh -chmod +x Comicviewer-0.1.4-x86_64.AppImage -./Comicviewer-0.1.4-x86_64.AppImage -``` - -FUSE를 사용할 수 없는 환경에서는 다음과 같이 임시 추출 방식으로 실행할 수 -있습니다. - -```sh -./Comicviewer-0.1.4-x86_64.AppImage --appimage-extract-and-run -``` - -## 5. 선택 사항: 수동으로 사용자 계정에 설치 - -AppImage를 사용자 전용 프로그램 폴더에 설치하고 명령어로 실행하려면 다음을 -사용합니다. - -```sh -mkdir -p ~/.local/opt/comicviewer ~/.local/bin -install -m 755 Comicviewer-0.1.4-x86_64.AppImage \ - ~/.local/opt/comicviewer/Comicviewer.AppImage -ln -sfn ~/.local/opt/comicviewer/Comicviewer.AppImage ~/.local/bin/comicviewer -``` - -이후 새 터미널에서 다음 명령으로 실행할 수 있습니다. - -```sh -comicviewer -``` - -`~/.local/bin`이 `PATH`에 없으면 AppImage의 전체 경로로 실행합니다. - ## 데이터 저장 위치 Comicviewer는 XDG 표준 경로를 사용합니다. diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index fd45977..ee6400a 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -88,27 +88,30 @@ git push origin v0.1.4 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 패치 버전을 만듭니다. -## 6. Git 서버 Release 생성 +## 6. Cafe24 배포 -Git 서버의 Releases 화면에서 푸시한 태그를 선택해 Release를 생성하고 다음 -파일을 첨부합니다. +Git 서버의 바이너리 다운로드는 사용하지 않습니다. FTP로 다음 파일을 +`/www/Downloads/Comicviewer/`에 업로드합니다. - `Comicviewer-0.1.4-x86_64.tar.gz` - `Comicviewer-0.1.4-x86_64.tar.gz.sha256` -- `Comicviewer-0.1.4-x86_64.AppImage` -- `Comicviewer-0.1.4-x86_64.AppImage.sha256` -호스트 라이브러리형 AppImage는 크기가 작지만, 업로드 후에도 반드시 원격에서 -다시 내려받아 체크섬과 실제 실행을 확인합니다. +업로드 후 다음 HTTPS 주소에서 두 파일을 새 디렉터리로 다시 내려받아 체크섬과 +실제 설치를 확인합니다. -Release 설명에는 주요 변경 사항과 함께 다음 요구사항을 안내합니다. +```text +https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz +https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256 +``` + +배포 안내에는 다음 내용을 사용합니다. ```text 지원 환경: CachyOS / Arch Linux x86_64 필수 패키지: fuse2, gtk4, dav1d, gvfs, gvfs-smb -권장 설치 또는 업데이트: -tar.gz와 tar.gz.sha256을 같은 폴더에 다운로드한 뒤 실행합니다. +설치 또는 업데이트: +위 HTTPS 주소에서 tar.gz와 tar.gz.sha256을 같은 폴더에 다운로드한 뒤 실행합니다. sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256 tar -xzf Comicviewer-0.1.4-x86_64.tar.gz cd Comicviewer-0.1.4-x86_64 From 5f9b5d979631256010a5afb35a579aec79a40dee Mon Sep 17 00:00:00 2001 From: burnintuna Date: Tue, 21 Jul 2026 21:18:36 +0900 Subject: [PATCH 02/30] Add standalone media and remote downloads --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 11 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 22 +- docs/RELEASING.ko.md | 40 +-- packaging/build-release-bundle.sh | 5 +- packaging/install-or-update.sh | 3 +- src/archive.rs | 41 ++- src/browser.rs | 46 ++- src/source.rs | 6 +- src/viewer.rs | 278 +++++++++++++++++- src/window.rs | 240 ++++++++++++++- src/zip_cache.rs | 72 ++++- 14 files changed, 683 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f5aced..28b8f59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.1.4" +version = "0.2.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index db68113..c648663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.1.4" +version = "0.2.0" edition = "2024" description = "A comic archive viewer for local and remote locations" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index d42fa28..2fcfc44 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Comicviewer -Comicviewer is a GTK4 desktop application for browsing and reading comic image -archives stored locally or on SMB, FTP, and SFTP servers. +Comicviewer is a GTK4 desktop application for browsing ZIP archives, images, +and videos stored locally or on SMB, FTP, and SFTP servers. 한국어 문서: @@ -11,15 +11,16 @@ archives stored locally or on SMB, FTP, and SFTP servers. The project is currently in early development. It supports saved local, SMB, FTP, and SFTP locations, complete remote ZIP downloads with an LRU cache, natural sorting, safe on-demand image decoding and prefetching, single-page and -two-page viewing, continuous vertical scrolling, reading progress, and -XDG-compliant configuration. +two-page viewing, continuous vertical scrolling, standalone image and video +playback, reading progress, and XDG-compliant configuration. ## Development dependencies On CachyOS or Arch Linux: ```sh -sudo pacman -S rust gtk4 dav1d sqlite gvfs gvfs-smb +sudo pacman -S rust gtk4 dav1d sqlite gvfs gvfs-smb gstreamer \ + gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav ``` Run the application with: diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml index 73db2cf..ad52674 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 10c8f08..5ebeffd 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -1,7 +1,7 @@ # Comicviewer 설치 및 실행 안내 -Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화 ZIP 뷰어입니다. -로컬 파일뿐 아니라 SMB, FTP, SFTP 서버의 폴더와 ZIP 파일도 열 수 있습니다. +Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디어 뷰어입니다. +로컬 및 SMB, FTP, SFTP 위치의 ZIP, 이미지, 동영상 파일을 열 수 있습니다. ## 1. 필수 패키지 @@ -9,34 +9,36 @@ Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화 ZIP 뷰 설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다. ```sh -sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb +sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer \ + gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav ``` - `fuse2`: AppImage 마운트 및 실행 - `gtk4`, `dav1d`: 화면 표시와 이미지 디코딩 - `gvfs`: FTP와 SFTP 접근 - `gvfs-smb`: SMB 공유 접근 +- `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생 ## 2. 권장 방법: 통합 번들로 설치 또는 업데이트 다음 두 파일을 같은 폴더에 다운로드합니다. -- [Comicviewer-0.1.4-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz) -- [Comicviewer-0.1.4-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256) +- [Comicviewer-latest-x86_64.tar.gz](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz) +- [Comicviewer-latest-x86_64.tar.gz.sha256](https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256) 터미널에서 직접 다운로드할 수도 있습니다. ```sh -curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz -curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256 +curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz +curl -fLO https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256 ``` 체크섬을 확인하고 압축을 푼 뒤 설치 스크립트를 실행합니다. ```sh -sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256 -tar -xzf Comicviewer-0.1.4-x86_64.tar.gz -cd Comicviewer-0.1.4-x86_64 +sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256 +tar -xzf Comicviewer-latest-x86_64.tar.gz +cd Comicviewer-latest-x86_64 ./install-or-update.sh ``` diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index ee6400a..befe386 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -7,10 +7,9 @@ Release 첨부 파일로 제공하는 방식을 권장합니다. 버전별 바 기존 `v0.1.0` AppImage 커밋은 배포 기록으로 유지하되, 다음 버전부터는 이 절차를 사용합니다. -태그, 프로젝트 버전, AppImage, 통합 번들의 버전은 반드시 같아야 합니다. -예를 들어 `v0.1.4` Release에는 이름에 `0.1.4`가 포함된 파일만 첨부합니다. -Git 태그만 푸시하면 첨부 파일은 자동으로 등록되지 않으므로, 마지막 단계에서 -Release를 만들고 파일 네 개를 직접 첨부해야 합니다. +태그, 프로젝트 버전, AppImage의 내부 버전은 반드시 같아야 합니다. 사용자가 +받는 통합 번들은 버전과 관계없이 `Comicviewer-latest-x86_64.tar.gz`라는 고정 +이름을 사용합니다. ## 1. 버전 갱신 @@ -56,9 +55,9 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.1.4-x86_64.AppImage.sha256` -- `dist/Comicviewer-0.1.4-x86_64.tar.gz` -- `dist/Comicviewer-0.1.4-x86_64.tar.gz.sha256` +- `dist/Comicviewer-0.2.0-x86_64.AppImage.sha256` +- `dist/Comicviewer-latest-x86_64.tar.gz` +- `dist/Comicviewer-latest-x86_64.tar.gz.sha256` 통합 번들에는 AppImage, 체크섬, 설치·업데이트 스크립트, 데스크톱 파일, 아이콘, 한국어 설치 안내문이 포함됩니다. `tar.gz`는 실행 권한과 디렉터리 구조를 @@ -67,9 +66,9 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 생성된 번들의 압축 목록과 체크섬을 확인합니다. ```sh -tar -tzf dist/Comicviewer-0.1.4-x86_64.tar.gz +tar -tzf dist/Comicviewer-latest-x86_64.tar.gz cd dist -sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256 +sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256 ``` ## 5. 소스 커밋과 태그 @@ -79,10 +78,10 @@ sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256 ```sh git add Cargo.toml Cargo.lock data packaging README.md docs -git commit -m "Prepare v0.1.4 release" -git tag -a v0.1.4 -m "Comicviewer v0.1.4" +git commit -m "Prepare v0.2.0 release" +git tag -a v0.2.0 -m "Comicviewer v0.2.0" git push origin main -git push origin v0.1.4 +git push origin v0.2.0 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 @@ -93,28 +92,29 @@ git push origin v0.1.4 Git 서버의 바이너리 다운로드는 사용하지 않습니다. FTP로 다음 파일을 `/www/Downloads/Comicviewer/`에 업로드합니다. -- `Comicviewer-0.1.4-x86_64.tar.gz` -- `Comicviewer-0.1.4-x86_64.tar.gz.sha256` +- `Comicviewer-latest-x86_64.tar.gz` +- `Comicviewer-latest-x86_64.tar.gz.sha256` 업로드 후 다음 HTTPS 주소에서 두 파일을 새 디렉터리로 다시 내려받아 체크섬과 실제 설치를 확인합니다. ```text -https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz -https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-0.1.4-x86_64.tar.gz.sha256 +https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz +https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256 ``` 배포 안내에는 다음 내용을 사용합니다. ```text 지원 환경: CachyOS / Arch Linux x86_64 -필수 패키지: fuse2, gtk4, dav1d, gvfs, gvfs-smb +필수 패키지: fuse2, gtk4, dav1d, gvfs, gvfs-smb, gstreamer, +gst-plugins-base, gst-plugins-good, gst-plugins-bad, gst-libav 설치 또는 업데이트: 위 HTTPS 주소에서 tar.gz와 tar.gz.sha256을 같은 폴더에 다운로드한 뒤 실행합니다. -sha256sum -c Comicviewer-0.1.4-x86_64.tar.gz.sha256 -tar -xzf Comicviewer-0.1.4-x86_64.tar.gz -cd Comicviewer-0.1.4-x86_64 +sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256 +tar -xzf Comicviewer-latest-x86_64.tar.gz +cd Comicviewer-latest-x86_64 ./install-or-update.sh ``` diff --git a/packaging/build-release-bundle.sh b/packaging/build-release-bundle.sh index 39b44bd..10e6025 100755 --- a/packaging/build-release-bundle.sh +++ b/packaging/build-release-bundle.sh @@ -4,8 +4,9 @@ set -euo pipefail root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" package_id="$(cargo pkgid --manifest-path "${root_dir}/Cargo.toml")" version="${package_id##*@}" -bundle_name="Comicviewer-${version}-x86_64" -appimage_name="${bundle_name}.AppImage" +artifact_name="Comicviewer-${version}-x86_64" +bundle_name="Comicviewer-latest-x86_64" +appimage_name="${artifact_name}.AppImage" appimage="${root_dir}/dist/${appimage_name}" checksum="${appimage}.sha256" archive="${root_dir}/dist/${bundle_name}.tar.gz" diff --git a/packaging/install-or-update.sh b/packaging/install-or-update.sh index 5941bb3..d650193 100755 --- a/packaging/install-or-update.sh +++ b/packaging/install-or-update.sh @@ -39,7 +39,8 @@ install_dependencies() { fi set -- - for package in fuse2 gtk4 dav1d gvfs gvfs-smb; do + for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer gst-plugins-base \ + gst-plugins-good gst-plugins-bad gst-libav; do if ! pacman -Q "${package}" >/dev/null 2>&1; then set -- "$@" "${package}" fi diff --git a/src/archive.rs b/src/archive.rs index 04e6090..ec7ad0f 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -70,16 +70,33 @@ pub fn decode_page(path: &Path, page: &ArchivePage) -> Result { bail!("실제 압축 해제 크기가 제한을 초과합니다: {}", page.name); } - let reader = ImageReader::new(Cursor::new(&encoded)) + decode_image_bytes(&encoded, &page.name) +} + +pub fn decode_image_file(path: &Path, display_name: &str) -> Result { + let file = File::open(path) + .with_context(|| format!("이미지 파일을 열 수 없습니다: {display_name}"))?; + let mut encoded = Vec::new(); + file.take(MAX_ENTRY_SIZE + 1) + .read_to_end(&mut encoded) + .with_context(|| format!("이미지 파일을 읽을 수 없습니다: {display_name}"))?; + if encoded.len() as u64 > MAX_ENTRY_SIZE { + bail!("이미지 크기가 제한을 초과합니다: {display_name} (최대 256MB)"); + } + decode_image_bytes(&encoded, display_name) +} + +fn decode_image_bytes(encoded: &[u8], name: &str) -> Result { + let reader = ImageReader::new(Cursor::new(encoded)) .with_guessed_format() - .with_context(|| format!("이미지 형식을 확인할 수 없습니다: {}", page.name))?; + .with_context(|| format!("이미지 형식을 확인할 수 없습니다: {name}"))?; let (width, height) = reader .into_dimensions() - .with_context(|| format!("이미지 크기를 읽을 수 없습니다: {}", page.name))?; - validate_dimensions(width, height, &page.name)?; + .with_context(|| format!("이미지 크기를 읽을 수 없습니다: {name}"))?; + validate_dimensions(width, height, name)?; - let image = image::load_from_memory(&encoded) - .with_context(|| format!("이미지를 디코딩할 수 없습니다: {}", page.name))?; + let image = image::load_from_memory(encoded) + .with_context(|| format!("이미지를 디코딩할 수 없습니다: {name}"))?; let rgba = image.into_rgba8(); Ok(DecodedPage { @@ -175,4 +192,16 @@ mod tests { std::fs::remove_file(path).unwrap(); } + + #[test] + fn decodes_a_standalone_image() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("photo.png"); + let image = image::RgbaImage::from_pixel(4, 5, image::Rgba([1, 2, 3, 255])); + image.save(&path).unwrap(); + + 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); + } } diff --git a/src/browser.rs b/src/browser.rs index 1613747..d70ef56 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -6,6 +6,15 @@ use std::time::SystemTime; pub enum EntryKind { Directory, ZipArchive, + Image, + Video, + OtherFile, +} + +impl EntryKind { + pub fn is_viewable(self) -> bool { + matches!(self, Self::ZipArchive | Self::Image | Self::Video) + } } #[derive(Debug, Clone)] @@ -34,9 +43,12 @@ pub enum SortDirection { pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) { entries.sort_by(|left, right| { - let kind_order = match (left.kind, right.kind) { - (EntryKind::Directory, EntryKind::ZipArchive) => Ordering::Less, - (EntryKind::ZipArchive, EntryKind::Directory) => Ordering::Greater, + let kind_order = match ( + left.kind == EntryKind::Directory, + right.kind == EntryKind::Directory, + ) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, _ => Ordering::Equal, }; if kind_order != Ordering::Equal { @@ -73,10 +85,19 @@ fn apply_direction(order: Ordering, direction: SortDirection) -> Ordering { } } -pub fn is_zip_path(path: &Path) -> bool { - path.extension() +pub fn classify_file(path: &Path) -> EntryKind { + let extension = path + .extension() .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("zip")) + .map(str::to_ascii_lowercase); + match extension.as_deref() { + Some("zip") => EntryKind::ZipArchive, + Some("jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp" | "avif") => EntryKind::Image, + Some("mp4" | "m4v" | "mkv" | "webm" | "mov" | "avi" | "mpeg" | "mpg" | "ogv") => { + EntryKind::Video + } + _ => EntryKind::OtherFile, + } } pub fn natural_cmp(left: &str, right: &str) -> Ordering { @@ -162,8 +183,17 @@ mod tests { #[test] fn zip_extension_is_case_insensitive() { - assert!(is_zip_path(Path::new("comic.ZIP"))); - assert!(!is_zip_path(Path::new("comic.cbz"))); + assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive); + assert_ne!(classify_file(Path::new("comic.cbz")), EntryKind::ZipArchive); + } + + #[test] + fn classifies_common_media_extensions_case_insensitively() { + assert_eq!(classify_file(Path::new("photo.JPG")), EntryKind::Image); + assert_eq!(classify_file(Path::new("animation.webp")), EntryKind::Image); + 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); } #[test] diff --git a/src/source.rs b/src/source.rs index d9aab4a..cc07e17 100644 --- a/src/source.rs +++ b/src/source.rs @@ -7,7 +7,7 @@ use gtk::gio::prelude::*; use gtk::glib; use percent_encoding::percent_decode_str; -use crate::browser::{BrowserEntry, EntryKind, is_zip_path}; +use crate::browser::{BrowserEntry, EntryKind, classify_file}; use crate::config::{LocationKind, LocationProfile}; const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value"; @@ -115,9 +115,7 @@ where let kind = match info.file_type() { gio::FileType::Directory => EntryKind::Directory, gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory, - gio::FileType::Regular if is_zip_path(Path::new(info.display_name().as_str())) => { - EntryKind::ZipArchive - } + gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())), _ => continue, }; let child = enumerator.child(&info); diff --git a/src/viewer.rs b/src/viewer.rs index d7ca3b7..f1ca10c 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -9,7 +9,8 @@ use gtk::gdk; use gtk::glib; use gtk::prelude::*; -use crate::archive::{ArchivePage, DecodedPage, decode_page, list_pages}; +use crate::archive::{ArchivePage, DecodedPage, decode_image_file, decode_page, list_pages}; +use crate::browser::EntryKind; use crate::database::Database; use crate::zip_cache::ArchiveSource; @@ -20,18 +21,17 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64; pub struct ViewerWindow; +pub type SiblingNavigator = Rc bool>; + impl ViewerWindow { pub fn open( application: >k::Application, source: ArchiveSource, database: Rc, fast_image_loading: bool, + sibling_navigator: Option, ) { - let title = source - .local_path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| "Comicviewer".into()); + let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() .application(application) .title(&title) @@ -119,8 +119,14 @@ impl ViewerWindow { .margin_start(8) .margin_end(8) .build(); + let page_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); + page_seek.set_draw_value(false); + page_seek.set_sensitive(false); + page_seek.set_margin_start(8); + page_seek.set_margin_end(8); root.append(&toolbar); root.append(&overlay); + root.append(&page_seek); root.append(&status_label); window.set_child(Some(&root)); @@ -136,6 +142,7 @@ impl ViewerWindow { continuous_scrolled, spinner, page_label, + page_seek, status_label, previous_button, next_button, @@ -144,6 +151,7 @@ impl ViewerWindow { mode_dropdown, direction_dropdown, fast_image_loading, + sibling_navigator, state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -210,6 +218,7 @@ struct ViewerController { continuous_scrolled: gtk::ScrolledWindow, spinner: gtk::Spinner, page_label: gtk::Label, + page_seek: gtk::Scale, status_label: gtk::Label, previous_button: gtk::Button, next_button: gtk::Button, @@ -218,6 +227,7 @@ struct ViewerController { mode_dropdown: gtk::DropDown, direction_dropdown: gtk::DropDown, fast_image_loading: bool, + sibling_navigator: Option, state: RefCell, } @@ -241,6 +251,14 @@ impl ViewerController { let controller = Rc::clone(self); self.direction_dropdown .connect_selected_notify(move |_| controller.render()); + let controller = Rc::clone(self); + self.page_seek.connect_value_changed(move |scale| { + if let Some(index) = + page_seek_index(scale.value(), controller.state.borrow().pages.len()) + { + controller.seek_page(index); + } + }); let adjustment = self.continuous_scrolled.vadjustment(); let controller = Rc::clone(self); @@ -278,6 +296,14 @@ impl ViewerController { controller.navigate(true); glib::Propagation::Stop } + _ if key.to_unicode() == Some('[') => { + controller.open_sibling(false); + glib::Propagation::Stop + } + _ if key.to_unicode() == Some(']') => { + controller.open_sibling(true); + glib::Propagation::Stop + } _ if key .to_unicode() .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => @@ -294,6 +320,14 @@ impl ViewerController { self.window.add_controller(key_controller); } + fn open_sibling(&self, forward: bool) { + if let Some(navigate) = &self.sibling_navigator + && navigate(forward) + { + self.window.close(); + } + } + fn toggle_fullscreen(&self) { if self.window.is_fullscreen() { self.window.unfullscreen(); @@ -332,6 +366,7 @@ impl ViewerController { .set_text("지원하는 이미지가 ZIP 파일에 없습니다."); } Ok(pages) => { + let page_count = pages.len(); let saved = self .database .load_progress(&self.source.source_uri) @@ -343,6 +378,8 @@ impl ViewerController { state.pages = pages; state.anchor = saved; drop(state); + self.page_seek.set_range(0.0, (page_count - 1) as f64); + self.page_seek.set_sensitive(page_count > 1); self.build_continuous_rows(); self.render(); } @@ -461,6 +498,23 @@ impl ViewerController { self.render(); } + fn seek_page(self: &Rc, index: usize) { + let mut state = self.state.borrow_mut(); + if state.pages.is_empty() { + return; + } + let index = index.min(state.pages.len() - 1); + if index == state.anchor { + return; + } + state.anchor = index; + drop(state); + self.render(); + if self.view_mode() == ViewMode::Continuous { + self.schedule_scroll_to_anchor(); + } + } + fn render(self: &Rc) { let visible = self.visible_indices(); if visible.is_empty() { @@ -838,26 +892,209 @@ impl ViewerController { } fn update_navigation(&self) { - let state = self.state.borrow(); - let count = state.pages.len(); + let (count, anchor) = { + let state = self.state.borrow(); + (state.pages.len(), state.anchor) + }; if count == 0 { self.page_label.set_text("- / -"); return; } let visible = self.visible_indices(); - let end = visible.last().copied().unwrap_or(state.anchor) + 1; + let end = visible.last().copied().unwrap_or(anchor) + 1; if visible.len() > 1 && self.view_mode() == ViewMode::TwoPage { self.page_label - .set_text(&format!("{}-{end} / {count}", state.anchor + 1)); + .set_text(&format!("{}-{end} / {count}", anchor + 1)); } else { self.page_label - .set_text(&format!("{} / {count}", state.anchor + 1)); + .set_text(&format!("{} / {count}", anchor + 1)); } - self.previous_button.set_sensitive(state.anchor > 0); + self.previous_button.set_sensitive(anchor > 0); self.next_button.set_sensitive(end < count); - self.shift_back_button.set_sensitive(state.anchor > 0); - self.shift_forward_button - .set_sensitive(state.anchor + 1 < count); + self.shift_back_button.set_sensitive(anchor > 0); + self.shift_forward_button.set_sensitive(anchor + 1 < count); + if (self.page_seek.value() - anchor as f64).abs() >= f64::EPSILON { + self.page_seek.set_value(anchor as f64); + } + } +} + +pub struct MediaViewerWindow; + +impl MediaViewerWindow { + pub fn open( + application: >k::Application, + source: ArchiveSource, + kind: EntryKind, + sibling_navigator: Option, + ) { + let title = source.display_name.clone(); + let window = gtk::ApplicationWindow::builder() + .application(application) + .title(&title) + .default_width(1100) + .default_height(850) + .build(); + let header = gtk::HeaderBar::new(); + header.set_title_widget(Some(>k::Label::new(Some(&title)))); + window.set_titlebar(Some(&header)); + + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let stack = gtk::Stack::new(); + stack.set_hexpand(true); + stack.set_vexpand(true); + let picture = page_picture(); + stack.add_named(&picture, Some("image")); + let video = if kind == EntryKind::Video { + gtk::Video::for_filename(Some(&source.local_path)) + } else { + gtk::Video::new() + }; + video.set_autoplay(true); + video.set_hexpand(true); + video.set_vexpand(true); + stack.add_named(&video, Some("video")); + let status_label = gtk::Label::builder() + .label(&title) + .xalign(0.0) + .margin_top(6) + .margin_bottom(6) + .margin_start(8) + .margin_end(8) + .build(); + root.append(&stack); + root.append(&status_label); + window.set_child(Some(&root)); + + let controller = Rc::new(MediaViewerController { + window, + source, + picture, + video, + stack, + status_label, + sibling_navigator, + }); + if let Some(stream) = controller.video.media_stream() { + let status_label = controller.status_label.clone(); + stream.connect_error_notify(move |stream| { + if let Some(error) = stream.error() { + status_label.set_text(&format!("동영상을 재생할 수 없습니다: {error}")); + } + }); + } + controller.connect_signals(); + controller.window.present(); + match kind { + EntryKind::Image => controller.load_image(), + EntryKind::Video => { + controller.stack.set_visible_child_name("video"); + if let Some(stream) = controller.video.media_stream() { + stream.play(); + } + } + _ => {} + } + } +} + +struct MediaViewerController { + window: gtk::ApplicationWindow, + source: ArchiveSource, + picture: gtk::Picture, + video: gtk::Video, + stack: gtk::Stack, + status_label: gtk::Label, + sibling_navigator: Option, +} + +impl MediaViewerController { + fn connect_signals(self: &Rc) { + let keys = gtk::EventControllerKey::new(); + let controller = Rc::clone(self); + keys.connect_key_pressed(move |_, key, _, _| match key.to_unicode() { + Some('[') => { + controller.open_sibling(false); + glib::Propagation::Stop + } + Some(']') => { + controller.open_sibling(true); + glib::Propagation::Stop + } + Some(character) if character.eq_ignore_ascii_case(&'f') => { + controller.toggle_fullscreen(); + glib::Propagation::Stop + } + _ if key == gdk::Key::Escape && controller.window.is_fullscreen() => { + controller.window.unfullscreen(); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + }); + self.window.add_controller(keys); + } + + fn open_sibling(&self, forward: bool) { + if let Some(navigate) = &self.sibling_navigator + && navigate(forward) + { + if let Some(stream) = self.video.media_stream() { + stream.pause(); + } + self.window.close(); + } + } + + fn toggle_fullscreen(&self) { + if self.window.is_fullscreen() { + self.window.unfullscreen(); + } else { + self.window.fullscreen(); + } + } + + fn load_image(self: &Rc) { + self.stack.set_visible_child_name("image"); + self.status_label.set_text("이미지를 읽는 중..."); + let path = self.source.local_path.clone(); + let display_name = self.source.display_name.clone(); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result = + decode_image_file(&path, &display_name).map_err(|error| format!("{error:#}")); + let _ = sender.send(result); + }); + let controller = Rc::downgrade(self); + glib::timeout_add_local(Duration::from_millis(20), move || { + match receiver.try_recv() { + Ok(result) => { + if let Some(controller) = controller.upgrade() { + controller.finish_image_load(result); + } + glib::ControlFlow::Break + } + Err(TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(TryRecvError::Disconnected) => glib::ControlFlow::Break, + } + }); + } + + fn finish_image_load(&self, result: Result) { + match result { + Ok(page) => { + 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, + ); + self.picture.set_paintable(Some(&texture)); + self.status_label.set_text(&self.source.display_name); + } + Err(error) => self.status_label.set_text(&error), + } } } @@ -918,6 +1155,10 @@ fn intersecting_rows(bounds: &[Option<(f64, f64)>], top: f64, bottom: f64) -> Ve .collect() } +fn page_seek_index(value: f64, count: usize) -> Option { + (count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1)) +} + #[cfg(test)] mod tests { use super::*; @@ -960,4 +1201,11 @@ mod tests { ] ); } + + #[test] + fn page_seek_rounds_and_clamps_to_archive() { + assert_eq!(page_seek_index(3.6, 10), Some(4)); + assert_eq!(page_seek_index(20.0, 10), Some(9)); + assert_eq!(page_seek_index(0.0, 0), None); + } } diff --git a/src/window.rs b/src/window.rs index 1d1be43..042803f 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,4 +1,5 @@ use std::cell::RefCell; +use std::fs; use std::path::PathBuf; use std::rc::Rc; use std::time::{Duration, Instant}; @@ -17,7 +18,7 @@ use crate::source::{ display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation, parent_within_root, root_file, validate_profile, }; -use crate::viewer::ViewerWindow; +use crate::viewer::{MediaViewerWindow, SiblingNavigator, ViewerWindow}; use crate::zip_cache::ZipCache; pub struct ComicWindow { @@ -239,6 +240,13 @@ impl BrowserController { self.list_box .connect_row_activated(move |_, row| controller.activate_row(row.index())); + let context_click = gtk::GestureClick::builder().button(3).build(); + let controller = Rc::clone(self); + context_click.connect_pressed(move |_, _, _, y| { + controller.show_context_menu(y as i32); + }); + self.list_box.add_controller(context_click); + let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); key_controller @@ -480,14 +488,71 @@ impl BrowserController { self.search_entry.set_text(""); self.refresh(); } - EntryKind::ZipArchive => self.open_archive(entry), + EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { + let siblings = { + let state = self.state.borrow(); + let mut entries = state + .entries + .iter() + .filter(|entry| entry.kind.is_viewable()) + .cloned() + .collect::>(); + sort_entries(&mut entries, state.sort_field, state.sort_direction); + entries + }; + let siblings = Rc::new(siblings); + if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { + self.open_viewable(entry, siblings, current); + } + } + EntryKind::OtherFile => {} } } - fn open_archive(self: &Rc, entry: BrowserEntry) { + fn open_viewable( + self: &Rc, + entry: BrowserEntry, + siblings: Rc>, + current: usize, + ) { + match entry.kind { + EntryKind::ZipArchive => self.open_archive(entry, siblings, current), + EntryKind::Image | EntryKind::Video => self.open_media(entry, siblings, current), + _ => {} + } + } + + fn sibling_navigator( + self: &Rc, + siblings: Rc>, + current: usize, + ) -> SiblingNavigator { + let controller = Rc::downgrade(self); + Rc::new(move |forward| { + let Some(controller) = controller.upgrade() else { + return false; + }; + let Some(next) = sibling_index(current, siblings.len(), forward) else { + controller + .status_label + .set_text("더 이상 이동할 파일이 없습니다."); + return false; + }; + controller.open_viewable(siblings[next].clone(), Rc::clone(&siblings), next); + true + }) + } + + fn open_archive( + self: &Rc, + entry: BrowserEntry, + siblings: Rc>, + current: usize, + ) { self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); let progress_label = self.status_label.clone(); let controller = Rc::clone(self); + let navigator = self.sibling_navigator(siblings, current); self.zip_cache.prepare( entry, move |current, total| { @@ -505,6 +570,7 @@ impl BrowserController { source, Rc::clone(&controller.database), controller.config.borrow().fast_image_loading, + Some(navigator), ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), @@ -512,6 +578,138 @@ impl BrowserController { ); } + fn open_media( + self: &Rc, + entry: BrowserEntry, + siblings: Rc>, + current: usize, + ) { + let kind = entry.kind; + self.status_label + .set_text("미디어 파일 다운로드 준비 중..."); + let progress_label = self.status_label.clone(); + let controller = Rc::clone(self); + let navigator = self.sibling_navigator(siblings, current); + self.zip_cache.prepare_media( + entry, + move |current, total| { + 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("다운로드 완료"); + let Some(application) = controller.window.application() else { + return; + }; + MediaViewerWindow::open(&application, source, kind, Some(navigator)); + } + Err(error) => controller.status_label.set_text(&format!("{error:#}")), + }, + ); + } + + fn show_context_menu(self: &Rc, y: i32) { + let Some(row) = self.list_box.row_at_y(y) else { + return; + }; + let Some(entry) = self + .state + .borrow() + .visible_entries + .get(row.index() as usize) + .cloned() + else { + return; + }; + if entry.kind == EntryKind::Directory || gio::File::for_uri(&entry.uri).is_native() { + return; + } + self.list_box.select_row(Some(&row)); + let popover = gtk::Popover::new(); + popover.set_parent(&row); + let download_button = gtk::Button::with_label("다운로드..."); + download_button.set_margin_top(6); + download_button.set_margin_bottom(6); + download_button.set_margin_start(6); + download_button.set_margin_end(6); + popover.set_child(Some(&download_button)); + let controller = Rc::clone(self); + let popover_to_close = popover.clone(); + download_button.connect_clicked(move |_| { + popover_to_close.popdown(); + controller.choose_download_destination(entry.clone()); + }); + popover.popup(); + } + + fn choose_download_destination(self: &Rc, entry: BrowserEntry) { + let dialog = gtk::FileDialog::builder() + .title("다운로드 위치 선택") + .initial_name(&entry.name) + .build(); + let controller = Rc::downgrade(self); + let parent = self.window.clone(); + glib::MainContext::default().spawn_local(async move { + let result = dialog.save_future(Some(&parent)).await; + let Some(controller) = controller.upgrade() else { + return; + }; + match result { + Ok(destination) if destination.is_native() => { + controller.download_remote_file(entry, destination); + } + Ok(_) => controller + .status_label + .set_text("로컬 파일 시스템 위치를 선택해 주세요."), + Err(error) if error.matches(gio::IOErrorEnum::Cancelled) => {} + Err(error) => controller + .status_label + .set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")), + } + }); + } + + fn download_remote_file(&self, entry: BrowserEntry, destination: gio::File) { + let Some(final_path) = destination.path() else { + self.status_label + .set_text("로컬 파일 시스템 위치를 선택해 주세요."); + return; + }; + let part_path = download_part_path(&final_path); + let source = gio::File::for_uri(&entry.uri); + let part_file = gio::File::for_path(&part_path); + let progress_label = self.status_label.clone(); + let result_label = self.status_label.clone(); + let expected_size = entry.size; + source.copy_async( + &part_file, + gio::FileCopyFlags::OVERWRITE, + glib::Priority::DEFAULT, + gio::Cancellable::NONE, + Some(Box::new(move |current, total| { + let percent = if total > 0 { current * 100 / total } else { 0 }; + progress_label.set_text(&format!("{} 다운로드 중... {percent}%", entry.name)); + })), + move |result| { + let completed = result.map_err(anyhow::Error::from).and_then(|()| { + let actual_size = fs::metadata(&part_path)?.len(); + if expected_size.is_some_and(|expected| expected != actual_size) { + anyhow::bail!("다운로드 크기가 원본과 다릅니다."); + } + fs::rename(&part_path, &final_path)?; + Ok(()) + }); + if let Err(error) = completed { + let _ = fs::remove_file(&part_path); + result_label.set_text(&format!("다운로드 실패: {error:#}")); + } else { + result_label.set_text(&format!("다운로드 완료: {}", final_path.display())); + } + }, + ); + } + fn set_fast_image_loading(&self, switch: >k::Switch) { let active = switch.is_active(); let previous = self.config.borrow().fast_image_loading; @@ -752,6 +950,9 @@ fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow { let icon = match entry.kind { EntryKind::Directory => "folder-symbolic", EntryKind::ZipArchive => "package-x-generic-symbolic", + EntryKind::Image => "image-x-generic-symbolic", + EntryKind::Video => "video-x-generic-symbolic", + EntryKind::OtherFile => "text-x-generic-symbolic", }; content.append(>k::Image::from_icon_name(icon)); content.append( @@ -790,6 +991,23 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt .find(|index| entries[*index].name.to_lowercase().starts_with(&query)) } +fn sibling_index(current: usize, count: usize, forward: bool) -> Option { + if forward { + (current + 1 < count).then_some(current + 1) + } else { + current.checked_sub(1) + } +} + +fn download_part_path(final_path: &std::path::Path) -> PathBuf { + final_path.with_file_name(format!( + "{}.part", + final_path + .file_name() + .map_or_else(|| "download".into(), |name| name.to_string_lossy()) + )) +} + #[cfg(test)] mod tests { use super::*; @@ -819,4 +1037,20 @@ mod tests { assert_eq!(find_prefix_index(&entries, "b", 2), Some(2)); assert_eq!(find_prefix_index(&entries, "b", 3), Some(1)); } + + #[test] + fn sibling_navigation_stops_at_boundaries() { + assert_eq!(sibling_index(1, 3, false), Some(0)); + assert_eq!(sibling_index(1, 3, true), Some(2)); + assert_eq!(sibling_index(0, 3, false), None); + assert_eq!(sibling_index(2, 3, true), None); + } + + #[test] + fn incomplete_download_uses_distinct_suffix() { + assert_eq!( + download_part_path(std::path::Path::new("/tmp/만화.zip")), + PathBuf::from("/tmp/만화.zip.part") + ); + } } diff --git a/src/zip_cache.rs b/src/zip_cache.rs index d7bff44..5cd3268 100644 --- a/src/zip_cache.rs +++ b/src/zip_cache.rs @@ -17,6 +17,7 @@ use crate::browser::BrowserEntry; pub struct ArchiveSource { pub local_path: PathBuf, pub source_uri: String, + pub display_name: String, _temporary: Option>, _pin: Option>, } @@ -75,6 +76,7 @@ impl ZipCache { callback(Ok(ArchiveSource { local_path: path, source_uri: entry.uri, + display_name: entry.name, _temporary: None, _pin: None, })); @@ -82,24 +84,50 @@ impl ZipCache { } if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) { - self.download_temporary(source, entry.uri, entry.size, progress, callback); + self.download_temporary(source, entry, true, progress, callback); } else { self.download_persistent(source, entry, progress, callback); } } + pub fn prepare_media(self: &Rc, entry: BrowserEntry, progress: P, callback: F) + where + F: FnOnce(Result) + 'static, + P: FnMut(i64, i64) + 'static, + { + let source = gio::File::for_uri(&entry.uri); + if let Some(path) = native_path(&source) { + callback(Ok(ArchiveSource { + local_path: path, + source_uri: entry.uri, + display_name: entry.name, + _temporary: None, + _pin: None, + })); + return; + } + self.download_temporary(source, entry, false, progress, callback); + } + fn download_temporary( self: &Rc, source: gio::File, - source_uri: String, - expected_size: Option, + entry: BrowserEntry, + validate_zip: bool, progress: P, callback: F, ) where F: FnOnce(Result) + 'static, P: FnMut(i64, i64) + 'static, { - let temporary = match tempfile::Builder::new().suffix(".zip.part").tempfile() { + let suffix = Path::new(&entry.name) + .extension() + .and_then(|extension| extension.to_str()) + .map_or_else( + || ".part".to_owned(), + |extension| format!(".part.{extension}"), + ); + let temporary = match tempfile::Builder::new().suffix(&suffix).tempfile() { Ok(file) => Rc::new(file.into_temp_path()), Err(error) => { callback(Err(error.into())); @@ -108,6 +136,7 @@ impl ZipCache { }; let destination = gio::File::for_path(temporary.as_ref()); let retained = Rc::clone(&temporary); + let expected_size = entry.size; source.copy_async( &destination, gio::FileCopyFlags::OVERWRITE, @@ -116,10 +145,14 @@ impl ZipCache { Some(Box::new(progress)), move |result| { callback(result.map_err(Into::into).and_then(|()| { - validate_download(retained.as_ref(), expected_size)?; + validate_size(retained.as_ref(), expected_size)?; + if validate_zip { + validate_zip_file(retained.as_ref())?; + } Ok(ArchiveSource { local_path: retained.to_path_buf(), - source_uri, + source_uri: entry.uri, + display_name: entry.name, _temporary: Some(retained), _pin: None, }) @@ -143,7 +176,7 @@ impl ZipCache { if final_path.is_file() { if validate_download(&final_path, entry.size).is_ok() { let _ = filetime::set_file_mtime(&final_path, FileTime::now()); - callback(Ok(self.lease(final_path, entry.uri))); + callback(Ok(self.lease(final_path, entry.uri, entry.name))); return; } let _ = fs::remove_file(&final_path); @@ -168,7 +201,7 @@ impl ZipCache { ) })?; cache.enforce_quota(&final_path)?; - Ok(cache.lease(final_path, entry.uri)) + Ok(cache.lease(final_path, entry.uri, entry.name)) }); if result.is_err() { let _ = fs::remove_file(&part_path); @@ -205,11 +238,17 @@ impl ZipCache { Ok(()) } - fn lease(self: &Rc, path: PathBuf, source_uri: String) -> ArchiveSource { + fn lease( + self: &Rc, + path: PathBuf, + source_uri: String, + display_name: String, + ) -> ArchiveSource { *self.pinned.borrow_mut().entry(path.clone()).or_default() += 1; ArchiveSource { local_path: path.clone(), source_uri, + display_name, _temporary: None, _pin: Some(Rc::new(CachePin { path, @@ -224,6 +263,11 @@ fn native_path(file: &gio::File) -> Option { } fn validate_download(path: &Path, expected_size: Option) -> Result<()> { + validate_size(path, expected_size)?; + validate_zip_file(path) +} + +fn validate_size(path: &Path, expected_size: Option) -> Result<()> { let actual_size = fs::metadata(path)?.len(); if let Some(expected_size) = expected_size && actual_size != expected_size @@ -232,6 +276,10 @@ fn validate_download(path: &Path, expected_size: Option) -> Result<()> { "다운로드 크기가 원본과 다릅니다: 예상 {expected_size}바이트, 실제 {actual_size}바이트" ); } + Ok(()) +} + +fn validate_zip_file(path: &Path) -> Result<()> { let file = File::open(path)?; zip::ZipArchive::new(file).context("다운로드된 파일이 올바른 ZIP이 아닙니다")?; Ok(()) @@ -307,7 +355,11 @@ mod tests { pinned: RefCell::new(HashMap::new()), }); - let lease = cache.lease(first.clone(), "file:///first.zip".into()); + let lease = cache.lease( + first.clone(), + "file:///first.zip".into(), + "first.zip".into(), + ); cache.enforce_quota(&second).unwrap(); assert!(first.exists()); assert!(second.exists()); From 490c925f62b02737ea6820ed27a77f722e780df3 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 01:24:37 +0900 Subject: [PATCH 03/30] Add local and remote favorites --- src/config.rs | 26 +++++ src/source.rs | 20 ++++ src/viewer.rs | 37 +++++++ src/window.rs | 302 +++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 357 insertions(+), 28 deletions(-) diff --git a/src/config.rs b/src/config.rs index 0471dd2..1775522 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ pub struct AppConfig { pub cache_quota_gb: u64, pub fast_image_loading: bool, pub locations: Vec, + pub favorites: Vec, } impl Default for AppConfig { @@ -19,6 +20,7 @@ impl Default for AppConfig { cache_quota_gb: 10, fast_image_loading: true, locations: Vec::new(), + favorites: Vec::new(), } } } @@ -36,6 +38,14 @@ pub struct LocationProfile { pub domain: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FavoriteEntry { + pub name: String, + pub uri: String, + pub is_directory: bool, + pub profile: LocationProfile, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LocationKind { @@ -137,16 +147,32 @@ mod tests { password: "plain-password".into(), domain: String::new(), }], + favorites: vec![FavoriteEntry { + name: "원격 만화".into(), + uri: "sftp://example.invalid/comics/book.zip".into(), + is_directory: false, + profile: LocationProfile { + name: "즐겨찾기 서버".into(), + kind: LocationKind::Sftp, + address: "sftp://example.invalid/comics".into(), + username: "favorite-reader".into(), + password: "favorite-password".into(), + domain: String::new(), + }, + }], }; let encoded = toml::to_string(&config).unwrap(); let decoded: AppConfig = toml::from_str(&encoded).unwrap(); assert_eq!(decoded.locations[0].password, "plain-password"); + assert_eq!(decoded.favorites[0].profile.username, "favorite-reader"); + assert_eq!(decoded.favorites[0].profile.password, "favorite-password"); } #[test] fn fast_image_loading_is_enabled_for_existing_configs() { let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); assert!(decoded.fast_image_loading); + assert!(decoded.favorites.is_empty()); } } diff --git a/src/source.rs b/src/source.rs index cc07e17..7dd535e 100644 --- a/src/source.rs +++ b/src/source.rs @@ -177,6 +177,26 @@ pub fn mount_operation( operation } +pub async fn ensure_mounted( + file: &gio::File, + profile: &LocationProfile, + operation: >k::MountOperation, +) -> Result<()> { + if profile.kind == LocationKind::Local || file.is_native() { + return Ok(()); + } + match file + .mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation)) + .await + { + Ok(()) => Ok(()), + Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("원격 위치를 마운트할 수 없습니다: {}", file.uri())) + } + } +} + async fn enumerate_with_mount_retry( directory: &gio::File, operation: >k::MountOperation, diff --git a/src/viewer.rs b/src/viewer.rs index f1ca10c..9e85b6a 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -23,6 +23,11 @@ pub struct ViewerWindow; pub type SiblingNavigator = Rc bool>; +pub struct FavoriteControl { + pub active: bool, + pub toggle: Rc bool>, +} + impl ViewerWindow { pub fn open( application: >k::Application, @@ -30,6 +35,7 @@ impl ViewerWindow { database: Rc, fast_image_loading: bool, sibling_navigator: Option, + favorite_control: FavoriteControl, ) { let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() @@ -40,6 +46,8 @@ impl ViewerWindow { .build(); 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)); + header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -152,6 +160,8 @@ impl ViewerWindow { direction_dropdown, fast_image_loading, sibling_navigator, + favorite_button, + favorite_toggle: favorite_control.toggle, state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -228,6 +238,8 @@ struct ViewerController { direction_dropdown: gtk::DropDown, fast_image_loading: bool, sibling_navigator: Option, + favorite_button: gtk::Button, + favorite_toggle: Rc bool>, state: RefCell, } @@ -252,6 +264,11 @@ impl ViewerController { self.direction_dropdown .connect_selected_notify(move |_| controller.render()); let controller = Rc::clone(self); + self.favorite_button.connect_clicked(move |_| { + let active = (controller.favorite_toggle)(); + controller.favorite_button.set_label(favorite_label(active)); + }); + let controller = Rc::clone(self); self.page_seek.connect_value_changed(move |scale| { if let Some(index) = page_seek_index(scale.value(), controller.state.borrow().pages.len()) @@ -927,6 +944,7 @@ impl MediaViewerWindow { source: ArchiveSource, kind: EntryKind, sibling_navigator: Option, + favorite_control: FavoriteControl, ) { let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() @@ -937,6 +955,8 @@ impl MediaViewerWindow { .build(); 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)); + header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -974,6 +994,8 @@ impl MediaViewerWindow { stack, status_label, sibling_navigator, + favorite_button, + favorite_toggle: favorite_control.toggle, }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); @@ -1006,6 +1028,8 @@ struct MediaViewerController { stack: gtk::Stack, status_label: gtk::Label, sibling_navigator: Option, + favorite_button: gtk::Button, + favorite_toggle: Rc bool>, } impl MediaViewerController { @@ -1032,6 +1056,11 @@ impl MediaViewerController { _ => glib::Propagation::Proceed, }); self.window.add_controller(keys); + let controller = Rc::clone(self); + self.favorite_button.connect_clicked(move |_| { + let active = (controller.favorite_toggle)(); + controller.favorite_button.set_label(favorite_label(active)); + }); } fn open_sibling(&self, forward: bool) { @@ -1098,6 +1127,14 @@ impl MediaViewerController { } } +fn favorite_label(active: bool) -> &'static str { + if active { + "즐겨찾기 제거" + } else { + "즐겨찾기 추가" + } +} + fn page_picture() -> gtk::Picture { let picture = gtk::Picture::new(); picture.set_content_fit(gtk::ContentFit::Contain); diff --git a/src/window.rs b/src/window.rs index 042803f..95c4229 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,6 +1,6 @@ use std::cell::RefCell; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::rc::Rc; use std::time::{Duration, Instant}; @@ -11,14 +11,16 @@ use gtk::gio::prelude::*; use gtk::glib; use gtk::prelude::*; -use crate::browser::{BrowserEntry, EntryKind, SortDirection, SortField, sort_entries}; -use crate::config::{AppConfig, LocationKind, LocationProfile}; +use crate::browser::{ + BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, +}; +use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile}; use crate::database::Database; use crate::source::{ - display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation, - parent_within_root, root_file, validate_profile, + display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root, + mount_operation, parent_within_root, root_file, validate_profile, }; -use crate::viewer::{MediaViewerWindow, SiblingNavigator, ViewerWindow}; +use crate::viewer::{FavoriteControl, MediaViewerWindow, SiblingNavigator, ViewerWindow}; use crate::zip_cache::ZipCache; pub struct ComicWindow { @@ -66,6 +68,12 @@ impl ComicWindow { locations.append(&edit_location_button); locations.append(&delete_location_button); + let favorites = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let favorite_dropdown = gtk::DropDown::from_strings(&["즐겨찾기 선택"]); + favorite_dropdown.set_hexpand(true); + favorites.append(>k::Label::new(Some("즐겨찾기"))); + favorites.append(&favorite_dropdown); + let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let up_button = gtk::Button::builder() .icon_name("go-up-symbolic") @@ -109,6 +117,7 @@ impl ComicWindow { let status_label = gtk::Label::builder().xalign(0.0).build(); root.append(&locations); + root.append(&favorites); root.append(&navigation); root.append(&controls); root.append(&scrolled); @@ -118,6 +127,7 @@ impl ComicWindow { let controller = Rc::new(BrowserController { window, location_dropdown, + favorite_dropdown, edit_location_button, delete_location_button, path_entry, @@ -128,6 +138,7 @@ impl ComicWindow { status_label, state: RefCell::new(BrowserState { profile_index: 0, + favorite_profile: None, root_uri: root_uri.clone(), current_uri: root_uri, entries: Vec::new(), @@ -145,6 +156,7 @@ impl ComicWindow { }); controller.rebuild_profiles(); + controller.rebuild_favorites(); controller.connect_signals( open_button, refresh_button, @@ -163,6 +175,7 @@ impl ComicWindow { struct BrowserState { profile_index: usize, + favorite_profile: Option, root_uri: String, current_uri: String, entries: Vec, @@ -177,6 +190,7 @@ struct BrowserState { struct BrowserController { window: gtk::ApplicationWindow, location_dropdown: gtk::DropDown, + favorite_dropdown: gtk::DropDown, edit_location_button: gtk::Button, delete_location_button: gtk::Button, path_entry: gtk::Entry, @@ -228,6 +242,9 @@ impl BrowserController { self.location_dropdown .connect_selected_notify(move |_| controller.select_profile()); let controller = Rc::clone(self); + self.favorite_dropdown + .connect_selected_notify(move |_| controller.select_favorite()); + let controller = Rc::clone(self); self.search_entry .connect_search_changed(move |_| controller.render()); let controller = Rc::clone(self); @@ -265,6 +282,16 @@ impl BrowserController { profiles } + fn current_profile(&self) -> LocationProfile { + let state = self.state.borrow(); + state.favorite_profile.clone().unwrap_or_else(|| { + self.profiles() + .get(state.profile_index) + .cloned() + .unwrap_or_else(home_profile) + }) + } + fn rebuild_profiles(&self) { let profiles = self.profiles(); let names: Vec<_> = profiles @@ -275,6 +302,24 @@ impl BrowserController { .set_model(Some(>k::StringList::new(&names))); } + fn rebuild_favorites(&self) { + let config = self.config.borrow(); + let mut names = Vec::with_capacity(config.favorites.len() + 1); + names.push("즐겨찾기 선택".to_owned()); + names.extend(config.favorites.iter().map(|favorite| { + let marker = if favorite.is_directory { + "폴더" + } else { + "파일" + }; + format!("[{marker}] {}", favorite.name) + })); + let names: Vec<_> = names.iter().map(String::as_str).collect(); + self.favorite_dropdown + .set_model(Some(>k::StringList::new(&names))); + self.favorite_dropdown.set_selected(0); + } + fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len(); @@ -289,6 +334,7 @@ impl BrowserController { let root_uri: String = root.uri().into(); let mut state = self.state.borrow_mut(); state.profile_index = profile_index; + state.favorite_profile = None; state.root_uri = root_uri.clone(); state.current_uri = root_uri; drop(state); @@ -299,8 +345,77 @@ impl BrowserController { } } + fn select_favorite(self: &Rc) { + let Some(index) = (self.favorite_dropdown.selected() as usize).checked_sub(1) else { + return; + }; + let Some(favorite) = self.config.borrow().favorites.get(index).cloned() else { + self.favorite_dropdown.set_selected(0); + return; + }; + self.favorite_dropdown.set_selected(0); + let Ok(root) = root_file(&favorite.profile) else { + self.status_label + .set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다."); + return; + }; + let favorite_file = gio::File::for_uri(&favorite.uri); + if !is_within_root(&favorite_file, &root) { + self.status_label + .set_text("즐겨찾기가 저장된 위치의 루트 밖을 가리킵니다."); + return; + } + if favorite.is_directory { + self.location_dropdown + .set_selected(gtk::INVALID_LIST_POSITION); + self.edit_location_button.set_sensitive(false); + self.delete_location_button.set_sensitive(false); + let mut state = self.state.borrow_mut(); + state.favorite_profile = Some(favorite.profile.clone()); + state.root_uri = root.uri().into(); + state.current_uri = favorite.uri; + drop(state); + self.search_entry.set_text(""); + self.refresh(); + return; + } + + 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 file = gio::File::for_uri(&favorite.uri); + let result = ensure_mounted(&file, &favorite.profile, &operation).await; + let Some(controller) = controller.upgrade() else { + return; + }; + if let Err(error) = result { + controller.status_label.set_text(&format!("{error:#}")); + return; + } + let kind = classify_file(Path::new(&favorite.name)); + if !kind.is_viewable() { + controller + .status_label + .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()]); + controller.open_viewable(entry, siblings, 0, favorite.profile); + }); + } + fn open_entered_path(self: &Rc) { - let profile = self.profiles()[self.state.borrow().profile_index].clone(); + let profile = self.current_profile(); let entered = self.path_entry.text(); let file = file_from_user_input(profile.kind, entered.as_str()); let root = gio::File::for_uri(&self.state.borrow().root_uri); @@ -331,7 +446,10 @@ impl BrowserController { state.typeahead_query.clear(); state.typeahead_last_input = None; ( - self.profiles()[state.profile_index].clone(), + state + .favorite_profile + .clone() + .unwrap_or_else(|| self.profiles()[state.profile_index].clone()), state.current_uri.clone(), state.generation, ) @@ -502,7 +620,7 @@ impl BrowserController { }; let siblings = Rc::new(siblings); if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { - self.open_viewable(entry, siblings, current); + self.open_viewable(entry, siblings, current, self.current_profile()); } } EntryKind::OtherFile => {} @@ -514,10 +632,13 @@ impl BrowserController { entry: BrowserEntry, siblings: Rc>, current: usize, + profile: LocationProfile, ) { match entry.kind { - EntryKind::ZipArchive => self.open_archive(entry, siblings, current), - EntryKind::Image | EntryKind::Video => self.open_media(entry, siblings, current), + EntryKind::ZipArchive => self.open_archive(entry, siblings, current, profile), + EntryKind::Image | EntryKind::Video => { + self.open_media(entry, siblings, current, profile) + } _ => {} } } @@ -526,6 +647,7 @@ impl BrowserController { self: &Rc, siblings: Rc>, current: usize, + profile: LocationProfile, ) -> SiblingNavigator { let controller = Rc::downgrade(self); Rc::new(move |forward| { @@ -538,7 +660,12 @@ impl BrowserController { .set_text("더 이상 이동할 파일이 없습니다."); return false; }; - controller.open_viewable(siblings[next].clone(), Rc::clone(&siblings), next); + controller.open_viewable( + siblings[next].clone(), + Rc::clone(&siblings), + next, + profile.clone(), + ); true }) } @@ -548,11 +675,13 @@ impl BrowserController { entry: BrowserEntry, siblings: Rc>, current: usize, + profile: LocationProfile, ) { self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); let progress_label = self.status_label.clone(); let controller = Rc::clone(self); - let navigator = self.sibling_navigator(siblings, current); + let navigator = self.sibling_navigator(siblings, current, profile.clone()); + let favorite_control = self.favorite_control(&entry, &profile); self.zip_cache.prepare( entry, move |current, total| { @@ -571,6 +700,7 @@ impl BrowserController { Rc::clone(&controller.database), controller.config.borrow().fast_image_loading, Some(navigator), + favorite_control, ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), @@ -583,13 +713,15 @@ impl BrowserController { entry: BrowserEntry, siblings: Rc>, current: usize, + profile: LocationProfile, ) { let kind = entry.kind; self.status_label .set_text("미디어 파일 다운로드 준비 중..."); let progress_label = self.status_label.clone(); let controller = Rc::clone(self); - let navigator = self.sibling_navigator(siblings, current); + let navigator = self.sibling_navigator(siblings, current, profile.clone()); + let favorite_control = self.favorite_control(&entry, &profile); self.zip_cache.prepare_media( entry, move |current, total| { @@ -602,7 +734,13 @@ impl BrowserController { let Some(application) = controller.window.application() else { return; }; - MediaViewerWindow::open(&application, source, kind, Some(navigator)); + MediaViewerWindow::open( + &application, + source, + kind, + Some(navigator), + favorite_control, + ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), }, @@ -622,27 +760,97 @@ impl BrowserController { else { return; }; - if entry.kind == EntryKind::Directory || gio::File::for_uri(&entry.uri).is_native() { + let can_favorite = entry.kind == EntryKind::Directory || entry.kind.is_viewable(); + let can_download = + entry.kind != EntryKind::Directory && !gio::File::for_uri(&entry.uri).is_native(); + if !can_favorite && !can_download { return; } self.list_box.select_row(Some(&row)); let popover = gtk::Popover::new(); popover.set_parent(&row); - let download_button = gtk::Button::with_label("다운로드..."); - download_button.set_margin_top(6); - download_button.set_margin_bottom(6); - download_button.set_margin_start(6); - download_button.set_margin_end(6); - popover.set_child(Some(&download_button)); - let controller = Rc::clone(self); - let popover_to_close = popover.clone(); - download_button.connect_clicked(move |_| { - popover_to_close.popdown(); - controller.choose_download_destination(entry.clone()); - }); + let actions = gtk::Box::new(gtk::Orientation::Vertical, 4); + actions.set_margin_top(6); + actions.set_margin_bottom(6); + actions.set_margin_start(6); + actions.set_margin_end(6); + if can_favorite { + let is_favorite = self.is_favorite(&entry.uri); + let favorite_button = gtk::Button::with_label(if is_favorite { + "즐겨찾기 제거" + } else { + "즐겨찾기 추가" + }); + actions.append(&favorite_button); + let controller = Rc::clone(self); + let popover_to_close = popover.clone(); + let favorite_entry = entry.clone(); + favorite_button.connect_clicked(move |_| { + popover_to_close.popdown(); + let profile = controller.current_profile(); + controller.toggle_favorite(&favorite_entry, &profile); + }); + } + if can_download { + let download_button = gtk::Button::with_label("다운로드..."); + actions.append(&download_button); + let controller = Rc::clone(self); + let popover_to_close = popover.clone(); + download_button.connect_clicked(move |_| { + popover_to_close.popdown(); + controller.choose_download_destination(entry.clone()); + }); + } + popover.set_child(Some(&actions)); popover.popup(); } + fn is_favorite(&self, uri: &str) -> bool { + self.config + .borrow() + .favorites + .iter() + .any(|favorite| favorite.uri == uri) + } + + fn favorite_control( + self: &Rc, + entry: &BrowserEntry, + profile: &LocationProfile, + ) -> FavoriteControl { + let entry = entry.clone(); + let profile = profile.clone(); + let controller = Rc::downgrade(self); + FavoriteControl { + active: self.is_favorite(&entry.uri), + toggle: Rc::new(move || { + let Some(controller) = controller.upgrade() else { + return false; + }; + controller.toggle_favorite(&entry, &profile) + }), + } + } + + fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { + let mut updated = self.config.borrow().clone(); + let was_active = self.is_favorite(&entry.uri); + let active = toggle_favorite_entry(&mut updated, entry, profile); + if let Err(error) = updated.save(&self.config_path) { + self.status_label + .set_text(&format!("즐겨찾기를 저장할 수 없습니다: {error:#}")); + return was_active; + } + *self.config.borrow_mut() = updated; + self.rebuild_favorites(); + self.status_label.set_text(if active { + "즐겨찾기에 추가했습니다." + } else { + "즐겨찾기에서 제거했습니다." + }); + active + } + fn choose_download_destination(self: &Rc, entry: BrowserEntry) { let dialog = gtk::FileDialog::builder() .title("다운로드 위치 선택") @@ -1008,6 +1216,29 @@ fn download_part_path(final_path: &std::path::Path) -> PathBuf { )) } +fn toggle_favorite_entry( + config: &mut AppConfig, + entry: &BrowserEntry, + profile: &LocationProfile, +) -> bool { + if let Some(index) = config + .favorites + .iter() + .position(|favorite| favorite.uri == entry.uri) + { + config.favorites.remove(index); + false + } else { + config.favorites.push(FavoriteEntry { + name: entry.name.clone(), + uri: entry.uri.clone(), + is_directory: entry.kind == EntryKind::Directory, + profile: profile.clone(), + }); + true + } +} + #[cfg(test)] mod tests { use super::*; @@ -1053,4 +1284,19 @@ mod tests { PathBuf::from("/tmp/만화.zip.part") ); } + + #[test] + fn favorite_toggle_adds_and_removes_the_same_uri() { + let mut config = AppConfig::default(); + let entry = entry("Comics"); + let profile = home_profile(); + + assert!(toggle_favorite_entry(&mut config, &entry, &profile)); + assert_eq!(config.favorites.len(), 1); + assert!(config.favorites[0].is_directory); + assert_eq!(config.favorites[0].profile.address, profile.address); + + assert!(!toggle_favorite_entry(&mut config, &entry, &profile)); + assert!(config.favorites.is_empty()); + } } From ef2a0a7915c02fc03b2fde27c08aa42bd4960153 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 01:41:01 +0900 Subject: [PATCH 04/30] Add viewer navigation and file downloads --- src/browser.rs | 8 +++ src/viewer.rs | 148 ++++++++++++++++++++++++++++++++++++++++++++++--- src/window.rs | 108 ++++++++++++++++++++++++------------ 3 files changed, 221 insertions(+), 43 deletions(-) diff --git a/src/browser.rs b/src/browser.rs index d70ef56..1d5eb2f 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -15,6 +15,10 @@ impl EntryKind { pub fn is_viewable(self) -> bool { matches!(self, Self::ZipArchive | Self::Image | Self::Video) } + + pub fn is_media(self) -> bool { + matches!(self, Self::Image | Self::Video) + } } #[derive(Debug, Clone)] @@ -194,6 +198,10 @@ mod tests { assert_eq!(classify_file(Path::new("movie.MP4")), EntryKind::Video); assert_eq!(classify_file(Path::new("movie.mkv")), EntryKind::Video); assert_eq!(classify_file(Path::new("notes.txt")), EntryKind::OtherFile); + assert!(EntryKind::Image.is_media()); + assert!(EntryKind::Video.is_media()); + assert!(!EntryKind::ZipArchive.is_media()); + assert!(!EntryKind::OtherFile.is_media()); } #[test] diff --git a/src/viewer.rs b/src/viewer.rs index 9e85b6a..9fc5108 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use std::sync::mpsc::{self, TryRecvError}; @@ -21,13 +21,42 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64; pub struct ViewerWindow; -pub type SiblingNavigator = Rc bool>; +pub struct SiblingNavigator { + current: usize, + count: usize, + open: Rc bool>, +} + +impl SiblingNavigator { + pub fn new(current: usize, count: usize, open: Rc bool>) -> Self { + Self { + current, + count, + open, + } + } + + fn open_relative(&self, forward: bool) -> bool { + let Some(index) = sibling_index(self.current, self.count, forward) else { + return false; + }; + (self.open)(index) + } + + fn open_index(&self, index: usize) -> bool { + index < self.count && index != self.current && (self.open)(index) + } +} pub struct FavoriteControl { pub active: bool, pub toggle: Rc bool>, } +pub struct FileDownloadControl { + pub download: Rc, +} + impl ViewerWindow { pub fn open( application: >k::Application, @@ -36,6 +65,7 @@ impl ViewerWindow { fast_image_loading: bool, sibling_navigator: Option, favorite_control: FavoriteControl, + download_control: FileDownloadControl, ) { let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() @@ -47,6 +77,8 @@ impl ViewerWindow { let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); + let download_button = gtk::Button::with_label("파일 다운로드"); + header.pack_end(&download_button); header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); @@ -162,6 +194,8 @@ impl ViewerWindow { sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, + download_button, + file_download: download_control.download, state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -240,6 +274,8 @@ struct ViewerController { sibling_navigator: Option, favorite_button: gtk::Button, favorite_toggle: Rc bool>, + download_button: gtk::Button, + file_download: Rc, state: RefCell, } @@ -269,6 +305,13 @@ impl ViewerController { controller.favorite_button.set_label(favorite_label(active)); }); let controller = Rc::clone(self); + self.download_button.connect_clicked(move |_| { + (controller.file_download)( + controller.window.clone().upcast(), + controller.status_label.clone(), + ); + }); + let controller = Rc::clone(self); self.page_seek.connect_value_changed(move |scale| { if let Some(index) = page_seek_index(scale.value(), controller.state.borrow().pages.len()) @@ -339,7 +382,7 @@ impl ViewerController { fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator - && navigate(forward) + && navigate.open_relative(forward) { self.window.close(); } @@ -945,6 +988,7 @@ impl MediaViewerWindow { kind: EntryKind, sibling_navigator: Option, favorite_control: FavoriteControl, + download_control: FileDownloadControl, ) { let title = source.display_name.clone(); let window = gtk::ApplicationWindow::builder() @@ -956,6 +1000,8 @@ impl MediaViewerWindow { let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); + let download_button = gtk::Button::with_label("파일 다운로드"); + header.pack_end(&download_button); header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); @@ -982,7 +1028,19 @@ impl MediaViewerWindow { .margin_start(8) .margin_end(8) .build(); + let media_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); + media_seek.set_draw_value(false); + media_seek.set_margin_start(8); + media_seek.set_margin_end(8); + if let Some(navigator) = &sibling_navigator { + media_seek.set_range(0.0, navigator.count.saturating_sub(1) as f64); + media_seek.set_value(navigator.current as f64); + media_seek.set_sensitive(navigator.count > 1); + } else { + media_seek.set_sensitive(false); + } root.append(&stack); + root.append(&media_seek); root.append(&status_label); window.set_child(Some(&root)); @@ -993,9 +1051,14 @@ impl MediaViewerWindow { video, stack, status_label, + media_seek, sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, + download_button, + file_download: download_control.download, + seek_generation: Cell::new(0), + seek_opening: Cell::new(false), }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); @@ -1027,9 +1090,14 @@ struct MediaViewerController { video: gtk::Video, stack: gtk::Stack, status_label: gtk::Label, + media_seek: gtk::Scale, sibling_navigator: Option, favorite_button: gtk::Button, favorite_toggle: Rc bool>, + download_button: gtk::Button, + file_download: Rc, + seek_generation: Cell, + seek_opening: Cell, } impl MediaViewerController { @@ -1061,19 +1129,67 @@ impl MediaViewerController { let active = (controller.favorite_toggle)(); controller.favorite_button.set_label(favorite_label(active)); }); + let controller = Rc::clone(self); + self.download_button.connect_clicked(move |_| { + (controller.file_download)( + controller.window.clone().upcast(), + controller.status_label.clone(), + ); + }); + let controller = Rc::clone(self); + self.media_seek.connect_value_changed(move |scale| { + let Some(navigator) = &controller.sibling_navigator else { + return; + }; + let Some(index) = page_seek_index(scale.value(), navigator.count) else { + return; + }; + controller.schedule_seek(index); + }); } fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator - && navigate(forward) + && navigate.open_relative(forward) { - if let Some(stream) = self.video.media_stream() { - stream.pause(); - } - self.window.close(); + self.close_for_navigation(); } } + fn schedule_seek(self: &Rc, index: usize) { + let Some(navigator) = &self.sibling_navigator else { + return; + }; + if index == navigator.current || self.seek_opening.get() { + return; + } + let generation = self.seek_generation.get().wrapping_add(1); + self.seek_generation.set(generation); + let controller = Rc::downgrade(self); + glib::timeout_add_local_once(Duration::from_millis(180), move || { + let Some(controller) = controller.upgrade() else { + return; + }; + if controller.seek_generation.get() != generation || controller.seek_opening.get() { + return; + } + let Some(navigator) = &controller.sibling_navigator else { + return; + }; + if navigator.open_index(index) { + controller.seek_opening.set(true); + controller.close_for_navigation(); + } + }); + } + + fn close_for_navigation(&self) { + if let Some(stream) = self.video.media_stream() { + stream.pause(); + } + self.window.close(); + } + fn toggle_fullscreen(&self) { if self.window.is_fullscreen() { self.window.unfullscreen(); @@ -1196,6 +1312,14 @@ fn page_seek_index(value: f64, count: usize) -> Option { (count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1)) } +fn sibling_index(current: usize, count: usize, forward: bool) -> Option { + if forward { + (current + 1 < count).then_some(current + 1) + } else { + current.checked_sub(1) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1245,4 +1369,12 @@ mod tests { assert_eq!(page_seek_index(20.0, 10), Some(9)); assert_eq!(page_seek_index(0.0, 0), None); } + + #[test] + fn sibling_navigation_stops_at_boundaries() { + assert_eq!(sibling_index(1, 3, false), Some(0)); + assert_eq!(sibling_index(1, 3, true), Some(2)); + assert_eq!(sibling_index(0, 3, false), None); + assert_eq!(sibling_index(2, 3, true), None); + } } diff --git a/src/window.rs b/src/window.rs index 95c4229..d95852a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -20,7 +20,9 @@ use crate::source::{ display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root, mount_operation, parent_within_root, root_file, validate_profile, }; -use crate::viewer::{FavoriteControl, MediaViewerWindow, SiblingNavigator, ViewerWindow}; +use crate::viewer::{ + FavoriteControl, FileDownloadControl, MediaViewerWindow, SiblingNavigator, ViewerWindow, +}; use crate::zip_cache::ZipCache; pub struct ComicWindow { @@ -607,12 +609,13 @@ impl BrowserController { self.refresh(); } EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { + let current_kind = entry.kind; let siblings = { let state = self.state.borrow(); let mut entries = state .entries .iter() - .filter(|entry| entry.kind.is_viewable()) + .filter(|entry| navigable_sibling(current_kind, entry.kind)) .cloned() .collect::>(); sort_entries(&mut entries, state.sort_field, state.sort_direction); @@ -637,7 +640,19 @@ impl BrowserController { match entry.kind { EntryKind::ZipArchive => self.open_archive(entry, siblings, current, profile), EntryKind::Image | EntryKind::Video => { - self.open_media(entry, siblings, current, profile) + let media_siblings = Rc::new( + siblings + .iter() + .filter(|sibling| sibling.kind.is_media()) + .cloned() + .collect::>(), + ); + if let Some(current) = media_siblings + .iter() + .position(|sibling| sibling.uri == entry.uri) + { + self.open_media(entry, media_siblings, current, profile); + } } _ => {} } @@ -650,16 +665,17 @@ impl BrowserController { profile: LocationProfile, ) -> SiblingNavigator { let controller = Rc::downgrade(self); - Rc::new(move |forward| { + let count = siblings.len(); + let open = Rc::new(move |next: usize| { let Some(controller) = controller.upgrade() else { return false; }; - let Some(next) = sibling_index(current, siblings.len(), forward) else { + if next >= siblings.len() { controller .status_label .set_text("더 이상 이동할 파일이 없습니다."); return false; - }; + } controller.open_viewable( siblings[next].clone(), Rc::clone(&siblings), @@ -667,7 +683,8 @@ impl BrowserController { profile.clone(), ); true - }) + }); + SiblingNavigator::new(current, count, open) } fn open_archive( @@ -682,6 +699,7 @@ impl BrowserController { let controller = Rc::clone(self); let navigator = self.sibling_navigator(siblings, current, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); + let download_control = self.download_control(&entry); self.zip_cache.prepare( entry, move |current, total| { @@ -701,6 +719,7 @@ impl BrowserController { controller.config.borrow().fast_image_loading, Some(navigator), favorite_control, + download_control, ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), @@ -722,6 +741,7 @@ impl BrowserController { let controller = Rc::clone(self); let navigator = self.sibling_navigator(siblings, current, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); + let download_control = self.download_control(&entry); self.zip_cache.prepare_media( entry, move |current, total| { @@ -740,6 +760,7 @@ impl BrowserController { kind, Some(navigator), favorite_control, + download_control, ); } Err(error) => controller.status_label.set_text(&format!("{error:#}")), @@ -761,8 +782,7 @@ impl BrowserController { return; }; let can_favorite = entry.kind == EntryKind::Directory || entry.kind.is_viewable(); - let can_download = - entry.kind != EntryKind::Directory && !gio::File::for_uri(&entry.uri).is_native(); + let can_download = entry.kind != EntryKind::Directory; if !can_favorite && !can_download { return; } @@ -792,13 +812,17 @@ impl BrowserController { }); } if can_download { - let download_button = gtk::Button::with_label("다운로드..."); + let download_button = gtk::Button::with_label("파일 다운로드..."); actions.append(&download_button); let controller = Rc::clone(self); let popover_to_close = popover.clone(); download_button.connect_clicked(move |_| { popover_to_close.popdown(); - controller.choose_download_destination(entry.clone()); + controller.choose_download_destination( + entry.clone(), + controller.window.clone().upcast(), + controller.status_label.clone(), + ); }); } popover.set_child(Some(&actions)); @@ -832,6 +856,19 @@ impl BrowserController { } } + fn download_control(self: &Rc, entry: &BrowserEntry) -> FileDownloadControl { + let entry = entry.clone(); + let controller = Rc::downgrade(self); + FileDownloadControl { + download: Rc::new(move |parent, status_label| { + let Some(controller) = controller.upgrade() else { + return; + }; + controller.choose_download_destination(entry.clone(), parent, status_label); + }), + } + } + fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { let mut updated = self.config.borrow().clone(); let was_active = self.is_favorite(&entry.uri); @@ -851,13 +888,17 @@ impl BrowserController { active } - fn choose_download_destination(self: &Rc, entry: BrowserEntry) { + fn choose_download_destination( + self: &Rc, + entry: BrowserEntry, + parent: gtk::Window, + status_label: gtk::Label, + ) { let dialog = gtk::FileDialog::builder() .title("다운로드 위치 선택") .initial_name(&entry.name) .build(); let controller = Rc::downgrade(self); - let parent = self.window.clone(); glib::MainContext::default().spawn_local(async move { let result = dialog.save_future(Some(&parent)).await; let Some(controller) = controller.upgrade() else { @@ -865,30 +906,27 @@ impl BrowserController { }; match result { Ok(destination) if destination.is_native() => { - controller.download_remote_file(entry, destination); + controller.download_file(entry, destination, status_label); } - Ok(_) => controller - .status_label - .set_text("로컬 파일 시스템 위치를 선택해 주세요."), + Ok(_) => status_label.set_text("로컬 파일 시스템 위치를 선택해 주세요."), Err(error) if error.matches(gio::IOErrorEnum::Cancelled) => {} - Err(error) => controller - .status_label - .set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")), + Err(error) => { + status_label.set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")); + } } }); } - fn download_remote_file(&self, entry: BrowserEntry, destination: gio::File) { + fn download_file(&self, entry: BrowserEntry, destination: gio::File, status_label: gtk::Label) { let Some(final_path) = destination.path() else { - self.status_label - .set_text("로컬 파일 시스템 위치를 선택해 주세요."); + status_label.set_text("로컬 파일 시스템 위치를 선택해 주세요."); return; }; let part_path = download_part_path(&final_path); let source = gio::File::for_uri(&entry.uri); let part_file = gio::File::for_path(&part_path); - let progress_label = self.status_label.clone(); - let result_label = self.status_label.clone(); + let progress_label = status_label.clone(); + let result_label = status_label; let expected_size = entry.size; source.copy_async( &part_file, @@ -1199,11 +1237,11 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt .find(|index| entries[*index].name.to_lowercase().starts_with(&query)) } -fn sibling_index(current: usize, count: usize, forward: bool) -> Option { - if forward { - (current + 1 < count).then_some(current + 1) - } else { - current.checked_sub(1) +fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { + match current { + EntryKind::ZipArchive => candidate.is_viewable(), + EntryKind::Image | EntryKind::Video => candidate.is_media(), + _ => false, } } @@ -1270,11 +1308,11 @@ mod tests { } #[test] - fn sibling_navigation_stops_at_boundaries() { - assert_eq!(sibling_index(1, 3, false), Some(0)); - assert_eq!(sibling_index(1, 3, true), Some(2)); - assert_eq!(sibling_index(0, 3, false), None); - assert_eq!(sibling_index(2, 3, true), None); + fn media_navigation_excludes_zip_archives() { + assert!(navigable_sibling(EntryKind::Image, EntryKind::Video)); + assert!(navigable_sibling(EntryKind::Video, EntryKind::Image)); + assert!(!navigable_sibling(EntryKind::Image, EntryKind::ZipArchive)); + assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); } #[test] From bb73c1abea940c3b46f26070df7baa3cd2088540 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 02:37:56 +0900 Subject: [PATCH 05/30] Reuse viewer windows and organize favorites --- src/config.rs | 51 +++++++ src/viewer.rs | 408 ++++++++++++++++++++++++++++++++++++++------------ src/window.rs | 291 +++++++++++++++++++++++++++-------- 3 files changed, 587 insertions(+), 163 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1775522..d84f5de 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; pub struct AppConfig { pub cache_quota_gb: u64, pub fast_image_loading: bool, + pub viewer_mode: ViewerMode, pub locations: Vec, pub favorites: Vec, } @@ -19,6 +20,7 @@ impl Default for AppConfig { Self { cache_quota_gb: 10, fast_image_loading: true, + viewer_mode: ViewerMode::FitWidth, locations: Vec::new(), favorites: Vec::new(), } @@ -55,6 +57,39 @@ pub enum LocationKind { Sftp, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ViewerMode { + ActualSize, + #[default] + FitWidth, + FitHeight, + TwoPage, + Continuous, +} + +impl ViewerMode { + pub fn dropdown_index(self) -> u32 { + match self { + Self::ActualSize => 0, + Self::FitWidth => 1, + Self::FitHeight => 2, + Self::TwoPage => 3, + Self::Continuous => 4, + } + } + + pub fn from_dropdown_index(index: u32) -> Self { + match index { + 0 => Self::ActualSize, + 2 => Self::FitHeight, + 3 => Self::TwoPage, + 4 => Self::Continuous, + _ => Self::FitWidth, + } + } +} + impl AppConfig { pub fn load_or_create(path: &Path) -> Result { if path.exists() { @@ -139,6 +174,7 @@ mod tests { let config = AppConfig { cache_quota_gb: 0, fast_image_loading: true, + viewer_mode: ViewerMode::FitWidth, locations: vec![LocationProfile { name: "테스트".into(), kind: LocationKind::Sftp, @@ -173,6 +209,21 @@ mod tests { fn fast_image_loading_is_enabled_for_existing_configs() { let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); assert!(decoded.fast_image_loading); + assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert!(decoded.favorites.is_empty()); } + + #[test] + fn viewer_modes_round_trip_through_dropdown_indices() { + for mode in [ + ViewerMode::ActualSize, + ViewerMode::FitWidth, + ViewerMode::FitHeight, + ViewerMode::TwoPage, + ViewerMode::Continuous, + ] { + assert_eq!(ViewerMode::from_dropdown_index(mode.dropdown_index()), mode); + } + assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth); + } } diff --git a/src/viewer.rs b/src/viewer.rs index 9fc5108..5217886 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -24,11 +24,15 @@ pub struct ViewerWindow; pub struct SiblingNavigator { current: usize, count: usize, - open: Rc bool>, + open: Rc bool>, } impl SiblingNavigator { - pub fn new(current: usize, count: usize, open: Rc bool>) -> Self { + pub fn new( + current: usize, + count: usize, + open: Rc bool>, + ) -> Self { Self { current, count, @@ -36,15 +40,15 @@ impl SiblingNavigator { } } - fn open_relative(&self, forward: bool) -> bool { + fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool { let Some(index) = sibling_index(self.current, self.count, forward) else { return false; }; - (self.open)(index) + (self.open)(index, window) } - fn open_index(&self, index: usize) -> bool { - index < self.count && index != self.current && (self.open)(index) + fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool { + index < self.count && index != self.current && (self.open)(index, window) } } @@ -57,23 +61,43 @@ pub struct FileDownloadControl { pub download: Rc, } +pub struct ViewModeControl { + pub selected: u32, + pub change: Rc, +} + +pub struct FileViewerControls { + pub sibling_navigator: Option, + pub favorite_control: FavoriteControl, + pub download_control: FileDownloadControl, + pub reuse_window: Option, +} + impl ViewerWindow { pub fn open( application: >k::Application, source: ArchiveSource, database: Rc, fast_image_loading: bool, - sibling_navigator: Option, - favorite_control: FavoriteControl, - download_control: FileDownloadControl, + view_mode_control: ViewModeControl, + controls: FileViewerControls, ) { + let FileViewerControls { + sibling_navigator, + favorite_control, + download_control, + reuse_window, + } = controls; let title = source.display_name.clone(); - let window = gtk::ApplicationWindow::builder() - .application(application) - .title(&title) - .default_width(1100) - .default_height(850) - .build(); + let window = reuse_window.unwrap_or_else(|| { + gtk::ApplicationWindow::builder() + .application(application) + .title(&title) + .default_width(1100) + .default_height(850) + .build() + }); + 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)); @@ -109,7 +133,7 @@ impl ViewerWindow { "2페이지", "이어서 보기", ]); - mode_dropdown.set_selected(1); + mode_dropdown.set_selected(view_mode_control.selected); toolbar.append(&previous_button); toolbar.append(&shift_back_button); toolbar.append(&shift_forward_button); @@ -159,14 +183,12 @@ impl ViewerWindow { .margin_start(8) .margin_end(8) .build(); - let page_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); - page_seek.set_draw_value(false); + let page_seek = navigation_scale(); + let page_seek_row = navigation_scale_row(&page_seek); page_seek.set_sensitive(false); - page_seek.set_margin_start(8); - page_seek.set_margin_end(8); root.append(&toolbar); root.append(&overlay); - root.append(&page_seek); + root.append(&page_seek_row); root.append(&status_label); window.set_child(Some(&root)); @@ -183,6 +205,7 @@ impl ViewerWindow { spinner, page_label, page_seek, + page_seek_row, status_label, previous_button, next_button, @@ -196,6 +219,11 @@ impl ViewerWindow { favorite_toggle: favorite_control.toggle, download_button, file_download: download_control.download, + view_mode_change: view_mode_control.change, + seek_hide_timer: RefCell::new(None), + last_pointer_position: Cell::new(None), + key_controller: RefCell::new(None), + motion_controller: RefCell::new(None), state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -263,6 +291,7 @@ struct ViewerController { spinner: gtk::Spinner, page_label: gtk::Label, page_seek: gtk::Scale, + page_seek_row: gtk::Box, status_label: gtk::Label, previous_button: gtk::Button, next_button: gtk::Button, @@ -276,6 +305,11 @@ struct ViewerController { favorite_toggle: Rc bool>, download_button: gtk::Button, file_download: Rc, + view_mode_change: Rc, + seek_hide_timer: RefCell>, + last_pointer_position: Cell>, + key_controller: RefCell>, + motion_controller: RefCell>, state: RefCell, } @@ -294,8 +328,10 @@ impl ViewerController { self.shift_forward_button .connect_clicked(move |_| controller.shift_pairing(1)); let controller = Rc::clone(self); - self.mode_dropdown - .connect_selected_notify(move |_| controller.apply_view_mode()); + self.mode_dropdown.connect_selected_notify(move |dropdown| { + (controller.view_mode_change)(dropdown.selected()); + controller.apply_view_mode(); + }); let controller = Rc::clone(self); self.direction_dropdown .connect_selected_notify(move |_| controller.render()); @@ -313,9 +349,8 @@ impl ViewerController { }); let controller = Rc::clone(self); self.page_seek.connect_value_changed(move |scale| { - if let Some(index) = - page_seek_index(scale.value(), controller.state.borrow().pages.len()) - { + let page_count = controller.state.borrow().pages.len(); + if let Some(index) = page_seek_index(scale.value(), page_count) { controller.seek_page(index); } }); @@ -345,49 +380,115 @@ impl ViewerController { }); self.stack.add_controller(scroll_controller); + 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)); + 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); - key_controller.connect_key_pressed(move |_, key, _, _| match key { - gdk::Key::Left | gdk::Key::Page_Up => { - controller.navigate(false); - glib::Propagation::Stop + key_controller.connect_key_pressed(move |_, key, _, _| { + controller.hide_seek(); + match key { + gdk::Key::Left | gdk::Key::Page_Up => { + controller.navigate(false); + glib::Propagation::Stop + } + gdk::Key::Right | gdk::Key::Page_Down | gdk::Key::space => { + controller.navigate(true); + glib::Propagation::Stop + } + _ if key.to_unicode() == Some('[') => { + controller.open_sibling(false); + glib::Propagation::Stop + } + _ if key.to_unicode() == Some(']') => { + controller.open_sibling(true); + glib::Propagation::Stop + } + _ if key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => + { + controller.toggle_fullscreen(); + glib::Propagation::Stop + } + gdk::Key::Escape if controller.window.is_fullscreen() => { + controller.window.unfullscreen(); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, } - gdk::Key::Right | gdk::Key::Page_Down | gdk::Key::space => { - controller.navigate(true); - glib::Propagation::Stop - } - _ if key.to_unicode() == Some('[') => { - controller.open_sibling(false); - glib::Propagation::Stop - } - _ if key.to_unicode() == Some(']') => { - controller.open_sibling(true); - glib::Propagation::Stop - } - _ if key - .to_unicode() - .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => - { - controller.toggle_fullscreen(); - glib::Propagation::Stop - } - gdk::Key::Escape if controller.window.is_fullscreen() => { - controller.window.unfullscreen(); - glib::Propagation::Stop - } - _ => glib::Propagation::Proceed, }); - self.window.add_controller(key_controller); + self.window.add_controller(key_controller.clone()); + self.key_controller.replace(Some(key_controller)); } fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator - && navigate.open_relative(forward) + && navigate.open_relative(forward, self.window.clone()) { - self.window.close(); + self.detach_window_controllers(); } } + fn detach_window_controllers(&self) { + if let Some(controller) = self.key_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } + if let Some(controller) = self.motion_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } + } + + fn show_seek_temporarily(self: &Rc) { + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.set_seek_visible(true); + let controller = Rc::downgrade(self); + 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); + } + }); + self.seek_hide_timer.replace(Some(timer)); + } + + fn pointer_moved(self: &Rc, x: f64, y: f64) { + if self.last_pointer_position.get() == Some((x, y)) { + return; + } + self.last_pointer_position.set(Some((x, y))); + self.show_seek_temporarily(); + } + + fn hide_seek(self: &Rc) { + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.set_seek_visible(false); + } + + fn set_seek_visible(self: &Rc, visible: bool) { + if self.page_seek_row.is_visible() == visible { + return; + } + self.page_seek_row.set_visible(visible); + self.window.queue_allocate(); + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + if let Some(controller) = controller.upgrade() { + controller.stack.queue_allocate(); + controller.resize_continuous_rows(); + controller.render(); + } + }); + } + fn toggle_fullscreen(&self) { if self.window.is_fullscreen() { self.window.unfullscreen(); @@ -438,7 +539,8 @@ impl ViewerController { state.pages = pages; state.anchor = saved; drop(state); - self.page_seek.set_range(0.0, (page_count - 1) as f64); + self.page_seek + .set_range(0.0, navigation_scale_upper(page_count)); self.page_seek.set_sensitive(page_count > 1); self.build_continuous_rows(); self.render(); @@ -986,17 +1088,24 @@ impl MediaViewerWindow { application: >k::Application, source: ArchiveSource, kind: EntryKind, - sibling_navigator: Option, - favorite_control: FavoriteControl, - download_control: FileDownloadControl, + controls: FileViewerControls, ) { + let FileViewerControls { + sibling_navigator, + favorite_control, + download_control, + reuse_window, + } = controls; let title = source.display_name.clone(); - let window = gtk::ApplicationWindow::builder() - .application(application) - .title(&title) - .default_width(1100) - .default_height(850) - .build(); + let window = reuse_window.unwrap_or_else(|| { + gtk::ApplicationWindow::builder() + .application(application) + .title(&title) + .default_width(1100) + .default_height(850) + .build() + }); + 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)); @@ -1028,19 +1137,17 @@ impl MediaViewerWindow { .margin_start(8) .margin_end(8) .build(); - let media_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); - media_seek.set_draw_value(false); - media_seek.set_margin_start(8); - media_seek.set_margin_end(8); + let media_seek = navigation_scale(); + let media_seek_row = navigation_scale_row(&media_seek); if let Some(navigator) = &sibling_navigator { - media_seek.set_range(0.0, navigator.count.saturating_sub(1) as f64); + media_seek.set_range(0.0, navigation_scale_upper(navigator.count)); media_seek.set_value(navigator.current as f64); media_seek.set_sensitive(navigator.count > 1); } else { media_seek.set_sensitive(false); } root.append(&stack); - root.append(&media_seek); + root.append(&media_seek_row); root.append(&status_label); window.set_child(Some(&root)); @@ -1052,6 +1159,7 @@ impl MediaViewerWindow { stack, status_label, media_seek, + media_seek_row, sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, @@ -1059,6 +1167,10 @@ impl MediaViewerWindow { file_download: download_control.download, seek_generation: Cell::new(0), seek_opening: Cell::new(false), + seek_hide_timer: RefCell::new(None), + last_pointer_position: Cell::new(None), + key_controller: RefCell::new(None), + motion_controller: RefCell::new(None), }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); @@ -1091,6 +1203,7 @@ struct MediaViewerController { stack: gtk::Stack, status_label: gtk::Label, media_seek: gtk::Scale, + media_seek_row: gtk::Box, sibling_navigator: Option, favorite_button: gtk::Button, favorite_toggle: Rc bool>, @@ -1098,32 +1211,47 @@ struct MediaViewerController { file_download: Rc, seek_generation: Cell, seek_opening: Cell, + seek_hide_timer: RefCell>, + last_pointer_position: Cell>, + key_controller: RefCell>, + motion_controller: RefCell>, } 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)); + 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, _, _| match key.to_unicode() { - Some('[') => { - controller.open_sibling(false); - glib::Propagation::Stop + keys.connect_key_pressed(move |_, key, _, _| { + controller.hide_seek(); + match key.to_unicode() { + Some('[') => { + controller.open_sibling(false); + glib::Propagation::Stop + } + Some(']') => { + controller.open_sibling(true); + glib::Propagation::Stop + } + Some(character) if character.eq_ignore_ascii_case(&'f') => { + controller.toggle_fullscreen(); + glib::Propagation::Stop + } + _ if key == gdk::Key::Escape && controller.window.is_fullscreen() => { + controller.window.unfullscreen(); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, } - Some(']') => { - controller.open_sibling(true); - glib::Propagation::Stop - } - Some(character) if character.eq_ignore_ascii_case(&'f') => { - controller.toggle_fullscreen(); - glib::Propagation::Stop - } - _ if key == gdk::Key::Escape && controller.window.is_fullscreen() => { - controller.window.unfullscreen(); - glib::Propagation::Stop - } - _ => glib::Propagation::Proceed, }); - self.window.add_controller(keys); + self.window.add_controller(keys.clone()); + self.key_controller.replace(Some(keys)); let controller = Rc::clone(self); self.favorite_button.connect_clicked(move |_| { let active = (controller.favorite_toggle)(); @@ -1150,12 +1278,58 @@ impl MediaViewerController { fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator - && navigate.open_relative(forward) + && navigate.open_relative(forward, self.window.clone()) { - self.close_for_navigation(); + self.prepare_for_navigation(); } } + fn show_seek_temporarily(self: &Rc) { + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.set_seek_visible(true); + let controller = Rc::downgrade(self); + 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); + } + }); + self.seek_hide_timer.replace(Some(timer)); + } + + fn pointer_moved(self: &Rc, x: f64, y: f64) { + if self.last_pointer_position.get() == Some((x, y)) { + return; + } + self.last_pointer_position.set(Some((x, y))); + self.show_seek_temporarily(); + } + + fn hide_seek(self: &Rc) { + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.set_seek_visible(false); + } + + fn set_seek_visible(self: &Rc, visible: bool) { + if self.media_seek_row.is_visible() == visible { + return; + } + self.media_seek_row.set_visible(visible); + self.window.queue_allocate(); + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + if let Some(controller) = controller.upgrade() { + controller.stack.queue_allocate(); + controller.picture.queue_allocate(); + controller.video.queue_allocate(); + } + }); + } + fn schedule_seek(self: &Rc, index: usize) { let Some(navigator) = &self.sibling_navigator else { return; @@ -1176,18 +1350,23 @@ impl MediaViewerController { let Some(navigator) = &controller.sibling_navigator else { return; }; - if navigator.open_index(index) { + if navigator.open_index(index, controller.window.clone()) { controller.seek_opening.set(true); - controller.close_for_navigation(); + controller.prepare_for_navigation(); } }); } - fn close_for_navigation(&self) { + fn prepare_for_navigation(&self) { if let Some(stream) = self.video.media_stream() { stream.pause(); } - self.window.close(); + if let Some(controller) = self.key_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } + if let Some(controller) = self.motion_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } } fn toggle_fullscreen(&self) { @@ -1259,6 +1438,33 @@ fn page_picture() -> gtk::Picture { picture } +fn navigation_scale() -> gtk::Scale { + let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0); + scale.set_draw_value(false); + scale.set_hexpand(true); + scale.set_height_request(32); + scale.set_valign(gtk::Align::Center); + scale.set_focusable(false); + scale.set_margin_top(2); + scale.set_margin_bottom(8); + scale.set_margin_start(8); + scale.set_margin_end(8); + scale +} + +fn navigation_scale_row(scale: >k::Scale) -> gtk::Box { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 0); + row.set_height_request(42); + row.set_hexpand(true); + row.append(scale); + row.set_visible(false); + row +} + +fn navigation_scale_upper(count: usize) -> f64 { + count.saturating_sub(1).max(1) as f64 +} + fn spread_indices(anchor: usize, count: usize) -> Vec { if count == 0 { return Vec::new(); @@ -1370,6 +1576,14 @@ mod tests { assert_eq!(page_seek_index(0.0, 0), None); } + #[test] + fn navigation_scale_always_has_a_valid_range() { + assert_eq!(navigation_scale_upper(0), 1.0); + assert_eq!(navigation_scale_upper(1), 1.0); + assert_eq!(navigation_scale_upper(2), 1.0); + assert_eq!(navigation_scale_upper(10), 9.0); + } + #[test] fn sibling_navigation_stops_at_boundaries() { assert_eq!(sibling_index(1, 3, false), Some(0)); diff --git a/src/window.rs b/src/window.rs index d95852a..86730df 100644 --- a/src/window.rs +++ b/src/window.rs @@ -14,14 +14,15 @@ use gtk::prelude::*; use crate::browser::{ BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, }; -use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile}; +use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile, ViewerMode}; use crate::database::Database; use crate::source::{ display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root, mount_operation, parent_within_root, root_file, validate_profile, }; use crate::viewer::{ - FavoriteControl, FileDownloadControl, MediaViewerWindow, SiblingNavigator, ViewerWindow, + FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator, + ViewModeControl, ViewerWindow, }; use crate::zip_cache::ZipCache; @@ -70,12 +71,6 @@ impl ComicWindow { locations.append(&edit_location_button); locations.append(&delete_location_button); - let favorites = gtk::Box::new(gtk::Orientation::Horizontal, 6); - let favorite_dropdown = gtk::DropDown::from_strings(&["즐겨찾기 선택"]); - favorite_dropdown.set_hexpand(true); - favorites.append(>k::Label::new(Some("즐겨찾기"))); - favorites.append(&favorite_dropdown); - let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let up_button = gtk::Button::builder() .icon_name("go-up-symbolic") @@ -119,7 +114,6 @@ impl ComicWindow { let status_label = gtk::Label::builder().xalign(0.0).build(); root.append(&locations); - root.append(&favorites); root.append(&navigation); root.append(&controls); root.append(&scrolled); @@ -129,7 +123,6 @@ impl ComicWindow { let controller = Rc::new(BrowserController { window, location_dropdown, - favorite_dropdown, edit_location_button, delete_location_button, path_entry, @@ -141,6 +134,7 @@ impl ComicWindow { state: RefCell::new(BrowserState { profile_index: 0, favorite_profile: None, + browsing_favorites: false, root_uri: root_uri.clone(), current_uri: root_uri, entries: Vec::new(), @@ -158,7 +152,6 @@ impl ComicWindow { }); controller.rebuild_profiles(); - controller.rebuild_favorites(); controller.connect_signals( open_button, refresh_button, @@ -178,6 +171,7 @@ impl ComicWindow { struct BrowserState { profile_index: usize, favorite_profile: Option, + browsing_favorites: bool, root_uri: String, current_uri: String, entries: Vec, @@ -192,7 +186,6 @@ struct BrowserState { struct BrowserController { window: gtk::ApplicationWindow, location_dropdown: gtk::DropDown, - favorite_dropdown: gtk::DropDown, edit_location_button: gtk::Button, delete_location_button: gtk::Button, path_entry: gtk::Entry, @@ -244,9 +237,6 @@ impl BrowserController { self.location_dropdown .connect_selected_notify(move |_| controller.select_profile()); let controller = Rc::clone(self); - self.favorite_dropdown - .connect_selected_notify(move |_| controller.select_favorite()); - let controller = Rc::clone(self); self.search_entry .connect_search_changed(move |_| controller.render()); let controller = Rc::clone(self); @@ -261,8 +251,8 @@ impl BrowserController { let context_click = gtk::GestureClick::builder().button(3).build(); let controller = Rc::clone(self); - context_click.connect_pressed(move |_, _, _, y| { - controller.show_context_menu(y as i32); + context_click.connect_pressed(move |_, _, x, y| { + controller.show_context_menu(x, y); }); self.list_box.add_controller(context_click); @@ -296,38 +286,26 @@ impl BrowserController { fn rebuild_profiles(&self) { let profiles = self.profiles(); - let names: Vec<_> = profiles + let mut names: Vec<_> = profiles .iter() - .map(|profile| profile.name.as_str()) + .map(|profile| profile.name.clone()) .collect(); + names.push("즐겨찾기".into()); + let names: Vec<_> = names.iter().map(String::as_str).collect(); self.location_dropdown .set_model(Some(>k::StringList::new(&names))); } - fn rebuild_favorites(&self) { - let config = self.config.borrow(); - let mut names = Vec::with_capacity(config.favorites.len() + 1); - names.push("즐겨찾기 선택".to_owned()); - names.extend(config.favorites.iter().map(|favorite| { - let marker = if favorite.is_directory { - "폴더" - } else { - "파일" - }; - format!("[{marker}] {}", favorite.name) - })); - let names: Vec<_> = names.iter().map(String::as_str).collect(); - self.favorite_dropdown - .set_model(Some(>k::StringList::new(&names))); - self.favorite_dropdown.set_selected(0); - } - fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; - let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len(); + let profiles = self.profiles(); + if profile_index == profiles.len() { + self.show_favorites(); + return; + } + let editable = profile_index > 0 && profile_index < profiles.len(); self.edit_location_button.set_sensitive(editable); self.delete_location_button.set_sensitive(editable); - let profiles = self.profiles(); let Some(profile) = profiles.get(profile_index) else { return; }; @@ -337,9 +315,11 @@ impl BrowserController { let mut state = self.state.borrow_mut(); state.profile_index = profile_index; state.favorite_profile = None; + state.browsing_favorites = false; state.root_uri = root_uri.clone(); state.current_uri = root_uri; drop(state); + self.path_entry.set_editable(true); self.search_entry.set_text(""); self.refresh(); } @@ -347,15 +327,28 @@ impl BrowserController { } } - fn select_favorite(self: &Rc) { - let Some(index) = (self.favorite_dropdown.selected() as usize).checked_sub(1) else { - return; - }; - let Some(favorite) = self.config.borrow().favorites.get(index).cloned() else { - self.favorite_dropdown.set_selected(0); - return; - }; - self.favorite_dropdown.set_selected(0); + fn show_favorites(&self) { + self.edit_location_button.set_sensitive(false); + self.delete_location_button.set_sensitive(false); + self.path_entry.set_text("즐겨찾기"); + self.path_entry.set_editable(false); + self.search_entry.set_text(""); + let entries = self + .config + .borrow() + .favorites + .iter() + .map(favorite_browser_entry) + .collect(); + let mut state = self.state.borrow_mut(); + state.browsing_favorites = true; + state.favorite_profile = None; + state.entries = entries; + drop(state); + self.render(); + } + + fn open_favorite(self: &Rc, favorite: FavoriteEntry) { let Ok(root) = root_file(&favorite.profile) else { self.status_label .set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다."); @@ -374,6 +367,7 @@ impl BrowserController { self.delete_location_button.set_sensitive(false); let mut state = self.state.borrow_mut(); state.favorite_profile = Some(favorite.profile.clone()); + state.browsing_favorites = false; state.root_uri = root.uri().into(); state.current_uri = favorite.uri; drop(state); @@ -412,11 +406,15 @@ impl BrowserController { etag: None, }; let siblings = Rc::new(vec![entry.clone()]); - controller.open_viewable(entry, siblings, 0, favorite.profile); + controller.open_viewable(entry, siblings, 0, favorite.profile, None); }); } fn open_entered_path(self: &Rc) { + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + return; + } let profile = self.current_profile(); let entered = self.path_entry.text(); let file = file_from_user_input(profile.kind, entered.as_str()); @@ -431,6 +429,10 @@ impl BrowserController { } fn go_up(self: &Rc) { + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + return; + } let state = self.state.borrow(); let current = gio::File::for_uri(&state.current_uri); let root = gio::File::for_uri(&state.root_uri); @@ -442,6 +444,11 @@ impl BrowserController { } fn refresh(self: &Rc) { + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + self.show_favorites(); + return; + } let (profile, uri, generation) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); @@ -602,6 +609,20 @@ impl BrowserController { else { return; }; + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + let favorite = self + .config + .borrow() + .favorites + .iter() + .find(|favorite| favorite.uri == entry.uri) + .cloned(); + if let Some(favorite) = favorite { + self.open_favorite(favorite); + } + return; + } match entry.kind { EntryKind::Directory => { self.state.borrow_mut().current_uri = entry.uri; @@ -623,7 +644,7 @@ impl BrowserController { }; let siblings = Rc::new(siblings); if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { - self.open_viewable(entry, siblings, current, self.current_profile()); + self.open_viewable(entry, siblings, current, self.current_profile(), None); } } EntryKind::OtherFile => {} @@ -636,9 +657,12 @@ impl BrowserController { siblings: Rc>, current: usize, profile: LocationProfile, + reuse_window: Option, ) { match entry.kind { - EntryKind::ZipArchive => self.open_archive(entry, siblings, current, profile), + EntryKind::ZipArchive => { + self.open_archive(entry, siblings, current, profile, reuse_window) + } EntryKind::Image | EntryKind::Video => { let media_siblings = Rc::new( siblings @@ -651,7 +675,7 @@ impl BrowserController { .iter() .position(|sibling| sibling.uri == entry.uri) { - self.open_media(entry, media_siblings, current, profile); + self.open_media(entry, media_siblings, current, profile, reuse_window); } } _ => {} @@ -666,7 +690,7 @@ impl BrowserController { ) -> SiblingNavigator { let controller = Rc::downgrade(self); let count = siblings.len(); - let open = Rc::new(move |next: usize| { + let open = Rc::new(move |next: usize, reuse_window: gtk::ApplicationWindow| { let Some(controller) = controller.upgrade() else { return false; }; @@ -681,6 +705,7 @@ impl BrowserController { Rc::clone(&siblings), next, profile.clone(), + Some(reuse_window), ); true }); @@ -693,13 +718,19 @@ impl BrowserController { siblings: Rc>, current: usize, profile: LocationProfile, + reuse_window: Option, ) { self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); - let progress_label = self.status_label.clone(); + let progress_label = reuse_window.as_ref().map_or_else( + || self.status_label.clone(), + |window| show_viewer_loading(window, &entry.name, "ZIP 파일을 준비하는 중..."), + ); + let result_label = progress_label.clone(); let controller = Rc::clone(self); let navigator = self.sibling_navigator(siblings, current, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); + let view_mode_control = self.view_mode_control(); self.zip_cache.prepare( entry, move |current, total| { @@ -709,6 +740,7 @@ impl BrowserController { move |result| match result { Ok(source) => { controller.status_label.set_text("다운로드 완료"); + result_label.set_text("ZIP 파일을 여는 중..."); let Some(application) = controller.window.application() else { return; }; @@ -717,12 +749,20 @@ impl BrowserController { source, Rc::clone(&controller.database), controller.config.borrow().fast_image_loading, - Some(navigator), - favorite_control, - download_control, + view_mode_control, + FileViewerControls { + sibling_navigator: Some(navigator), + favorite_control, + download_control, + reuse_window, + }, ); } - Err(error) => controller.status_label.set_text(&format!("{error:#}")), + Err(error) => { + let message = format!("{error:#}"); + controller.status_label.set_text(&message); + result_label.set_text(&message); + } }, ); } @@ -733,11 +773,16 @@ impl BrowserController { siblings: Rc>, current: usize, profile: LocationProfile, + reuse_window: Option, ) { let kind = entry.kind; self.status_label .set_text("미디어 파일 다운로드 준비 중..."); - let progress_label = self.status_label.clone(); + let progress_label = reuse_window.as_ref().map_or_else( + || self.status_label.clone(), + |window| show_viewer_loading(window, &entry.name, "미디어 파일을 준비하는 중..."), + ); + let result_label = progress_label.clone(); let controller = Rc::clone(self); let navigator = self.sibling_navigator(siblings, current, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); @@ -751,6 +796,7 @@ impl BrowserController { move |result| match result { Ok(source) => { controller.status_label.set_text("다운로드 완료"); + result_label.set_text("미디어 파일을 여는 중..."); let Some(application) = controller.window.application() else { return; }; @@ -758,18 +804,37 @@ impl BrowserController { &application, source, kind, - Some(navigator), - favorite_control, - download_control, + FileViewerControls { + sibling_navigator: Some(navigator), + favorite_control, + download_control, + reuse_window, + }, ); } - Err(error) => controller.status_label.set_text(&format!("{error:#}")), + Err(error) => { + let message = format!("{error:#}"); + controller.status_label.set_text(&message); + result_label.set_text(&message); + } }, ); } - fn show_context_menu(self: &Rc, y: i32) { - let Some(row) = self.list_box.row_at_y(y) else { + fn show_context_menu(self: &Rc, x: f64, y: f64) { + let Some(widget) = self.list_box.pick(x, y, gtk::PickFlags::DEFAULT) else { + return; + }; + let Some(row) = widget + .clone() + .downcast::() + .ok() + .or_else(|| { + widget + .ancestor(gtk::ListBoxRow::static_type()) + .and_then(|widget| widget.downcast::().ok()) + }) + else { return; }; let Some(entry) = self @@ -869,6 +934,32 @@ impl BrowserController { } } + fn view_mode_control(self: &Rc) -> ViewModeControl { + let selected = self.config.borrow().viewer_mode.dropdown_index(); + let controller = Rc::downgrade(self); + ViewModeControl { + selected, + change: Rc::new(move |index| { + let Some(controller) = controller.upgrade() else { + return; + }; + let viewer_mode = ViewerMode::from_dropdown_index(index); + if controller.config.borrow().viewer_mode == viewer_mode { + return; + } + let mut updated = controller.config.borrow().clone(); + updated.viewer_mode = viewer_mode; + if let Err(error) = updated.save(&controller.config_path) { + controller + .status_label + .set_text(&format!("보기 모드를 저장할 수 없습니다: {error:#}")); + return; + } + *controller.config.borrow_mut() = updated; + }), + } + } + fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { let mut updated = self.config.borrow().clone(); let was_active = self.is_favorite(&entry.uri); @@ -879,7 +970,10 @@ impl BrowserController { return was_active; } *self.config.borrow_mut() = updated; - self.rebuild_favorites(); + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + self.show_favorites(); + } self.status_label.set_text(if active { "즐겨찾기에 추가했습니다." } else { @@ -1245,6 +1339,51 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { } } +fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { + BrowserEntry { + uri: favorite.uri.clone(), + name: favorite.name.clone(), + kind: if favorite.is_directory { + EntryKind::Directory + } else { + classify_file(Path::new(&favorite.name)) + }, + size: None, + modified: None, + created: None, + etag: None, + } +} + +fn show_viewer_loading( + window: >k::ApplicationWindow, + file_name: &str, + status: &str, +) -> gtk::Label { + 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)); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_halign(gtk::Align::Center); + content.set_valign(gtk::Align::Center); + content.set_hexpand(true); + content.set_vexpand(true); + let spinner = gtk::Spinner::new(); + spinner.set_spinning(true); + let name_label = gtk::Label::new(Some(file_name)); + name_label.add_css_class("title-2"); + name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + name_label.set_max_width_chars(60); + let status_label = gtk::Label::new(Some(status)); + content.append(&spinner); + content.append(&name_label); + content.append(&status_label); + window.set_child(Some(&content)); + status_label +} + fn download_part_path(final_path: &std::path::Path) -> PathBuf { final_path.with_file_name(format!( "{}.part", @@ -1315,6 +1454,26 @@ mod tests { assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); } + #[test] + fn favorites_are_rendered_as_browser_paths_and_files() { + let profile = home_profile(); + let folder = favorite_browser_entry(&FavoriteEntry { + name: "Comics".into(), + uri: "file:///tmp/Comics".into(), + is_directory: true, + profile: profile.clone(), + }); + let archive = favorite_browser_entry(&FavoriteEntry { + name: "book.zip".into(), + uri: "file:///tmp/book.zip".into(), + is_directory: false, + profile, + }); + + assert_eq!(folder.kind, EntryKind::Directory); + assert_eq!(archive.kind, EntryKind::ZipArchive); + } + #[test] fn incomplete_download_uses_distinct_suffix() { assert_eq!( From f0d5cf3496cc999d56b6f79d7393946fc51d6b34 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 03:01:12 +0900 Subject: [PATCH 06/30] Sync viewer navigation with browser sorting --- src/archive.rs | 8 +- src/browser.rs | 55 ++++++++++--- src/config.rs | 5 ++ src/viewer.rs | 177 ++++++++++++++++++++++++++++++++++------ src/window.rs | 217 ++++++++++++++++++++++++++++++++++++++----------- 5 files changed, 375 insertions(+), 87 deletions(-) diff --git a/src/archive.rs b/src/archive.rs index ec7ad0f..a3e5db0 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result, bail}; use image::ImageReader; use zip::ZipArchive; -use crate::browser::natural_cmp; +use crate::browser::natural_cmp_with_case; const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024; const MAX_IMAGE_DIMENSION: u32 = 30_000; @@ -24,7 +24,7 @@ pub struct DecodedPage { pub rgba: Vec, } -pub fn list_pages(path: &Path) -> Result> { +pub fn list_pages(path: &Path, case_insensitive: bool) -> Result> { let file = File::open(path) .with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?; let mut archive = ZipArchive::new(file) @@ -41,7 +41,7 @@ pub fn list_pages(path: &Path) -> Result> { } } - pages.sort_by(|left, right| natural_cmp(&left.name, &right.name)); + pages.sort_by(|left, right| natural_cmp_with_case(&left.name, &right.name, case_insensitive)); Ok(pages) } @@ -178,7 +178,7 @@ mod tests { archive.write_all(b"ignored").unwrap(); archive.finish().unwrap(); - let pages = list_pages(&path).unwrap(); + let pages = list_pages(&path, true).unwrap(); assert_eq!( pages .iter() diff --git a/src/browser.rs b/src/browser.rs index 1d5eb2f..defbd1d 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -45,7 +45,12 @@ pub enum SortDirection { Descending, } -pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) { +pub fn sort_entries( + entries: &mut [BrowserEntry], + field: SortField, + direction: SortDirection, + case_insensitive: bool, +) { entries.sort_by(|left, right| { let kind_order = match ( left.kind == EntryKind::Directory, @@ -60,11 +65,24 @@ pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: S } match field { - SortField::Name => apply_direction(natural_cmp(&left.name, &right.name), direction), + SortField::Name => apply_direction( + natural_cmp_with_case(&left.name, &right.name, case_insensitive), + direction, + ), SortField::Modified => compare_optional_time(left.modified, right.modified, direction) - .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)), + .then_with(|| { + apply_direction( + natural_cmp_with_case(&left.name, &right.name, case_insensitive), + direction, + ) + }), SortField::Created => compare_optional_time(left.created, right.created, direction) - .then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)), + .then_with(|| { + apply_direction( + natural_cmp_with_case(&left.name, &right.name, case_insensitive), + direction, + ) + }), } }); } @@ -104,13 +122,13 @@ pub fn classify_file(path: &Path) -> EntryKind { } } -pub fn natural_cmp(left: &str, right: &str) -> Ordering { +pub fn natural_cmp_with_case(left: &str, right: &str, case_insensitive: bool) -> Ordering { let mut left_chars = left.chars().peekable(); let mut right_chars = right.chars().peekable(); loop { match (left_chars.peek(), right_chars.peek()) { - (None, None) => return Ordering::Equal, + (None, None) => return left.cmp(right), (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, (Some(left_char), Some(right_char)) @@ -143,10 +161,11 @@ pub fn natural_cmp(left: &str, right: &str) -> Ordering { (Some(_), Some(_)) => { let left_char = left_chars.next().unwrap(); let right_char = right_chars.next().unwrap(); - let order = left_char - .to_lowercase() - .cmp(right_char.to_lowercase()) - .then_with(|| left_char.cmp(&right_char)); + let order = if case_insensitive { + left_char.to_lowercase().cmp(right_char.to_lowercase()) + } else { + left_char.cmp(&right_char) + }; if order != Ordering::Equal { return order; } @@ -173,18 +192,30 @@ mod tests { #[test] fn natural_order_places_two_before_ten() { let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"]; - names.sort_by(|left, right| natural_cmp(left, right)); + names.sort_by(|left, right| natural_cmp_with_case(left, right, true)); assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]); } #[test] fn natural_order_handles_numbers_larger_than_integers() { assert_eq!( - natural_cmp("9.zip", "123456789012345678901234567890.zip"), + natural_cmp_with_case("9.zip", "123456789012345678901234567890.zip", true), Ordering::Less ); } + #[test] + fn natural_order_can_ignore_or_respect_case() { + assert_eq!( + natural_cmp_with_case("a2.jpg", "A10.jpg", true), + Ordering::Less + ); + assert_eq!( + natural_cmp_with_case("a2.jpg", "A10.jpg", false), + Ordering::Greater + ); + } + #[test] fn zip_extension_is_case_insensitive() { assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive); diff --git a/src/config.rs b/src/config.rs index d84f5de..9378f78 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; pub struct AppConfig { pub cache_quota_gb: u64, pub fast_image_loading: bool, + pub case_insensitive_sort: bool, pub viewer_mode: ViewerMode, pub locations: Vec, pub favorites: Vec, @@ -20,6 +21,7 @@ impl Default for AppConfig { Self { cache_quota_gb: 10, fast_image_loading: true, + case_insensitive_sort: true, viewer_mode: ViewerMode::FitWidth, locations: Vec::new(), favorites: Vec::new(), @@ -167,6 +169,7 @@ mod tests { #[test] fn default_cache_quota_is_ten_gigabytes() { assert_eq!(AppConfig::default().cache_quota_gb, 10); + assert!(AppConfig::default().case_insensitive_sort); } #[test] @@ -174,6 +177,7 @@ mod tests { let config = AppConfig { cache_quota_gb: 0, fast_image_loading: true, + case_insensitive_sort: true, viewer_mode: ViewerMode::FitWidth, locations: vec![LocationProfile { name: "테스트".into(), @@ -209,6 +213,7 @@ mod tests { fn fast_image_loading_is_enabled_for_existing_configs() { let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); assert!(decoded.fast_image_loading); + assert!(decoded.case_insensitive_sort); assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert!(decoded.favorites.is_empty()); } diff --git a/src/viewer.rs b/src/viewer.rs index 5217886..47904e8 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -22,33 +22,43 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64; pub struct ViewerWindow; pub struct SiblingNavigator { - current: usize, - count: usize, - open: Rc bool>, + position: Rc Option<(usize, usize)>>, + navigate: Rc bool>, } impl SiblingNavigator { pub fn new( - current: usize, - count: usize, - open: Rc bool>, + position: Rc Option<(usize, usize)>>, + navigate: Rc bool>, ) -> Self { - Self { - current, - count, - open, - } + Self { position, navigate } } fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool { - let Some(index) = sibling_index(self.current, self.count, forward) else { - return false; - }; - (self.open)(index, window) + (self.navigate)(SiblingTarget::Relative(forward), window) } fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool { - index < self.count && index != self.current && (self.open)(index, window) + (self.navigate)(SiblingTarget::Index(index), window) + } + + fn position(&self) -> Option<(usize, usize)> { + (self.position)() + } +} + +#[derive(Clone, Copy)] +pub enum SiblingTarget { + Relative(bool), + Index(usize), +} + +impl SiblingTarget { + pub fn resolve(self, current: usize, count: usize) -> Option { + match self { + Self::Relative(forward) => sibling_index(current, count, forward), + Self::Index(index) => (index < count && index != current).then_some(index), + } } } @@ -79,6 +89,7 @@ impl ViewerWindow { source: ArchiveSource, database: Rc, fast_image_loading: bool, + case_insensitive_sort: bool, view_mode_control: ViewModeControl, controls: FileViewerControls, ) { @@ -190,7 +201,11 @@ impl ViewerWindow { root.append(&overlay); root.append(&page_seek_row); root.append(&status_label); - window.set_child(Some(&root)); + let window_overlay = gtk::Overlay::new(); + window_overlay.set_child(Some(&root)); + let (navigation_message, navigation_message_label) = navigation_message(); + window_overlay.add_overlay(&navigation_message); + window.set_child(Some(&window_overlay)); let controller = Rc::new(ViewerController { window, @@ -214,6 +229,7 @@ impl ViewerWindow { mode_dropdown, direction_dropdown, fast_image_loading, + case_insensitive_sort, sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, @@ -224,6 +240,9 @@ impl ViewerWindow { last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), + navigation_message, + navigation_message_label, + navigation_message_generation: Rc::new(Cell::new(0)), state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -300,6 +319,7 @@ struct ViewerController { mode_dropdown: gtk::DropDown, direction_dropdown: gtk::DropDown, fast_image_loading: bool, + case_insensitive_sort: bool, sibling_navigator: Option, favorite_button: gtk::Button, favorite_toggle: Rc bool>, @@ -310,6 +330,9 @@ struct ViewerController { last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, + navigation_message: gtk::Box, + navigation_message_label: gtk::Label, + navigation_message_generation: Rc>, state: RefCell, } @@ -431,9 +454,26 @@ impl ViewerController { && navigate.open_relative(forward, self.window.clone()) { self.detach_window_controllers(); + } else { + self.show_navigation_error(forward); } } + fn show_navigation_error(&self, forward: bool) { + self.navigation_message_label + .set_text(navigation_error_message(forward)); + self.navigation_message.set_visible(true); + let message = self.navigation_message.clone(); + let generation = self.navigation_message_generation.get().wrapping_add(1); + self.navigation_message_generation.set(generation); + let current_generation = Rc::clone(&self.navigation_message_generation); + glib::timeout_add_local_once(Duration::from_secs(2), move || { + if current_generation.get() == generation { + message.set_visible(false); + } + }); + } + fn detach_window_controllers(&self) { if let Some(controller) = self.key_controller.borrow_mut().take() { self.window.remove_controller(&controller); @@ -500,9 +540,12 @@ impl ViewerController { fn load_archive(self: &Rc) { self.spinner.start(); let path = self.source.local_path.clone(); + let case_insensitive_sort = self.case_insensitive_sort; let (sender, receiver) = mpsc::channel(); thread::spawn(move || { - let _ = sender.send(list_pages(&path).map_err(|error| format!("{error:#}"))); + let _ = sender.send( + list_pages(&path, case_insensitive_sort).map_err(|error| format!("{error:#}")), + ); }); let controller = Rc::downgrade(self); glib::timeout_add_local(Duration::from_millis(20), move || { @@ -1139,17 +1182,24 @@ impl MediaViewerWindow { .build(); let media_seek = navigation_scale(); let media_seek_row = navigation_scale_row(&media_seek); - if let Some(navigator) = &sibling_navigator { - media_seek.set_range(0.0, navigation_scale_upper(navigator.count)); - media_seek.set_value(navigator.current as f64); - media_seek.set_sensitive(navigator.count > 1); + if let Some((current, count)) = sibling_navigator + .as_ref() + .and_then(SiblingNavigator::position) + { + media_seek.set_range(0.0, navigation_scale_upper(count)); + media_seek.set_value(current as f64); + media_seek.set_sensitive(count > 1); } else { media_seek.set_sensitive(false); } root.append(&stack); root.append(&media_seek_row); root.append(&status_label); - window.set_child(Some(&root)); + let window_overlay = gtk::Overlay::new(); + window_overlay.set_child(Some(&root)); + let (navigation_message, navigation_message_label) = navigation_message(); + window_overlay.add_overlay(&navigation_message); + window.set_child(Some(&window_overlay)); let controller = Rc::new(MediaViewerController { window, @@ -1171,6 +1221,9 @@ impl MediaViewerWindow { last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), + navigation_message, + navigation_message_label, + navigation_message_generation: Rc::new(Cell::new(0)), }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); @@ -1215,6 +1268,9 @@ struct MediaViewerController { last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, + navigation_message: gtk::Box, + navigation_message_label: gtk::Label, + navigation_message_generation: Rc>, } impl MediaViewerController { @@ -1269,7 +1325,10 @@ impl MediaViewerController { let Some(navigator) = &controller.sibling_navigator else { return; }; - let Some(index) = page_seek_index(scale.value(), navigator.count) else { + let Some((_, count)) = navigator.position() else { + return; + }; + let Some(index) = page_seek_index(scale.value(), count) else { return; }; controller.schedule_seek(index); @@ -1281,13 +1340,40 @@ impl MediaViewerController { && navigate.open_relative(forward, self.window.clone()) { self.prepare_for_navigation(); + } else { + self.show_navigation_error(forward); } } + fn show_navigation_error(&self, forward: bool) { + self.navigation_message_label + .set_text(navigation_error_message(forward)); + self.navigation_message.set_visible(true); + let message = self.navigation_message.clone(); + let generation = self.navigation_message_generation.get().wrapping_add(1); + self.navigation_message_generation.set(generation); + let current_generation = Rc::clone(&self.navigation_message_generation); + glib::timeout_add_local_once(Duration::from_secs(2), move || { + if current_generation.get() == generation { + message.set_visible(false); + } + }); + } + fn show_seek_temporarily(self: &Rc) { 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) + { + self.media_seek + .set_range(0.0, navigation_scale_upper(count)); + self.media_seek.set_sensitive(count > 1); + self.media_seek.set_value(current as f64); + } self.set_seek_visible(true); let controller = Rc::downgrade(self); let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { @@ -1334,7 +1420,11 @@ impl MediaViewerController { let Some(navigator) = &self.sibling_navigator else { return; }; - if index == navigator.current || self.seek_opening.get() { + if navigator + .position() + .is_none_or(|(current, _)| index == current) + || self.seek_opening.get() + { return; } let generation = self.seek_generation.get().wrapping_add(1); @@ -1452,6 +1542,31 @@ fn navigation_scale() -> gtk::Scale { scale } +fn navigation_message() -> (gtk::Box, gtk::Label) { + let message = gtk::Box::new(gtk::Orientation::Horizontal, 0); + message.add_css_class("osd"); + message.set_halign(gtk::Align::Center); + message.set_valign(gtk::Align::Center); + message.set_can_target(false); + message.set_margin_top(12); + message.set_margin_bottom(12); + message.set_margin_start(18); + message.set_margin_end(18); + message.set_visible(false); + let label = gtk::Label::new(None); + label.add_css_class("title-2"); + message.append(&label); + (message, label) +} + +fn navigation_error_message(forward: bool) -> &'static str { + if forward { + "더 이상 다음 파일이 없습니다." + } else { + "더 이상 이전 파일이 없습니다." + } +} + fn navigation_scale_row(scale: >k::Scale) -> gtk::Box { let row = gtk::Box::new(gtk::Orientation::Horizontal, 0); row.set_height_request(42); @@ -1591,4 +1706,16 @@ mod tests { assert_eq!(sibling_index(0, 3, false), None); assert_eq!(sibling_index(2, 3, true), None); } + + #[test] + fn navigation_boundary_messages_match_direction() { + assert_eq!( + navigation_error_message(false), + "더 이상 이전 파일이 없습니다." + ); + assert_eq!( + navigation_error_message(true), + "더 이상 다음 파일이 없습니다." + ); + } } diff --git a/src/window.rs b/src/window.rs index 86730df..51159a1 100644 --- a/src/window.rs +++ b/src/window.rs @@ -22,7 +22,7 @@ use crate::source::{ }; use crate::viewer::{ FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator, - ViewModeControl, ViewerWindow, + SiblingTarget, ViewModeControl, ViewerWindow, }; use crate::zip_cache::ZipCache; @@ -94,6 +94,8 @@ impl ComicWindow { .build(); let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); let direction_button = gtk::ToggleButton::with_label("오름차순"); + let case_insensitive_check = gtk::CheckButton::with_label("대소문자 구분 안 함"); + case_insensitive_check.set_active(config.case_insensitive_sort); let fast_loading_switch = gtk::Switch::builder() .active(config.fast_image_loading) .tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지") @@ -101,6 +103,7 @@ impl ComicWindow { controls.append(&search_entry); controls.append(&sort_dropdown); controls.append(&direction_button); + controls.append(&case_insensitive_check); controls.append(>k::Label::new(Some("빠른 이미지 로딩"))); controls.append(&fast_loading_switch); @@ -157,6 +160,7 @@ impl ComicWindow { refresh_button, up_button, add_location_button, + case_insensitive_check, fast_loading_switch, ); controller.refresh(); @@ -208,6 +212,7 @@ impl BrowserController { refresh_button: gtk::Button, up_button: gtk::Button, add_location_button: gtk::Button, + case_insensitive_check: gtk::CheckButton, fast_loading_switch: gtk::Switch, ) { let controller = Rc::clone(self); @@ -246,6 +251,10 @@ impl BrowserController { self.direction_button .connect_toggled(move |_| controller.change_sort_direction()); let controller = Rc::clone(self); + case_insensitive_check.connect_toggled(move |check| { + controller.set_case_insensitive_sort(check); + }); + let controller = Rc::clone(self); self.list_box .connect_row_activated(move |_, row| controller.activate_row(row.index())); @@ -406,7 +415,7 @@ impl BrowserController { etag: None, }; let siblings = Rc::new(vec![entry.clone()]); - controller.open_viewable(entry, siblings, 0, favorite.profile, None); + controller.open_viewable(entry, siblings, favorite.profile, None); }); } @@ -502,6 +511,7 @@ impl BrowserController { self.list_box.remove(&child); } let query = self.search_entry.text().to_lowercase(); + let case_insensitive = self.config.borrow().case_insensitive_sort; let mut state = self.state.borrow_mut(); let mut visible_entries: Vec<_> = state .entries @@ -509,7 +519,12 @@ impl BrowserController { .filter(|entry| entry.name.to_lowercase().contains(query.as_str())) .cloned() .collect(); - sort_entries(&mut visible_entries, state.sort_field, state.sort_direction); + sort_entries( + &mut visible_entries, + state.sort_field, + state.sort_direction, + case_insensitive, + ); for entry in &visible_entries { self.list_box.append(&entry_row(entry)); } @@ -631,20 +646,20 @@ impl BrowserController { } EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { let current_kind = entry.kind; + let case_insensitive = self.config.borrow().case_insensitive_sort; let siblings = { let state = self.state.borrow(); - let mut entries = state - .entries - .iter() - .filter(|entry| navigable_sibling(current_kind, entry.kind)) - .cloned() - .collect::>(); - sort_entries(&mut entries, state.sort_field, state.sort_direction); - entries + sorted_navigable_entries( + &state.entries, + current_kind, + state.sort_field, + state.sort_direction, + case_insensitive, + ) }; let siblings = Rc::new(siblings); - if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { - self.open_viewable(entry, siblings, current, self.current_profile(), None); + if siblings.iter().any(|item| item.uri == entry.uri) { + self.open_viewable(entry, siblings, self.current_profile(), None); } } EntryKind::OtherFile => {} @@ -655,14 +670,11 @@ impl BrowserController { self: &Rc, entry: BrowserEntry, siblings: Rc>, - current: usize, profile: LocationProfile, reuse_window: Option, ) { match entry.kind { - EntryKind::ZipArchive => { - self.open_archive(entry, siblings, current, profile, reuse_window) - } + EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window), EntryKind::Image | EntryKind::Video => { let media_siblings = Rc::new( siblings @@ -671,11 +683,11 @@ impl BrowserController { .cloned() .collect::>(), ); - if let Some(current) = media_siblings + if media_siblings .iter() - .position(|sibling| sibling.uri == entry.uri) + .any(|sibling| sibling.uri == entry.uri) { - self.open_media(entry, media_siblings, current, profile, reuse_window); + self.open_media(entry, media_siblings, profile, reuse_window); } } _ => {} @@ -684,39 +696,79 @@ impl BrowserController { fn sibling_navigator( self: &Rc, + entry: &BrowserEntry, siblings: Rc>, - current: usize, profile: LocationProfile, ) -> SiblingNavigator { - let controller = Rc::downgrade(self); - let count = siblings.len(); - let open = Rc::new(move |next: usize, reuse_window: gtk::ApplicationWindow| { - let Some(controller) = controller.upgrade() else { - return false; - }; - if next >= siblings.len() { - controller - .status_label - .set_text("더 이상 이동할 파일이 없습니다."); - return false; - } - controller.open_viewable( - siblings[next].clone(), - Rc::clone(&siblings), - next, - profile.clone(), - Some(reuse_window), - ); - true + let current_uri = entry.uri.clone(); + let current_kind = entry.kind; + let position_controller = Rc::downgrade(self); + let position_uri = current_uri.clone(); + let position_fallback = Rc::clone(&siblings); + let position = Rc::new(move || { + let controller = position_controller.upgrade()?; + let siblings = + controller.navigation_siblings(&position_uri, current_kind, &position_fallback); + siblings + .iter() + .position(|entry| entry.uri == position_uri) + .map(|current| (current, siblings.len())) }); - SiblingNavigator::new(current, count, open) + + let controller = Rc::downgrade(self); + let navigate = Rc::new( + move |target: SiblingTarget, reuse_window: gtk::ApplicationWindow| { + let Some(controller) = controller.upgrade() else { + return false; + }; + let siblings = + controller.navigation_siblings(¤t_uri, current_kind, &siblings); + let Some(current) = siblings.iter().position(|entry| entry.uri == current_uri) + else { + return false; + }; + let Some(next) = target.resolve(current, siblings.len()) else { + return false; + }; + controller.open_viewable( + siblings[next].clone(), + Rc::clone(&siblings), + profile.clone(), + Some(reuse_window), + ); + true + }, + ); + SiblingNavigator::new(position, navigate) + } + + fn navigation_siblings( + &self, + current_uri: &str, + current_kind: EntryKind, + fallback: &Rc>, + ) -> Rc> { + let case_insensitive = self.config.borrow().case_insensitive_sort; + let state = self.state.borrow(); + let entries = sorted_navigable_entries( + &state.entries, + current_kind, + state.sort_field, + state.sort_direction, + case_insensitive, + ); + drop(state); + if entries.iter().any(|entry| entry.uri == current_uri) { + Rc::new(entries) + } else { + Rc::clone(fallback) + } } fn open_archive( self: &Rc, entry: BrowserEntry, siblings: Rc>, - current: usize, profile: LocationProfile, reuse_window: Option, ) { @@ -727,7 +779,7 @@ impl BrowserController { ); let result_label = progress_label.clone(); let controller = Rc::clone(self); - let navigator = self.sibling_navigator(siblings, current, profile.clone()); + let navigator = self.sibling_navigator(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); let view_mode_control = self.view_mode_control(); @@ -749,6 +801,7 @@ impl BrowserController { source, Rc::clone(&controller.database), controller.config.borrow().fast_image_loading, + controller.config.borrow().case_insensitive_sort, view_mode_control, FileViewerControls { sibling_navigator: Some(navigator), @@ -771,7 +824,6 @@ impl BrowserController { self: &Rc, entry: BrowserEntry, siblings: Rc>, - current: usize, profile: LocationProfile, reuse_window: Option, ) { @@ -784,7 +836,7 @@ impl BrowserController { ); let result_label = progress_label.clone(); let controller = Rc::clone(self); - let navigator = self.sibling_navigator(siblings, current, profile.clone()); + let navigator = self.sibling_navigator(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); self.zip_cache.prepare_media( @@ -1050,6 +1102,27 @@ impl BrowserController { ); } + fn set_case_insensitive_sort(&self, check: >k::CheckButton) { + let active = check.is_active(); + let previous = self.config.borrow().case_insensitive_sort; + if active == previous { + return; + } + let mut updated = self.config.borrow().clone(); + updated.case_insensitive_sort = active; + match updated.save(&self.config_path) { + Ok(()) => { + *self.config.borrow_mut() = updated; + self.render(); + } + Err(error) => { + self.status_label + .set_text(&format!("정렬 설정을 저장할 수 없습니다: {error:#}")); + check.set_active(previous); + } + } + } + fn set_fast_image_loading(&self, switch: >k::Switch) { let active = switch.is_active(); let previous = self.config.borrow().fast_image_loading; @@ -1339,6 +1412,22 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { } } +fn sorted_navigable_entries( + entries: &[BrowserEntry], + current_kind: EntryKind, + sort_field: SortField, + sort_direction: SortDirection, + case_insensitive: bool, +) -> Vec { + let mut siblings = entries + .iter() + .filter(|entry| navigable_sibling(current_kind, entry.kind)) + .cloned() + .collect::>(); + sort_entries(&mut siblings, sort_field, sort_direction, case_insensitive); + siblings +} + fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { BrowserEntry { uri: favorite.uri.clone(), @@ -1432,6 +1521,12 @@ mod tests { } } + fn media_entry(name: &str) -> BrowserEntry { + let mut entry = entry(name); + entry.kind = EntryKind::Image; + entry + } + #[test] fn typeahead_matches_case_insensitively() { let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; @@ -1454,6 +1549,36 @@ mod tests { assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); } + #[test] + fn media_navigation_recalculates_position_for_case_sorting() { + let entries = [media_entry("a2.jpg"), media_entry("A10.jpg")]; + let insensitive = sorted_navigable_entries( + &entries, + EntryKind::Image, + SortField::Name, + SortDirection::Ascending, + true, + ); + let sensitive = sorted_navigable_entries( + &entries, + EntryKind::Image, + SortField::Name, + SortDirection::Ascending, + false, + ); + + assert_eq!(insensitive[0].name, "a2.jpg"); + assert_eq!(sensitive[0].name, "A10.jpg"); + assert_eq!( + insensitive.iter().position(|entry| entry.name == "a2.jpg"), + Some(0) + ); + assert_eq!( + sensitive.iter().position(|entry| entry.name == "a2.jpg"), + Some(1) + ); + } + #[test] fn favorites_are_rendered_as_browser_paths_and_files() { let profile = home_profile(); From 1b3783b79541f62ee5030c84adf20905703f71ec Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 03:10:57 +0900 Subject: [PATCH 07/30] Fix continuous page seeking --- src/viewer.rs | 56 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/src/viewer.rs b/src/viewer.rs index 47904e8..6e601d6 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -243,6 +243,7 @@ impl ViewerWindow { navigation_message, navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), + continuous_programmatic_scroll: Cell::new(false), state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -333,6 +334,7 @@ struct ViewerController { navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, + continuous_programmatic_scroll: Cell, state: RefCell, } @@ -948,13 +950,16 @@ impl ViewerController { } fn continuous_scrolled(self: &Rc) { - if self.view_mode() != ViewMode::Continuous || self.state.borrow().pages.is_empty() { + let pages_empty = self.state.borrow().pages.is_empty(); + if self.view_mode() != ViewMode::Continuous || pages_empty { return; } let visible = self.visible_indices(); let new_anchor = visible.first().copied(); - let changed = new_anchor.is_some_and(|index| index != self.state.borrow().anchor); - if let Some(index) = new_anchor { + let update_anchor = !self.continuous_programmatic_scroll.get(); + let changed = + update_anchor && new_anchor.is_some_and(|index| index != self.state.borrow().anchor); + if let Some(index) = new_anchor.filter(|_| update_anchor) { self.state.borrow_mut().anchor = index; } self.render_continuous(&visible); @@ -1014,16 +1019,28 @@ impl ViewerController { } fn scroll_to_anchor(&self) { - let state = self.state.borrow(); - let Some(row) = state.continuous_rows.get(state.anchor) else { - return; + let frame = { + let state = self.state.borrow(); + let Some(row) = state.continuous_rows.get(state.anchor) else { + return; + }; + row.frame.clone() }; - let Some(bounds) = row.frame.compute_bounds(&self.continuous_box) else { + let Some(bounds) = frame.compute_bounds(&self.continuous_box) else { return; }; let adjustment = self.continuous_scrolled.vadjustment(); let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); - adjustment.set_value(f64::from(bounds.y()).clamp(adjustment.lower(), maximum)); + let target = centered_scroll_value( + f64::from(bounds.y()), + f64::from(bounds.height()), + adjustment.page_size(), + adjustment.lower(), + maximum, + ); + self.continuous_programmatic_scroll.set(true); + adjustment.set_value(target); + self.continuous_programmatic_scroll.set(false); } fn clear_continuous_paintables(&self) { @@ -1629,6 +1646,16 @@ fn intersecting_rows(bounds: &[Option<(f64, f64)>], top: f64, bottom: f64) -> Ve .collect() } +fn centered_scroll_value( + row_top: f64, + row_height: f64, + viewport_height: f64, + minimum: f64, + maximum: f64, +) -> f64 { + (row_top + row_height / 2.0 - viewport_height / 2.0).clamp(minimum, maximum) +} + fn page_seek_index(value: f64, count: usize) -> Option { (count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1)) } @@ -1673,6 +1700,19 @@ mod tests { assert_eq!(intersecting_rows(&bounds, 3000.0, 3800.0), [2]); } + #[test] + fn continuous_seek_centers_the_selected_row() { + assert_eq!( + centered_scroll_value(1_000.0, 800.0, 600.0, 0.0, 5_000.0), + 1_100.0 + ); + assert_eq!(centered_scroll_value(0.0, 400.0, 800.0, 0.0, 5_000.0), 0.0); + assert_eq!( + centered_scroll_value(5_000.0, 800.0, 600.0, 0.0, 5_000.0), + 5_000.0 + ); + } + #[test] fn fast_loading_prefetches_a_larger_window() { assert_eq!(prefetch_window(&[10], 30, false), [10, 11, 12, 9]); From b13939506789ccc44d5a99fbaeaa583980071cac Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 03:17:18 +0900 Subject: [PATCH 08/30] Add application about dialog --- src/window.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/window.rs b/src/window.rs index 51159a1..effba41 100644 --- a/src/window.rs +++ b/src/window.rs @@ -49,6 +49,8 @@ impl ComicWindow { let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some("Comicviewer")))); + let about_button = gtk::Button::with_label("정보"); + header.pack_end(&about_button); window.set_titlebar(Some(&header)); let root = gtk::Box::new(gtk::Orientation::Vertical, 8); @@ -125,6 +127,7 @@ impl ComicWindow { let controller = Rc::new(BrowserController { window, + about_button, location_dropdown, edit_location_button, delete_location_button, @@ -189,6 +192,7 @@ struct BrowserState { struct BrowserController { window: gtk::ApplicationWindow, + about_button: gtk::Button, location_dropdown: gtk::DropDown, edit_location_button: gtk::Button, delete_location_button: gtk::Button, @@ -227,6 +231,9 @@ impl BrowserController { let controller = Rc::clone(self); add_location_button.connect_clicked(move |_| controller.show_location_dialog(None)); let controller = Rc::clone(self); + self.about_button + .connect_clicked(move |_| controller.show_about_dialog()); + let controller = Rc::clone(self); self.edit_location_button.connect_clicked(move |_| { if let Some(index) = controller.selected_config_index() { controller.show_location_dialog(Some(index)); @@ -1153,6 +1160,36 @@ impl BrowserController { (index < self.config.borrow().locations.len()).then_some(index) } + fn show_about_dialog(&self) { + let dialog = gtk::Window::builder() + .title("Comicviewer 정보") + .transient_for(&self.window) + .modal(true) + .resizable(false) + .default_width(360) + .build(); + let content = gtk::Box::new(gtk::Orientation::Vertical, 14); + content.set_margin_top(24); + content.set_margin_bottom(16); + content.set_margin_start(24); + content.set_margin_end(24); + let name = gtk::Label::new(Some(&format!( + "Comicviewer ({})", + env!("CARGO_PKG_VERSION") + ))); + name.add_css_class("title-2"); + let contact = gtk::Label::new(Some("개발자 연락처 : burningtuna@gmail.com")); + let close_button = gtk::Button::with_label("닫기"); + close_button.set_halign(gtk::Align::End); + let dialog_to_close = dialog.clone(); + close_button.connect_clicked(move |_| dialog_to_close.close()); + content.append(&name); + content.append(&contact); + content.append(&close_button); + dialog.set_child(Some(&content)); + 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()); From ffa8bc93bab83289b498bfb8dadea55abb4440d5 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 04:09:30 +0900 Subject: [PATCH 09/30] Improve viewer controls and navigation --- src/viewer.rs | 412 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/window.rs | 81 +++++++++- 2 files changed, 477 insertions(+), 16 deletions(-) diff --git a/src/viewer.rs b/src/viewer.rs index 6e601d6..ac33744 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -42,6 +42,17 @@ impl SiblingNavigator { (self.navigate)(SiblingTarget::Index(index), window) } + fn open_edge(&self, end: bool, window: gtk::ApplicationWindow) -> bool { + (self.navigate)( + if end { + SiblingTarget::Last + } else { + SiblingTarget::First + }, + window, + ) + } + fn position(&self) -> Option<(usize, usize)> { (self.position)() } @@ -51,6 +62,8 @@ impl SiblingNavigator { pub enum SiblingTarget { Relative(bool), Index(usize), + First, + Last, } impl SiblingTarget { @@ -58,6 +71,8 @@ impl SiblingTarget { match self { Self::Relative(forward) => sibling_index(current, count, forward), Self::Index(index) => (index < count && index != current).then_some(index), + Self::First => (count > 0 && current > 0).then_some(0), + Self::Last => count.checked_sub(1).filter(|last| current < *last), } } } @@ -81,6 +96,7 @@ pub struct FileViewerControls { pub favorite_control: FavoriteControl, pub download_control: FileDownloadControl, pub reuse_window: Option, + pub view_mode_control: Option, } impl ViewerWindow { @@ -98,6 +114,7 @@ impl ViewerWindow { favorite_control, download_control, reuse_window, + view_mode_control: _, } = controls; let title = source.display_name.clone(); let window = reuse_window.unwrap_or_else(|| { @@ -154,10 +171,12 @@ impl ViewerWindow { toolbar.append(&mode_dropdown); let single_picture = page_picture(); + let single_box = gtk::Box::new(gtk::Orientation::Horizontal, 0); + single_box.append(&single_picture); let single_scrolled = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(true) - .child(&single_picture) + .child(&single_box) .build(); let left_picture = page_picture(); let right_picture = page_picture(); @@ -212,6 +231,8 @@ impl ViewerWindow { source, database, stack, + single_scrolled, + single_box, single_picture, left_picture, right_picture, @@ -244,6 +265,7 @@ impl ViewerWindow { navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), continuous_programmatic_scroll: Cell::new(false), + view_layout_generation: Cell::new(0), state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -303,6 +325,8 @@ struct ViewerController { source: ArchiveSource, database: Rc, stack: gtk::Stack, + single_scrolled: gtk::ScrolledWindow, + single_box: gtk::Box, single_picture: gtk::Picture, left_picture: gtk::Picture, right_picture: gtk::Picture, @@ -335,6 +359,7 @@ struct ViewerController { navigation_message_label: gtk::Label, navigation_message_generation: Rc>, continuous_programmatic_scroll: Cell, + view_layout_generation: Cell, state: RefCell, } @@ -390,6 +415,16 @@ impl ViewerController { .connect_notify_local(Some("width"), move |_, _| { controller.resize_continuous_rows(); }); + let controller = Rc::clone(self); + self.single_scrolled + .connect_notify_local(Some("width"), move |_, _| { + controller.resize_single_picture(); + }); + let controller = Rc::clone(self); + self.single_picture + .connect_notify_local(Some("width"), move |_, _| { + controller.resize_single_picture(); + }); let scroll_controller = gtk::EventControllerScroll::new( gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, @@ -397,7 +432,11 @@ impl ViewerController { scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::clone(self); scroll_controller.connect_scroll(move |_, _, delta_y| { - if controller.view_mode() == ViewMode::Continuous || delta_y == 0.0 { + if matches!( + controller.view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous + ) || delta_y == 0.0 + { return glib::Propagation::Proceed; } controller.navigate(delta_y > 0.0); @@ -417,6 +456,32 @@ impl ViewerController { key_controller.connect_key_pressed(move |_, key, _, _| { controller.hide_seek(); match key { + gdk::Key::Home => { + controller.seek_edge(false); + glib::Propagation::Stop + } + gdk::Key::End => { + controller.seek_edge(true); + glib::Propagation::Stop + } + gdk::Key::Up + if matches!( + controller.view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous + ) => + { + controller.scroll_vertically(false); + glib::Propagation::Stop + } + gdk::Key::Down + if matches!( + controller.view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous + ) => + { + controller.scroll_vertically(true); + glib::Propagation::Stop + } gdk::Key::Left | gdk::Key::Page_Up => { controller.navigate(false); glib::Propagation::Stop @@ -655,15 +720,73 @@ impl ViewerController { self.single_picture.set_paintable(gdk::Paintable::NONE); self.left_picture.set_paintable(gdk::Paintable::NONE); self.right_picture.set_paintable(gdk::Paintable::NONE); - self.schedule_scroll_to_anchor(); } else { self.clear_continuous_paintables(); } let actual = mode == ViewMode::ActualSize; + self.single_box.set_hexpand(!actual); + self.single_box.set_vexpand(mode == ViewMode::FitHeight); + self.single_box.set_halign(match mode { + ViewMode::ActualSize => gtk::Align::Start, + ViewMode::FitHeight => gtk::Align::Center, + _ => gtk::Align::Fill, + }); + self.single_box.set_valign(if actual { + gtk::Align::Start + } else { + gtk::Align::Fill + }); self.single_picture.set_can_shrink(!actual); self.single_picture.set_hexpand(mode == ViewMode::FitWidth); self.single_picture.set_vexpand(mode == ViewMode::FitHeight); + if matches!(mode, ViewMode::ActualSize | ViewMode::FitWidth) { + self.single_picture.set_content_fit(gtk::ContentFit::Fill); + self.single_picture.set_valign(gtk::Align::Start); + self.single_picture + .set_halign(if mode == ViewMode::FitWidth { + gtk::Align::Fill + } else { + gtk::Align::Start + }); + } else { + self.single_picture + .set_content_fit(gtk::ContentFit::Contain); + self.single_picture.set_valign(gtk::Align::Fill); + self.single_picture + .set_halign(if mode == ViewMode::FitHeight { + gtk::Align::Center + } else { + gtk::Align::Fill + }); + self.single_picture.set_size_request(-1, -1); + } self.render(); + self.schedule_view_mode_refresh(); + } + + fn schedule_view_mode_refresh(self: &Rc) { + let generation = self.view_layout_generation.get().wrapping_add(1); + self.view_layout_generation.set(generation); + self.stack.queue_allocate(); + self.window.queue_allocate(); + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + let Some(controller) = controller.upgrade() else { + return; + }; + if controller.view_layout_generation.get() != generation { + return; + } + match controller.view_mode() { + ViewMode::FitWidth => controller.resize_single_picture(), + ViewMode::Continuous => controller.resize_continuous_rows(), + _ => {} + } + controller.render(); + if controller.view_mode() == ViewMode::Continuous { + controller.schedule_scroll_to_anchor(); + } + }); } fn navigate(self: &Rc, forward: bool) { @@ -686,12 +809,33 @@ impl ViewerController { } state.anchor = next; drop(state); + self.reset_single_scroll(); self.render(); if self.view_mode() == ViewMode::Continuous { self.schedule_scroll_to_anchor(); } } + fn seek_edge(self: &Rc, end: bool) { + let count = self.state.borrow().pages.len(); + if count == 0 { + return; + } + self.seek_page(if end { count - 1 } else { 0 }); + } + + fn scroll_vertically(&self, down: bool) { + let adjustment = if self.view_mode() == ViewMode::Continuous { + self.continuous_scrolled.vadjustment() + } else { + self.single_scrolled.vadjustment() + }; + let step = adjustment.step_increment().max(64.0); + let delta = if down { step } else { -step }; + let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); + adjustment.set_value((adjustment.value() + delta).clamp(adjustment.lower(), maximum)); + } + fn shift_pairing(self: &Rc, offset: isize) { let mut state = self.state.borrow_mut(); if state.pages.is_empty() { @@ -716,6 +860,7 @@ impl ViewerController { } state.anchor = index; drop(state); + self.reset_single_scroll(); self.render(); if self.view_mode() == ViewMode::Continuous { self.schedule_scroll_to_anchor(); @@ -893,6 +1038,35 @@ impl ViewerController { self.single_picture .set_paintable(state.cache.get(&index).map(|page| &page.texture)); self.status_label.set_text(&state.pages[index].name); + drop(state); + self.resize_single_picture(); + } + + fn resize_single_picture(&self) { + let dimensions = { + let state = self.state.borrow(); + state + .cache + .get(&state.anchor) + .map(|page| (page.texture.width(), page.texture.height())) + }; + if let Some((texture_width, texture_height)) = dimensions + && let Some((width, height)) = single_picture_request( + self.view_mode(), + self.stack.allocated_width().max(1), + texture_width, + texture_height, + ) + { + self.single_picture.set_size_request(width, height); + } + } + + fn reset_single_scroll(&self) { + if matches!(self.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth) { + let adjustment = self.single_scrolled.vadjustment(); + adjustment.set_value(adjustment.lower()); + } } fn render_spread(&self, indices: &[usize]) { @@ -1071,7 +1245,7 @@ impl ViewerController { } fn continuous_content_width(&self) -> i32 { - (self.continuous_scrolled.allocated_width() - 24).max(320) + continuous_content_width(self.stack.allocated_width()) } fn evict_cache(&self) { @@ -1155,6 +1329,7 @@ impl MediaViewerWindow { favorite_control, download_control, reuse_window, + view_mode_control, } = controls; let title = source.display_name.clone(); let window = reuse_window.unwrap_or_else(|| { @@ -1170,6 +1345,20 @@ impl MediaViewerWindow { 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 loop_check = gtk::CheckButton::with_label("반복 재생"); + loop_check.set_visible(kind == EntryKind::Video); + let image_mode_dropdown = gtk::DropDown::from_strings(&["100%", "폭 맞춤", "높이 맞춤"]); + let (image_mode_selected, image_mode_change) = + view_mode_control.map_or((1, None), |control| { + ( + standalone_mode_index(control.selected), + Some(control.change), + ) + }); + image_mode_dropdown.set_selected(image_mode_selected); + image_mode_dropdown.set_visible(kind == EntryKind::Image); + header.pack_start(&image_mode_dropdown); + header.pack_end(&loop_check); header.pack_end(&download_button); header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); @@ -1179,7 +1368,14 @@ impl MediaViewerWindow { stack.set_hexpand(true); stack.set_vexpand(true); let picture = page_picture(); - stack.add_named(&picture, Some("image")); + let image_box = gtk::Box::new(gtk::Orientation::Horizontal, 0); + image_box.append(&picture); + let image_scrolled = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .child(&image_box) + .build(); + stack.add_named(&image_scrolled, Some("image")); let video = if kind == EntryKind::Video { gtk::Video::for_filename(Some(&source.local_path)) } else { @@ -1188,6 +1384,8 @@ impl MediaViewerWindow { video.set_autoplay(true); video.set_hexpand(true); video.set_vexpand(true); + let video_for_loop = video.clone(); + loop_check.connect_toggled(move |check| video_for_loop.set_loop(check.is_active())); stack.add_named(&video, Some("video")); let status_label = gtk::Label::builder() .label(&title) @@ -1221,7 +1419,13 @@ impl MediaViewerWindow { let controller = Rc::new(MediaViewerController { window, source, + kind, picture, + image_box, + image_scrolled, + image_mode_dropdown, + image_mode_change, + image_dimensions: Cell::new(None), video, stack, status_label, @@ -1251,6 +1455,7 @@ impl MediaViewerWindow { }); } controller.connect_signals(); + controller.apply_image_view_mode(); controller.window.present(); match kind { EntryKind::Image => controller.load_image(), @@ -1268,7 +1473,13 @@ impl MediaViewerWindow { struct MediaViewerController { window: gtk::ApplicationWindow, source: ArchiveSource, + kind: EntryKind, picture: gtk::Picture, + image_box: gtk::Box, + image_scrolled: gtk::ScrolledWindow, + image_mode_dropdown: gtk::DropDown, + image_mode_change: Option>, + image_dimensions: Cell>, video: gtk::Video, stack: gtk::Stack, status_label: gtk::Label, @@ -1303,20 +1514,51 @@ impl MediaViewerController { let controller = Rc::clone(self); keys.connect_key_pressed(move |_, key, _, _| { controller.hide_seek(); - match key.to_unicode() { - Some('[') => { + match key { + gdk::Key::Home => { + controller.open_edge(false); + glib::Propagation::Stop + } + gdk::Key::End => { + controller.open_edge(true); + glib::Propagation::Stop + } + gdk::Key::Up + if controller.kind == EntryKind::Image + && matches!( + controller.image_view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth + ) => + { + controller.scroll_image(false); + glib::Propagation::Stop + } + gdk::Key::Down + if controller.kind == EntryKind::Image + && matches!( + controller.image_view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth + ) => + { + controller.scroll_image(true); + glib::Propagation::Stop + } + _ if key.to_unicode() == Some('[') => { controller.open_sibling(false); glib::Propagation::Stop } - Some(']') => { + _ if key.to_unicode() == Some(']') => { controller.open_sibling(true); glib::Propagation::Stop } - Some(character) if character.eq_ignore_ascii_case(&'f') => { + _ if key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => + { controller.toggle_fullscreen(); glib::Propagation::Stop } - _ if key == gdk::Key::Escape && controller.window.is_fullscreen() => { + gdk::Key::Escape if controller.window.is_fullscreen() => { controller.window.unfullscreen(); glib::Propagation::Stop } @@ -1337,6 +1579,19 @@ impl MediaViewerController { controller.status_label.clone(), ); }); + if let Some(change) = self.image_mode_change.clone() { + let controller = Rc::clone(self); + self.image_mode_dropdown + .connect_selected_notify(move |dropdown| { + change(dropdown.selected()); + controller.apply_image_view_mode(); + }); + } + let controller = Rc::clone(self); + self.image_scrolled + .connect_notify_local(Some("width"), move |_, _| { + controller.resize_image_picture(); + }); let controller = Rc::clone(self); self.media_seek.connect_value_changed(move |scale| { let Some(navigator) = &controller.sibling_navigator else { @@ -1352,6 +1607,79 @@ impl MediaViewerController { }); } + fn image_view_mode(&self) -> ViewMode { + match self.image_mode_dropdown.selected() { + 0 => ViewMode::ActualSize, + 2 => ViewMode::FitHeight, + _ => ViewMode::FitWidth, + } + } + + fn apply_image_view_mode(&self) { + if self.kind != EntryKind::Image { + return; + } + let mode = self.image_view_mode(); + let actual = mode == ViewMode::ActualSize; + self.image_box.set_hexpand(!actual); + self.image_box.set_vexpand(mode == ViewMode::FitHeight); + self.image_box.set_halign(match mode { + ViewMode::ActualSize => gtk::Align::Start, + ViewMode::FitHeight => gtk::Align::Center, + _ => gtk::Align::Fill, + }); + self.image_box.set_valign(if actual { + gtk::Align::Start + } else { + gtk::Align::Fill + }); + self.picture.set_can_shrink(!actual); + self.picture.set_hexpand(mode == ViewMode::FitWidth); + self.picture.set_vexpand(mode == ViewMode::FitHeight); + if matches!(mode, ViewMode::ActualSize | ViewMode::FitWidth) { + self.picture.set_content_fit(gtk::ContentFit::Fill); + self.picture.set_halign(if actual { + gtk::Align::Start + } else { + gtk::Align::Fill + }); + self.picture.set_valign(gtk::Align::Start); + } else { + self.picture.set_content_fit(gtk::ContentFit::Contain); + self.picture.set_halign(if mode == ViewMode::FitHeight { + gtk::Align::Center + } else { + gtk::Align::Fill + }); + self.picture.set_valign(gtk::Align::Fill); + self.picture.set_size_request(-1, -1); + } + self.resize_image_picture(); + self.stack.queue_allocate(); + } + + fn resize_image_picture(&self) { + let Some((texture_width, texture_height)) = self.image_dimensions.get() else { + return; + }; + if let Some((width, height)) = single_picture_request( + self.image_view_mode(), + self.stack.allocated_width().max(1), + texture_width, + texture_height, + ) { + self.picture.set_size_request(width, height); + } + } + + fn scroll_image(&self, down: bool) { + let adjustment = self.image_scrolled.vadjustment(); + let step = adjustment.step_increment().max(64.0); + let delta = if down { step } else { -step }; + let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); + adjustment.set_value((adjustment.value() + delta).clamp(adjustment.lower(), maximum)); + } + fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator && navigate.open_relative(forward, self.window.clone()) @@ -1362,6 +1690,16 @@ impl MediaViewerController { } } + fn open_edge(&self, end: bool) { + if let Some(navigate) = &self.sibling_navigator + && navigate.open_edge(end, self.window.clone()) + { + self.prepare_for_navigation(); + } else { + self.show_navigation_error(end); + } + } + fn show_navigation_error(&self, forward: bool) { self.navigation_message_label .set_text(navigation_error_message(forward)); @@ -1513,6 +1851,8 @@ impl MediaViewerController { fn finish_image_load(&self, result: Result) { match result { 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, @@ -1522,6 +1862,7 @@ impl MediaViewerController { page.width as usize * 4, ); self.picture.set_paintable(Some(&texture)); + self.apply_image_view_mode(); self.status_label.set_text(&self.source.display_name); } Err(error) => self.status_label.set_text(&error), @@ -1537,6 +1878,13 @@ fn favorite_label(active: bool) -> &'static str { } } +fn standalone_mode_index(global_index: u32) -> u32 { + match global_index { + 0 | 2 => global_index, + _ => 1, + } +} + fn page_picture() -> gtk::Picture { let picture = gtk::Picture::new(); picture.set_content_fit(gtk::ContentFit::Contain); @@ -1613,6 +1961,26 @@ fn row_height(width: i32, ratio: f32) -> i32 { ((width as f32 / ratio).round() as i32).clamp(1, 30_000) } +fn single_picture_request( + mode: ViewMode, + viewport_width: i32, + texture_width: i32, + texture_height: i32, +) -> Option<(i32, i32)> { + match mode { + ViewMode::ActualSize => Some((texture_width, texture_height)), + ViewMode::FitWidth => Some(( + -1, + row_height(viewport_width, texture_width as f32 / texture_height as f32), + )), + _ => None, + } +} + +fn continuous_content_width(stack_width: i32) -> i32 { + (stack_width - 24).max(320) +} + fn prefetch_window(visible: &[usize], count: usize, fast: bool) -> Vec { if visible.is_empty() || count == 0 { return Vec::new(); @@ -1687,6 +2055,25 @@ mod tests { fn continuous_row_height_preserves_image_ratio() { assert_eq!(row_height(1200, 2.0 / 3.0), 1800); assert_eq!(row_height(1200, 1.5), 800); + assert_eq!(continuous_content_width(1200), 1176); + assert_eq!(continuous_content_width(0), 320); + assert_eq!( + single_picture_request(ViewMode::ActualSize, 1200, 800, 1600), + Some((800, 1600)) + ); + assert_eq!( + single_picture_request(ViewMode::FitWidth, 1200, 800, 1600), + Some((-1, 2400)) + ); + assert_eq!( + single_picture_request(ViewMode::FitHeight, 1200, 800, 1600), + None + ); + assert_eq!(standalone_mode_index(0), 0); + assert_eq!(standalone_mode_index(1), 1); + assert_eq!(standalone_mode_index(2), 2); + assert_eq!(standalone_mode_index(3), 1); + assert_eq!(standalone_mode_index(4), 1); } #[test] @@ -1745,6 +2132,11 @@ mod tests { assert_eq!(sibling_index(1, 3, true), Some(2)); assert_eq!(sibling_index(0, 3, false), None); assert_eq!(sibling_index(2, 3, true), None); + assert_eq!(SiblingTarget::First.resolve(2, 5), Some(0)); + assert_eq!(SiblingTarget::Last.resolve(1, 5), Some(4)); + assert_eq!(SiblingTarget::First.resolve(0, 5), None); + assert_eq!(SiblingTarget::Last.resolve(4, 5), None); + assert_eq!(SiblingTarget::Last.resolve(0, 0), None); } #[test] diff --git a/src/window.rs b/src/window.rs index effba41..476bfcf 100644 --- a/src/window.rs +++ b/src/window.rs @@ -248,6 +248,44 @@ impl BrowserController { let controller = Rc::clone(self); self.location_dropdown .connect_selected_notify(move |_| controller.select_profile()); + let location_factory = gtk::SignalListItemFactory::new(); + let controller = Rc::downgrade(self); + location_factory.connect_setup(move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let label = gtk::Label::builder().xalign(0.0).build(); + let click = gtk::GestureClick::builder().button(1).build(); + item.set_child(Some(&label)); + let item = item.downgrade(); + let controller = controller.clone(); + click.connect_released(move |_, _, _, _| { + let Some(item) = item.upgrade() else { + return; + }; + let Some(controller) = controller.upgrade() else { + return; + }; + if is_favorites_selection(item.position(), controller.profiles().len()) { + controller.show_favorites(); + } + }); + label.add_controller(click); + }); + location_factory.connect_bind(|_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let Some(label) = item.child().and_downcast::() else { + return; + }; + let Some(value) = item.item().and_downcast::() else { + return; + }; + label.set_text(value.string().as_str()); + }); + self.location_dropdown + .set_list_factory(Some(&location_factory)); let controller = Rc::clone(self); self.search_entry .connect_search_changed(move |_| controller.render()); @@ -274,8 +312,14 @@ impl BrowserController { let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); - key_controller - .connect_key_pressed(move |_, key, _, modifiers| controller.typeahead(key, modifiers)); + key_controller.connect_key_pressed(move |_, key, _, modifiers| { + if key == gtk::gdk::Key::BackSpace { + controller.go_up(); + glib::Propagation::Stop + } else { + controller.typeahead(key, modifiers) + } + }); self.list_box.add_controller(key_controller); let controller = Rc::clone(self); @@ -315,7 +359,7 @@ impl BrowserController { fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; let profiles = self.profiles(); - if profile_index == profiles.len() { + if is_favorites_selection(self.location_dropdown.selected(), profiles.len()) { self.show_favorites(); return; } @@ -348,7 +392,6 @@ impl BrowserController { self.delete_location_button.set_sensitive(false); self.path_entry.set_text("즐겨찾기"); self.path_entry.set_editable(false); - self.search_entry.set_text(""); let entries = self .config .borrow() @@ -357,10 +400,14 @@ impl BrowserController { .map(favorite_browser_entry) .collect(); let mut state = self.state.borrow_mut(); + state.generation = state.generation.wrapping_add(1); + state.typeahead_query.clear(); + state.typeahead_last_input = None; state.browsing_favorites = true; state.favorite_profile = None; state.entries = entries; drop(state); + self.search_entry.set_text(""); self.render(); } @@ -500,7 +547,10 @@ impl BrowserController { let Some(controller) = controller.upgrade() else { return; }; - if controller.state.borrow().generation != generation { + let state = controller.state.borrow(); + let stale = state.generation != generation || state.browsing_favorites; + drop(state); + if stale { return; } match result { @@ -815,6 +865,7 @@ impl BrowserController { favorite_control, download_control, reuse_window, + view_mode_control: None, }, ); } @@ -846,6 +897,7 @@ impl BrowserController { let navigator = self.sibling_navigator(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); + let view_mode_control = (kind == EntryKind::Image).then(|| self.view_mode_control()); self.zip_cache.prepare_media( entry, move |current, total| { @@ -868,6 +920,7 @@ impl BrowserController { favorite_control, download_control, reuse_window, + view_mode_control, }, ); } @@ -1166,7 +1219,7 @@ impl BrowserController { .transient_for(&self.window) .modal(true) .resizable(false) - .default_width(360) + .default_width(520) .build(); let content = gtk::Box::new(gtk::Orientation::Vertical, 14); content.set_margin_top(24); @@ -1179,12 +1232,21 @@ impl BrowserController { ))); name.add_css_class("title-2"); let contact = gtk::Label::new(Some("개발자 연락처 : burningtuna@gmail.com")); + let libraries = gtk::Label::new(Some( + "사용한 라이브러리\n\ + GTK4 / GIO · GStreamer · image · zip · rusqlite / SQLite\n\ + serde · toml · anyhow · chrono · filetime · percent-encoding\n\ + sha2 · tempfile", + )); + libraries.set_xalign(0.0); + libraries.set_wrap(true); let close_button = gtk::Button::with_label("닫기"); close_button.set_halign(gtk::Align::End); let dialog_to_close = dialog.clone(); close_button.connect_clicked(move |_| dialog_to_close.close()); content.append(&name); content.append(&contact); + content.append(&libraries); content.append(&close_button); dialog.set_child(Some(&content)); dialog.present(); @@ -1449,6 +1511,10 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { } } +fn is_favorites_selection(selected: u32, profile_count: usize) -> bool { + selected != gtk::INVALID_LIST_POSITION && selected as usize == profile_count +} + fn sorted_navigable_entries( entries: &[BrowserEntry], current_kind: EntryKind, @@ -1634,6 +1700,9 @@ mod tests { assert_eq!(folder.kind, EntryKind::Directory); assert_eq!(archive.kind, EntryKind::ZipArchive); + assert!(is_favorites_selection(3, 3)); + assert!(!is_favorites_selection(2, 3)); + assert!(!is_favorites_selection(gtk::INVALID_LIST_POSITION, 3)); } #[test] From 048df6355e16f7c36c90a3511f431bfb4fb386c3 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 14:42:23 +0900 Subject: [PATCH 10/30] 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); + } } From b31acd3f582df88430fac80bc670f560e525f6b8 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 17:34:37 +0900 Subject: [PATCH 11/30] Release v0.2.2 --- AGENTS.md | 50 +++- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 2 + docs/RELEASING.ko.md | 8 +- src/viewer.rs | 30 +++ src/window.rs | 230 +++++++++++++++++- 8 files changed, 309 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64ce761..9d88581 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ ## Project Overview Comicviewer is a desktop comic archive viewer for CachyOS and Arch Linux. -It browses local, SMB, FTP, and SFTP locations and opens image files stored in -ZIP archives. +It browses local, SMB, FTP, and SFTP locations and opens ZIP archives, +standalone images, and videos. ## Technology @@ -20,10 +20,20 @@ ZIP archives. - Store credentials as plain text in the application configuration as requested, but restrict the configuration file permissions to `0600`. - Cache remote directory metadata and refresh it asynchronously when browsing. +- Cancel a superseded remote directory request so only the latest request can + consume resources, update progress, or replace browser entries. - Download remote ZIP archives before opening them. - Limit the persistent ZIP cache with a user-configurable LRU quota. The default is 10 GB. A quota of 0 disables persistent ZIP caching and uses temporary files. -- Support JPEG, PNG, WebP, static GIF, BMP, and AVIF images in ZIP archives. +- Evict the oldest unpinned ZIP cache files before a download needs space, at + startup, and after an open archive releases its cache pin. Allow users to clear + cached ZIPs without deleting open archives or reading history. +- Keep temporary remote media in the application cache directory, remove it when + its viewer closes, and clean stale temporary downloads on the next startup. +- Support JPEG, PNG, WebP, GIF, BMP, and AVIF images both standalone and in ZIP + archives. Play animated GIF and WebP frames with their delays and loop counts. +- Support common standalone video formats through GStreamer. In video playback, + Left/Right seek by five seconds and Up/Down adjust volume by five percent. - Sort archive images and browser entries using natural filename ordering. - Support ascending and descending sorting by name, modification time, and creation time. Treat unavailable creation times as unknown rather than @@ -33,6 +43,17 @@ ZIP archives. - Provide 100%, fit-width, fit-height, two-page, and continuous vertical viewing. - Support both right-to-left and left-to-right two-page layouts, plus controls that advance by one page to adjust page pairing. +- Reuse viewer windows when moving between ZIP, image, and video files. Preserve + fullscreen state and keep the registered GTK titlebar widget stable while the + displayed header changes. +- Provide a toggleable file list on the right side of each viewer. Double-click + or Enter opens the selected viewable file; its context menu provides favorite + and download actions. +- Accept one local file or folder dropped onto the main window or a viewer. + Browse dropped folders in the main window; for dropped files, browse the + parent folder and open the exact naturally sorted item in a new viewer window. +- Save favorites for local and remote folders and files, and preserve the saved + location profile needed to reconnect to remote favorites. - Save progress per ZIP and save the most recently read ZIP for resume support. ## Data Locations @@ -50,11 +71,23 @@ Do not write application state into the source tree. - Prefer the smallest correct implementation and avoid speculative abstractions. - Keep network and archive work off the GTK main thread. - Show cached directory data immediately, then update it in the background. +- Abort superseded GIO futures and guard asynchronous progress and completion + callbacks against stale generations or closed/reused windows. +- Handle file drops as `COPY` actions through `GdkFileList`. Inspect local paths + asynchronously, reject ambiguous/multiple drops, and never reuse the source + viewer window for a dropped file. - Use atomic writes for configuration, database migrations, and completed remote downloads. Incomplete downloads must use a distinct temporary suffix. +- Reserve persistent cache capacity before writing a known-size remote ZIP. + Cache cleanup must skip active temporary downloads and pinned/open archives. - Do not extract an entire archive to disk. Decode only requested images and prefetch a small window around the current page. -- Validate archive entry sizes and image dimensions before allocation. +- Validate archive entry sizes, image dimensions, animation frame counts, and + cumulative decoded animation size before retaining decoded data. +- Cancel animation timers when pictures are hidden, viewers are replaced, or + windows close. GTK/GDK objects must only be created on the GTK main thread. +- Avoid strong `Rc` cycles between GTK signal handlers and controllers. Viewer + teardown must release temporary files, ZIP cache pins, textures, and timers. - Never trust archive paths or use them as extraction destinations. - Preserve errors from GIO, ZIP parsing, and image decoding and present concise, actionable messages in the UI. @@ -74,8 +107,13 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --all-features ``` -Add focused tests for natural sorting, cache eviction, metadata refresh, -archive filtering, reading progress, and remote file change detection. +Add focused tests for natural sorting, oldest-first cache eviction, pin and +temporary-file protection, metadata refresh, archive filtering, animated image +decoding, video seek/volume calculations, viewer sibling navigation, reading +progress, dropped-file sorting/index selection, and remote file change +detection. For UI or packaging changes, also run a GTK startup smoke test with +isolated XDG directories; release builds must verify the AppImage and downloaded +release bundle checksums. ## Git Practices diff --git a/Cargo.lock b/Cargo.lock index 8696120..43d3758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 72c8f1c..ee19cc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.1" +version = "0.2.2" 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 84e954c..7feae22 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 a116d4c..1094e7f 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -92,5 +92,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - `파일 목록`: 우측 폴더 목록 표시 또는 숨기기 - 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기 - 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드 +- 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기 +- 메인 창이나 뷰어에 로컬 폴더 드롭: 메인 창에서 해당 폴더 열기 애니메이션 GIF와 WebP는 standalone 이미지와 ZIP 내부 이미지에서 재생됩니다. diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index 8f8694b..0225d74 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.1-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.2-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.1 release" -git tag -a v0.2.1 -m "Comicviewer v0.2.1" +git commit -m "Prepare v0.2.2 release" +git tag -a v0.2.2 -m "Comicviewer v0.2.2" git push origin main -git push origin v0.2.1 +git push origin v0.2.2 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 883ab74..40f30e9 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -182,10 +182,36 @@ pub struct FileViewerControls { pub sibling_navigator: Option, pub favorite_control: FavoriteControl, pub download_control: FileDownloadControl, + pub file_drop_control: FileDropControl, pub reuse_window: Option, pub view_mode_control: Option, } +#[derive(Clone)] +pub struct FileDropControl { + pub handle: Rc, gtk::Window)>, +} + +pub(crate) fn install_file_drop_target(window: >k::ApplicationWindow, control: FileDropControl) { + if window.has_css_class("comicviewer-file-drop-target") { + return; + } + window.add_css_class("comicviewer-file-drop-target"); + let target = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::COPY); + let weak_window = window.downgrade(); + target.connect_drop(move |_, value, _, _| { + let Ok(file_list) = value.get::() else { + return false; + }; + let Some(window) = weak_window.upgrade() else { + return false; + }; + (control.handle)(file_list.files(), window.upcast()); + true + }); + window.add_controller(target); +} + impl ViewerWindow { pub fn open( application: >k::Application, @@ -200,6 +226,7 @@ impl ViewerWindow { sibling_navigator, favorite_control, download_control, + file_drop_control, reuse_window, view_mode_control: _, } = controls; @@ -215,6 +242,7 @@ impl ViewerWindow { .default_height(850) .build() }); + install_file_drop_target(&window, file_drop_control); ensure_viewer_close_handler(&window); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); @@ -1681,6 +1709,7 @@ impl MediaViewerWindow { sibling_navigator, favorite_control, download_control, + file_drop_control, reuse_window, view_mode_control, } = controls; @@ -1696,6 +1725,7 @@ impl MediaViewerWindow { .default_height(850) .build() }); + install_file_drop_target(&window, file_drop_control); ensure_viewer_close_handler(&window); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); diff --git a/src/window.rs b/src/window.rs index e60c6d3..593370e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; use std::time::{Duration, Instant}; -use anyhow::Result; +use anyhow::{Context, Result, bail}; use chrono::{DateTime, Local}; use gtk::gio; use gtk::gio::prelude::*; @@ -21,9 +21,9 @@ use crate::source::{ mount_operation, parent_within_root, root_file, validate_profile, }; use crate::viewer::{ - FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingFile, - SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow, - set_viewer_header, + FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow, + SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, + ViewerWindow, install_file_drop_target, set_viewer_header, }; use crate::zip_cache::ZipCache; @@ -171,6 +171,7 @@ impl ComicWindow { case_insensitive_check, fast_loading_switch, ); + install_file_drop_target(&controller.window, controller.file_drop_control()); controller.refresh(); Ok(Self { controller }) } @@ -956,6 +957,7 @@ impl BrowserController { sibling_navigator: Some(navigator), favorite_control, download_control, + file_drop_control: controller.file_drop_control(), reuse_window, view_mode_control: None, }, @@ -1019,6 +1021,7 @@ impl BrowserController { sibling_navigator: Some(navigator), favorite_control, download_control, + file_drop_control: controller.file_drop_control(), reuse_window, view_mode_control, }, @@ -1147,6 +1150,140 @@ impl BrowserController { } } + fn file_drop_control(self: &Rc) -> FileDropControl { + let controller = Rc::downgrade(self); + FileDropControl { + handle: Rc::new(move |files, parent| { + if let Some(controller) = controller.upgrade() { + controller.open_dropped_files(files, parent); + } + }), + } + } + + fn open_dropped_files(self: &Rc, files: Vec, parent: gtk::Window) { + let [file] = files.as_slice() else { + show_drop_error(&parent, "한 번에 하나의 파일 또는 폴더만 열 수 있습니다."); + return; + }; + if !file.is_native() { + show_drop_error(&parent, "로컬 파일 또는 폴더만 드래그해서 열 수 있습니다."); + return; + } + let file = file.clone(); + self.cancel_listing(); + let generation = { + let mut state = self.state.borrow_mut(); + state.generation = state.generation.wrapping_add(1); + state.generation + }; + self.status_label.set_text("드롭한 항목을 확인하는 중..."); + let controller = Rc::downgrade(self); + let task = glib::MainContext::default().spawn_local(async move { + let result: Result<_> = async { + let info = file + .query_info_future( + "standard::type,standard::display-name", + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await + .context("드롭한 항목을 확인할 수 없습니다")?; + let selected = match info.file_type() { + gio::FileType::Directory => None, + gio::FileType::Regular => { + if !classify_file(Path::new(info.display_name().as_str())).is_viewable() { + bail!("지원하지 않는 파일 형식입니다: {}", info.display_name()); + } + Some(file.clone()) + } + _ => bail!("일반 파일 또는 폴더만 열 수 있습니다"), + }; + let directory = selected + .as_ref() + .and_then(gio::File::parent) + .unwrap_or_else(|| file.clone()); + let profile = local_profile_for_directory(&directory)?; + let operation = mount_operation(&profile, &parent); + let entries = + enumerate_directory(&profile, directory.uri().as_str(), &operation, |_| {}) + .await?; + Ok((directory, profile, entries, selected)) + } + .await; + + let Some(controller) = controller.upgrade() else { + return; + }; + if controller.state.borrow().generation != generation || !parent.is_visible() { + return; + } + let (directory, profile, entries, selected) = match result { + Ok(result) => result, + Err(error) => { + let message = format!("드롭한 항목을 열 수 없습니다: {error:#}"); + controller.status_label.set_text(&message); + show_drop_error(&parent, &message); + return; + } + }; + controller.show_dropped_directory(&directory, profile.clone(), entries.clone()); + let Some(selected) = selected else { + return; + }; + let Some(entry) = entries + .iter() + .find(|entry| gio::File::for_uri(&entry.uri).equal(&selected)) + .cloned() + else { + show_drop_error(&parent, "드롭한 파일을 부모 폴더에서 찾을 수 없습니다."); + return; + }; + let state = controller.state.borrow(); + let Some((siblings, _selected_index)) = sorted_navigable_selection( + &entries, + &entry.uri, + state.sort_field, + state.sort_direction, + controller.config.borrow().case_insensitive_sort, + ) else { + drop(state); + show_drop_error(&parent, "드롭한 파일의 목록 위치를 확인할 수 없습니다."); + return; + }; + drop(state); + controller.open_viewable(entry, Rc::new(siblings), profile, None); + }); + self.listing_task.replace(Some(task)); + } + + fn show_dropped_directory( + &self, + directory: &gio::File, + profile: LocationProfile, + entries: Vec, + ) { + self.location_dropdown + .set_selected(gtk::INVALID_LIST_POSITION); + self.edit_location_button.set_sensitive(false); + self.delete_location_button.set_sensitive(false); + self.path_entry.set_editable(true); + let uri: String = directory.uri().into(); + let mut state = self.state.borrow_mut(); + state.favorite_profile = Some(profile); + state.browsing_favorites = false; + state.root_uri = uri.clone(); + state.current_uri = uri.clone(); + state.entries = entries; + state.typeahead_query.clear(); + state.typeahead_last_input = None; + drop(state); + self.path_entry.set_text(&display_uri(&uri)); + self.search_entry.set_text(""); + self.render(); + self.window.present(); + } + fn view_mode_control(self: &Rc) -> ViewModeControl { let selected = self.config.borrow().viewer_mode.dropdown_index(); let controller = Rc::downgrade(self); @@ -1599,6 +1736,37 @@ fn home_profile() -> LocationProfile { } } +fn local_profile_for_directory(directory: &gio::File) -> Result { + let path = directory + .path() + .context("로컬 폴더 경로를 확인할 수 없습니다")?; + let address = path + .to_str() + .context("UTF-8로 표현할 수 없는 로컬 경로는 열 수 없습니다")?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(address) + .to_owned(); + Ok(LocationProfile { + name, + kind: LocationKind::Local, + address: address.to_owned(), + username: String::new(), + password: String::new(), + domain: String::new(), + }) +} + +fn show_drop_error(parent: >k::Window, message: &str) { + gtk::AlertDialog::builder() + .modal(true) + .message(message) + .build() + .show(Some(parent)); +} + fn display_uri(uri: &str) -> String { let file = gio::File::for_uri(uri); file.path().map_or_else( @@ -1682,6 +1850,24 @@ fn sorted_navigable_entries( siblings } +fn sorted_navigable_selection( + entries: &[BrowserEntry], + selected_uri: &str, + sort_field: SortField, + sort_direction: SortDirection, + case_insensitive: bool, +) -> Option<(Vec, usize)> { + let entries = sorted_navigable_entries( + entries, + EntryKind::Image, + sort_field, + sort_direction, + case_insensitive, + ); + let selected = entries.iter().position(|entry| entry.uri == selected_uri)?; + Some((entries, selected)) +} + fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { BrowserEntry { uri: favorite.uri.clone(), @@ -1835,6 +2021,42 @@ mod tests { ); } + #[test] + fn dropped_file_selection_uses_sorted_parent_index() { + let entries = [ + media_entry("page10.jpg"), + entry("folder"), + media_entry("page2.jpg"), + ]; + let selected_uri = entries[0].uri.clone(); + + let (siblings, selected) = sorted_navigable_selection( + &entries, + &selected_uri, + SortField::Name, + SortDirection::Ascending, + true, + ) + .unwrap(); + + assert_eq!(siblings.len(), 2); + assert_eq!(siblings[0].name, "page2.jpg"); + assert_eq!(siblings[selected].name, "page10.jpg"); + } + + #[test] + fn dropped_directory_creates_local_root_profile() { + let directory = tempfile::tempdir().unwrap(); + let file = gio::File::for_path(directory.path()); + + let profile = local_profile_for_directory(&file).unwrap(); + + assert_eq!(profile.kind, LocationKind::Local); + assert_eq!(Path::new(&profile.address), directory.path()); + assert!(profile.username.is_empty()); + assert!(profile.password.is_empty()); + } + #[test] fn favorites_are_rendered_as_browser_paths_and_files() { let profile = home_profile(); From 86f3185c593385c872d7b1929ec92a64ca44aaab Mon Sep 17 00:00:00 2001 From: burnintuna Date: Thu, 23 Jul 2026 20:09:11 +0900 Subject: [PATCH 12/30] Release v0.2.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../io.github.comicviewer.Comicviewer.desktop | 4 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 +- packaging/install-or-update.sh | 2 +- src/browser.rs | 45 +- src/database.rs | 168 +++++++ src/main.rs | 88 +++- src/source.rs | 197 +++++++++ src/viewer.rs | 413 +++++++++++++++++- src/window.rs | 392 +++++++++++++++-- src/zip_cache.rs | 1 + 13 files changed, 1255 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43d3758..c37f0fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.2" +version = "0.2.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index ee19cc1..a779210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.2" +version = "0.2.3" 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.desktop b/data/io.github.comicviewer.Comicviewer.desktop index 7578ae7..ee70b54 100644 --- a/data/io.github.comicviewer.Comicviewer.desktop +++ b/data/io.github.comicviewer.Comicviewer.desktop @@ -4,9 +4,9 @@ Name=Comicviewer Name[ko]=코믹뷰어 Comment=Read comic images stored in ZIP archives Comment[ko]=ZIP으로 압축된 만화 이미지를 봅니다 -Exec=comicviewer +Exec=comicviewer %f Icon=io.github.comicviewer.Comicviewer Terminal=false Categories=Graphics;Viewer; -MimeType=application/zip;application/vnd.comicbook+zip; +MimeType=application/zip;image/jpeg;image/png;image/webp;image/gif;image/bmp;image/avif;video/mp4;video/x-m4v;video/matroska;video/x-matroska;video/webm;video/quicktime;video/vnd.avi;video/x-msvideo;video/mpeg;video/ogg;inode/directory; StartupNotify=true diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml index 7feae22..63dad52 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 0225d74..fc4f5b0 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.2-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.3-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.2 release" -git tag -a v0.2.2 -m "Comicviewer v0.2.2" +git commit -m "Release v0.2.3" +git tag -a v0.2.3 -m "Comicviewer v0.2.3" git push origin main -git push origin v0.2.2 +git push origin v0.2.3 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/packaging/install-or-update.sh b/packaging/install-or-update.sh index d650193..df1d294 100755 --- a/packaging/install-or-update.sh +++ b/packaging/install-or-update.sh @@ -109,7 +109,7 @@ temporary_appimage="" ln -sfn "${installed_appimage}" "${launcher}" while IFS= read -r line || [ -n "${line}" ]; do case "${line}" in - Exec=*) printf 'Exec="%s"\n' "${launcher}" ;; + Exec=*) printf 'Exec="%s" %%f\n' "${launcher}" ;; *) printf '%s\n' "${line}" ;; esac done < "${desktop_file}" > "${temporary_desktop}" diff --git a/src/browser.rs b/src/browser.rs index d3f31e3..65b5d8a 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -21,6 +21,7 @@ impl EntryKind { pub struct BrowserEntry { pub uri: String, pub name: String, + pub list_name: Option, pub kind: EntryKind, pub size: Option, pub modified: Option, @@ -28,6 +29,12 @@ pub struct BrowserEntry { pub etag: Option, } +impl BrowserEntry { + pub fn display_name(&self) -> &str { + self.list_name.as_deref().unwrap_or(&self.name) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortField { Name, @@ -62,20 +69,28 @@ pub fn sort_entries( match field { SortField::Name => apply_direction( - natural_cmp_with_case(&left.name, &right.name, case_insensitive), + natural_cmp_with_case(left.display_name(), right.display_name(), case_insensitive), direction, ), SortField::Modified => compare_optional_time(left.modified, right.modified, direction) .then_with(|| { apply_direction( - natural_cmp_with_case(&left.name, &right.name, case_insensitive), + natural_cmp_with_case( + left.display_name(), + right.display_name(), + case_insensitive, + ), direction, ) }), SortField::Created => compare_optional_time(left.created, right.created, direction) .then_with(|| { apply_direction( - natural_cmp_with_case(&left.name, &right.name, case_insensitive), + natural_cmp_with_case( + left.display_name(), + right.display_name(), + case_insensitive, + ), direction, ) }), @@ -212,6 +227,30 @@ mod tests { ); } + #[test] + fn recursive_entries_sort_by_relative_display_path() { + let mut entries = ["10권/page.jpg", "2권/page.jpg"].map(|list_name| BrowserEntry { + uri: format!("file:///{list_name}"), + name: "page.jpg".into(), + list_name: Some(list_name.into()), + kind: EntryKind::Image, + size: None, + modified: None, + created: None, + etag: None, + }); + + sort_entries( + &mut entries, + SortField::Name, + SortDirection::Ascending, + true, + ); + + assert_eq!(entries[0].display_name(), "2권/page.jpg"); + assert_eq!(entries[1].display_name(), "10권/page.jpg"); + } + #[test] fn zip_extension_is_case_insensitive() { assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive); diff --git a/src/database.rs b/src/database.rs index c8aa0af..41e114b 100644 --- a/src/database.rs +++ b/src/database.rs @@ -2,8 +2,11 @@ use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; +use gtk::gio::prelude::*; use rusqlite::{Connection, OptionalExtension, params}; +use crate::browser::{BrowserEntry, EntryKind}; + pub struct Database { connection: Connection, } @@ -105,6 +108,124 @@ impl Database { )?; Ok(()) } + + pub fn load_cached_tree(&self, location_id: &str, root_uri: &str) -> Result> { + let mut statement = self.connection.prepare( + "WITH RECURSIVE tree(uri, name, path, entry_kind, size, modified_at, created_at, etag) AS ( + SELECT uri, name, name, entry_kind, size, modified_at, created_at, etag + FROM cached_entries + WHERE location_id = ?1 AND parent_uri = ?2 + UNION ALL + SELECT child.uri, child.name, tree.path || '/' || child.name, + child.entry_kind, child.size, child.modified_at, child.created_at, child.etag + FROM cached_entries AS child + JOIN tree ON child.parent_uri = tree.uri + WHERE child.location_id = ?1 + ) + SELECT uri, name, path, entry_kind, size, modified_at, created_at, etag + FROM tree ORDER BY path", + )?; + let rows = statement.query_map(params![location_id, root_uri], |row| { + let kind = entry_kind_from_i64(row.get(3)?); + let size = row + .get::<_, Option>(4)? + .and_then(|value| u64::try_from(value).ok()); + Ok(BrowserEntry { + uri: row.get(0)?, + name: row.get(1)?, + list_name: Some(row.get(2)?), + kind, + size, + modified: row.get::<_, Option>(5)?.and_then(system_time_from_i64), + created: row.get::<_, Option>(6)?.and_then(system_time_from_i64), + etag: row.get(7)?, + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } + + pub fn replace_cached_tree( + &self, + location_id: &str, + root_uri: &str, + entries: &[BrowserEntry], + ) -> Result<()> { + let transaction = self.connection.unchecked_transaction()?; + transaction.execute( + "WITH RECURSIVE tree(uri) AS ( + SELECT uri FROM cached_entries WHERE location_id = ?1 AND parent_uri = ?2 + UNION + SELECT child.uri FROM cached_entries AS child + JOIN tree ON child.parent_uri = tree.uri + WHERE child.location_id = ?1 + ) + DELETE FROM cached_entries + WHERE location_id = ?1 AND uri IN (SELECT uri FROM tree)", + params![location_id, root_uri], + )?; + let refreshed_at = system_time_to_i64(SystemTime::now()); + { + let mut statement = transaction.prepare( + "INSERT INTO cached_entries + (location_id, parent_uri, uri, name, entry_kind, size, modified_at, + created_at, etag, refreshed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + )?; + for entry in entries { + let parent_uri = gtk::gio::File::for_uri(&entry.uri) + .parent() + .map(|parent| parent.uri().to_string()) + .unwrap_or_else(|| root_uri.to_owned()); + statement.execute(params![ + location_id, + parent_uri, + entry.uri, + entry.name, + entry_kind_to_i64(entry.kind), + entry.size.and_then(|value| i64::try_from(value).ok()), + entry.modified.map(system_time_to_i64), + entry.created.map(system_time_to_i64), + entry.etag, + refreshed_at, + ])?; + } + } + transaction.commit()?; + Ok(()) + } +} + +fn entry_kind_to_i64(kind: EntryKind) -> i64 { + match kind { + EntryKind::Directory => 0, + EntryKind::ZipArchive => 1, + EntryKind::Image => 2, + EntryKind::Video => 3, + EntryKind::OtherFile => 4, + } +} + +fn entry_kind_from_i64(value: i64) -> EntryKind { + match value { + 0 => EntryKind::Directory, + 1 => EntryKind::ZipArchive, + 2 => EntryKind::Image, + 3 => EntryKind::Video, + _ => EntryKind::OtherFile, + } +} + +fn system_time_to_i64(time: SystemTime) -> i64 { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn system_time_from_i64(seconds: i64) -> Option { + u64::try_from(seconds) + .ok() + .map(|seconds| UNIX_EPOCH + std::time::Duration::from_secs(seconds)) } #[cfg(test)] @@ -122,4 +243,51 @@ mod tests { database.save_progress("test.zip", 9, 20).unwrap(); assert_eq!(database.load_progress("test.zip").unwrap(), Some(9)); } + + #[test] + fn cached_tree_replaces_descendants_and_restores_relative_paths() { + let database = Database::open(Path::new(":memory:")).unwrap(); + let entries = vec![ + BrowserEntry { + uri: "file:///library/series".into(), + name: "series".into(), + list_name: Some("series".into()), + kind: EntryKind::Directory, + size: None, + modified: None, + created: None, + etag: None, + }, + BrowserEntry { + uri: "file:///library/series/book.zip".into(), + name: "book.zip".into(), + list_name: Some("series/book.zip".into()), + kind: EntryKind::ZipArchive, + size: Some(42), + modified: None, + created: None, + etag: Some("v1".into()), + }, + ]; + + database + .replace_cached_tree("library", "file:///library", &entries) + .unwrap(); + let loaded = database + .load_cached_tree("library", "file:///library") + .unwrap(); + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[1].display_name(), "series/book.zip"); + + database + .replace_cached_tree("library", "file:///library", &entries[..1]) + .unwrap(); + assert_eq!( + database + .load_cached_tree("library", "file:///library") + .unwrap() + .len(), + 1 + ); + } } diff --git a/src/main.rs b/src/main.rs index f48b1a6..dccd524 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,36 +8,86 @@ mod viewer; mod window; mod zip_cache; +use std::cell::RefCell; +use std::rc::Rc; + use anyhow::Result; +use gtk::gio; use gtk::prelude::*; const APP_ID: &str = "io.github.comicviewer.Comicviewer"; fn main() { - let application = gtk::Application::builder().application_id(APP_ID).build(); + let application = gtk::Application::builder() + .application_id(APP_ID) + .flags(application_flags()) + .build(); + let state = Rc::new(ApplicationState::default()); - application.connect_activate(|application| { - if let Err(error) = activate(application) { - eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}"); - application.quit(); + let activate_state = Rc::clone(&state); + application.connect_activate(move |application| { + match activate_state.ensure_main_window(application) { + Ok(window) => window.present(), + Err(error) => startup_failed(application, &error), + } + }); + application.connect_open(move |application, files, _| { + match state.ensure_main_window(application) { + Ok(window) => window.open_local_items(files), + Err(error) => startup_failed(application, &error), } }); application.run(); } -fn activate(application: >k::Application) -> Result<()> { - let app_paths = paths::AppPaths::discover()?; - let config = config::AppConfig::load_or_create(&app_paths.config_file)?; - let database = database::Database::open(&app_paths.database_file)?; - let zip_cache = zip_cache::ZipCache::new(app_paths.zip_cache_dir, config.cache_quota_gb)?; - let window = window::ComicWindow::new( - application, - config, - app_paths.config_file, - database, - zip_cache, - )?; - window.present(); - Ok(()) +#[derive(Default)] +struct ApplicationState { + main_window: RefCell>>, +} + +impl ApplicationState { + fn ensure_main_window( + &self, + application: >k::Application, + ) -> Result> { + if let Some(window) = self.main_window.borrow().as_ref() { + return Ok(Rc::clone(window)); + } + let app_paths = paths::AppPaths::discover()?; + let config = config::AppConfig::load_or_create(&app_paths.config_file)?; + let database = database::Database::open(&app_paths.database_file)?; + let zip_cache = zip_cache::ZipCache::new(app_paths.zip_cache_dir, config.cache_quota_gb)?; + let window = Rc::new(window::ComicWindow::new( + application, + config, + app_paths.config_file, + database, + zip_cache, + )?); + self.main_window.replace(Some(Rc::clone(&window))); + Ok(window) + } +} + +fn startup_failed(application: >k::Application, error: &anyhow::Error) { + eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}"); + application.quit(); +} + +fn application_flags() -> gio::ApplicationFlags { + gio::ApplicationFlags::HANDLES_OPEN +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn application_accepts_files_as_a_single_instance() { + let flags = application_flags(); + assert!(flags.contains(gio::ApplicationFlags::HANDLES_OPEN)); + assert!(!flags.contains(gio::ApplicationFlags::NON_UNIQUE)); + assert!(!flags.contains(gio::ApplicationFlags::HANDLES_COMMAND_LINE)); + } } diff --git a/src/source.rs b/src/source.rs index 7dd535e..fc9b2c2 100644 --- a/src/source.rs +++ b/src/source.rs @@ -1,3 +1,4 @@ +use std::collections::{HashSet, VecDeque}; use std::path::Path; use std::time::{Duration, UNIX_EPOCH}; @@ -11,6 +12,17 @@ use crate::browser::{BrowserEntry, EntryKind, classify_file}; use crate::config::{LocationKind, LocationProfile}; const ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::size,standard::target-uri,mountable::can-mount,time::modified,time::created,etag::value"; +const RECURSIVE_ATTRIBUTES: &str = "standard::display-name,standard::name,standard::type,standard::is-symlink,standard::size,time::modified,time::created,etag::value,id::file,id::filesystem"; +const MAX_RECURSIVE_DIRECTORIES: usize = 10_000; +const MAX_RECURSIVE_ENTRIES: usize = 100_000; + +#[derive(Clone, Copy, Default)] +pub struct RecursiveProgress { + pub directories: usize, + pub scanned: usize, + pub viewable: usize, + pub skipped: usize, +} pub fn root_file(profile: &LocationProfile) -> Result { validate_profile(profile)?; @@ -125,6 +137,7 @@ where entries.push(BrowserEntry { uri, name: info.display_name().into(), + list_name: None, kind, size: info .has_attribute("standard::size") @@ -144,6 +157,143 @@ where Ok(entries) } +pub async fn enumerate_tree_entries( + profile: &LocationProfile, + root: &gio::File, + operation: Option<>k::MountOperation>, + mut progress: F, +) -> Result<(Vec, RecursiveProgress)> +where + F: FnMut(RecursiveProgress), +{ + if profile.kind != LocationKind::Local && !root.is_native() { + let operation = operation.context("원격 폴더 마운트 정보가 없습니다")?; + ensure_mounted(root, profile, operation).await?; + } + let mut pending = VecDeque::from([(root.clone(), String::new())]); + let mut visited = HashSet::new(); + let mut visited_identities = HashSet::new(); + if let Ok(info) = root + .query_info_future( + "id::file,id::filesystem", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + glib::Priority::DEFAULT, + ) + .await + && let Some(identity) = recursive_file_identity(&info) + { + visited_identities.insert(identity); + } + let mut entries = Vec::new(); + let mut state = RecursiveProgress::default(); + + while let Some((directory, relative_directory)) = pending.pop_front() { + let directory_uri: String = directory.uri().into(); + if !visited.insert(directory_uri.clone()) { + continue; + } + if visited.len() > MAX_RECURSIVE_DIRECTORIES { + bail!("하위 폴더 수가 안전 제한을 초과합니다 (최대 {MAX_RECURSIVE_DIRECTORIES}개)"); + } + let enumerator = match directory + .enumerate_children_future( + RECURSIVE_ATTRIBUTES, + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + glib::Priority::DEFAULT, + ) + .await + { + Ok(enumerator) => enumerator, + Err(error) if directory.equal(root) => { + return Err(error) + .with_context(|| format!("폴더에 접근할 수 없습니다: {}", directory.uri())); + } + Err(_) => { + state.skipped += 1; + progress(state); + continue; + } + }; + state.directories += 1; + loop { + let batch = enumerator + .next_files_future(128, glib::Priority::DEFAULT) + .await?; + if batch.is_empty() { + break; + } + state.scanned += batch.len(); + if state.scanned > MAX_RECURSIVE_ENTRIES { + bail!("하위 항목 수가 안전 제한을 초과합니다 (최대 {MAX_RECURSIVE_ENTRIES}개)"); + } + for info in batch { + let child = enumerator.child(&info); + if info.is_symlink() || !is_within_root(&child, root) { + continue; + } + let display_name = info.display_name(); + let relative_name = if relative_directory.is_empty() { + display_name.to_string() + } else { + format!("{relative_directory}/{display_name}") + }; + match info.file_type() { + gio::FileType::Directory => { + if let Some(identity) = recursive_file_identity(&info) + && !visited_identities.insert(identity) + { + continue; + } + pending.push_back((child.clone(), relative_name.clone())); + entries.push(BrowserEntry { + uri: child.uri().into(), + name: display_name.into(), + list_name: Some(relative_name), + kind: EntryKind::Directory, + size: None, + modified: attribute_time(&info, "time::modified"), + created: attribute_time(&info, "time::created"), + etag: info + .has_attribute("etag::value") + .then(|| info.etag()) + .flatten() + .map(Into::into), + }); + } + gio::FileType::Regular => { + let kind = classify_file(Path::new(display_name.as_str())); + if !kind.is_viewable() { + continue; + } + state.viewable += 1; + entries.push(BrowserEntry { + uri: child.uri().into(), + name: display_name.into(), + list_name: Some(relative_name), + kind, + size: info + .has_attribute("standard::size") + .then(|| info.size()) + .filter(|size| *size >= 0) + .map(|size| size as u64), + modified: attribute_time(&info, "time::modified"), + created: attribute_time(&info, "time::created"), + etag: info + .has_attribute("etag::value") + .then(|| info.etag()) + .flatten() + .map(Into::into), + }); + } + _ => {} + } + } + progress(state); + } + } + Ok((entries, state)) +} + pub fn mount_operation( profile: &LocationProfile, parent: &impl glib::object::IsA, @@ -239,8 +389,16 @@ fn attribute_time(info: &gio::FileInfo, attribute: &str) -> Option Option { + let filesystem = info.attribute_string("id::filesystem")?; + let file = info.attribute_string("id::file")?; + Some(format!("{filesystem}:{file}")) +} + #[cfg(test)] mod tests { + use std::fs; + use super::*; #[test] @@ -290,4 +448,43 @@ mod tests { let encoded_input = file_from_user_input(LocationKind::Ftp, encoded.uri().as_str()); assert!(encoded_input.equal(&encoded)); } + + #[test] + fn recursively_lists_viewable_files_without_following_symlinks() { + let directory = tempfile::tempdir().unwrap(); + let subdirectory = directory.path().join("2권"); + fs::create_dir(&subdirectory).unwrap(); + fs::write(directory.path().join("10.zip"), []).unwrap(); + fs::write(subdirectory.join("page2.jpg"), []).unwrap(); + fs::write(subdirectory.join("notes.txt"), []).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(directory.path(), subdirectory.join("loop")).unwrap(); + let profile = LocationProfile { + name: "test".into(), + kind: LocationKind::Local, + address: directory.path().to_string_lossy().into_owned(), + username: String::new(), + password: String::new(), + domain: String::new(), + }; + let root = gio::File::for_path(directory.path()); + let (entries, progress) = glib::MainContext::default() + .block_on(enumerate_tree_entries(&profile, &root, None, |_| {})) + .unwrap(); + + assert!( + entries.iter().any(|entry| { + entry.kind == EntryKind::Directory && entry.display_name() == "2권" + }) + ); + assert!(entries.iter().any(|entry| { + entry.kind == EntryKind::ZipArchive && entry.display_name() == "10.zip" + })); + assert!(entries.iter().any(|entry| { + entry.kind == EntryKind::Image && entry.display_name() == "2권/page2.jpg" + })); + assert!(!entries.iter().any(|entry| entry.name == "notes.txt")); + assert!(!entries.iter().any(|entry| entry.name == "loop")); + assert_eq!(progress.skipped, 0); + } } diff --git a/src/viewer.rs b/src/viewer.rs index 40f30e9..5050408 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::{Rc, Weak}; use std::sync::mpsc::{self, TryRecvError}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use gtk::gdk; use gtk::glib; @@ -26,12 +26,12 @@ 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(); + controller.detach_window_controllers(); } if let Some(controller) = window.steal_data::>(MEDIA_VIEWER_CONTROLLER_KEY) { - controller.stop_playback(); + controller.prepare_for_navigation(); } } } @@ -47,7 +47,45 @@ fn ensure_viewer_close_handler(window: >k::ApplicationWindow) { }); } -pub(crate) fn set_viewer_header(window: >k::ApplicationWindow, header: >k::HeaderBar) { +pub(crate) fn set_viewer_loading(window: >k::ApplicationWindow, loading: bool) { + if !window.has_css_class("viewer-loading-key-handler") { + window.add_css_class("viewer-loading-key-handler"); + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak_window = window.downgrade(); + keys.connect_key_pressed(move |_, key, _, _| { + let Some(window) = weak_window.upgrade() else { + return glib::Propagation::Proceed; + }; + if !window.has_css_class("viewer-loading") || !window.is_fullscreen() { + return glib::Propagation::Proceed; + } + if let Some(titlebar) = window.titlebar() { + titlebar.set_visible(true); + } + if key == gdk::Key::Escape + || key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) + { + window.unfullscreen(); + return glib::Propagation::Stop; + } + glib::Propagation::Proceed + }); + window.add_controller(keys); + } + if loading { + window.add_css_class("viewer-loading"); + } else { + window.remove_css_class("viewer-loading"); + } +} + +pub(crate) fn set_viewer_header( + window: >k::ApplicationWindow, + header: >k::HeaderBar, +) -> gtk::Box { let host = window .titlebar() .and_downcast::() @@ -62,6 +100,8 @@ pub(crate) fn set_viewer_header(window: >k::ApplicationWindow, header: >k::H } header.set_hexpand(true); host.append(header); + host.set_visible(!window.is_fullscreen()); + host } pub struct ViewerWindow; @@ -244,6 +284,7 @@ impl ViewerWindow { }); install_file_drop_target(&window, file_drop_control); ensure_viewer_close_handler(&window); + set_viewer_loading(&window, false); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); @@ -255,7 +296,7 @@ impl ViewerWindow { header.pack_end(&download_button); header.pack_end(&favorite_button); header.pack_end(&file_list_button); - set_viewer_header(&window, &header); + let titlebar_host = set_viewer_header(&window, &header); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6); @@ -358,6 +399,7 @@ impl ViewerWindow { let controller = Rc::new(ViewerController { window, + titlebar_host, source, database, stack, @@ -372,6 +414,7 @@ impl ViewerWindow { page_label, page_seek, page_seek_row, + toolbar, status_label, previous_button, next_button, @@ -395,6 +438,10 @@ impl ViewerWindow { last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), + fullscreen_key_controller: RefCell::new(None), + fullscreen_ui_hide_timer: RefCell::new(None), + fullscreen_ui_visible: Cell::new(false), + fullscreen_notify_handler: RefCell::new(None), navigation_message, navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), @@ -417,6 +464,7 @@ impl ViewerWindow { }), }); controller.connect_signals(); + controller.fullscreen_changed(); if controller.file_list_button.is_active() { controller.refresh_file_list(); } @@ -506,6 +554,7 @@ struct ViewerState { struct ViewerController { window: gtk::ApplicationWindow, + titlebar_host: gtk::Box, source: ArchiveSource, database: Rc, stack: gtk::Stack, @@ -520,6 +569,7 @@ struct ViewerController { page_label: gtk::Label, page_seek: gtk::Scale, page_seek_row: gtk::Box, + toolbar: gtk::Box, status_label: gtk::Label, previous_button: gtk::Button, next_button: gtk::Button, @@ -543,6 +593,10 @@ struct ViewerController { last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, + fullscreen_key_controller: RefCell>, + fullscreen_ui_hide_timer: RefCell>, + fullscreen_ui_visible: Cell, + fullscreen_notify_handler: RefCell>, navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, @@ -612,7 +666,11 @@ impl ViewerController { 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()); + controller.file_list_panel.set_visible( + button.is_active() + && (!controller.window.is_fullscreen() + || controller.fullscreen_ui_visible.get()), + ); if button.is_active() { controller.window.add_css_class("viewer-file-list-visible"); } else { @@ -631,6 +689,22 @@ impl ViewerController { controller.open_file_list_row(row.index()); } }); + let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default())); + let file_list_keys = gtk::EventControllerKey::new(); + let controller = Rc::downgrade(self); + file_list_keys.connect_key_pressed(move |_, key, _, modifiers| { + let Some(controller) = controller.upgrade() else { + return glib::Propagation::Proceed; + }; + file_list_typeahead_select( + &controller.file_list, + &controller.file_list_files.borrow(), + &mut file_list_typeahead.borrow_mut(), + key, + modifiers, + ) + }); + self.file_list.add_controller(file_list_keys); let file_list_click = gtk::GestureClick::builder().button(3).build(); let controller = Rc::downgrade(self); file_list_click.connect_pressed(move |_, _, x, y| { @@ -717,13 +791,30 @@ impl ViewerController { self.window.add_controller(motion_controller.clone()); self.motion_controller.replace(Some(motion_controller)); + let fullscreen_keys = gtk::EventControllerKey::new(); + fullscreen_keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let controller = Rc::downgrade(self); + fullscreen_keys.connect_key_pressed(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() + && controller.window.is_fullscreen() + { + controller.show_fullscreen_ui_temporarily(); + } + glib::Propagation::Proceed + }); + self.window.add_controller(fullscreen_keys.clone()); + self.fullscreen_key_controller + .replace(Some(fullscreen_keys)); + let key_controller = gtk::EventControllerKey::new(); 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(); + if !controller.window.is_fullscreen() { + controller.hide_seek(); + } match key { gdk::Key::Home => { controller.seek_edge(false); @@ -783,6 +874,15 @@ impl ViewerController { }); self.window.add_controller(key_controller.clone()); self.key_controller.replace(Some(key_controller)); + let controller = Rc::downgrade(self); + let handler = self + .window + .connect_notify_local(Some("fullscreened"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.fullscreen_changed(); + } + }); + self.fullscreen_notify_handler.replace(Some(handler)); } fn open_sibling(&self, forward: bool) { @@ -876,12 +976,22 @@ impl ViewerController { fn detach_window_controllers(&self) { self.stop_animations(); + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.cancel_fullscreen_ui_timer(); + if let Some(handler) = self.fullscreen_notify_handler.borrow_mut().take() { + self.window.disconnect(handler); + } if let Some(controller) = self.key_controller.borrow_mut().take() { self.window.remove_controller(&controller); } if let Some(controller) = self.motion_controller.borrow_mut().take() { self.window.remove_controller(&controller); } + if let Some(controller) = self.fullscreen_key_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } } fn stop_animations(&self) { @@ -913,7 +1023,11 @@ impl ViewerController { return; } self.last_pointer_position.set(Some((x, y))); - self.show_seek_temporarily(); + if self.window.is_fullscreen() { + self.show_fullscreen_ui_temporarily(); + } else { + self.show_seek_temporarily(); + } } fn hide_seek(self: &Rc) { @@ -947,6 +1061,62 @@ impl ViewerController { } } + fn fullscreen_changed(self: &Rc) { + self.cancel_fullscreen_ui_timer(); + self.hide_seek(); + if self.window.is_fullscreen() { + self.set_fullscreen_ui_visible(false); + } else { + self.fullscreen_ui_visible.set(true); + self.titlebar_host.set_visible(true); + self.toolbar.set_visible(true); + self.status_label.set_visible(true); + self.file_list_panel + .set_visible(self.file_list_button.is_active()); + } + } + + fn show_fullscreen_ui_temporarily(self: &Rc) { + if !self.window.is_fullscreen() { + return; + } + self.cancel_fullscreen_ui_timer(); + self.set_fullscreen_ui_visible(true); + let controller = Rc::downgrade(self); + let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { + if let Some(controller) = controller.upgrade() { + controller.fullscreen_ui_hide_timer.borrow_mut().take(); + controller.set_fullscreen_ui_visible(false); + } + }); + self.fullscreen_ui_hide_timer.replace(Some(timer)); + } + + fn cancel_fullscreen_ui_timer(&self) { + if let Some(timer) = self.fullscreen_ui_hide_timer.borrow_mut().take() { + timer.remove(); + } + } + + fn set_fullscreen_ui_visible(self: &Rc, visible: bool) { + self.fullscreen_ui_visible.set(visible); + self.titlebar_host.set_visible(visible); + self.toolbar.set_visible(visible); + self.page_seek_row.set_visible(visible); + self.status_label.set_visible(visible); + self.file_list_panel + .set_visible(visible && self.file_list_button.is_active()); + self.window.queue_allocate(); + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + if let Some(controller) = controller.upgrade() { + controller.stack.queue_allocate(); + controller.resize_continuous_rows(); + controller.render(); + } + }); + } + fn load_archive(self: &Rc) { self.spinner.start(); let path = self.source.local_path.clone(); @@ -1727,6 +1897,7 @@ impl MediaViewerWindow { }); install_file_drop_target(&window, file_drop_control); ensure_viewer_close_handler(&window); + set_viewer_loading(&window, false); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); @@ -1752,7 +1923,7 @@ impl MediaViewerWindow { header.pack_end(&download_button); header.pack_end(&favorite_button); header.pack_end(&file_list_button); - set_viewer_header(&window, &header); + let titlebar_host = set_viewer_header(&window, &header); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); let stack = gtk::Stack::new(); @@ -1816,6 +1987,7 @@ impl MediaViewerWindow { let controller = Rc::new(MediaViewerController { window, + titlebar_host, source, kind, picture, @@ -1845,6 +2017,10 @@ impl MediaViewerWindow { last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), + fullscreen_key_controller: RefCell::new(None), + fullscreen_ui_hide_timer: RefCell::new(None), + fullscreen_ui_visible: Cell::new(false), + fullscreen_notify_handler: RefCell::new(None), navigation_message, navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), @@ -1858,6 +2034,7 @@ impl MediaViewerWindow { }); } controller.connect_signals(); + controller.fullscreen_changed(); if controller.file_list_button.is_active() { controller.refresh_file_list(); } @@ -1883,6 +2060,7 @@ impl MediaViewerWindow { struct MediaViewerController { window: gtk::ApplicationWindow, + titlebar_host: gtk::Box, source: ArchiveSource, kind: EntryKind, picture: gtk::Picture, @@ -1912,6 +2090,10 @@ struct MediaViewerController { last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, + fullscreen_key_controller: RefCell>, + fullscreen_ui_hide_timer: RefCell>, + fullscreen_ui_visible: Cell, + fullscreen_notify_handler: RefCell>, navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, @@ -1930,6 +2112,21 @@ impl MediaViewerController { self.window.add_controller(motion_controller.clone()); self.motion_controller.replace(Some(motion_controller)); + let fullscreen_keys = gtk::EventControllerKey::new(); + fullscreen_keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let controller = Rc::downgrade(self); + fullscreen_keys.connect_key_pressed(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() + && controller.window.is_fullscreen() + { + controller.show_fullscreen_ui_temporarily(); + } + glib::Propagation::Proceed + }); + self.window.add_controller(fullscreen_keys.clone()); + self.fullscreen_key_controller + .replace(Some(fullscreen_keys)); + let keys = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); keys.connect_key_pressed(move |_, key, _, modifiers| { @@ -1943,7 +2140,9 @@ impl MediaViewerController { ) { return glib::Propagation::Proceed; } - controller.hide_seek(); + if !controller.window.is_fullscreen() { + controller.hide_seek(); + } match key { gdk::Key::Left if controller.kind == EntryKind::Video => { controller.seek_video(-VIDEO_SEEK_STEP_US); @@ -2014,6 +2213,15 @@ impl MediaViewerController { self.window.add_controller(keys.clone()); self.key_controller.replace(Some(keys)); let controller = Rc::downgrade(self); + let handler = self + .window + .connect_notify_local(Some("fullscreened"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.fullscreen_changed(); + } + }); + self.fullscreen_notify_handler.replace(Some(handler)); + let controller = Rc::downgrade(self); self.favorite_button.connect_clicked(move |_| { if let Some(controller) = controller.upgrade() { let active = (controller.favorite_toggle)(); @@ -2032,7 +2240,11 @@ impl MediaViewerController { 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()); + controller.file_list_panel.set_visible( + button.is_active() + && (!controller.window.is_fullscreen() + || controller.fullscreen_ui_visible.get()), + ); if button.is_active() { controller.window.add_css_class("viewer-file-list-visible"); } else { @@ -2051,6 +2263,22 @@ impl MediaViewerController { controller.open_file_list_row(row.index()); } }); + let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default())); + let file_list_keys = gtk::EventControllerKey::new(); + let controller = Rc::downgrade(self); + file_list_keys.connect_key_pressed(move |_, key, _, modifiers| { + let Some(controller) = controller.upgrade() else { + return glib::Propagation::Proceed; + }; + file_list_typeahead_select( + &controller.file_list, + &controller.file_list_files.borrow(), + &mut file_list_typeahead.borrow_mut(), + key, + modifiers, + ) + }); + self.file_list.add_controller(file_list_keys); let file_list_click = gtk::GestureClick::builder().button(3).build(); let controller = Rc::downgrade(self); file_list_click.connect_pressed(move |_, _, x, y| { @@ -2318,7 +2546,11 @@ impl MediaViewerController { return; } self.last_pointer_position.set(Some((x, y))); - self.show_seek_temporarily(); + if self.window.is_fullscreen() { + self.show_fullscreen_ui_temporarily(); + } else { + self.show_seek_temporarily(); + } } fn hide_seek(self: &Rc) { @@ -2377,12 +2609,22 @@ impl MediaViewerController { fn prepare_for_navigation(&self) { self.stop_playback(); + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.cancel_fullscreen_ui_timer(); + if let Some(handler) = self.fullscreen_notify_handler.borrow_mut().take() { + self.window.disconnect(handler); + } if let Some(controller) = self.key_controller.borrow_mut().take() { self.window.remove_controller(&controller); } if let Some(controller) = self.motion_controller.borrow_mut().take() { self.window.remove_controller(&controller); } + if let Some(controller) = self.fullscreen_key_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } } fn stop_playback(&self) { @@ -2400,6 +2642,61 @@ impl MediaViewerController { } } + fn fullscreen_changed(self: &Rc) { + self.cancel_fullscreen_ui_timer(); + self.hide_seek(); + if self.window.is_fullscreen() { + self.set_fullscreen_ui_visible(false); + } else { + self.fullscreen_ui_visible.set(true); + self.titlebar_host.set_visible(true); + self.status_label.set_visible(true); + self.file_list_panel + .set_visible(self.file_list_button.is_active()); + } + } + + fn show_fullscreen_ui_temporarily(self: &Rc) { + if !self.window.is_fullscreen() { + return; + } + self.cancel_fullscreen_ui_timer(); + self.set_fullscreen_ui_visible(true); + let controller = Rc::downgrade(self); + let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { + if let Some(controller) = controller.upgrade() { + controller.fullscreen_ui_hide_timer.borrow_mut().take(); + controller.set_fullscreen_ui_visible(false); + } + }); + self.fullscreen_ui_hide_timer.replace(Some(timer)); + } + + fn cancel_fullscreen_ui_timer(&self) { + if let Some(timer) = self.fullscreen_ui_hide_timer.borrow_mut().take() { + timer.remove(); + } + } + + fn set_fullscreen_ui_visible(self: &Rc, visible: bool) { + self.fullscreen_ui_visible.set(visible); + self.titlebar_host.set_visible(visible); + self.media_seek_row.set_visible(visible); + self.status_label.set_visible(visible); + self.file_list_panel + .set_visible(visible && self.file_list_button.is_active()); + self.window.queue_allocate(); + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + if let Some(controller) = controller.upgrade() { + controller.stack.queue_allocate(); + controller.picture.queue_allocate(); + controller.video.queue_allocate(); + controller.resize_image_picture(); + } + }); + } + fn load_image(self: &Rc) { self.stack.set_visible_child_name("image"); self.status_label.set_text("이미지를 읽는 중..."); @@ -2458,6 +2755,85 @@ struct ViewerFileList { list: gtk::ListBox, } +#[derive(Default)] +struct TypeaheadBuffer { + query: String, + last_input: Option, +} + +fn file_list_typeahead_select( + list: >k::ListBox, + files: &[SiblingFile], + state: &mut TypeaheadBuffer, + key: gdk::Key, + modifiers: gdk::ModifierType, +) -> glib::Propagation { + let blocked = gdk::ModifierType::CONTROL_MASK + | gdk::ModifierType::ALT_MASK + | gdk::ModifierType::SUPER_MASK; + if modifiers.intersects(blocked) { + return glib::Propagation::Proceed; + } + if key == gdk::Key::Escape { + state.query.clear(); + state.last_input = None; + return glib::Propagation::Proceed; + } + if matches!(key, gdk::Key::space) + || key.to_unicode().is_some_and(|character| { + character.eq_ignore_ascii_case(&'f') || matches!(character, '[' | ']') + }) + { + return glib::Propagation::Proceed; + } + let now = Instant::now(); + if state + .last_input + .is_none_or(|last| now.duration_since(last) > Duration::from_millis(1200)) + { + state.query.clear(); + } + if key == gdk::Key::BackSpace { + return if state.query.pop().is_some() { + state.last_input = Some(now); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + }; + } + let Some(character) = key.to_unicode().filter(|character| !character.is_control()) else { + return glib::Propagation::Proceed; + }; + let input = character.to_lowercase().collect::(); + let repeated_single = state.query.chars().count() == 1 && state.query == input; + if !repeated_single { + state.query.push_str(&input); + } + state.last_input = Some(now); + let selected = list.selected_row().map(|row| row.index() as usize); + let start = if repeated_single { + selected.map_or(0, |index| index + 1) + } else { + 0 + }; + let found = find_sibling_prefix(files, &state.query, start); + if let Some(row) = found.and_then(|index| list.row_at_index(index as i32)) { + list.select_row(Some(&row)); + let _ = row.grab_focus(); + } + glib::Propagation::Stop +} + +fn find_sibling_prefix(files: &[SiblingFile], query: &str, start: usize) -> Option { + if files.is_empty() { + return None; + } + let query = query.to_lowercase(); + (0..files.len()) + .map(|offset| (start + offset) % files.len()) + .find(|index| files[*index].name.to_lowercase().starts_with(&query)) +} + fn viewer_file_list() -> ViewerFileList { let list = gtk::ListBox::new(); list.set_selection_mode(gtk::SelectionMode::Single); @@ -2943,6 +3319,19 @@ mod tests { assert_eq!(video_volume_target(0.5, 0.05), 0.55); } + #[test] + fn viewer_file_typeahead_wraps_by_display_name() { + let files = ["Alpha.zip", "beta.jpg", "Bravo.mp4"].map(|name| SiblingFile { + uri: format!("file:///{name}"), + name: name.into(), + kind: EntryKind::Image, + }); + + assert_eq!(find_sibling_prefix(&files, "br", 0), Some(2)); + assert_eq!(find_sibling_prefix(&files, "b", 2), Some(2)); + assert_eq!(find_sibling_prefix(&files, "b", 3), Some(1)); + } + #[test] fn navigation_boundary_messages_match_direction() { assert_eq!( diff --git a/src/window.rs b/src/window.rs index 593370e..258c5d6 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::fs; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -17,13 +17,14 @@ use crate::browser::{ use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile, ViewerMode}; use crate::database::Database; use crate::source::{ - display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root, - mount_operation, parent_within_root, root_file, validate_profile, + display_remote_uri, ensure_mounted, enumerate_directory, enumerate_tree_entries, + file_from_user_input, is_within_root, mount_operation, parent_within_root, root_file, + validate_profile, }; use crate::viewer::{ FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow, SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, - ViewerWindow, install_file_drop_target, set_viewer_header, + ViewerWindow, install_file_drop_target, set_viewer_header, set_viewer_loading, }; use crate::zip_cache::ZipCache; @@ -94,7 +95,7 @@ impl ComicWindow { let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6); let search_entry = gtk::SearchEntry::builder() - .placeholder_text("현재 폴더에서 검색") + .placeholder_text("현재 위치 아래 폴더와 ZIP 검색") .hexpand(true) .build(); let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); @@ -149,6 +150,10 @@ impl ComicWindow { current_uri: root_uri, entries: Vec::new(), visible_entries: Vec::new(), + recursive_entries: Vec::new(), + recursive_root_uri: None, + recursive_ready: false, + recursive_scanning: false, sort_field: SortField::Name, sort_direction: SortDirection::Ascending, generation: 0, @@ -160,6 +165,8 @@ impl ComicWindow { database: Rc::new(database), zip_cache, listing_task: RefCell::new(None), + search_task: RefCell::new(None), + search_generation: Cell::new(0), }); controller.rebuild_profiles(); @@ -179,6 +186,12 @@ impl ComicWindow { pub fn present(&self) { self.controller.window.present(); } + + pub fn open_local_items(&self, files: &[gio::File]) { + self.present(); + self.controller + .open_dropped_files(files.to_vec(), self.controller.window.clone().upcast()); + } } struct BrowserState { @@ -189,6 +202,10 @@ struct BrowserState { current_uri: String, entries: Vec, visible_entries: Vec, + recursive_entries: Vec, + recursive_root_uri: Option, + recursive_ready: bool, + recursive_scanning: bool, sort_field: SortField, sort_direction: SortDirection, generation: u64, @@ -215,6 +232,8 @@ struct BrowserController { database: Rc, zip_cache: Rc, listing_task: RefCell>>, + search_task: RefCell>>, + search_generation: Cell, } impl BrowserController { @@ -296,7 +315,7 @@ impl BrowserController { .set_list_factory(Some(&location_factory)); let controller = Rc::clone(self); self.search_entry - .connect_search_changed(move |_| controller.render()); + .connect_search_changed(move |_| controller.search_changed()); let controller = Rc::clone(self); self.sort_dropdown .connect_selected_notify(move |_| controller.change_sort_field()); @@ -321,7 +340,7 @@ impl BrowserController { let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); key_controller.connect_key_pressed(move |_, key, _, modifiers| { - if key == gtk::gdk::Key::BackSpace { + if key == gtk::gdk::Key::BackSpace && !controller.typeahead_is_active() { controller.go_up(); glib::Propagation::Stop } else { @@ -400,6 +419,7 @@ impl BrowserController { fn show_favorites(&self) { self.cancel_listing(); + self.cancel_recursive_search(); self.edit_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false); self.path_entry.set_text("즐겨찾기"); @@ -495,6 +515,7 @@ impl BrowserController { .unwrap_or_else(|| BrowserEntry { uri: favorite.uri.clone(), name: favorite.name.clone(), + list_name: None, kind, size: None, modified: None, @@ -560,6 +581,7 @@ impl BrowserController { return; } self.cancel_listing(); + self.cancel_recursive_search(); let (profile, uri, generation) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); @@ -605,6 +627,9 @@ impl BrowserController { Ok(entries) => { controller.state.borrow_mut().entries = entries; controller.render(); + if !controller.search_entry.text().is_empty() { + controller.start_recursive_search(); + } } Err(error) => controller.status_label.set_text(&format!("{error:#}")), } @@ -618,6 +643,142 @@ impl BrowserController { } } + fn cancel_recursive_search(&self) { + if let Some(task) = self.search_task.borrow_mut().take() { + task.abort(); + } + self.search_generation + .set(self.search_generation.get().wrapping_add(1)); + let mut state = self.state.borrow_mut(); + state.recursive_entries.clear(); + state.recursive_root_uri = None; + state.recursive_ready = false; + state.recursive_scanning = false; + } + + fn search_changed(self: &Rc) { + if self.search_entry.text().is_empty() { + self.cancel_recursive_search(); + self.render(); + return; + } + self.render(); + if !self.state.borrow().browsing_favorites { + self.start_recursive_search(); + } + } + + fn start_recursive_search(self: &Rc) { + let (profile, root_uri) = { + let mut state = self.state.borrow_mut(); + if state.browsing_favorites + || (state.recursive_root_uri.as_deref() == Some(&state.current_uri) + && (state.recursive_ready || state.recursive_scanning)) + { + return; + } + let root_uri = state.current_uri.clone(); + state.recursive_root_uri = Some(root_uri.clone()); + state.recursive_entries.clear(); + state.recursive_ready = false; + state.recursive_scanning = true; + ( + state + .favorite_profile + .clone() + .unwrap_or_else(|| self.profiles()[state.profile_index].clone()), + root_uri, + ) + }; + if let Some(task) = self.search_task.borrow_mut().take() { + task.abort(); + } + let generation = self.search_generation.get().wrapping_add(1); + self.search_generation.set(generation); + let root = gio::File::for_uri(&root_uri); + let location_id = root_file(&profile) + .map(|root| root.uri().to_string()) + .unwrap_or_else(|_| profile.address.clone()); + if let Ok(cached_entries) = self.database.load_cached_tree(&location_id, &root_uri) + && !cached_entries.is_empty() + { + let mut state = self.state.borrow_mut(); + state.recursive_entries = cached_entries; + state.recursive_ready = true; + drop(state); + self.render(); + } + let operation = mount_operation(&profile, &self.window); + let progress_controller = Rc::downgrade(self); + let controller = Rc::downgrade(self); + let task = glib::MainContext::default().spawn_local(async move { + let result = + enumerate_tree_entries(&profile, &root, Some(&operation), move |progress| { + let Some(controller) = progress_controller.upgrade() else { + return; + }; + if controller.search_generation.get() == generation + && !controller.search_entry.text().is_empty() + { + controller.status_label.set_text(&format!( + "하위 폴더 검색 색인 생성 중... (폴더 {}개, 항목 {}개)", + progress.directories, progress.scanned + )); + } + }) + .await; + let Some(controller) = controller.upgrade() else { + return; + }; + if controller.search_generation.get() != generation + || controller.state.borrow().current_uri != root_uri + { + return; + } + match result { + Ok((entries, progress)) => { + let cache_error = (progress.skipped == 0) + .then(|| { + controller.database.replace_cached_tree( + &location_id, + &root_uri, + &entries, + ) + }) + .transpose() + .err(); + let mut state = controller.state.borrow_mut(); + state.recursive_entries = entries; + state.recursive_ready = true; + state.recursive_scanning = false; + drop(state); + if !controller.search_entry.text().is_empty() { + controller.render(); + if progress.skipped > 0 { + controller.status_label.set_text(&format!( + "검색 결과 생성 완료 · 접근할 수 없는 폴더 {}개 건너뜀", + progress.skipped + )); + } else if let Some(error) = cache_error { + controller + .status_label + .set_text(&format!("검색 색인을 저장할 수 없습니다: {error:#}")); + } + } + } + Err(error) => { + controller.state.borrow_mut().recursive_scanning = false; + if !controller.search_entry.text().is_empty() { + controller + .status_label + .set_text(&format!("하위 폴더 검색을 완료할 수 없습니다: {error:#}")); + } + } + } + }); + self.search_task.replace(Some(task)); + } + fn render(&self) { while let Some(child) = self.list_box.first_child() { self.list_box.remove(&child); @@ -625,10 +786,17 @@ impl BrowserController { let query = self.search_entry.text().to_lowercase(); let case_insensitive = self.config.borrow().case_insensitive_sort; let mut state = self.state.borrow_mut(); - let mut visible_entries: Vec<_> = state - .entries + let recursive_ready = !query.is_empty() + && state.recursive_ready + && state.recursive_root_uri.as_deref() == Some(&state.current_uri); + let source = if recursive_ready { + &state.recursive_entries + } else { + &state.entries + }; + let mut visible_entries: Vec<_> = source .iter() - .filter(|entry| entry.name.to_lowercase().contains(query.as_str())) + .filter(|entry| query.is_empty() || recursive_search_match(entry, query.as_str())) .cloned() .collect(); sort_entries( @@ -642,7 +810,14 @@ impl BrowserController { } let count = visible_entries.len(); state.visible_entries = visible_entries; - self.status_label.set_text(&format!("{count}개 항목")); + let status = if query.is_empty() { + format!("{count}개 항목") + } else if state.recursive_scanning { + format!("현재 폴더 결과 {count}개 · 하위 폴더 색인 생성 중...") + } else { + format!("하위 폴더 검색 결과 {count}개") + }; + self.status_label.set_text(&status); } fn typeahead( @@ -656,10 +831,21 @@ impl BrowserController { if modifiers.intersects(blocked) { return glib::Propagation::Proceed; } - let Some(character) = key - .to_unicode() - .filter(|character| character.is_alphanumeric()) - else { + if key == gtk::gdk::Key::Escape { + let mut state = self.state.borrow_mut(); + state.typeahead_query.clear(); + state.typeahead_last_input = None; + return glib::Propagation::Stop; + } + if key == gtk::gdk::Key::BackSpace { + let mut state = self.state.borrow_mut(); + if state.typeahead_query.pop().is_none() { + return glib::Propagation::Proceed; + } + state.typeahead_last_input = Some(Instant::now()); + return glib::Propagation::Stop; + } + let Some(character) = key.to_unicode().filter(|character| !character.is_control()) else { return glib::Propagation::Proceed; }; @@ -686,7 +872,7 @@ impl BrowserController { 0 }; let found = find_prefix_index(&state.visible_entries, &query, start); - let found_name = found.map(|index| state.visible_entries[index].name.clone()); + let found_name = found.map(|index| state.visible_entries[index].display_name().to_owned()); drop(state); if let Some(index) = found @@ -705,6 +891,14 @@ impl BrowserController { glib::Propagation::Stop } + fn typeahead_is_active(&self) -> bool { + let state = self.state.borrow(); + !state.typeahead_query.is_empty() + && state.typeahead_last_input.is_some_and(|last| { + Instant::now().duration_since(last) <= Duration::from_millis(1200) + }) + } + fn change_sort_field(&self) { self.state.borrow_mut().sort_field = match self.sort_dropdown.selected() { 1 => SortField::Modified, @@ -757,6 +951,23 @@ impl BrowserController { self.refresh(); } EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { + if !self.search_entry.text().is_empty() && entry.list_name.is_some() { + let siblings = self + .state + .borrow() + .visible_entries + .iter() + .filter(|candidate| candidate.kind.is_viewable()) + .cloned() + .collect::>(); + self.open_viewable( + entry.clone(), + Rc::new(siblings), + self.current_profile(), + None, + ); + return; + } let current_kind = entry.kind; let case_insensitive = self.config.borrow().case_insensitive_sort; let siblings = { @@ -859,7 +1070,7 @@ impl BrowserController { .iter() .map(|entry| SiblingFile { uri: entry.uri.clone(), - name: entry.name.clone(), + name: entry.display_name().to_owned(), kind: entry.kind, }) .collect() @@ -1075,6 +1286,17 @@ impl BrowserController { actions.set_margin_bottom(6); actions.set_margin_start(6); actions.set_margin_end(6); + if entry.kind == EntryKind::Directory { + let open_folder_button = gtk::Button::with_label("폴더 열기"); + actions.append(&open_folder_button); + let controller = Rc::clone(self); + let popover_to_close = popover.clone(); + let folder_entry = entry.clone(); + open_folder_button.connect_clicked(move |_| { + popover_to_close.popdown(); + controller.open_folder_recursive(folder_entry.clone()); + }); + } if can_favorite { let is_favorite = self.is_favorite(&entry.uri); let favorite_button = gtk::Button::with_label(if is_favorite { @@ -1088,7 +1310,7 @@ impl BrowserController { let favorite_entry = entry.clone(); favorite_button.connect_clicked(move |_| { popover_to_close.popdown(); - let profile = controller.current_profile(); + let profile = controller.profile_for_entry(&favorite_entry); controller.toggle_favorite(&favorite_entry, &profile); }); } @@ -1118,6 +1340,96 @@ impl BrowserController { .any(|favorite| favorite.uri == uri) } + fn profile_for_entry(&self, entry: &BrowserEntry) -> LocationProfile { + if self.state.borrow().browsing_favorites + && let Some(profile) = self + .config + .borrow() + .favorites + .iter() + .find(|favorite| favorite.uri == entry.uri) + .map(|favorite| favorite.profile.clone()) + { + return profile; + } + self.current_profile() + } + + fn open_folder_recursive(self: &Rc, entry: BrowserEntry) { + self.cancel_recursive_search(); + let profile = self.profile_for_entry(&entry); + let Ok(profile_root) = root_file(&profile) else { + self.status_label + .set_text("폴더의 저장된 위치 정보를 확인할 수 없습니다."); + return; + }; + let folder = gio::File::for_uri(&entry.uri); + if !is_within_root(&folder, &profile_root) { + self.status_label + .set_text("저장된 위치의 루트 아래 폴더만 열 수 있습니다."); + return; + } + self.cancel_listing(); + let (generation, sort_field, sort_direction) = { + let mut state = self.state.borrow_mut(); + state.generation = state.generation.wrapping_add(1); + (state.generation, state.sort_field, state.sort_direction) + }; + let case_insensitive = self.config.borrow().case_insensitive_sort; + self.status_label + .set_text("하위 폴더에서 열 수 있는 파일을 찾는 중..."); + let operation = mount_operation(&profile, &self.window); + let progress_controller = Rc::downgrade(self); + let controller = Rc::downgrade(self); + let task = glib::MainContext::default().spawn_local(async move { + let result = + enumerate_tree_entries(&profile, &folder, Some(&operation), move |progress| { + let Some(controller) = progress_controller.upgrade() else { + return; + }; + if controller.state.borrow().generation == generation { + controller.status_label.set_text(&format!( + "하위 폴더 검색 중... (폴더 {}개, 항목 {}개, 파일 {}개)", + progress.directories, progress.scanned, progress.viewable + )); + } + }) + .await; + let Some(controller) = controller.upgrade() else { + return; + }; + if controller.state.borrow().generation != generation { + return; + } + match result { + Ok((mut entries, progress)) => { + entries.retain(|entry| entry.kind.is_viewable()); + sort_entries(&mut entries, sort_field, sort_direction, case_insensitive); + let Some(initial) = entries.first().cloned() else { + controller + .status_label + .set_text("하위 폴더에 열 수 있는 파일이 없습니다."); + return; + }; + let skipped = if progress.skipped == 0 { + String::new() + } else { + format!(" · 접근할 수 없는 폴더 {}개 건너뜀", progress.skipped) + }; + controller.status_label.set_text(&format!( + "하위 폴더에서 {}개 파일을 찾았습니다{skipped}.", + entries.len() + )); + controller.open_viewable(initial, Rc::new(entries), profile, None); + } + Err(error) => controller + .status_label + .set_text(&format!("하위 폴더를 열 수 없습니다: {error:#}")), + } + }); + self.listing_task.replace(Some(task)); + } + fn favorite_control( self: &Rc, entry: &BrowserEntry, @@ -1172,12 +1484,13 @@ impl BrowserController { } let file = file.clone(); self.cancel_listing(); + self.cancel_recursive_search(); let generation = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.generation }; - self.status_label.set_text("드롭한 항목을 확인하는 중..."); + self.status_label.set_text("열 항목을 확인하는 중..."); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { let result: Result<_> = async { @@ -1188,7 +1501,7 @@ impl BrowserController { glib::Priority::DEFAULT, ) .await - .context("드롭한 항목을 확인할 수 없습니다")?; + .context("열 항목을 확인할 수 없습니다")?; let selected = match info.file_type() { gio::FileType::Directory => None, gio::FileType::Regular => { @@ -1221,7 +1534,7 @@ impl BrowserController { let (directory, profile, entries, selected) = match result { Ok(result) => result, Err(error) => { - let message = format!("드롭한 항목을 열 수 없습니다: {error:#}"); + let message = format!("항목을 열 수 없습니다: {error:#}"); controller.status_label.set_text(&message); show_drop_error(&parent, &message); return; @@ -1236,7 +1549,7 @@ impl BrowserController { .find(|entry| gio::File::for_uri(&entry.uri).equal(&selected)) .cloned() else { - show_drop_error(&parent, "드롭한 파일을 부모 폴더에서 찾을 수 없습니다."); + show_drop_error(&parent, "파일을 부모 폴더에서 찾을 수 없습니다."); return; }; let state = controller.state.borrow(); @@ -1248,7 +1561,7 @@ impl BrowserController { controller.config.borrow().case_insensitive_sort, ) else { drop(state); - show_drop_error(&parent, "드롭한 파일의 목록 위치를 확인할 수 없습니다."); + show_drop_error(&parent, "파일의 목록 위치를 확인할 수 없습니다."); return; }; drop(state); @@ -1792,7 +2105,7 @@ fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow { content.append(>k::Image::from_icon_name(icon)); content.append( >k::Label::builder() - .label(&entry.name) + .label(entry.display_name()) .hexpand(true) .xalign(0.0) .ellipsize(gtk::pango::EllipsizeMode::Middle) @@ -1823,7 +2136,12 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt let query = query.to_lowercase(); (0..entries.len()) .map(|offset| (start + offset) % entries.len()) - .find(|index| entries[*index].name.to_lowercase().starts_with(&query)) + .find(|index| { + entries[*index] + .display_name() + .to_lowercase() + .starts_with(&query) + }) } fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { @@ -1868,10 +2186,19 @@ fn sorted_navigable_selection( Some((entries, selected)) } +fn recursive_search_match(entry: &BrowserEntry, lowercase_query: &str) -> bool { + matches!(entry.kind, EntryKind::Directory | EntryKind::ZipArchive) + && entry + .display_name() + .to_lowercase() + .contains(lowercase_query) +} + fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { BrowserEntry { uri: favorite.uri.clone(), name: favorite.name.clone(), + list_name: None, kind: if favorite.is_directory { EntryKind::Directory } else { @@ -1889,6 +2216,7 @@ fn show_viewer_loading( file_name: &str, status: &str, ) -> gtk::Label { + set_viewer_loading(window, true); window.set_title(Some(file_name)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(file_name)))); @@ -1953,6 +2281,7 @@ mod tests { BrowserEntry { uri: format!("file:///{name}"), name: name.into(), + list_name: None, kind: EntryKind::Directory, size: None, modified: None, @@ -2044,6 +2373,19 @@ mod tests { assert_eq!(siblings[selected].name, "page10.jpg"); } + #[test] + fn recursive_search_only_matches_folders_and_zip_names() { + let mut folder = entry("Comics"); + folder.list_name = Some("Archive/Comics".into()); + let mut zip = media_entry("book.zip"); + zip.kind = EntryKind::ZipArchive; + let image = media_entry("book.jpg"); + + assert!(recursive_search_match(&folder, "comics")); + assert!(recursive_search_match(&zip, "book")); + assert!(!recursive_search_match(&image, "book")); + } + #[test] fn dropped_directory_creates_local_root_profile() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/zip_cache.rs b/src/zip_cache.rs index 4c118f1..ec5fd61 100644 --- a/src/zip_cache.rs +++ b/src/zip_cache.rs @@ -375,6 +375,7 @@ mod tests { let mut entry = BrowserEntry { uri: "sftp://example.invalid/a.zip".into(), name: "a.zip".into(), + list_name: None, kind: EntryKind::ZipArchive, size: Some(10), modified: None, From cbc762fb4c4ba757e81de16dbbb5645d5b04a817 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Thu, 23 Jul 2026 21:03:11 +0900 Subject: [PATCH 13/30] Release v0.2.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 ++-- src/viewer.rs | 38 ++++++++++++++++++- 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c37f0fc..e751c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index a779210..6de6a4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.3" +version = "0.2.4" 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 63dad52..008759d 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 fc4f5b0..172cc99 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.3-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.4-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.3" -git tag -a v0.2.3 -m "Comicviewer v0.2.3" +git commit -m "Release v0.2.4" +git tag -a v0.2.4 -m "Comicviewer v0.2.4" git push origin main -git push origin v0.2.3 +git push origin v0.2.4 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 5050408..05dfd18 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -794,9 +794,10 @@ impl ViewerController { let fullscreen_keys = gtk::EventControllerKey::new(); fullscreen_keys.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); - fullscreen_keys.connect_key_pressed(move |_, _, _, _| { + fullscreen_keys.connect_key_pressed(move |_, key, _, _| { if let Some(controller) = controller.upgrade() && controller.window.is_fullscreen() + && fullscreen_key_reveals_ui(key) { controller.show_fullscreen_ui_temporarily(); } @@ -2115,9 +2116,10 @@ impl MediaViewerController { let fullscreen_keys = gtk::EventControllerKey::new(); fullscreen_keys.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); - fullscreen_keys.connect_key_pressed(move |_, _, _, _| { + fullscreen_keys.connect_key_pressed(move |_, key, _, _| { if let Some(controller) = controller.upgrade() && controller.window.is_fullscreen() + && fullscreen_key_reveals_ui(key) { controller.show_fullscreen_ui_temporarily(); } @@ -2824,6 +2826,20 @@ fn file_list_typeahead_select( glib::Propagation::Stop } +fn fullscreen_key_reveals_ui(key: gdk::Key) -> bool { + !matches!( + key, + gdk::Key::Left + | gdk::Key::Right + | gdk::Key::Up + | gdk::Key::Down + | gdk::Key::KP_Left + | gdk::Key::KP_Right + | gdk::Key::KP_Up + | gdk::Key::KP_Down + ) +} + fn find_sibling_prefix(files: &[SiblingFile], query: &str, start: usize) -> Option { if files.is_empty() { return None; @@ -3332,6 +3348,24 @@ mod tests { assert_eq!(find_sibling_prefix(&files, "b", 3), Some(1)); } + #[test] + fn fullscreen_direction_keys_do_not_reveal_controls() { + for key in [ + gdk::Key::Left, + gdk::Key::Right, + gdk::Key::Up, + gdk::Key::Down, + gdk::Key::KP_Left, + gdk::Key::KP_Right, + gdk::Key::KP_Up, + gdk::Key::KP_Down, + ] { + assert!(!fullscreen_key_reveals_ui(key)); + } + assert!(fullscreen_key_reveals_ui(gdk::Key::bracketleft)); + assert!(fullscreen_key_reveals_ui(gdk::Key::bracketright)); + } + #[test] fn navigation_boundary_messages_match_direction() { assert_eq!( From b7a212c991f3898ba4167c17fea9b58305e2d211 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Thu, 23 Jul 2026 21:14:20 +0900 Subject: [PATCH 14/30] Release v0.2.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...github.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 ++++---- src/window.rs | 18 ++++++++++++++++++ 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e751c74..85f20c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 6de6a4f..0e39e2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.4" +version = "0.2.5" 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 008759d..7d8fb58 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 172cc99..8437a92 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.4-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.5-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.4" -git tag -a v0.2.4 -m "Comicviewer v0.2.4" +git commit -m "Release v0.2.5" +git tag -a v0.2.5 -m "Comicviewer v0.2.5" git push origin main -git push origin v0.2.4 +git push origin v0.2.5 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/window.rs b/src/window.rs index 258c5d6..e22dafc 100644 --- a/src/window.rs +++ b/src/window.rs @@ -28,6 +28,8 @@ use crate::viewer::{ }; use crate::zip_cache::ZipCache; +const MOUSE_BACK_BUTTON: u32 = 8; + pub struct ComicWindow { controller: Rc, } @@ -349,6 +351,17 @@ impl BrowserController { }); self.list_box.add_controller(key_controller); + let mouse_back = gtk::GestureClick::builder() + .button(MOUSE_BACK_BUTTON) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + let controller = Rc::clone(self); + mouse_back.connect_pressed(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + controller.go_up(); + }); + self.window.add_controller(mouse_back); + let controller = Rc::clone(self); fast_loading_switch.connect_active_notify(move |switch| { controller.set_fast_image_loading(switch); @@ -2386,6 +2399,11 @@ mod tests { assert!(!recursive_search_match(&image, "book")); } + #[test] + fn mouse_back_button_matches_standard_x11_button() { + assert_eq!(MOUSE_BACK_BUTTON, 8); + } + #[test] fn dropped_directory_creates_local_root_profile() { let directory = tempfile::tempdir().unwrap(); From be02626912bd505332fd245088c280fbb59e6879 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Thu, 23 Jul 2026 21:59:55 +0900 Subject: [PATCH 15/30] Release v0.2.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 6 +- docs/RELEASING.ko.md | 8 +- src/viewer.rs | 496 ++++++++++++++++-- 6 files changed, 473 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85f20c7..0088458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.5" +version = "0.2.6" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0e39e2c..b37e02f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.5" +version = "0.2.6" 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 7d8fb58..1025231 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 1094e7f..bf92e93 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -89,7 +89,11 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기 - 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동 - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 -- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기 +- `L`: 우측 파일 목록 표시 또는 숨기기 +- 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 +- 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 +- 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 +- 전체 화면에서 `←`, `→`, `↑`, `↓`: 컨트롤을 즉시 숨기고 해당 키 동작 실행 - 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기 - 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드 - 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index 8437a92..15dedde 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.5-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.6-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.5" -git tag -a v0.2.5 -m "Comicviewer v0.2.5" +git commit -m "Release v0.2.6" +git tag -a v0.2.6 -m "Comicviewer v0.2.6" git push origin main -git push origin v0.2.5 +git push origin v0.2.6 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 05dfd18..29d68ad 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -20,6 +20,9 @@ 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 IMAGE_ZOOM_STEP: f64 = 0.1; +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"; @@ -60,15 +63,15 @@ pub(crate) fn set_viewer_loading(window: >k::ApplicationWindow, loading: bool) if !window.has_css_class("viewer-loading") || !window.is_fullscreen() { return glib::Propagation::Proceed; } - if let Some(titlebar) = window.titlebar() { - titlebar.set_visible(true); - } if key == gdk::Key::Escape || key .to_unicode() .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) { window.unfullscreen(); + if let Some(titlebar) = window.titlebar() { + titlebar.set_visible(true); + } return glib::Propagation::Stop; } glib::Propagation::Proceed @@ -348,6 +351,11 @@ impl ViewerWindow { spread_box.set_homogeneous(true); spread_box.append(&left_picture); spread_box.append(&right_picture); + let spread_scrolled = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .child(&spread_box) + .build(); let continuous_box = gtk::Box::new(gtk::Orientation::Vertical, 8); continuous_box.set_halign(gtk::Align::Fill); @@ -360,7 +368,7 @@ impl ViewerWindow { stack.set_hexpand(true); stack.set_vexpand(true); stack.add_named(&single_scrolled, Some("single")); - stack.add_named(&spread_box, Some("spread")); + stack.add_named(&spread_scrolled, Some("spread")); stack.add_named(&continuous_scrolled, Some("continuous")); let spinner = gtk::Spinner::new(); @@ -408,6 +416,8 @@ impl ViewerWindow { single_picture, left_picture, right_picture, + spread_box, + spread_scrolled, continuous_box, continuous_scrolled, spinner, @@ -439,9 +449,11 @@ impl ViewerWindow { key_controller: RefCell::new(None), motion_controller: RefCell::new(None), fullscreen_key_controller: RefCell::new(None), + fullscreen_click_controller: RefCell::new(None), fullscreen_ui_hide_timer: RefCell::new(None), fullscreen_ui_visible: Cell::new(false), fullscreen_notify_handler: RefCell::new(None), + image_zoom: Cell::new(1.0), navigation_message, navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), @@ -563,6 +575,8 @@ struct ViewerController { single_picture: gtk::Picture, left_picture: gtk::Picture, right_picture: gtk::Picture, + spread_box: gtk::Box, + spread_scrolled: gtk::ScrolledWindow, continuous_box: gtk::Box, continuous_scrolled: gtk::ScrolledWindow, spinner: gtk::Spinner, @@ -594,9 +608,11 @@ struct ViewerController { key_controller: RefCell>, motion_controller: RefCell>, fullscreen_key_controller: RefCell>, + fullscreen_click_controller: RefCell>, fullscreen_ui_hide_timer: RefCell>, fullscreen_ui_visible: Cell, fullscreen_notify_handler: RefCell>, + image_zoom: Cell, navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, @@ -638,6 +654,7 @@ impl ViewerController { self.mode_dropdown.connect_selected_notify(move |dropdown| { if let Some(controller) = controller.upgrade() { (controller.view_mode_change)(dropdown.selected()); + controller.image_zoom.set(1.0); controller.apply_view_mode(); } }); @@ -752,22 +769,51 @@ impl ViewerController { } }); let controller = Rc::downgrade(self); + self.single_scrolled + .connect_notify_local(Some("height"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.resize_single_picture(); + } + }); + let controller = Rc::downgrade(self); self.single_picture .connect_notify_local(Some("width"), move |_, _| { if let Some(controller) = controller.upgrade() { controller.resize_single_picture(); } }); + let controller = Rc::downgrade(self); + self.spread_scrolled + .connect_notify_local(Some("width"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.resize_spread_pictures(); + } + }); + let controller = Rc::downgrade(self); + self.spread_scrolled + .connect_notify_local(Some("height"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.resize_spread_pictures(); + } + }); let scroll_controller = gtk::EventControllerScroll::new( gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, ); scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); - scroll_controller.connect_scroll(move |_, _, delta_y| { + scroll_controller.connect_scroll(move |scroll, _, delta_y| { let Some(controller) = controller.upgrade() else { return glib::Propagation::Proceed; }; + if scroll + .current_event_state() + .contains(gdk::ModifierType::CONTROL_MASK) + && delta_y != 0.0 + { + controller.adjust_image_zoom(delta_y < 0.0); + return glib::Propagation::Stop; + } if matches!( controller.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous @@ -780,6 +826,20 @@ impl ViewerController { }); self.stack.add_controller(scroll_controller); + let fullscreen_click = gtk::GestureClick::builder() + .button(0) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + let controller = Rc::downgrade(self); + fullscreen_click.connect_pressed(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() { + controller.controls_clicked(); + } + }); + self.window.add_controller(fullscreen_click.clone()); + self.fullscreen_click_controller + .replace(Some(fullscreen_click)); + let motion_controller = gtk::EventControllerMotion::new(); motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); @@ -797,9 +857,9 @@ impl ViewerController { fullscreen_keys.connect_key_pressed(move |_, key, _, _| { if let Some(controller) = controller.upgrade() && controller.window.is_fullscreen() - && fullscreen_key_reveals_ui(key) + && fullscreen_direction_key(key) { - controller.show_fullscreen_ui_temporarily(); + controller.hide_fullscreen_ui_now(); } glib::Propagation::Proceed }); @@ -809,7 +869,7 @@ impl ViewerController { let key_controller = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); - key_controller.connect_key_pressed(move |_, key, _, _| { + key_controller.connect_key_pressed(move |_, key, _, modifiers| { let Some(controller) = controller.upgrade() else { return glib::Propagation::Proceed; }; @@ -825,7 +885,7 @@ impl ViewerController { controller.seek_edge(true); glib::Propagation::Stop } - gdk::Key::Up + gdk::Key::Up | gdk::Key::KP_Up if matches!( controller.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous @@ -834,7 +894,7 @@ impl ViewerController { controller.scroll_vertically(false); glib::Propagation::Stop } - gdk::Key::Down + gdk::Key::Down | gdk::Key::KP_Down if matches!( controller.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous @@ -843,11 +903,11 @@ impl ViewerController { controller.scroll_vertically(true); glib::Propagation::Stop } - gdk::Key::Left | gdk::Key::Page_Up => { + gdk::Key::Left | gdk::Key::KP_Left | gdk::Key::Page_Up => { controller.navigate(false); glib::Propagation::Stop } - gdk::Key::Right | gdk::Key::Page_Down | gdk::Key::space => { + gdk::Key::Right | gdk::Key::KP_Right | gdk::Key::Page_Down | gdk::Key::space => { controller.navigate(true); glib::Propagation::Stop } @@ -859,6 +919,22 @@ impl ViewerController { controller.open_sibling(true); glib::Propagation::Stop } + gdk::Key::plus | gdk::Key::KP_Add => { + controller.adjust_image_zoom(true); + glib::Propagation::Stop + } + gdk::Key::minus | gdk::Key::KP_Subtract => { + controller.adjust_image_zoom(false); + glib::Propagation::Stop + } + _ if !shortcut_modifiers_blocked(modifiers) + && key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'l')) => + { + controller.toggle_file_list(); + glib::Propagation::Stop + } _ if key .to_unicode() .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => @@ -896,6 +972,17 @@ impl ViewerController { } } + fn toggle_file_list(&self) { + if !self.file_list_button.is_sensitive() { + return; + } + let active = !self.file_list_button.is_active(); + self.file_list_button.set_active(active); + if self.window.is_fullscreen() { + self.file_list_panel.set_visible(active); + } + } + fn refresh_file_list(&self) { while let Some(child) = self.file_list.first_child() { self.file_list.remove(&child); @@ -993,6 +1080,9 @@ impl ViewerController { if let Some(controller) = self.fullscreen_key_controller.borrow_mut().take() { self.window.remove_controller(&controller); } + if let Some(controller) = self.fullscreen_click_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } } fn stop_animations(&self) { @@ -1024,6 +1114,9 @@ impl ViewerController { return; } self.last_pointer_position.set(Some((x, y))); + } + + fn controls_clicked(self: &Rc) { if self.window.is_fullscreen() { self.show_fullscreen_ui_temporarily(); } else { @@ -1093,6 +1186,13 @@ impl ViewerController { self.fullscreen_ui_hide_timer.replace(Some(timer)); } + fn hide_fullscreen_ui_now(self: &Rc) { + if self.window.is_fullscreen() { + self.cancel_fullscreen_ui_timer(); + self.set_fullscreen_ui_visible(false); + } + } + fn cancel_fullscreen_ui_timer(&self) { if let Some(timer) = self.fullscreen_ui_hide_timer.borrow_mut().take() { timer.remove(); @@ -1296,6 +1396,27 @@ impl ViewerController { self.schedule_view_mode_refresh(); } + fn adjust_image_zoom(self: &Rc, zoom_in: bool) { + let zoom = next_image_zoom(self.image_zoom.get(), zoom_in); + if (zoom - self.image_zoom.get()).abs() < f64::EPSILON { + return; + } + self.image_zoom.set(zoom); + if (zoom - 1.0).abs() < f64::EPSILON { + self.apply_view_mode(); + return; + } + match self.view_mode() { + ViewMode::TwoPage => self.resize_spread_pictures(), + ViewMode::Continuous => { + self.resize_continuous_rows(); + self.continuous_scrolled(); + } + _ => self.resize_single_picture(), + } + self.stack.queue_allocate(); + } + fn schedule_view_mode_refresh(self: &Rc) { let generation = self.view_layout_generation.get().wrapping_add(1); self.view_layout_generation.set(generation); @@ -1538,7 +1659,7 @@ impl ViewerController { }; if let Some(frame) = frame { frame.set_ratio(ratio); - frame.set_size_request(-1, row_height(self.continuous_content_width(), ratio)); + self.resize_continuous_rows(); } } self.evict_cache(); @@ -1581,15 +1702,100 @@ impl ViewerController { .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( + if let Some((texture_width, texture_height)) = dimensions { + let zoom = self.image_zoom.get(); + if (zoom - 1.0).abs() >= f64::EPSILON { + let (width, height) = zoomed_picture_request( + self.view_mode(), + self.single_scrolled.allocated_width().max(1), + self.single_scrolled.allocated_height().max(1), + texture_width, + texture_height, + zoom, + ); + self.single_box.set_hexpand(true); + self.single_box.set_vexpand(true); + self.single_box.set_halign(gtk::Align::Fill); + self.single_box.set_valign(gtk::Align::Fill); + self.single_picture.set_can_shrink(false); + self.single_picture.set_hexpand(false); + self.single_picture.set_vexpand(false); + self.single_picture.set_content_fit(gtk::ContentFit::Fill); + self.single_picture.set_halign(gtk::Align::Center); + self.single_picture.set_valign(gtk::Align::Center); + self.single_picture.set_size_request(width, height); + } else if let Some((width, height)) = single_picture_request( self.view_mode(), self.stack.allocated_width().max(1), texture_width, texture_height, - ) + ) { + self.single_picture.set_size_request(width, height); + } + } + } + + fn resize_spread_pictures(&self) { + let zoom = self.image_zoom.get(); + if (zoom - 1.0).abs() < f64::EPSILON { + self.spread_box.set_homogeneous(true); + self.spread_box.set_halign(gtk::Align::Fill); + self.spread_box.set_valign(gtk::Align::Fill); + for picture in [&self.left_picture, &self.right_picture] { + picture.set_can_shrink(true); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_size_request(-1, -1); + } + return; + } + let visible = self.visible_indices(); + let state = self.state.borrow(); + let page_dimensions = |position: usize| { + visible.get(position).and_then(|index| { + state + .cache + .get(index) + .and_then(|page| page.frames.first()) + .map(|frame| (frame.texture.width(), frame.texture.height())) + }) + }; + let first = page_dimensions(0); + let second = page_dimensions(1); + let dimensions = if self.direction_dropdown.selected() == 1 { + [second, first] + } else { + [first, second] + }; + drop(state); + let count = visible.len().max(1) as i32; + let viewport_width = (self.spread_scrolled.allocated_width().max(1) - 8) / count; + let viewport_height = self.spread_scrolled.allocated_height().max(1); + self.spread_box.set_homogeneous(false); + self.spread_box.set_halign(gtk::Align::Center); + self.spread_box.set_valign(gtk::Align::Center); + for (picture, dimensions) in [&self.left_picture, &self.right_picture] + .into_iter() + .zip(dimensions) { - self.single_picture.set_size_request(width, height); + let Some((texture_width, texture_height)) = dimensions else { + picture.set_size_request(1, 1); + continue; + }; + let (width, height) = zoomed_picture_request( + ViewMode::FitHeight, + viewport_width, + viewport_height, + texture_width, + texture_height, + zoom, + ); + picture.set_can_shrink(false); + picture.set_hexpand(false); + picture.set_vexpand(false); + picture.set_content_fit(gtk::ContentFit::Fill); + picture.set_size_request(width, height); } } @@ -1628,6 +1834,7 @@ impl ViewerController { ); } self.status_label.set_text(&status); + self.resize_spread_pictures(); } fn render_continuous(&self, visible: &[usize]) { @@ -1783,7 +1990,14 @@ impl ViewerController { } fn resize_continuous_rows(&self) { - let width = self.continuous_content_width(); + let zoom = self.image_zoom.get(); + let width = scaled_dimension(self.continuous_content_width(), zoom); + let zoomed = (zoom - 1.0).abs() >= f64::EPSILON; + self.continuous_box.set_halign(if zoomed { + gtk::Align::Center + } else { + gtk::Align::Fill + }); let rows: Vec<_> = self .state .borrow() @@ -1792,7 +2006,8 @@ impl ViewerController { .map(|row| (row.frame.clone(), row.ratio)) .collect(); for (frame, ratio) in rows { - frame.set_size_request(-1, row_height(width, ratio)); + frame.set_hexpand(!zoomed); + frame.set_size_request(if zoomed { width } else { -1 }, row_height(width, ratio)); } } @@ -1997,6 +2212,7 @@ impl MediaViewerWindow { image_mode_dropdown, image_mode_change, image_dimensions: Cell::new(None), + image_zoom: Cell::new(1.0), image_animation_generation: Rc::new(AnimationState::default()), video, stack, @@ -2019,6 +2235,7 @@ impl MediaViewerWindow { key_controller: RefCell::new(None), motion_controller: RefCell::new(None), fullscreen_key_controller: RefCell::new(None), + fullscreen_click_controller: RefCell::new(None), fullscreen_ui_hide_timer: RefCell::new(None), fullscreen_ui_visible: Cell::new(false), fullscreen_notify_handler: RefCell::new(None), @@ -2070,6 +2287,7 @@ struct MediaViewerController { image_mode_dropdown: gtk::DropDown, image_mode_change: Option>, image_dimensions: Cell>, + image_zoom: Cell, image_animation_generation: Rc, video: gtk::Video, stack: gtk::Stack, @@ -2092,6 +2310,7 @@ struct MediaViewerController { key_controller: RefCell>, motion_controller: RefCell>, fullscreen_key_controller: RefCell>, + fullscreen_click_controller: RefCell>, fullscreen_ui_hide_timer: RefCell>, fullscreen_ui_visible: Cell, fullscreen_notify_handler: RefCell>, @@ -2102,6 +2321,20 @@ struct MediaViewerController { impl MediaViewerController { fn connect_signals(self: &Rc) { + let fullscreen_click = gtk::GestureClick::builder() + .button(0) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + let controller = Rc::downgrade(self); + fullscreen_click.connect_pressed(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() { + controller.controls_clicked(); + } + }); + self.window.add_controller(fullscreen_click.clone()); + self.fullscreen_click_controller + .replace(Some(fullscreen_click)); + let motion_controller = gtk::EventControllerMotion::new(); motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); @@ -2119,9 +2352,9 @@ impl MediaViewerController { fullscreen_keys.connect_key_pressed(move |_, key, _, _| { if let Some(controller) = controller.upgrade() && controller.window.is_fullscreen() - && fullscreen_key_reveals_ui(key) + && fullscreen_direction_key(key) { - controller.show_fullscreen_ui_temporarily(); + controller.hide_fullscreen_ui_now(); } glib::Propagation::Proceed }); @@ -2146,19 +2379,19 @@ impl MediaViewerController { controller.hide_seek(); } match key { - gdk::Key::Left if controller.kind == EntryKind::Video => { + gdk::Key::Left | gdk::Key::KP_Left if controller.kind == EntryKind::Video => { controller.seek_video(-VIDEO_SEEK_STEP_US); glib::Propagation::Stop } - gdk::Key::Right if controller.kind == EntryKind::Video => { + gdk::Key::Right | gdk::Key::KP_Right if controller.kind == EntryKind::Video => { controller.seek_video(VIDEO_SEEK_STEP_US); glib::Propagation::Stop } - gdk::Key::Up if controller.kind == EntryKind::Video => { + gdk::Key::Up | gdk::Key::KP_Up if controller.kind == EntryKind::Video => { controller.adjust_video_volume(VIDEO_VOLUME_STEP); glib::Propagation::Stop } - gdk::Key::Down if controller.kind == EntryKind::Video => { + gdk::Key::Down | gdk::Key::KP_Down if controller.kind == EntryKind::Video => { controller.adjust_video_volume(-VIDEO_VOLUME_STEP); glib::Propagation::Stop } @@ -2170,7 +2403,7 @@ impl MediaViewerController { controller.open_edge(true); glib::Propagation::Stop } - gdk::Key::Up + gdk::Key::Up | gdk::Key::KP_Up if controller.kind == EntryKind::Image && matches!( controller.image_view_mode(), @@ -2180,7 +2413,7 @@ impl MediaViewerController { controller.scroll_image(false); glib::Propagation::Stop } - gdk::Key::Down + gdk::Key::Down | gdk::Key::KP_Down if controller.kind == EntryKind::Image && matches!( controller.image_view_mode(), @@ -2198,6 +2431,21 @@ impl MediaViewerController { controller.open_sibling(true); glib::Propagation::Stop } + gdk::Key::plus | gdk::Key::KP_Add if controller.kind == EntryKind::Image => { + controller.adjust_image_zoom(true); + glib::Propagation::Stop + } + gdk::Key::minus | gdk::Key::KP_Subtract if controller.kind == EntryKind::Image => { + controller.adjust_image_zoom(false); + glib::Propagation::Stop + } + _ if key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'l')) => + { + controller.toggle_file_list(); + glib::Propagation::Stop + } _ if key .to_unicode() .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => @@ -2295,6 +2543,7 @@ impl MediaViewerController { .connect_selected_notify(move |dropdown| { if let Some(controller) = controller.upgrade() { change(dropdown.selected()); + controller.image_zoom.set(1.0); controller.apply_image_view_mode(); } }); @@ -2307,6 +2556,35 @@ impl MediaViewerController { } }); let controller = Rc::downgrade(self); + self.image_scrolled + .connect_notify_local(Some("height"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.resize_image_picture(); + } + }); + let image_scroll = gtk::EventControllerScroll::new( + gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, + ); + image_scroll.set_propagation_phase(gtk::PropagationPhase::Capture); + let controller = Rc::downgrade(self); + image_scroll.connect_scroll(move |scroll, _, delta_y| { + let Some(controller) = controller.upgrade() else { + return glib::Propagation::Proceed; + }; + if controller.kind == EntryKind::Image + && scroll + .current_event_state() + .contains(gdk::ModifierType::CONTROL_MASK) + && delta_y != 0.0 + { + controller.adjust_image_zoom(delta_y < 0.0); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + self.image_scrolled.add_controller(image_scroll); + let controller = Rc::downgrade(self); self.media_seek.connect_value_changed(move |scale| { let Some(controller) = controller.upgrade() else { return; @@ -2396,6 +2674,17 @@ impl MediaViewerController { } } + fn toggle_file_list(&self) { + if !self.file_list_button.is_sensitive() { + return; + } + let active = !self.file_list_button.is_active(); + self.file_list_button.set_active(active); + if self.window.is_fullscreen() { + self.file_list_panel.set_visible(active); + } + } + fn seek_video(&self, delta: i64) { let Some(stream) = self .video @@ -2461,11 +2750,49 @@ impl MediaViewerController { self.stack.queue_allocate(); } + fn adjust_image_zoom(&self, zoom_in: bool) { + if self.kind != EntryKind::Image { + return; + } + let zoom = next_image_zoom(self.image_zoom.get(), zoom_in); + if (zoom - self.image_zoom.get()).abs() < f64::EPSILON { + return; + } + self.image_zoom.set(zoom); + if (zoom - 1.0).abs() < f64::EPSILON { + self.apply_image_view_mode(); + } else { + self.resize_image_picture(); + self.stack.queue_allocate(); + } + } + fn resize_image_picture(&self) { let Some((texture_width, texture_height)) = self.image_dimensions.get() else { return; }; - if let Some((width, height)) = single_picture_request( + let zoom = self.image_zoom.get(); + if (zoom - 1.0).abs() >= f64::EPSILON { + let (width, height) = zoomed_picture_request( + self.image_view_mode(), + self.image_scrolled.allocated_width().max(1), + self.image_scrolled.allocated_height().max(1), + texture_width, + texture_height, + zoom, + ); + self.image_box.set_hexpand(true); + self.image_box.set_vexpand(true); + self.image_box.set_halign(gtk::Align::Fill); + self.image_box.set_valign(gtk::Align::Fill); + self.picture.set_can_shrink(false); + self.picture.set_hexpand(false); + self.picture.set_vexpand(false); + self.picture.set_content_fit(gtk::ContentFit::Fill); + self.picture.set_halign(gtk::Align::Center); + self.picture.set_valign(gtk::Align::Center); + self.picture.set_size_request(width, height); + } else if let Some((width, height)) = single_picture_request( self.image_view_mode(), self.stack.allocated_width().max(1), texture_width, @@ -2548,6 +2875,9 @@ impl MediaViewerController { return; } self.last_pointer_position.set(Some((x, y))); + } + + fn controls_clicked(self: &Rc) { if self.window.is_fullscreen() { self.show_fullscreen_ui_temporarily(); } else { @@ -2627,6 +2957,9 @@ impl MediaViewerController { if let Some(controller) = self.fullscreen_key_controller.borrow_mut().take() { self.window.remove_controller(&controller); } + if let Some(controller) = self.fullscreen_click_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } } fn stop_playback(&self) { @@ -2674,6 +3007,13 @@ impl MediaViewerController { self.fullscreen_ui_hide_timer.replace(Some(timer)); } + fn hide_fullscreen_ui_now(self: &Rc) { + if self.window.is_fullscreen() { + self.cancel_fullscreen_ui_timer(); + self.set_fullscreen_ui_visible(false); + } + } + fn cancel_fullscreen_ui_timer(&self) { if let Some(timer) = self.fullscreen_ui_hide_timer.borrow_mut().take() { timer.remove(); @@ -2783,7 +3123,9 @@ fn file_list_typeahead_select( } if matches!(key, gdk::Key::space) || key.to_unicode().is_some_and(|character| { - character.eq_ignore_ascii_case(&'f') || matches!(character, '[' | ']') + character.eq_ignore_ascii_case(&'f') + || character.eq_ignore_ascii_case(&'l') + || matches!(character, '[' | ']' | '+' | '-') }) { return glib::Propagation::Proceed; @@ -2826,8 +3168,8 @@ fn file_list_typeahead_select( glib::Propagation::Stop } -fn fullscreen_key_reveals_ui(key: gdk::Key) -> bool { - !matches!( +fn fullscreen_direction_key(key: gdk::Key) -> bool { + matches!( key, gdk::Key::Left | gdk::Key::Right @@ -2840,6 +3182,14 @@ fn fullscreen_key_reveals_ui(key: gdk::Key) -> bool { ) } +fn shortcut_modifiers_blocked(modifiers: gdk::ModifierType) -> bool { + modifiers.intersects( + gdk::ModifierType::CONTROL_MASK + | gdk::ModifierType::ALT_MASK + | gdk::ModifierType::SUPER_MASK, + ) +} + fn find_sibling_prefix(files: &[SiblingFile], query: &str, start: usize) -> Option { if files.is_empty() { return None; @@ -3053,6 +3403,49 @@ 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) +} + +fn scaled_dimension(value: i32, zoom: f64) -> i32 { + ((value.max(1) as f64 * zoom).round() as i32).clamp(1, 30_000) +} + +fn zoomed_picture_request( + mode: ViewMode, + viewport_width: i32, + viewport_height: i32, + texture_width: i32, + texture_height: i32, + zoom: f64, +) -> (i32, i32) { + let viewport_width = viewport_width.max(1); + let viewport_height = viewport_height.max(1); + let texture_width = texture_width.max(1); + let texture_height = texture_height.max(1); + let (base_width, base_height) = match mode { + ViewMode::ActualSize => (texture_width, texture_height), + ViewMode::FitWidth | ViewMode::Continuous => ( + viewport_width, + row_height(viewport_width, texture_width as f32 / texture_height as f32), + ), + ViewMode::FitHeight | ViewMode::TwoPage => { + let scale = (viewport_width as f64 / texture_width as f64) + .min(viewport_height as f64 / texture_height as f64); + ( + (texture_width as f64 * scale).round() as i32, + (texture_height as f64 * scale).round() as i32, + ) + } + }; + ( + scaled_dimension(base_width, zoom.clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX)), + scaled_dimension(base_height, zoom.clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX)), + ) +} + fn texture_frames(page: DecodedPage) -> (Rc, usize) { let width = page.width; let height = page.height; @@ -3349,7 +3742,7 @@ mod tests { } #[test] - fn fullscreen_direction_keys_do_not_reveal_controls() { + fn fullscreen_direction_keys_hide_controls() { for key in [ gdk::Key::Left, gdk::Key::Right, @@ -3360,10 +3753,43 @@ mod tests { gdk::Key::KP_Up, gdk::Key::KP_Down, ] { - assert!(!fullscreen_key_reveals_ui(key)); + assert!(fullscreen_direction_key(key)); } - assert!(fullscreen_key_reveals_ui(gdk::Key::bracketleft)); - assert!(fullscreen_key_reveals_ui(gdk::Key::bracketright)); + assert!(!fullscreen_direction_key(gdk::Key::bracketleft)); + assert!(!fullscreen_direction_key(gdk::Key::bracketright)); + assert!(!fullscreen_direction_key(gdk::Key::f)); + } + + #[test] + 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(IMAGE_ZOOM_MAX, true), IMAGE_ZOOM_MAX); + assert_eq!(next_image_zoom(IMAGE_ZOOM_MIN, false), IMAGE_ZOOM_MIN); + } + + #[test] + fn file_list_shortcut_rejects_command_modifiers() { + assert!(!shortcut_modifiers_blocked(gdk::ModifierType::SHIFT_MASK)); + assert!(shortcut_modifiers_blocked(gdk::ModifierType::CONTROL_MASK)); + assert!(shortcut_modifiers_blocked(gdk::ModifierType::ALT_MASK)); + assert!(shortcut_modifiers_blocked(gdk::ModifierType::SUPER_MASK)); + } + + #[test] + fn zoomed_image_request_preserves_aspect_ratio() { + assert_eq!( + zoomed_picture_request(ViewMode::ActualSize, 1000, 800, 800, 1600, 1.1), + (880, 1760) + ); + assert_eq!( + zoomed_picture_request(ViewMode::FitWidth, 1000, 800, 800, 1600, 0.9), + (900, 1800) + ); + assert_eq!( + zoomed_picture_request(ViewMode::FitHeight, 1000, 800, 800, 1600, 1.1), + (440, 880) + ); } #[test] From f4a0c484ffc0775ad773123bb13bd47f43e302e1 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 24 Jul 2026 02:36:05 +0900 Subject: [PATCH 16/30] 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( From 6c54ec8139c3f2d7c0f9d058fc8dc3ecf60c0144 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 24 Jul 2026 21:55:41 +0900 Subject: [PATCH 17/30] Release v0.2.8 --- AGENTS.md | 8 +- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 2 + docs/RELEASING.ko.md | 8 +- src/database.rs | 168 ------------ src/viewer.rs | 116 ++++++++- src/window.rs | 244 +++--------------- 9 files changed, 151 insertions(+), 400 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d88581..b2da2b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ standalone images, and videos. - Rust - GTK4 with GIO/GVfs for desktop and remote filesystem integration -- SQLite for cached directory metadata, search indexes, and reading history +- SQLite for cached directory metadata and reading history - AppImage as the primary distribution format - Korean-first UI with all user-facing strings kept translatable @@ -38,8 +38,8 @@ standalone images, and videos. - Support ascending and descending sorting by name, modification time, and creation time. Treat unavailable creation times as unknown rather than inventing values. -- Search folder and ZIP names recursively below the current location using the - cached index. +- Filter the currently displayed browser directory by filename and restore all + entries when the search text is cleared. - Provide 100%, fit-width, fit-height, two-page, and continuous vertical viewing. - Support both right-to-left and left-to-right two-page layouts, plus controls that advance by one page to adjust page pairing. @@ -49,6 +49,8 @@ standalone images, and videos. - Provide a toggleable file list on the right side of each viewer. Double-click or Enter opens the selected viewable file; its context menu provides favorite and download actions. +- Filter each viewer's current file list with a temporary search field and + restore the complete list when the search text is cleared. - Accept one local file or folder dropped onto the main window or a viewer. Browse dropped folders in the main window; for dropped files, browse the parent folder and open the exact naturally sorted item in a new viewer window. diff --git a/Cargo.lock b/Cargo.lock index 0021796..2b7713f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.7" +version = "0.2.8" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5312a7a..9598d0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.7" +version = "0.2.8" 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 a5d9062..682e7d6 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 bf92e93..16e3805 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -82,6 +82,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다. - 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다. - 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다. +- 메인 창 검색은 현재 표시 중인 폴더의 파일명을 필터링하며, 입력을 지우면 전체 목록을 복원합니다. ## 뷰어 조작 @@ -90,6 +91,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동 - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 - `L`: 우측 파일 목록 표시 또는 숨기기 +- 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원 - 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index 5a2f002..b2a446f 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.7-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.8-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.7" -git tag -a v0.2.7 -m "Comicviewer v0.2.7" +git commit -m "Release v0.2.8" +git tag -a v0.2.8 -m "Comicviewer v0.2.8" git push origin main -git push origin v0.2.7 +git push origin v0.2.8 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/database.rs b/src/database.rs index 41e114b..c8aa0af 100644 --- a/src/database.rs +++ b/src/database.rs @@ -2,11 +2,8 @@ use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use gtk::gio::prelude::*; use rusqlite::{Connection, OptionalExtension, params}; -use crate::browser::{BrowserEntry, EntryKind}; - pub struct Database { connection: Connection, } @@ -108,124 +105,6 @@ impl Database { )?; Ok(()) } - - pub fn load_cached_tree(&self, location_id: &str, root_uri: &str) -> Result> { - let mut statement = self.connection.prepare( - "WITH RECURSIVE tree(uri, name, path, entry_kind, size, modified_at, created_at, etag) AS ( - SELECT uri, name, name, entry_kind, size, modified_at, created_at, etag - FROM cached_entries - WHERE location_id = ?1 AND parent_uri = ?2 - UNION ALL - SELECT child.uri, child.name, tree.path || '/' || child.name, - child.entry_kind, child.size, child.modified_at, child.created_at, child.etag - FROM cached_entries AS child - JOIN tree ON child.parent_uri = tree.uri - WHERE child.location_id = ?1 - ) - SELECT uri, name, path, entry_kind, size, modified_at, created_at, etag - FROM tree ORDER BY path", - )?; - let rows = statement.query_map(params![location_id, root_uri], |row| { - let kind = entry_kind_from_i64(row.get(3)?); - let size = row - .get::<_, Option>(4)? - .and_then(|value| u64::try_from(value).ok()); - Ok(BrowserEntry { - uri: row.get(0)?, - name: row.get(1)?, - list_name: Some(row.get(2)?), - kind, - size, - modified: row.get::<_, Option>(5)?.and_then(system_time_from_i64), - created: row.get::<_, Option>(6)?.and_then(system_time_from_i64), - etag: row.get(7)?, - }) - })?; - rows.collect::>>() - .map_err(Into::into) - } - - pub fn replace_cached_tree( - &self, - location_id: &str, - root_uri: &str, - entries: &[BrowserEntry], - ) -> Result<()> { - let transaction = self.connection.unchecked_transaction()?; - transaction.execute( - "WITH RECURSIVE tree(uri) AS ( - SELECT uri FROM cached_entries WHERE location_id = ?1 AND parent_uri = ?2 - UNION - SELECT child.uri FROM cached_entries AS child - JOIN tree ON child.parent_uri = tree.uri - WHERE child.location_id = ?1 - ) - DELETE FROM cached_entries - WHERE location_id = ?1 AND uri IN (SELECT uri FROM tree)", - params![location_id, root_uri], - )?; - let refreshed_at = system_time_to_i64(SystemTime::now()); - { - let mut statement = transaction.prepare( - "INSERT INTO cached_entries - (location_id, parent_uri, uri, name, entry_kind, size, modified_at, - created_at, etag, refreshed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - )?; - for entry in entries { - let parent_uri = gtk::gio::File::for_uri(&entry.uri) - .parent() - .map(|parent| parent.uri().to_string()) - .unwrap_or_else(|| root_uri.to_owned()); - statement.execute(params![ - location_id, - parent_uri, - entry.uri, - entry.name, - entry_kind_to_i64(entry.kind), - entry.size.and_then(|value| i64::try_from(value).ok()), - entry.modified.map(system_time_to_i64), - entry.created.map(system_time_to_i64), - entry.etag, - refreshed_at, - ])?; - } - } - transaction.commit()?; - Ok(()) - } -} - -fn entry_kind_to_i64(kind: EntryKind) -> i64 { - match kind { - EntryKind::Directory => 0, - EntryKind::ZipArchive => 1, - EntryKind::Image => 2, - EntryKind::Video => 3, - EntryKind::OtherFile => 4, - } -} - -fn entry_kind_from_i64(value: i64) -> EntryKind { - match value { - 0 => EntryKind::Directory, - 1 => EntryKind::ZipArchive, - 2 => EntryKind::Image, - 3 => EntryKind::Video, - _ => EntryKind::OtherFile, - } -} - -fn system_time_to_i64(time: SystemTime) -> i64 { - time.duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64 -} - -fn system_time_from_i64(seconds: i64) -> Option { - u64::try_from(seconds) - .ok() - .map(|seconds| UNIX_EPOCH + std::time::Duration::from_secs(seconds)) } #[cfg(test)] @@ -243,51 +122,4 @@ mod tests { database.save_progress("test.zip", 9, 20).unwrap(); assert_eq!(database.load_progress("test.zip").unwrap(), Some(9)); } - - #[test] - fn cached_tree_replaces_descendants_and_restores_relative_paths() { - let database = Database::open(Path::new(":memory:")).unwrap(); - let entries = vec![ - BrowserEntry { - uri: "file:///library/series".into(), - name: "series".into(), - list_name: Some("series".into()), - kind: EntryKind::Directory, - size: None, - modified: None, - created: None, - etag: None, - }, - BrowserEntry { - uri: "file:///library/series/book.zip".into(), - name: "book.zip".into(), - list_name: Some("series/book.zip".into()), - kind: EntryKind::ZipArchive, - size: Some(42), - modified: None, - created: None, - etag: Some("v1".into()), - }, - ]; - - database - .replace_cached_tree("library", "file:///library", &entries) - .unwrap(); - let loaded = database - .load_cached_tree("library", "file:///library") - .unwrap(); - assert_eq!(loaded.len(), 2); - assert_eq!(loaded[1].display_name(), "series/book.zip"); - - database - .replace_cached_tree("library", "file:///library", &entries[..1]) - .unwrap(); - assert_eq!( - database - .load_cached_tree("library", "file:///library") - .unwrap() - .len(), - 1 - ); - } } diff --git a/src/viewer.rs b/src/viewer.rs index 6cf15f5..98bf9b9 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 VIEWER_FILE_SEARCH_KEY: &str = "comicviewer-file-search"; const PRESERVE_SEEK_CLASS: &str = "viewer-preserve-seek"; fn clear_viewer_controller(window: >k::ApplicationWindow) { @@ -275,6 +276,10 @@ impl ViewerWindow { view_mode_control: _, } = controls; let title = source.display_name.clone(); + let file_list_query = reuse_window + .as_ref() + .map(viewer_file_search_query) + .unwrap_or_default(); if let Some(window) = reuse_window.as_ref() { clear_viewer_controller(window); } @@ -399,7 +404,7 @@ impl ViewerWindow { window_overlay.add_overlay(&navigation_message); window_overlay.set_hexpand(true); window_overlay.set_vexpand(true); - let file_list = viewer_file_list(); + let file_list = viewer_file_list(&file_list_query); 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); @@ -438,6 +443,7 @@ impl ViewerWindow { sibling_navigator, file_list_button, file_list_panel: file_list.panel, + file_list_search: file_list.search, file_list: file_list.list, file_list_files: RefCell::new(Vec::new()), favorite_button, @@ -605,7 +611,8 @@ struct ViewerController { case_insensitive_sort: bool, sibling_navigator: Option, file_list_button: gtk::ToggleButton, - file_list_panel: gtk::ScrolledWindow, + file_list_panel: gtk::Box, + file_list_search: gtk::SearchEntry, file_list: gtk::ListBox, file_list_files: RefCell>, favorite_button: gtk::Button, @@ -717,6 +724,13 @@ impl ViewerController { controller.open_file_list_row(row.index()); } }); + let controller = Rc::downgrade(self); + self.file_list_search.connect_search_changed(move |search| { + if let Some(controller) = controller.upgrade() { + store_viewer_file_search_query(&controller.window, search.text().as_str()); + controller.refresh_file_list(); + } + }); let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default())); let file_list_keys = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); @@ -890,6 +904,7 @@ impl ViewerController { let controller = Rc::downgrade(self); fullscreen_keys.connect_key_pressed(move |_, key, _, _| { if let Some(controller) = controller.upgrade() + && !widget_contains_focus(&controller.file_list_search, &controller.window) && controller.window.is_fullscreen() && fullscreen_direction_key(key) { @@ -907,6 +922,9 @@ impl ViewerController { let Some(controller) = controller.upgrade() else { return glib::Propagation::Proceed; }; + if widget_contains_focus(&controller.file_list_search, &controller.window) { + return glib::Propagation::Proceed; + } if !controller.window.is_fullscreen() { controller.hide_seek(); } @@ -1026,6 +1044,7 @@ impl ViewerController { .as_ref() .map(SiblingNavigator::files) .unwrap_or_default(); + let files = filter_sibling_files(files, self.file_list_search.text().as_str()); let current = files .iter() .position(|file| file.uri == self.source.source_uri); @@ -2167,6 +2186,10 @@ impl MediaViewerWindow { view_mode_control, } = controls; let title = source.display_name.clone(); + let file_list_query = reuse_window + .as_ref() + .map(viewer_file_search_query) + .unwrap_or_default(); if let Some(window) = reuse_window.as_ref() { clear_viewer_controller(window); } @@ -2263,7 +2286,7 @@ impl MediaViewerWindow { window_overlay.add_overlay(&navigation_message); window_overlay.set_hexpand(true); window_overlay.set_vexpand(true); - let file_list = viewer_file_list(); + let file_list = viewer_file_list(&file_list_query); 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); @@ -2292,6 +2315,7 @@ impl MediaViewerWindow { sibling_navigator, file_list_button, file_list_panel: file_list.panel, + file_list_search: file_list.search, file_list: file_list.list, file_list_files: RefCell::new(Vec::new()), favorite_button, @@ -2377,7 +2401,8 @@ struct MediaViewerController { seek_files: RefCell>, sibling_navigator: Option, file_list_button: gtk::ToggleButton, - file_list_panel: gtk::ScrolledWindow, + file_list_panel: gtk::Box, + file_list_search: gtk::SearchEntry, file_list: gtk::ListBox, file_list_files: RefCell>, favorite_button: gtk::Button, @@ -2434,6 +2459,7 @@ impl MediaViewerController { let controller = Rc::downgrade(self); fullscreen_keys.connect_key_pressed(move |_, key, _, _| { if let Some(controller) = controller.upgrade() + && !widget_contains_focus(&controller.file_list_search, &controller.window) && controller.window.is_fullscreen() && fullscreen_direction_key(key) { @@ -2451,6 +2477,9 @@ impl MediaViewerController { let Some(controller) = controller.upgrade() else { return glib::Propagation::Proceed; }; + if widget_contains_focus(&controller.file_list_search, &controller.window) { + return glib::Propagation::Proceed; + } if modifiers.intersects( gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::ALT_MASK @@ -2596,6 +2625,13 @@ impl MediaViewerController { controller.open_file_list_row(row.index()); } }); + let controller = Rc::downgrade(self); + self.file_list_search.connect_search_changed(move |search| { + if let Some(controller) = controller.upgrade() { + store_viewer_file_search_query(&controller.window, search.text().as_str()); + controller.refresh_file_list(); + } + }); let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default())); let file_list_keys = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); @@ -2716,6 +2752,7 @@ impl MediaViewerController { .as_ref() .map(SiblingNavigator::files) .unwrap_or_default(); + let files = filter_sibling_files(files, self.file_list_search.text().as_str()); let current = files .iter() .position(|file| file.uri == self.source.source_uri); @@ -3254,7 +3291,8 @@ fn favorite_label(active: bool) -> &'static str { } struct ViewerFileList { - panel: gtk::ScrolledWindow, + panel: gtk::Box, + search: gtk::SearchEntry, list: gtk::ListBox, } @@ -3361,18 +3399,56 @@ fn find_sibling_prefix(files: &[SiblingFile], query: &str, start: usize) -> Opti .find(|index| files[*index].name.to_lowercase().starts_with(&query)) } -fn viewer_file_list() -> ViewerFileList { +fn viewer_file_list(query: &str) -> ViewerFileList { + let search = gtk::SearchEntry::builder() + .placeholder_text("재생 목록 검색") + .margin_top(8) + .margin_bottom(8) + .margin_start(8) + .margin_end(8) + .build(); + search.set_text(query); 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) + let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) .child(&list) .build(); + let panel = gtk::Box::new(gtk::Orientation::Vertical, 0); + panel.set_width_request(280); + panel.set_vexpand(true); + panel.append(&search); + panel.append(&scrolled); panel.add_css_class("sidebar"); panel.set_visible(false); - ViewerFileList { panel, list } + ViewerFileList { + panel, + search, + list, + } +} + +fn viewer_file_search_query(window: >k::ApplicationWindow) -> String { + unsafe { + window + .data::(VIEWER_FILE_SEARCH_KEY) + .map(|query| query.as_ref().clone()) + .unwrap_or_default() + } +} + +fn store_viewer_file_search_query(window: >k::ApplicationWindow, query: &str) { + unsafe { + window.set_data(VIEWER_FILE_SEARCH_KEY, query.to_owned()); + } +} + +fn widget_contains_focus(widget: &impl IsA, window: >k::ApplicationWindow) -> bool { + gtk::prelude::GtkWindowExt::focus(window).is_some_and(|focus| { + let widget = widget.as_ref(); + focus == *widget || focus.is_ancestor(widget) + }) } fn viewer_file_row(file: &SiblingFile) -> gtk::ListBoxRow { @@ -3774,6 +3850,14 @@ fn seek_sibling_files( files } +fn filter_sibling_files(mut files: Vec, query: &str) -> Vec { + let query = query.to_lowercase(); + if !query.is_empty() { + files.retain(|file| file.name.to_lowercase().contains(&query)); + } + files +} + fn seek_position(files: &[SiblingFile], current_uri: &str) -> Option { files.iter().position(|file| file.uri == current_uri) } @@ -3981,6 +4065,20 @@ mod tests { assert_eq!(seek_sibling_files(files, EntryKind::Image, true).len(), 3); } + #[test] + fn playlist_search_filters_and_empty_query_restores_files() { + let files = ["Alpha.zip", "beta.jpg", "Bravo.mp4"].map(|name| SiblingFile { + uri: format!("file:///{name}"), + name: name.into(), + kind: EntryKind::Image, + }); + + let filtered = filter_sibling_files(files.to_vec(), "BRAV"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "Bravo.mp4"); + assert_eq!(filter_sibling_files(files.to_vec(), "").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 8df633e..534741c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,4 +1,4 @@ -use std::cell::{Cell, RefCell}; +use std::cell::RefCell; use std::fs; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -97,7 +97,7 @@ impl ComicWindow { let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6); let search_entry = gtk::SearchEntry::builder() - .placeholder_text("현재 위치 아래 폴더와 ZIP 검색") + .placeholder_text("현재 폴더에서 검색") .hexpand(true) .build(); let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); @@ -152,10 +152,6 @@ impl ComicWindow { current_uri: root_uri, entries: Vec::new(), visible_entries: Vec::new(), - recursive_entries: Vec::new(), - recursive_root_uri: None, - recursive_ready: false, - recursive_scanning: false, sort_field: SortField::Name, sort_direction: SortDirection::Ascending, generation: 0, @@ -167,8 +163,6 @@ impl ComicWindow { database: Rc::new(database), zip_cache, listing_task: RefCell::new(None), - search_task: RefCell::new(None), - search_generation: Cell::new(0), }); controller.rebuild_profiles(); @@ -204,10 +198,6 @@ struct BrowserState { current_uri: String, entries: Vec, visible_entries: Vec, - recursive_entries: Vec, - recursive_root_uri: Option, - recursive_ready: bool, - recursive_scanning: bool, sort_field: SortField, sort_direction: SortDirection, generation: u64, @@ -234,8 +224,6 @@ struct BrowserController { database: Rc, zip_cache: Rc, listing_task: RefCell>>, - search_task: RefCell>>, - search_generation: Cell, } impl BrowserController { @@ -317,7 +305,7 @@ impl BrowserController { .set_list_factory(Some(&location_factory)); let controller = Rc::clone(self); self.search_entry - .connect_search_changed(move |_| controller.search_changed()); + .connect_search_changed(move |_| controller.render()); let controller = Rc::clone(self); self.sort_dropdown .connect_selected_notify(move |_| controller.change_sort_field()); @@ -432,7 +420,6 @@ impl BrowserController { fn show_favorites(&self) { self.cancel_listing(); - self.cancel_recursive_search(); self.edit_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false); self.path_entry.set_text("즐겨찾기"); @@ -594,12 +581,13 @@ impl BrowserController { return; } self.cancel_listing(); - self.cancel_recursive_search(); let (profile, uri, generation) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.typeahead_query.clear(); state.typeahead_last_input = None; + state.entries.clear(); + state.visible_entries.clear(); ( state .favorite_profile @@ -609,6 +597,9 @@ impl BrowserController { state.generation, ) }; + while let Some(child) = self.list_box.first_child() { + self.list_box.remove(&child); + } self.path_entry.set_text(&display_uri(&uri)); self.status_label .set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)"); @@ -640,9 +631,6 @@ impl BrowserController { Ok(entries) => { controller.state.borrow_mut().entries = entries; controller.render(); - if !controller.search_entry.text().is_empty() { - controller.start_recursive_search(); - } } Err(error) => controller.status_label.set_text(&format!("{error:#}")), } @@ -656,142 +644,6 @@ impl BrowserController { } } - fn cancel_recursive_search(&self) { - if let Some(task) = self.search_task.borrow_mut().take() { - task.abort(); - } - self.search_generation - .set(self.search_generation.get().wrapping_add(1)); - let mut state = self.state.borrow_mut(); - state.recursive_entries.clear(); - state.recursive_root_uri = None; - state.recursive_ready = false; - state.recursive_scanning = false; - } - - fn search_changed(self: &Rc) { - if self.search_entry.text().is_empty() { - self.cancel_recursive_search(); - self.render(); - return; - } - self.render(); - if !self.state.borrow().browsing_favorites { - self.start_recursive_search(); - } - } - - fn start_recursive_search(self: &Rc) { - let (profile, root_uri) = { - let mut state = self.state.borrow_mut(); - if state.browsing_favorites - || (state.recursive_root_uri.as_deref() == Some(&state.current_uri) - && (state.recursive_ready || state.recursive_scanning)) - { - return; - } - let root_uri = state.current_uri.clone(); - state.recursive_root_uri = Some(root_uri.clone()); - state.recursive_entries.clear(); - state.recursive_ready = false; - state.recursive_scanning = true; - ( - state - .favorite_profile - .clone() - .unwrap_or_else(|| self.profiles()[state.profile_index].clone()), - root_uri, - ) - }; - if let Some(task) = self.search_task.borrow_mut().take() { - task.abort(); - } - let generation = self.search_generation.get().wrapping_add(1); - self.search_generation.set(generation); - let root = gio::File::for_uri(&root_uri); - let location_id = root_file(&profile) - .map(|root| root.uri().to_string()) - .unwrap_or_else(|_| profile.address.clone()); - if let Ok(cached_entries) = self.database.load_cached_tree(&location_id, &root_uri) - && !cached_entries.is_empty() - { - let mut state = self.state.borrow_mut(); - state.recursive_entries = cached_entries; - state.recursive_ready = true; - drop(state); - self.render(); - } - let operation = mount_operation(&profile, &self.window); - let progress_controller = Rc::downgrade(self); - let controller = Rc::downgrade(self); - let task = glib::MainContext::default().spawn_local(async move { - let result = - enumerate_tree_entries(&profile, &root, Some(&operation), move |progress| { - let Some(controller) = progress_controller.upgrade() else { - return; - }; - if controller.search_generation.get() == generation - && !controller.search_entry.text().is_empty() - { - controller.status_label.set_text(&format!( - "하위 폴더 검색 색인 생성 중... (폴더 {}개, 항목 {}개)", - progress.directories, progress.scanned - )); - } - }) - .await; - let Some(controller) = controller.upgrade() else { - return; - }; - if controller.search_generation.get() != generation - || controller.state.borrow().current_uri != root_uri - { - return; - } - match result { - Ok((entries, progress)) => { - let cache_error = (progress.skipped == 0) - .then(|| { - controller.database.replace_cached_tree( - &location_id, - &root_uri, - &entries, - ) - }) - .transpose() - .err(); - let mut state = controller.state.borrow_mut(); - state.recursive_entries = entries; - state.recursive_ready = true; - state.recursive_scanning = false; - drop(state); - if !controller.search_entry.text().is_empty() { - controller.render(); - if progress.skipped > 0 { - controller.status_label.set_text(&format!( - "검색 결과 생성 완료 · 접근할 수 없는 폴더 {}개 건너뜀", - progress.skipped - )); - } else if let Some(error) = cache_error { - controller - .status_label - .set_text(&format!("검색 색인을 저장할 수 없습니다: {error:#}")); - } - } - } - Err(error) => { - controller.state.borrow_mut().recursive_scanning = false; - if !controller.search_entry.text().is_empty() { - controller - .status_label - .set_text(&format!("하위 폴더 검색을 완료할 수 없습니다: {error:#}")); - } - } - } - }); - self.search_task.replace(Some(task)); - } - fn render(&self) { while let Some(child) = self.list_box.first_child() { self.list_box.remove(&child); @@ -799,17 +651,10 @@ impl BrowserController { let query = self.search_entry.text().to_lowercase(); let case_insensitive = self.config.borrow().case_insensitive_sort; let mut state = self.state.borrow_mut(); - let recursive_ready = !query.is_empty() - && state.recursive_ready - && state.recursive_root_uri.as_deref() == Some(&state.current_uri); - let source = if recursive_ready { - &state.recursive_entries - } else { - &state.entries - }; - let mut visible_entries: Vec<_> = source + let mut visible_entries: Vec<_> = state + .entries .iter() - .filter(|entry| query.is_empty() || recursive_search_match(entry, query.as_str())) + .filter(|entry| browser_search_match(entry, query.as_str())) .cloned() .collect(); sort_entries( @@ -823,14 +668,7 @@ impl BrowserController { } let count = visible_entries.len(); state.visible_entries = visible_entries; - let status = if query.is_empty() { - format!("{count}개 항목") - } else if state.recursive_scanning { - format!("현재 폴더 결과 {count}개 · 하위 폴더 색인 생성 중...") - } else { - format!("하위 폴더 검색 결과 {count}개") - }; - self.status_label.set_text(&status); + self.status_label.set_text(&format!("{count}개 항목")); } fn typeahead( @@ -964,23 +802,6 @@ impl BrowserController { self.refresh(); } EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { - if !self.search_entry.text().is_empty() && entry.list_name.is_some() { - let siblings = self - .state - .borrow() - .visible_entries - .iter() - .filter(|candidate| candidate.kind.is_viewable()) - .cloned() - .collect::>(); - self.open_viewable( - entry.clone(), - Rc::new(siblings), - self.current_profile(), - None, - ); - return; - } let current_kind = entry.kind; let case_insensitive = self.config.borrow().case_insensitive_sort; let siblings = { @@ -1363,7 +1184,6 @@ impl BrowserController { } fn open_folder_recursive(self: &Rc, entry: BrowserEntry) { - self.cancel_recursive_search(); let profile = self.profile_for_entry(&entry); let Ok(profile_root) = root_file(&profile) else { self.status_label @@ -1491,7 +1311,6 @@ impl BrowserController { } let file = file.clone(); self.cancel_listing(); - self.cancel_recursive_search(); let generation = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); @@ -2155,6 +1974,10 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { current.is_viewable() && candidate.is_viewable() } +fn browser_search_match(entry: &BrowserEntry, lowercase_query: &str) -> bool { + lowercase_query.is_empty() || entry.name.to_lowercase().contains(lowercase_query) +} + fn is_favorites_selection(selected: u32, profile_count: usize) -> bool { selected != gtk::INVALID_LIST_POSITION && selected as usize == profile_count } @@ -2193,14 +2016,6 @@ fn sorted_navigable_selection( Some((entries, selected)) } -fn recursive_search_match(entry: &BrowserEntry, lowercase_query: &str) -> bool { - matches!(entry.kind, EntryKind::Directory | EntryKind::ZipArchive) - && entry - .display_name() - .to_lowercase() - .contains(lowercase_query) -} - fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { BrowserEntry { uri: favorite.uri.clone(), @@ -2380,19 +2195,6 @@ mod tests { assert_eq!(siblings[selected].name, "page10.jpg"); } - #[test] - fn recursive_search_only_matches_folders_and_zip_names() { - let mut folder = entry("Comics"); - folder.list_name = Some("Archive/Comics".into()); - let mut zip = media_entry("book.zip"); - zip.kind = EntryKind::ZipArchive; - let image = media_entry("book.jpg"); - - assert!(recursive_search_match(&folder, "comics")); - assert!(recursive_search_match(&zip, "book")); - assert!(!recursive_search_match(&image, "book")); - } - #[test] fn mouse_back_button_matches_standard_x11_button() { assert_eq!(MOUSE_BACK_BUTTON, 8); @@ -2411,6 +2213,20 @@ mod tests { assert!(profile.password.is_empty()); } + #[test] + fn browser_search_filters_all_current_directory_entry_kinds() { + let folder = entry("Comics"); + let image = media_entry("cover.jpg"); + + assert!(browser_search_match(&folder, "comic")); + assert!(browser_search_match(&image, "cover")); + assert!(!browser_search_match(&image, "comic")); + assert!(browser_search_match(&image, "")); + let mut nested = media_entry("cover.jpg"); + nested.list_name = Some("Comics/cover.jpg".into()); + assert!(!browser_search_match(&nested, "comic")); + } + #[test] fn favorites_are_rendered_as_browser_paths_and_files() { let profile = home_profile(); From ae1f0c7c4a4c4d3e975ea6f898194b408f1c2869 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 24 Jul 2026 22:13:07 +0900 Subject: [PATCH 18/30] Release v0.2.9 --- AGENTS.md | 2 + Cargo.lock | 25 +- Cargo.toml | 4 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 4 +- docs/RELEASING.ko.md | 8 +- packaging/install-or-update.sh | 2 +- src/window.rs | 296 +++++++++++++++++- 8 files changed, 330 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2da2b4..a550f62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ standalone images, and videos. - Save favorites for local and remote folders and files, and preserve the saved location profile needed to reconnect to remote favorites. - Save progress per ZIP and save the most recently read ZIP for resume support. +- Let users update from the About window by downloading the fixed Cafe24 latest + bundle and checksum, verifying SHA-256, and reusing the atomic installer. ## Data Locations diff --git a/Cargo.lock b/Cargo.lock index 2b7713f..e6712b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,17 +182,19 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.8" +version = "0.2.9" dependencies = [ "anyhow", "chrono", "filetime", + "flate2", "gtk4", "image", "percent-encoding", "rusqlite", "serde", "sha2", + "tar", "tempfile", "toml 0.9.12+spec-1.1.0", "zip", @@ -1211,6 +1213,17 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1475,6 +1488,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "zerocopy" version = "0.8.54" diff --git a/Cargo.toml b/Cargo.toml index 9598d0e..786a6e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.8" +version = "0.2.9" edition = "2024" description = "A comic archive viewer for local and remote locations" license = "GPL-3.0-or-later" @@ -11,11 +11,13 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] } image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] } filetime = "0.2" +flate2 = "1.0" percent-encoding = "2.3" rusqlite = { version = "0.37", features = ["bundled"] } serde = { version = "1.0", features = ["derive"] } sha2 = "0.10" tempfile = "3.20" +tar = "0.4" toml = "0.9" zip = { version = "8", default-features = false, features = ["deflate"] } diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml index 682e7d6..93a76d1 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 16e3805..3f749dc 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -9,7 +9,7 @@ Comicviewer는 CachyOS와 Arch Linux에서 사용할 수 있는 만화·미디 설치합니다. 설치 과정에서 `sudo` 암호와 pacman 확인을 요청할 수 있습니다. ```sh -sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer \ +sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl \ gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav ``` @@ -18,6 +18,7 @@ sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer \ - `gvfs`: FTP와 SFTP 접근 - `gvfs-smb`: SMB 공유 접근 - `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생 +- `curl`: About 창에서 최신 업데이트 다운로드 ## 2. 권장 방법: 통합 번들로 설치 또는 업데이트 @@ -92,6 +93,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 - `L`: 우측 파일 목록 표시 또는 숨기기 - 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원 +- About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치 - 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index b2a446f..d56de51 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.8-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.9-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.8" -git tag -a v0.2.8 -m "Comicviewer v0.2.8" +git commit -m "Release v0.2.9" +git tag -a v0.2.9 -m "Comicviewer v0.2.9" git push origin main -git push origin v0.2.8 +git push origin v0.2.9 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/packaging/install-or-update.sh b/packaging/install-or-update.sh index df1d294..fbc1893 100755 --- a/packaging/install-or-update.sh +++ b/packaging/install-or-update.sh @@ -39,7 +39,7 @@ install_dependencies() { fi set -- - for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer gst-plugins-base \ + for package in fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl gst-plugins-base \ gst-plugins-good gst-plugins-bad gst-libav; do if ! pacman -Q "${package}" >/dev/null 2>&1; then set -- "$@" "${package}" diff --git a/src/window.rs b/src/window.rs index 534741c..e9fdfd3 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,15 +1,22 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::Command; use std::rc::Rc; +use std::sync::mpsc; +use std::thread; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Local}; +use flate2::read::GzDecoder; use gtk::gio; use gtk::gio::prelude::*; use gtk::glib; use gtk::prelude::*; +use sha2::{Digest, Sha256}; +use tar::Archive; use crate::browser::{ BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, @@ -29,6 +36,9 @@ use crate::viewer::{ use crate::zip_cache::ZipCache; const MOUSE_BACK_BUTTON: u32 = 8; +const UPDATE_BUNDLE_URL: &str = + "https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz"; +const UPDATE_CHECKSUM_URL: &str = "https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256"; pub struct ComicWindow { controller: Rc, @@ -163,6 +173,7 @@ impl ComicWindow { database: Rc::new(database), zip_cache, listing_task: RefCell::new(None), + update_in_progress: Rc::new(Cell::new(false)), }); controller.rebuild_profiles(); @@ -224,6 +235,7 @@ struct BrowserController { database: Rc, zip_cache: Rc, listing_task: RefCell>>, + update_in_progress: Rc>, } impl BrowserController { @@ -1613,18 +1625,72 @@ impl BrowserController { "사용한 라이브러리\n\ GTK4 / GIO · GStreamer · image · zip · rusqlite / SQLite\n\ serde · toml · anyhow · chrono · filetime · percent-encoding\n\ - sha2 · tempfile", + sha2 · tempfile · flate2 · tar", )); libraries.set_xalign(0.0); libraries.set_wrap(true); let close_button = gtk::Button::with_label("닫기"); - close_button.set_halign(gtk::Align::End); + let update_button = gtk::Button::with_label("업데이트"); + let update_status = gtk::Label::new(None); + update_status.set_wrap(true); + update_status.set_xalign(0.0); + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + actions.set_halign(gtk::Align::End); + actions.append(&update_button); + actions.append(&close_button); + if self.update_in_progress.get() { + update_button.set_sensitive(false); + update_status.set_text("다른 업데이트 작업이 진행 중입니다."); + } let dialog_to_close = dialog.clone(); close_button.connect_clicked(move |_| dialog_to_close.close()); + let status = update_status.clone(); + let update_in_progress = Rc::clone(&self.update_in_progress); + update_button.connect_clicked(move |button| { + if update_in_progress.replace(true) { + return; + } + button.set_sensitive(false); + status.set_text("업데이트를 다운로드하고 검증하는 중..."); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result = install_latest_update().map_err(|error| format!("{error:#}")); + let _ = sender.send(result); + }); + let button = button.clone(); + let status = status.clone(); + let update_in_progress = Rc::clone(&update_in_progress); + glib::timeout_add_local(Duration::from_millis(100), move || { + match receiver.try_recv() { + Ok(Ok(())) => { + update_in_progress.set(false); + status.set_text( + "업데이트를 완료했습니다. 앱을 종료한 뒤 ~/.local/bin/comicviewer로 다시 실행해 주세요.", + ); + button.set_label("업데이트 완료"); + glib::ControlFlow::Break + } + Ok(Err(error)) => { + update_in_progress.set(false); + status.set_text(&format!("업데이트 실패: {error}")); + button.set_sensitive(true); + glib::ControlFlow::Break + } + Err(mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(mpsc::TryRecvError::Disconnected) => { + update_in_progress.set(false); + status.set_text("업데이트 작업이 예기치 않게 종료되었습니다."); + button.set_sensitive(true); + glib::ControlFlow::Break + } + } + }); + }); content.append(&name); content.append(&contact); content.append(&libraries); - content.append(&close_button); + content.append(&update_status); + content.append(&actions); dialog.set_child(Some(&content)); dialog.present(); } @@ -1855,6 +1921,150 @@ impl BrowserController { } } +fn install_latest_update() -> Result<()> { + let directory = tempfile::tempdir().context("업데이트 임시 폴더를 만들 수 없습니다")?; + let archive = directory.path().join("Comicviewer-latest-x86_64.tar.gz"); + let checksum = directory + .path() + .join("Comicviewer-latest-x86_64.tar.gz.sha256"); + download_update_file(UPDATE_BUNDLE_URL, &archive)?; + download_update_file(UPDATE_CHECKSUM_URL, &checksum)?; + + let checksum_text = + fs::read_to_string(&checksum).context("업데이트 체크섬을 읽을 수 없습니다")?; + let expected = parse_update_checksum(&checksum_text)?; + let actual = sha256_file(&archive)?; + if actual != expected { + bail!("다운로드한 업데이트 체크섬이 일치하지 않습니다"); + } + + let extraction = directory.path().join("extracted"); + fs::create_dir(&extraction).context("업데이트 압축 해제 폴더를 만들 수 없습니다")?; + extract_update_archive(&archive, &extraction)?; + let installer = extraction + .join("Comicviewer-latest-x86_64") + .join("install-or-update.sh"); + if !installer.is_file() { + bail!("업데이트 번들에 설치 스크립트가 없습니다"); + } + run_update_command( + Command::new("sh") + .arg(&installer) + .env("COMICVIEWER_SKIP_DEPENDENCIES", "1"), + "업데이트 설치에 실패했습니다", + ) +} + +fn download_update_file(url: &str, destination: &Path) -> Result<()> { + let url = format!("{url}?from={}", env!("CARGO_PKG_VERSION")); + run_update_command( + Command::new("curl") + .arg("--fail") + .arg("--silent") + .arg("--show-error") + .arg("--location") + .arg("--proto") + .arg("=https") + .arg("--proto-redir") + .arg("=https") + .arg("--connect-timeout") + .arg("15") + .arg("--max-time") + .arg("300") + .arg("--max-filesize") + .arg((512_u64 * 1024 * 1024).to_string()) + .arg("--retry") + .arg("3") + .arg("--output") + .arg(destination) + .arg(url), + "업데이트를 다운로드할 수 없습니다. curl 설치와 네트워크 연결을 확인해 주세요", + ) +} + +fn run_update_command(command: &mut Command, context: &str) -> Result<()> { + let output = command.output().with_context(|| context.to_owned())?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr) + .trim() + .chars() + .take(600) + .collect::(); + if stderr.is_empty() { + bail!("{context}"); + } + bail!("{context}: {stderr}") +} + +fn extract_update_archive(archive_path: &Path, destination: &Path) -> Result<()> { + const MAX_ENTRIES: usize = 32; + const MAX_EXTRACTED_BYTES: u64 = 512 * 1024 * 1024; + + let file = fs::File::open(archive_path).context("업데이트 압축 파일을 열 수 없습니다")?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let mut count = 0_usize; + let mut extracted_bytes = 0_u64; + for entry in archive + .entries() + .context("업데이트 압축 목록을 읽을 수 없습니다")? + { + let mut entry = entry.context("업데이트 압축 항목을 읽을 수 없습니다")?; + count += 1; + if count > MAX_ENTRIES { + bail!("업데이트 압축 항목 수가 안전 제한을 초과합니다"); + } + let entry_type = entry.header().entry_type(); + if !entry_type.is_file() && !entry_type.is_dir() { + bail!("업데이트 압축에 허용되지 않은 파일 종류가 있습니다"); + } + let path = entry + .path() + .context("업데이트 압축 경로가 올바르지 않습니다")?; + if !path.starts_with("Comicviewer-latest-x86_64") { + bail!("업데이트 압축 경로가 올바르지 않습니다"); + } + extracted_bytes = extracted_bytes.saturating_add(entry.header().size().unwrap_or(u64::MAX)); + if extracted_bytes > MAX_EXTRACTED_BYTES { + bail!("업데이트 압축 크기가 안전 제한을 초과합니다"); + } + if !entry + .unpack_in(destination) + .context("업데이트 압축을 풀 수 없습니다")? + { + bail!("업데이트 압축 경로가 임시 폴더를 벗어납니다"); + } + } + Ok(()) +} + +fn parse_update_checksum(contents: &str) -> Result { + let checksum = contents + .split_whitespace() + .next() + .filter(|checksum| { + checksum.len() == 64 && checksum.chars().all(|value| value.is_ascii_hexdigit()) + }) + .context("업데이트 체크섬 파일이 올바르지 않습니다")?; + Ok(checksum.to_ascii_lowercase()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path).context("다운로드한 업데이트를 읽을 수 없습니다")?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(format!("{:x}", digest.finalize())) +} + fn location_kind_index(kind: LocationKind) -> u32 { match kind { LocationKind::Local => 0, @@ -2227,6 +2437,84 @@ mod tests { assert!(!browser_search_match(&nested, "comic")); } + #[test] + fn update_checksum_parser_validates_sha256_text() { + let checksum = + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789 bundle.tar.gz\n"; + assert_eq!( + parse_update_checksum(checksum).unwrap(), + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + ); + assert!(parse_update_checksum("not-a-checksum").is_err()); + } + + #[test] + fn update_sha256_matches_known_content() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("content"); + fs::write(&file, b"abc").unwrap(); + assert_eq!( + sha256_file(&file).unwrap(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn update_archive_extracts_regular_files_and_rejects_symlinks() { + let directory = tempfile::tempdir().unwrap(); + let valid_archive = directory.path().join("valid.tar.gz"); + { + let file = fs::File::create(&valid_archive).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + let content = b"#!/bin/sh\n"; + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive + .append_data( + &mut header, + "Comicviewer-latest-x86_64/install-or-update.sh", + &content[..], + ) + .unwrap(); + archive.into_inner().unwrap().finish().unwrap(); + } + let extracted = directory.path().join("valid"); + fs::create_dir(&extracted).unwrap(); + extract_update_archive(&valid_archive, &extracted).unwrap(); + assert!( + extracted + .join("Comicviewer-latest-x86_64/install-or-update.sh") + .is_file() + ); + + let link_archive = directory.path().join("link.tar.gz"); + { + let file = fs::File::create(&link_archive).unwrap(); + let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + let mut archive = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_link_name("../../escape").unwrap(); + header.set_cksum(); + archive + .append_data( + &mut header, + "Comicviewer-latest-x86_64/link", + std::io::empty(), + ) + .unwrap(); + archive.into_inner().unwrap().finish().unwrap(); + } + let rejected = directory.path().join("rejected"); + fs::create_dir(&rejected).unwrap(); + assert!(extract_update_archive(&link_archive, &rejected).is_err()); + } + #[test] fn favorites_are_rendered_as_browser_paths_and_files() { let profile = home_profile(); From f7c67c1a79215e63b85673d5b4f0e2bf34960797 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 24 Jul 2026 23:44:49 +0900 Subject: [PATCH 19/30] Release v0.2.10 --- AGENTS.md | 3 +- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 1 + docs/RELEASING.ko.md | 8 ++--- src/config.rs | 17 ++++++++++ src/viewer.rs | 34 +++++++++++++++---- src/window.rs | 34 +++++++++++++++++-- 9 files changed, 87 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a550f62..39e7884 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ standalone images, and videos. entries when the search text is cleared. - Provide 100%, fit-width, fit-height, two-page, and continuous vertical viewing. - Support both right-to-left and left-to-right two-page layouts, plus controls - that advance by one page to adjust page pairing. + that advance by one page to adjust page pairing. Persist the last selected + two-page direction and restore it in newly opened archive viewers. - Reuse viewer windows when moving between ZIP, image, and video files. Preserve fullscreen state and keep the registered GTK titlebar widget stable while the displayed header changes. diff --git a/Cargo.lock b/Cargo.lock index e6712b2..ab112d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.9" +version = "0.2.10" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 786a6e5..9c16b80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.9" +version = "0.2.10" 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 93a76d1..72ca04c 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 3f749dc..eaf3a2b 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -96,6 +96,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치 - 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 +- 2페이지의 `좌→우`/`우→좌` 방향은 마지막 선택을 저장해 다음 뷰어에서 복원 - 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 - 전체 화면에서 `←`, `→`, `↑`, `↓`: 컨트롤을 즉시 숨기고 해당 키 동작 실행 - 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index d56de51..4a16848 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.9-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.10-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.9" -git tag -a v0.2.9 -m "Comicviewer v0.2.9" +git commit -m "Release v0.2.10" +git tag -a v0.2.10 -m "Comicviewer v0.2.10" git push origin main -git push origin v0.2.9 +git push origin v0.2.10 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/config.rs b/src/config.rs index 9378f78..90c3473 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ pub struct AppConfig { pub fast_image_loading: bool, pub case_insensitive_sort: bool, pub viewer_mode: ViewerMode, + pub two_page_right_to_left: bool, pub locations: Vec, pub favorites: Vec, } @@ -23,6 +24,7 @@ impl Default for AppConfig { fast_image_loading: true, case_insensitive_sort: true, viewer_mode: ViewerMode::FitWidth, + two_page_right_to_left: true, locations: Vec::new(), favorites: Vec::new(), } @@ -179,6 +181,7 @@ mod tests { fast_image_loading: true, case_insensitive_sort: true, viewer_mode: ViewerMode::FitWidth, + two_page_right_to_left: true, locations: vec![LocationProfile { name: "테스트".into(), kind: LocationKind::Sftp, @@ -215,6 +218,7 @@ mod tests { assert!(decoded.fast_image_loading); assert!(decoded.case_insensitive_sort); assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); + assert!(decoded.two_page_right_to_left); assert!(decoded.favorites.is_empty()); } @@ -231,4 +235,17 @@ mod tests { } assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth); } + + #[test] + fn two_page_direction_defaults_to_rtl_and_round_trips_ltr() { + let existing: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); + assert!(existing.two_page_right_to_left); + + let config = AppConfig { + two_page_right_to_left: false, + ..AppConfig::default() + }; + let decoded: AppConfig = toml::from_str(&toml::to_string(&config).unwrap()).unwrap(); + assert!(!decoded.two_page_right_to_left); + } } diff --git a/src/viewer.rs b/src/viewer.rs index 98bf9b9..52ad233 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -223,6 +223,11 @@ pub struct ViewModeControl { pub change: Rc, } +pub struct TwoPageDirectionControl { + pub selected: u32, + pub change: Rc bool>, +} + pub struct FileViewerControls { pub sibling_navigator: Option, pub favorite_control: FavoriteControl, @@ -230,6 +235,7 @@ pub struct FileViewerControls { pub file_drop_control: FileDropControl, pub reuse_window: Option, pub view_mode_control: Option, + pub two_page_direction_control: Option, } #[derive(Clone)] @@ -274,7 +280,10 @@ impl ViewerWindow { file_drop_control, reuse_window, view_mode_control: _, + two_page_direction_control, } = controls; + let direction_control = two_page_direction_control + .expect("archive viewer requires a two-page direction control"); let title = source.display_name.clone(); let file_list_query = reuse_window .as_ref() @@ -326,7 +335,8 @@ impl ViewerWindow { let page_label = gtk::Label::new(Some("- / -")); page_label.set_hexpand(true); let direction_dropdown = gtk::DropDown::from_strings(&["좌→우", "우→좌"]); - direction_dropdown.set_selected(1); + let direction_selected = direction_control.selected.min(1); + direction_dropdown.set_selected(direction_selected); let mode_dropdown = gtk::DropDown::from_strings(&[ "100%", "폭 맞춤", @@ -451,6 +461,8 @@ impl ViewerWindow { download_button, file_download: download_control.download, view_mode_change: view_mode_control.change, + direction_change: direction_control.change, + direction_selected: Cell::new(direction_selected), seek_hide_timer: RefCell::new(None), seek_interacting: Cell::new(false), last_pointer_position: Cell::new(None), @@ -620,6 +632,8 @@ struct ViewerController { download_button: gtk::Button, file_download: Rc, view_mode_change: Rc, + direction_change: Rc bool>, + direction_selected: Cell, seek_hide_timer: RefCell>, seek_interacting: Cell, last_pointer_position: Cell>, @@ -677,11 +691,18 @@ impl ViewerController { } }); let controller = Rc::downgrade(self); - self.direction_dropdown.connect_selected_notify(move |_| { - if let Some(controller) = controller.upgrade() { - controller.render(); - } - }); + self.direction_dropdown + .connect_selected_notify(move |dropdown| { + if let Some(controller) = controller.upgrade() { + let selected = dropdown.selected().min(1); + if (controller.direction_change)(selected) { + controller.direction_selected.set(selected); + controller.render(); + } else { + dropdown.set_selected(controller.direction_selected.get()); + } + } + }); let controller = Rc::downgrade(self); self.favorite_button.connect_clicked(move |_| { if let Some(controller) = controller.upgrade() { @@ -2184,6 +2205,7 @@ impl MediaViewerWindow { file_drop_control, reuse_window, view_mode_control, + two_page_direction_control: _, } = controls; let title = source.display_name.clone(); let file_list_query = reuse_window diff --git a/src/window.rs b/src/window.rs index e9fdfd3..57be341 100644 --- a/src/window.rs +++ b/src/window.rs @@ -30,8 +30,8 @@ use crate::source::{ }; use crate::viewer::{ FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow, - SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, - ViewerWindow, install_file_drop_target, set_viewer_header, set_viewer_loading, + SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, TwoPageDirectionControl, + ViewModeControl, ViewerWindow, install_file_drop_target, set_viewer_header, set_viewer_loading, }; use crate::zip_cache::ZipCache; @@ -997,6 +997,7 @@ impl BrowserController { let Some(application) = controller.window.application() else { return; }; + let direction_control = controller.two_page_direction_control(); ViewerWindow::open( &application, source, @@ -1011,6 +1012,7 @@ impl BrowserController { file_drop_control: controller.file_drop_control(), reuse_window, view_mode_control: None, + two_page_direction_control: Some(direction_control), }, ); } @@ -1075,6 +1077,7 @@ impl BrowserController { file_drop_control: controller.file_drop_control(), reuse_window, view_mode_control, + two_page_direction_control: None, }, ); } @@ -1461,6 +1464,33 @@ impl BrowserController { } } + fn two_page_direction_control(self: &Rc) -> TwoPageDirectionControl { + let selected = u32::from(self.config.borrow().two_page_right_to_left); + let controller = Rc::downgrade(self); + TwoPageDirectionControl { + selected, + change: Rc::new(move |index| { + let Some(controller) = controller.upgrade() else { + return false; + }; + let right_to_left = index == 1; + if controller.config.borrow().two_page_right_to_left == right_to_left { + return true; + } + let mut updated = controller.config.borrow().clone(); + updated.two_page_right_to_left = right_to_left; + if let Err(error) = updated.save(&controller.config_path) { + controller + .status_label + .set_text(&format!("2페이지 방향을 저장할 수 없습니다: {error:#}")); + return false; + } + *controller.config.borrow_mut() = updated; + true + }), + } + } + fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { let mut updated = self.config.borrow().clone(); let was_active = self.is_favorite(&entry.uri); From 7d6202e0a2f1191ae71a513ecabe6ae7f55df7f8 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 29 Jul 2026 02:22:16 +0900 Subject: [PATCH 20/30] Release v0.2.11 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 +- src/viewer.rs | 113 ++++++++---------- 5 files changed, 55 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ab112d7..4e9026f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.10" +version = "0.2.11" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 9c16b80..962f340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.10" +version = "0.2.11" 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 72ca04c..4d02a91 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 4a16848..905fc7a 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.10-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.11-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.10" -git tag -a v0.2.10 -m "Comicviewer v0.2.10" +git commit -m "Release v0.2.11" +git tag -a v0.2.11 -m "Comicviewer v0.2.11" git push origin main -git push origin v0.2.10 +git push origin v0.2.11 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 52ad233..b4b6a94 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -640,7 +640,7 @@ struct ViewerController { key_controller: RefCell>, motion_controller: RefCell>, fullscreen_key_controller: RefCell>, - fullscreen_click_controller: RefCell>, + fullscreen_click_controller: RefCell>, fullscreen_ui_hide_timer: RefCell>, fullscreen_ui_visible: Cell, fullscreen_notify_handler: RefCell>, @@ -786,29 +786,24 @@ impl ViewerController { controller.seek_page(index); } }); - let seek_click = gtk::GestureClick::builder() - .button(gdk::BUTTON_PRIMARY) - .propagation_phase(gtk::PropagationPhase::Capture) - .build(); + let seek_events = gtk::EventControllerLegacy::new(); + seek_events.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); - seek_click.connect_pressed(move |_, _, _, _| { - if let Some(controller) = controller.upgrade() { - controller.begin_seek_interaction(); + seek_events.connect_event(move |_, event| { + if event + .downcast_ref::() + .is_some_and(|event| event.button() == gdk::BUTTON_PRIMARY) + && let Some(controller) = controller.upgrade() + { + match event.event_type() { + gdk::EventType::ButtonPress => controller.begin_seek_interaction(), + gdk::EventType::ButtonRelease => controller.end_seek_interaction(), + _ => {} + } } + glib::Propagation::Proceed }); - 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); + self.page_seek.add_controller(seek_events); let adjustment = self.continuous_scrolled.vadjustment(); let controller = Rc::downgrade(self); @@ -895,15 +890,17 @@ impl ViewerController { }); self.stack.add_controller(scroll_controller); - let fullscreen_click = gtk::GestureClick::builder() - .button(0) - .propagation_phase(gtk::PropagationPhase::Capture) - .build(); + let fullscreen_click = gtk::EventControllerLegacy::new(); + fullscreen_click.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); - fullscreen_click.connect_pressed(move |_, _, _, _| { - if let Some(controller) = controller.upgrade() { + fullscreen_click.connect_event(move |_, event| { + if event.downcast_ref::().is_some() + && event.event_type() == gdk::EventType::ButtonPress + && let Some(controller) = controller.upgrade() + { controller.controls_clicked(); } + glib::Propagation::Proceed }); self.window.add_controller(fullscreen_click.clone()); self.fullscreen_click_controller @@ -2440,7 +2437,7 @@ struct MediaViewerController { key_controller: RefCell>, motion_controller: RefCell>, fullscreen_key_controller: RefCell>, - fullscreen_click_controller: RefCell>, + fullscreen_click_controller: RefCell>, fullscreen_ui_hide_timer: RefCell>, fullscreen_ui_visible: Cell, fullscreen_notify_handler: RefCell>, @@ -2451,15 +2448,17 @@ struct MediaViewerController { impl MediaViewerController { fn connect_signals(self: &Rc) { - let fullscreen_click = gtk::GestureClick::builder() - .button(0) - .propagation_phase(gtk::PropagationPhase::Capture) - .build(); + let fullscreen_click = gtk::EventControllerLegacy::new(); + fullscreen_click.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); - fullscreen_click.connect_pressed(move |_, _, _, _| { - if let Some(controller) = controller.upgrade() { + fullscreen_click.connect_event(move |_, event| { + if event.downcast_ref::().is_some() + && event.event_type() == gdk::EventType::ButtonPress + && let Some(controller) = controller.upgrade() + { controller.controls_clicked(); } + glib::Propagation::Proceed }); self.window.add_controller(fullscreen_click.clone()); self.fullscreen_click_controller @@ -2740,29 +2739,24 @@ impl MediaViewerController { controller.schedule_seek(index); } }); - let seek_click = gtk::GestureClick::builder() - .button(gdk::BUTTON_PRIMARY) - .propagation_phase(gtk::PropagationPhase::Capture) - .build(); + let seek_events = gtk::EventControllerLegacy::new(); + seek_events.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); - seek_click.connect_pressed(move |_, _, _, _| { - if let Some(controller) = controller.upgrade() { - controller.begin_seek_interaction(); + seek_events.connect_event(move |_, event| { + if event + .downcast_ref::() + .is_some_and(|event| event.button() == gdk::BUTTON_PRIMARY) + && let Some(controller) = controller.upgrade() + { + match event.event_type() { + gdk::EventType::ButtonPress => controller.begin_seek_interaction(), + gdk::EventType::ButtonRelease => controller.end_seek_interaction(), + _ => {} + } } + glib::Propagation::Proceed }); - 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); + self.media_seek.add_controller(seek_events); } fn refresh_file_list(&self) { @@ -3064,17 +3058,6 @@ impl MediaViewerController { } } - 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; From 77d6e3b68b7ca11e51693e86816855e652cde86d Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 29 Jul 2026 08:32:58 +0900 Subject: [PATCH 21/30] Release v0.2.12 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 +- src/source.rs | 66 ++++++- src/viewer.rs | 119 +++++++++++- src/window.rs | 182 +++++++++++------- 7 files changed, 294 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e9026f..bd47889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 962f340..58af1a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.11" +version = "0.2.12" 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 4d02a91..999b213 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 905fc7a..ac64840 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.11-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.12-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.11" -git tag -a v0.2.11 -m "Comicviewer v0.2.11" +git commit -m "Release v0.2.12" +git tag -a v0.2.12 -m "Comicviewer v0.2.12" git push origin main -git push origin v0.2.11 +git push origin v0.2.12 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/source.rs b/src/source.rs index fc9b2c2..bf6b8e2 100644 --- a/src/source.rs +++ b/src/source.rs @@ -16,6 +16,32 @@ const RECURSIVE_ATTRIBUTES: &str = "standard::display-name,standard::name,standa const MAX_RECURSIVE_DIRECTORIES: usize = 10_000; const MAX_RECURSIVE_ENTRIES: usize = 100_000; +#[derive(Default)] +struct EnumerationProgress { + seen: HashSet, + new_in_batch: usize, +} + +impl EnumerationProgress { + fn begin_batch(&mut self) { + self.new_in_batch = 0; + } + + fn record(&mut self, uri: String) -> bool { + let inserted = self.seen.insert(uri); + self.new_in_batch += usize::from(inserted); + inserted + } + + fn repeated_batch(&self) -> bool { + self.new_in_batch == 0 + } + + fn len(&self) -> usize { + self.seen.len() + } +} + #[derive(Clone, Copy, Default)] pub struct RecursiveProgress { pub directories: usize, @@ -112,7 +138,7 @@ where } }; let mut entries = Vec::new(); - let mut scanned = 0; + let mut enumeration = EnumerationProgress::default(); loop { let batch = enumerator @@ -121,19 +147,21 @@ where if batch.is_empty() { break; } - scanned += batch.len(); - progress(scanned); + enumeration.begin_batch(); for info in batch { + let child = enumerator.child(&info); + let uri: String = info + .attribute_string("standard::target-uri") + .map_or_else(|| child.uri().into(), Into::into); + if !enumeration.record(uri.clone()) { + continue; + } let kind = match info.file_type() { gio::FileType::Directory => EntryKind::Directory, gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory, gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())), _ => continue, }; - let child = enumerator.child(&info); - let uri = info - .attribute_string("standard::target-uri") - .map_or_else(|| child.uri().into(), Into::into); entries.push(BrowserEntry { uri, name: info.display_name().into(), @@ -153,6 +181,10 @@ where .map(Into::into), }); } + if enumeration.repeated_batch() { + break; + } + progress(enumeration.len()); } Ok(entries) } @@ -449,6 +481,26 @@ mod tests { assert!(encoded_input.equal(&encoded)); } + #[test] + fn enumeration_allows_large_unique_directories_and_stops_repeated_batches() { + let mut enumeration = EnumerationProgress::default(); + for batch_start in (0..330_001).step_by(128) { + enumeration.begin_batch(); + for index in batch_start..(batch_start + 128).min(330_001) { + assert!(enumeration.record(format!("smb://server/share/file-{index}"))); + } + assert!(!enumeration.repeated_batch()); + } + assert_eq!(enumeration.len(), 330_001); + + enumeration.begin_batch(); + for index in 0..128 { + assert!(!enumeration.record(format!("smb://server/share/file-{index}"))); + } + assert!(enumeration.repeated_batch()); + assert_eq!(enumeration.len(), 330_001); + } + #[test] fn recursively_lists_viewable_files_without_following_symlinks() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/viewer.rs b/src/viewer.rs index b4b6a94..861094e 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -26,6 +26,7 @@ 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 VIEWER_FILE_SEARCH_KEY: &str = "comicviewer-file-search"; +const VIEWER_FILE_SCROLL_KEY: &str = "comicviewer-file-scroll"; const PRESERVE_SEEK_CLASS: &str = "viewer-preserve-seek"; fn clear_viewer_controller(window: >k::ApplicationWindow) { @@ -289,6 +290,10 @@ impl ViewerWindow { .as_ref() .map(viewer_file_search_query) .unwrap_or_default(); + let file_list_scroll = reuse_window + .as_ref() + .map(viewer_file_scroll_position) + .unwrap_or_default(); if let Some(window) = reuse_window.as_ref() { clear_viewer_controller(window); } @@ -305,7 +310,7 @@ impl ViewerWindow { set_viewer_loading(&window, false); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); - header.set_title_widget(Some(>k::Label::new(Some(&title)))); + header.set_title_widget(Some(&viewer_title_label(&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("파일 목록"); @@ -454,8 +459,10 @@ impl ViewerWindow { file_list_button, file_list_panel: file_list.panel, file_list_search: file_list.search, + file_list_scrolled: file_list.scrolled, file_list: file_list.list, file_list_files: RefCell::new(Vec::new()), + file_list_scroll_restore: Cell::new(Some(file_list_scroll)), favorite_button, favorite_toggle: favorite_control.toggle, download_button, @@ -625,8 +632,10 @@ struct ViewerController { file_list_button: gtk::ToggleButton, file_list_panel: gtk::Box, file_list_search: gtk::SearchEntry, + file_list_scrolled: gtk::ScrolledWindow, file_list: gtk::ListBox, file_list_files: RefCell>, + file_list_scroll_restore: Cell>, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, @@ -752,6 +761,16 @@ impl ViewerController { controller.refresh_file_list(); } }); + let controller = Rc::downgrade(self); + self.file_list_scrolled + .vadjustment() + .connect_value_changed(move |adjustment| { + if let Some(controller) = controller.upgrade() + && controller.file_list_scroll_restore.get().is_none() + { + store_viewer_file_scroll_position(&controller.window, adjustment.value()); + } + }); let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default())); let file_list_keys = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); @@ -1073,6 +1092,11 @@ impl ViewerController { if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) { self.file_list.select_row(Some(&row)); } + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); } fn open_file_list_row(&self, index: i32) { @@ -1264,6 +1288,11 @@ impl ViewerController { self.status_label.set_visible(true); self.file_list_panel .set_visible(self.file_list_button.is_active()); + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); } } @@ -1304,6 +1333,11 @@ impl ViewerController { self.status_label.set_visible(visible); self.file_list_panel .set_visible(visible && self.file_list_button.is_active()); + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); self.window.queue_allocate(); let controller = Rc::downgrade(self); glib::idle_add_local_once(move || { @@ -2209,6 +2243,10 @@ impl MediaViewerWindow { .as_ref() .map(viewer_file_search_query) .unwrap_or_default(); + let file_list_scroll = reuse_window + .as_ref() + .map(viewer_file_scroll_position) + .unwrap_or_default(); if let Some(window) = reuse_window.as_ref() { clear_viewer_controller(window); } @@ -2225,7 +2263,7 @@ impl MediaViewerWindow { set_viewer_loading(&window, false); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); - header.set_title_widget(Some(>k::Label::new(Some(&title)))); + header.set_title_widget(Some(&viewer_title_label(&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("파일 목록"); @@ -2335,8 +2373,10 @@ impl MediaViewerWindow { file_list_button, file_list_panel: file_list.panel, file_list_search: file_list.search, + file_list_scrolled: file_list.scrolled, file_list: file_list.list, file_list_files: RefCell::new(Vec::new()), + file_list_scroll_restore: Cell::new(Some(file_list_scroll)), favorite_button, favorite_toggle: favorite_control.toggle, download_button, @@ -2422,8 +2462,10 @@ struct MediaViewerController { file_list_button: gtk::ToggleButton, file_list_panel: gtk::Box, file_list_search: gtk::SearchEntry, + file_list_scrolled: gtk::ScrolledWindow, file_list: gtk::ListBox, file_list_files: RefCell>, + file_list_scroll_restore: Cell>, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, @@ -2653,6 +2695,16 @@ impl MediaViewerController { controller.refresh_file_list(); } }); + let controller = Rc::downgrade(self); + self.file_list_scrolled + .vadjustment() + .connect_value_changed(move |adjustment| { + if let Some(controller) = controller.upgrade() + && controller.file_list_scroll_restore.get().is_none() + { + store_viewer_file_scroll_position(&controller.window, adjustment.value()); + } + }); let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default())); let file_list_keys = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); @@ -2779,6 +2831,11 @@ impl MediaViewerController { if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) { self.file_list.select_row(Some(&row)); } + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); } fn open_file_list_row(&self, index: i32) { @@ -3191,6 +3248,11 @@ impl MediaViewerController { self.status_label.set_visible(true); self.file_list_panel .set_visible(self.file_list_button.is_active()); + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); } } @@ -3230,6 +3292,11 @@ impl MediaViewerController { self.status_label.set_visible(visible); self.file_list_panel .set_visible(visible && self.file_list_button.is_active()); + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); self.window.queue_allocate(); let controller = Rc::downgrade(self); glib::idle_add_local_once(move || { @@ -3295,9 +3362,19 @@ fn favorite_label(active: bool) -> &'static str { } } +fn viewer_title_label(title: &str) -> gtk::Label { + gtk::Label::builder() + .label(title) + .hexpand(true) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .tooltip_text(title) + .build() +} + struct ViewerFileList { panel: gtk::Box, search: gtk::SearchEntry, + scrolled: gtk::ScrolledWindow, list: gtk::ListBox, } @@ -3430,6 +3507,7 @@ fn viewer_file_list(query: &str) -> ViewerFileList { ViewerFileList { panel, search, + scrolled, list, } } @@ -3449,6 +3527,43 @@ fn store_viewer_file_search_query(window: >k::ApplicationWindow, query: &str) } } +fn viewer_file_scroll_position(window: >k::ApplicationWindow) -> f64 { + unsafe { + window + .data::(VIEWER_FILE_SCROLL_KEY) + .map(|position| *position.as_ref()) + .unwrap_or_default() + } +} + +fn store_viewer_file_scroll_position(window: >k::ApplicationWindow, position: f64) { + unsafe { + window.set_data(VIEWER_FILE_SCROLL_KEY, position.max(0.0)); + } +} + +fn restore_viewer_file_scroll(scrolled: >k::ScrolledWindow, position: f64) { + let scrolled = scrolled.clone(); + glib::idle_add_local_once(move || { + let adjustment = scrolled.vadjustment(); + let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); + adjustment.set_value(position.clamp(adjustment.lower(), maximum)); + }); +} + +fn restore_pending_viewer_file_scroll( + panel: >k::Box, + scrolled: >k::ScrolledWindow, + pending: &Cell>, +) { + if !panel.is_visible() { + return; + } + if let Some(position) = pending.take() { + restore_viewer_file_scroll(scrolled, position); + } +} + fn widget_contains_focus(widget: &impl IsA, window: >k::ApplicationWindow) -> bool { gtk::prelude::GtkWindowExt::focus(window).is_some_and(|focus| { let widget = widget.as_ref(); diff --git a/src/window.rs b/src/window.rs index 57be341..31a28b0 100644 --- a/src/window.rs +++ b/src/window.rs @@ -125,12 +125,13 @@ impl ComicWindow { controls.append(>k::Label::new(Some("빠른 이미지 로딩"))); controls.append(&fast_loading_switch); - let list_box = gtk::ListBox::new(); - list_box.set_selection_mode(gtk::SelectionMode::Single); - list_box.set_activate_on_single_click(false); + let list_selection = gtk::SingleSelection::new(None::); + let list_view = + gtk::ListView::new(Some(list_selection.clone()), None::); + list_view.set_single_click_activate(false); let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) - .child(&list_box) + .child(&list_view) .build(); let status_label = gtk::Label::builder().xalign(0.0).build(); @@ -152,7 +153,8 @@ impl ComicWindow { search_entry, sort_dropdown, direction_button, - list_box, + list_view, + list_selection, status_label, state: RefCell::new(BrowserState { profile_index: 0, @@ -227,7 +229,8 @@ struct BrowserController { search_entry: gtk::SearchEntry, sort_dropdown: gtk::DropDown, direction_button: gtk::ToggleButton, - list_box: gtk::ListBox, + list_view: gtk::ListView, + list_selection: gtk::SingleSelection, status_label: gtk::Label, state: RefCell, config: RefCell, @@ -328,16 +331,50 @@ impl BrowserController { case_insensitive_check.connect_toggled(move |check| { controller.set_case_insensitive_sort(check); }); - let controller = Rc::clone(self); - self.list_box - .connect_row_activated(move |_, row| controller.activate_row(row.index())); - - let context_click = gtk::GestureClick::builder().button(3).build(); - let controller = Rc::clone(self); - context_click.connect_pressed(move |_, _, x, y| { - controller.show_context_menu(x, y); + let factory = gtk::SignalListItemFactory::new(); + let controller = Rc::downgrade(self); + factory.connect_setup(move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let content = entry_content(); + item.set_child(Some(&content)); + let context_click = gtk::GestureClick::builder().button(3).build(); + let item = item.downgrade(); + let controller = controller.clone(); + let menu_parent = content.clone(); + context_click.connect_pressed(move |_, _, _, _| { + let Some(item) = item.upgrade() else { + return; + }; + let Some(controller) = controller.upgrade() else { + return; + }; + controller.show_context_menu(item.position(), &menu_parent); + }); + content.add_controller(context_click); }); - self.list_box.add_controller(context_click); + let controller = Rc::downgrade(self); + factory.connect_bind(move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let Some(content) = item.child().and_downcast::() else { + return; + }; + let Some(controller) = controller.upgrade() else { + return; + }; + let state = controller.state.borrow(); + let Some(entry) = state.visible_entries.get(item.position() as usize) else { + return; + }; + bind_entry_content(&content, entry); + }); + self.list_view.set_factory(Some(&factory)); + let controller = Rc::clone(self); + self.list_view + .connect_activate(move |_, position| controller.activate_row(position)); let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); @@ -349,7 +386,7 @@ impl BrowserController { controller.typeahead(key, modifiers) } }); - self.list_box.add_controller(key_controller); + self.list_view.add_controller(key_controller); let mouse_back = gtk::GestureClick::builder() .button(MOUSE_BACK_BUTTON) @@ -609,9 +646,7 @@ impl BrowserController { state.generation, ) }; - while let Some(child) = self.list_box.first_child() { - self.list_box.remove(&child); - } + self.list_selection.set_model(None::<&gio::ListModel>); self.path_entry.set_text(&display_uri(&uri)); self.status_label .set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)"); @@ -657,9 +692,6 @@ impl BrowserController { } fn render(&self) { - while let Some(child) = self.list_box.first_child() { - self.list_box.remove(&child); - } let query = self.search_entry.text().to_lowercase(); let case_insensitive = self.config.borrow().case_insensitive_sort; let mut state = self.state.borrow_mut(); @@ -675,11 +707,15 @@ impl BrowserController { state.sort_direction, case_insensitive, ); - for entry in &visible_entries { - self.list_box.append(&entry_row(entry)); - } let count = visible_entries.len(); + let names = visible_entries + .iter() + .map(BrowserEntry::display_name) + .collect::>(); + let model = gtk::StringList::new(&names); state.visible_entries = visible_entries; + drop(state); + self.list_selection.set_model(Some(&model)); self.status_label.set_text(&format!("{count}개 항목")); } @@ -712,7 +748,8 @@ impl BrowserController { return glib::Propagation::Proceed; }; - let selected = self.list_box.selected_row().map(|row| row.index() as usize); + let selected = (self.list_selection.selected() != gtk::INVALID_LIST_POSITION) + .then(|| self.list_selection.selected() as usize); let now = Instant::now(); let mut state = self.state.borrow_mut(); if state @@ -738,11 +775,9 @@ impl BrowserController { let found_name = found.map(|index| state.visible_entries[index].display_name().to_owned()); drop(state); - if let Some(index) = found - && let Some(row) = self.list_box.row_at_index(index as i32) - { - self.list_box.select_row(Some(&row)); - let _ = row.grab_focus(); + if let Some(index) = found { + self.list_selection.set_selected(index as u32); + let _ = self.list_view.grab_focus(); self.status_label.set_text(&format!( "'{query}'로 이동: {}", found_name.unwrap_or_default() @@ -783,7 +818,7 @@ impl BrowserController { self.render(); } - fn activate_row(self: &Rc, index: i32) { + fn activate_row(self: &Rc, index: u32) { let Some(entry) = self .state .borrow() @@ -1091,27 +1126,16 @@ impl BrowserController { ); } - fn show_context_menu(self: &Rc, x: f64, y: f64) { - let Some(widget) = self.list_box.pick(x, y, gtk::PickFlags::DEFAULT) else { - return; - }; - let Some(row) = widget - .clone() - .downcast::() - .ok() - .or_else(|| { - widget - .ancestor(gtk::ListBoxRow::static_type()) - .and_then(|widget| widget.downcast::().ok()) - }) - else { - return; - }; + fn show_context_menu( + self: &Rc, + position: u32, + parent: &impl glib::object::IsA, + ) { let Some(entry) = self .state .borrow() .visible_entries - .get(row.index() as usize) + .get(position as usize) .cloned() else { return; @@ -1121,9 +1145,9 @@ impl BrowserController { if !can_favorite && !can_download { return; } - self.list_box.select_row(Some(&row)); + self.list_selection.set_selected(position); let popover = gtk::Popover::new(); - popover.set_parent(&row); + popover.set_parent(parent); let actions = gtk::Box::new(gtk::Orientation::Vertical, 4); actions.set_margin_top(6); actions.set_margin_bottom(6); @@ -2154,37 +2178,53 @@ fn display_uri(uri: &str) -> String { ) } -fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow { - let row = gtk::ListBoxRow::new(); +fn entry_content() -> gtk::Box { let content = gtk::Box::new(gtk::Orientation::Horizontal, 12); content.set_margin_top(8); content.set_margin_bottom(8); content.set_margin_start(10); content.set_margin_end(10); - let icon = match entry.kind { + content.append(>k::Image::new()); + content.append( + >k::Label::builder() + .hexpand(true) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .build(), + ); + for _ in 0..2 { + let label = gtk::Label::new(None); + label.set_width_chars(17); + label.set_xalign(1.0); + content.append(&label); + } + content +} + +fn bind_entry_content(content: >k::Box, entry: &BrowserEntry) { + let icon_name = match entry.kind { EntryKind::Directory => "folder-symbolic", EntryKind::ZipArchive => "package-x-generic-symbolic", EntryKind::Image => "image-x-generic-symbolic", EntryKind::Video => "video-x-generic-symbolic", EntryKind::OtherFile => "text-x-generic-symbolic", }; - content.append(>k::Image::from_icon_name(icon)); - content.append( - >k::Label::builder() - .label(entry.display_name()) - .hexpand(true) - .xalign(0.0) - .ellipsize(gtk::pango::EllipsizeMode::Middle) - .build(), - ); - for time in [entry.modified, entry.created] { - let label = gtk::Label::new(Some(&format_time(time))); - label.set_width_chars(17); - label.set_xalign(1.0); - content.append(&label); - } - row.set_child(Some(&content)); - row + let Some(icon) = content.first_child().and_downcast::() else { + return; + }; + icon.set_icon_name(Some(icon_name)); + let Some(name) = icon.next_sibling().and_downcast::() else { + return; + }; + name.set_text(entry.display_name()); + let Some(modified) = name.next_sibling().and_downcast::() else { + return; + }; + modified.set_text(&format_time(entry.modified)); + let Some(created) = modified.next_sibling().and_downcast::() else { + return; + }; + created.set_text(&format_time(entry.created)); } fn format_time(time: Option) -> String { From 195bd2554ff09658c53c9d0bdf48cca4d58fa0a6 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 29 Jul 2026 08:36:19 +0900 Subject: [PATCH 22/30] Ignore local release credentials --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index afa6e8e..e899205 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ /dist/*.tar.gz.part-* /dist/*.tar.gz.parts.sha256 /dist/*-install.sh +/.release-credentials From 605248a9693b919b35ba8b09031b94a988415db6 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 21 Aug 2026 21:29:25 +0900 Subject: [PATCH 23/30] Release v0.2.13 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 2 + docs/RELEASING.ko.md | 8 +- src/viewer.rs | 189 ++++++++++++++---- 6 files changed, 156 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd47889..2d20caf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 58af1a5..ec6a456 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.12" +version = "0.2.13" 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 999b213..2764138 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 eaf3a2b..b444ff3 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -91,6 +91,8 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기 - 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동 - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 +- 이미지에서 `1`, `2`, `3`, `4`: 폭 맞춤, 높이 맞춤, 2페이지, 100%로 전환 (`3`은 ZIP 뷰어에서 사용) +- 창보다 크게 표시된 이미지에서 `←`, `→`, `↑`, `↓`: 해당 방향으로 최대 100px 이동 - `L`: 우측 파일 목록 표시 또는 숨기기 - 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원 - About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index ac64840..b2ef6c2 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.12-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.13-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.12" -git tag -a v0.2.12 -m "Comicviewer v0.2.12" +git commit -m "Release v0.2.13" +git tag -a v0.2.13 -m "Comicviewer v0.2.13" git push origin main -git push origin v0.2.12 +git push origin v0.2.13 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 861094e..bf1c005 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -23,6 +23,7 @@ const VIDEO_VOLUME_STEP: f64 = 0.05; const IMAGE_ZOOM_STEP: f64 = 0.1; const IMAGE_ZOOM_MIN: f64 = 0.1; const IMAGE_ZOOM_MAX: f64 = 4.0; +const IMAGE_PAN_STEP: f64 = 100.0; const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller"; const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-viewer-controller"; const VIEWER_FILE_SEARCH_KEY: &str = "comicviewer-file-search"; @@ -974,22 +975,16 @@ impl ViewerController { controller.seek_edge(true); glib::Propagation::Stop } - gdk::Key::Up | gdk::Key::KP_Up - if matches!( - controller.view_mode(), - ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous - ) => - { - controller.scroll_vertically(false); + gdk::Key::Up | gdk::Key::KP_Up if controller.pan_image(false, false) => { glib::Propagation::Stop } - gdk::Key::Down | gdk::Key::KP_Down - if matches!( - controller.view_mode(), - ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous - ) => - { - controller.scroll_vertically(true); + gdk::Key::Down | gdk::Key::KP_Down if controller.pan_image(false, true) => { + glib::Propagation::Stop + } + gdk::Key::Left | gdk::Key::KP_Left if controller.pan_image(true, false) => { + glib::Propagation::Stop + } + gdk::Key::Right | gdk::Key::KP_Right if controller.pan_image(true, true) => { glib::Propagation::Stop } gdk::Key::Left | gdk::Key::KP_Left | gdk::Key::Page_Up => { @@ -1016,6 +1011,22 @@ impl ViewerController { controller.adjust_image_zoom(false); glib::Propagation::Stop } + gdk::Key::_1 | gdk::Key::KP_1 => { + controller.select_view_mode(1); + glib::Propagation::Stop + } + gdk::Key::_2 | gdk::Key::KP_2 => { + controller.select_view_mode(2); + glib::Propagation::Stop + } + gdk::Key::_3 | gdk::Key::KP_3 => { + controller.select_view_mode(3); + glib::Propagation::Stop + } + gdk::Key::_4 | gdk::Key::KP_4 => { + controller.select_view_mode(0); + glib::Propagation::Stop + } _ if !shortcut_modifiers_blocked(modifiers) && key .to_unicode() @@ -1548,6 +1559,15 @@ impl ViewerController { self.stack.queue_allocate(); } + fn select_view_mode(self: &Rc, index: u32) { + if self.mode_dropdown.selected() != index { + self.mode_dropdown.set_selected(index); + } else { + self.image_zoom.set(1.0); + self.apply_view_mode(); + } + } + fn schedule_view_mode_refresh(self: &Rc) { let generation = self.view_layout_generation.get().wrapping_add(1); self.view_layout_generation.set(generation); @@ -1615,16 +1635,18 @@ impl ViewerController { self.seek_page(if end { count - 1 } else { 0 }); } - fn scroll_vertically(&self, down: bool) { - let adjustment = if self.view_mode() == ViewMode::Continuous { - self.continuous_scrolled.vadjustment() - } else { - self.single_scrolled.vadjustment() + fn pan_image(&self, horizontal: bool, forward: bool) -> bool { + let scrolled = match self.view_mode() { + ViewMode::TwoPage => &self.spread_scrolled, + ViewMode::Continuous => &self.continuous_scrolled, + _ => &self.single_scrolled, }; - let step = adjustment.step_increment().max(64.0); - let delta = if down { step } else { -step }; - let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); - adjustment.set_value((adjustment.value() + delta).clamp(adjustment.lower(), maximum)); + let adjustment = if horizontal { + scrolled.hadjustment() + } else { + scrolled.vadjustment() + }; + pan_adjustment(&adjustment, forward) } fn shift_pairing(self: &Rc, offset: isize) { @@ -2306,11 +2328,17 @@ impl MediaViewerWindow { } else { gtk::Video::new() }; - video.set_autoplay(true); + video.set_autoplay(false); video.set_hexpand(true); video.set_vexpand(true); let video_for_loop = video.clone(); - loop_check.connect_toggled(move |check| video_for_loop.set_loop(check.is_active())); + loop_check.connect_toggled(move |check| { + let active = check.is_active(); + video_for_loop.set_loop(active); + if let Some(stream) = video_for_loop.media_stream() { + stream.set_loop(active); + } + }); stack.add_named(&video, Some("video")); let status_label = gtk::Label::builder() .label(&title) @@ -2431,6 +2459,7 @@ impl MediaViewerWindow { EntryKind::Video => { controller.stack.set_visible_child_name("video"); if let Some(stream) = controller.video.media_stream() { + initialize_video_volume(&stream); stream.play(); } } @@ -2580,22 +2609,23 @@ impl MediaViewerController { } gdk::Key::Up | gdk::Key::KP_Up if controller.kind == EntryKind::Image - && matches!( - controller.image_view_mode(), - ViewMode::ActualSize | ViewMode::FitWidth - ) => + && controller.pan_image(false, false) => { - controller.scroll_image(false); glib::Propagation::Stop } gdk::Key::Down | gdk::Key::KP_Down - if controller.kind == EntryKind::Image - && matches!( - controller.image_view_mode(), - ViewMode::ActualSize | ViewMode::FitWidth - ) => + if controller.kind == EntryKind::Image && controller.pan_image(false, true) => + { + glib::Propagation::Stop + } + gdk::Key::Left | gdk::Key::KP_Left + if controller.kind == EntryKind::Image && controller.pan_image(true, false) => + { + glib::Propagation::Stop + } + gdk::Key::Right | gdk::Key::KP_Right + if controller.kind == EntryKind::Image && controller.pan_image(true, true) => { - controller.scroll_image(true); glib::Propagation::Stop } _ if key.to_unicode() == Some('[') => { @@ -2614,6 +2644,18 @@ impl MediaViewerController { controller.adjust_image_zoom(false); glib::Propagation::Stop } + gdk::Key::_1 | gdk::Key::KP_1 if controller.kind == EntryKind::Image => { + controller.select_image_view_mode(1); + glib::Propagation::Stop + } + gdk::Key::_2 | gdk::Key::KP_2 if controller.kind == EntryKind::Image => { + controller.select_image_view_mode(2); + glib::Propagation::Stop + } + gdk::Key::_4 | gdk::Key::KP_4 if controller.kind == EntryKind::Image => { + controller.select_image_view_mode(0); + glib::Propagation::Stop + } _ if key .to_unicode() .is_some_and(|character| character.eq_ignore_ascii_case(&'l')) => @@ -2982,6 +3024,15 @@ impl MediaViewerController { } } + fn select_image_view_mode(&self, index: u32) { + if self.image_mode_dropdown.selected() != index { + self.image_mode_dropdown.set_selected(index); + } else { + self.image_zoom.set(1.0); + self.apply_image_view_mode(); + } + } + fn resize_image_picture(&self) { let Some((texture_width, texture_height)) = self.image_dimensions.get() else { return; @@ -3017,12 +3068,13 @@ impl MediaViewerController { } } - fn scroll_image(&self, down: bool) { - let adjustment = self.image_scrolled.vadjustment(); - let step = adjustment.step_increment().max(64.0); - let delta = if down { step } else { -step }; - let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); - adjustment.set_value((adjustment.value() + delta).clamp(adjustment.lower(), maximum)); + fn pan_image(&self, horizontal: bool, forward: bool) -> bool { + let adjustment = if horizontal { + self.image_scrolled.hadjustment() + } else { + self.image_scrolled.vadjustment() + }; + pan_adjustment(&adjustment, forward) } fn open_sibling(&self, forward: bool) { @@ -3995,6 +4047,46 @@ fn video_volume_target(volume: f64, delta: f64) -> f64 { (volume + delta).clamp(0.0, 1.0) } +fn initialize_video_volume(stream: >k::MediaStream) { + let volume = stream.volume().clamp(0.0, 1.0); + // Force the realized playback backend and GTK controls to receive the same initial value. + stream.set_volume(if volume > 0.0 { 0.0 } else { 1.0 }); + stream.set_volume(volume); +} + +fn pan_adjustment(adjustment: >k::Adjustment, forward: bool) -> bool { + let Some(value) = image_pan_target( + adjustment.value(), + adjustment.lower(), + adjustment.upper(), + adjustment.page_size(), + forward, + ) else { + return false; + }; + adjustment.set_value(value); + true +} + +fn image_pan_target( + value: f64, + lower: f64, + upper: f64, + page_size: f64, + forward: bool, +) -> Option { + let maximum = (upper - page_size).max(lower); + if maximum <= lower { + return None; + } + let delta = if forward { + IMAGE_PAN_STEP + } else { + -IMAGE_PAN_STEP + }; + Some((value + delta).clamp(lower, maximum)) +} + #[cfg(test)] mod tests { use super::*; @@ -4111,6 +4203,19 @@ mod tests { assert_eq!(video_volume_target(0.5, 0.05), 0.55); } + #[test] + fn image_pan_moves_at_most_100_pixels_and_consumes_clipped_axis() { + assert_eq!( + image_pan_target(250.0, 0.0, 1_000.0, 400.0, true), + Some(350.0) + ); + assert_eq!( + image_pan_target(600.0, 0.0, 1_000.0, 400.0, true), + Some(600.0) + ); + assert_eq!(image_pan_target(0.0, 0.0, 400.0, 400.0, true), None); + } + #[test] fn viewer_file_typeahead_wraps_by_display_name() { let files = ["Alpha.zip", "beta.jpg", "Bravo.mp4"].map(|name| SiblingFile { From 063770749e988aafe05e0a59ad9dcb3353b1bade Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 21 Aug 2026 21:47:59 +0900 Subject: [PATCH 24/30] Release v0.2.14 --- Cargo.lock | 2 +- Cargo.toml | 2 +- data/io.github.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 2 +- docs/RELEASING.ko.md | 8 ++++---- src/viewer.rs | 6 ++++-- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d20caf..ef41b2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.13" +version = "0.2.14" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index ec6a456..9a0e272 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.13" +version = "0.2.14" 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 2764138..d7e57fd 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 b444ff3..c0bb076 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -96,7 +96,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - `L`: 우측 파일 목록 표시 또는 숨기기 - 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원 - About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치 -- 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 +- 이미지에서 `+`/`=`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 2페이지의 `좌→우`/`우→좌` 방향은 마지막 선택을 저장해 다음 뷰어에서 복원 - 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index b2ef6c2..a05bf1f 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.13-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.14-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.13" -git tag -a v0.2.13 -m "Comicviewer v0.2.13" +git commit -m "Release v0.2.14" +git tag -a v0.2.14 -m "Comicviewer v0.2.14" git push origin main -git push origin v0.2.13 +git push origin v0.2.14 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index bf1c005..8832a74 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1003,7 +1003,7 @@ impl ViewerController { controller.open_sibling(true); glib::Propagation::Stop } - gdk::Key::plus | gdk::Key::KP_Add => { + gdk::Key::plus | gdk::Key::equal | gdk::Key::KP_Add => { controller.adjust_image_zoom(true); glib::Propagation::Stop } @@ -2636,7 +2636,9 @@ impl MediaViewerController { controller.open_sibling(true); glib::Propagation::Stop } - gdk::Key::plus | gdk::Key::KP_Add if controller.kind == EntryKind::Image => { + gdk::Key::plus | gdk::Key::equal | gdk::Key::KP_Add + if controller.kind == EntryKind::Image => + { controller.adjust_image_zoom(true); glib::Propagation::Stop } From 9aac91ccb1a069be907a619743df0b17822e6913 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 21 Aug 2026 22:06:04 +0900 Subject: [PATCH 25/30] Release v0.2.15 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 ++-- src/viewer.rs | 43 +++++++++++++++++-- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef41b2c..d2bdc54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.14" +version = "0.2.15" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 9a0e272..5af3812 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.14" +version = "0.2.15" 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 d7e57fd..9fa6f2b 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 a05bf1f..d2551ee 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.14-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.15-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.14" -git tag -a v0.2.14 -m "Comicviewer v0.2.14" +git commit -m "Release v0.2.15" +git tag -a v0.2.15 -m "Comicviewer v0.2.15" git push origin main -git push origin v0.2.14 +git push origin v0.2.15 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 8832a74..3587a91 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -286,7 +286,11 @@ impl ViewerWindow { } = controls; let direction_control = two_page_direction_control .expect("archive viewer requires a two-page direction control"); - let title = source.display_name.clone(); + let title_files = sibling_navigator + .as_ref() + .map(SiblingNavigator::files) + .unwrap_or_default(); + let title = viewer_file_title(&source.display_name, &source.source_uri, &title_files); let file_list_query = reuse_window .as_ref() .map(viewer_file_search_query) @@ -2260,7 +2264,11 @@ impl MediaViewerWindow { view_mode_control, two_page_direction_control: _, } = controls; - let title = source.display_name.clone(); + let title_files = sibling_navigator + .as_ref() + .map(SiblingNavigator::files) + .unwrap_or_default(); + let title = viewer_file_title(&source.display_name, &source.source_uri, &title_files); let file_list_query = reuse_window .as_ref() .map(viewer_file_search_query) @@ -2341,7 +2349,7 @@ impl MediaViewerWindow { }); stack.add_named(&video, Some("video")); let status_label = gtk::Label::builder() - .label(&title) + .label(&source.display_name) .xalign(0.0) .margin_top(6) .margin_bottom(6) @@ -3425,6 +3433,13 @@ fn viewer_title_label(title: &str) -> gtk::Label { .build() } +fn viewer_file_title(display_name: &str, current_uri: &str, files: &[SiblingFile]) -> String { + seek_position(files, current_uri).map_or_else( + || display_name.to_owned(), + |index| format!("{display_name} ({}/{})", index + 1, files.len()), + ) +} + struct ViewerFileList { panel: gtk::Box, search: gtk::SearchEntry, @@ -4292,6 +4307,28 @@ mod tests { assert_eq!(seek_sibling_files(files, EntryKind::Image, true).len(), 3); } + #[test] + fn viewer_title_includes_current_file_index() { + let files = ["first.zip", "second.jpg", "third.mp4"].map(|name| SiblingFile { + uri: format!("file:///{name}"), + name: name.into(), + kind: EntryKind::Image, + }); + + assert_eq!( + viewer_file_title("second.jpg", "file:///second.jpg", &files), + "second.jpg (2/3)" + ); + assert_eq!( + viewer_file_title("missing.jpg", "file:///missing.jpg", &files), + "missing.jpg" + ); + assert_eq!( + viewer_file_title("only.jpg", "file:///only.jpg", &[]), + "only.jpg" + ); + } + #[test] fn playlist_search_filters_and_empty_query_restores_files() { let files = ["Alpha.zip", "beta.jpg", "Bravo.mp4"].map(|name| SiblingFile { From d1bdefdeff5f23af36468760aa0be51f4b23f945 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 21 Aug 2026 22:31:22 +0900 Subject: [PATCH 26/30] Release v0.2.16 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 ++--- src/viewer.rs | 32 +++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2bdc54..63da7b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.15" +version = "0.2.16" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5af3812..7d0d6bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.15" +version = "0.2.16" 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 9fa6f2b..24fc3f9 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 d2551ee..a48cbc4 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.15-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.16-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.15" -git tag -a v0.2.15 -m "Comicviewer v0.2.15" +git commit -m "Release v0.2.16" +git tag -a v0.2.16 -m "Comicviewer v0.2.16" git push origin main -git push origin v0.2.15 +git push origin v0.2.16 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 3587a91..042f5fb 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -2441,6 +2441,26 @@ impl MediaViewerWindow { status_label.set_text(&format!("동영상을 재생할 수 없습니다: {error}")); } }); + let loop_check = loop_check.downgrade(); + stream.connect_ended_notify(move |stream| { + let Some(check) = loop_check.upgrade() else { + return; + }; + if !video_loop_restart_needed(stream.is_ended(), check.is_active()) { + return; + } + let stream = stream.clone(); + let loop_check = loop_check.clone(); + glib::timeout_add_local_once(Duration::from_millis(50), move || { + let Some(check) = loop_check.upgrade() else { + return; + }; + if video_loop_restart_needed(stream.is_ended(), check.is_active()) { + stream.seek(0); + stream.play(); + } + }); + }); } controller.connect_signals(); controller.fullscreen_changed(); @@ -4064,6 +4084,10 @@ fn video_volume_target(volume: f64, delta: f64) -> f64 { (volume + delta).clamp(0.0, 1.0) } +fn video_loop_restart_needed(ended: bool, loop_active: bool) -> bool { + ended && loop_active +} + fn initialize_video_volume(stream: >k::MediaStream) { let volume = stream.volume().clamp(0.0, 1.0); // Force the realized playback backend and GTK controls to receive the same initial value. @@ -4220,6 +4244,14 @@ mod tests { assert_eq!(video_volume_target(0.5, 0.05), 0.55); } + #[test] + fn video_loop_restarts_only_after_ending_when_enabled() { + assert!(video_loop_restart_needed(true, true)); + assert!(!video_loop_restart_needed(false, true)); + assert!(!video_loop_restart_needed(true, false)); + assert!(!video_loop_restart_needed(false, false)); + } + #[test] fn image_pan_moves_at_most_100_pixels_and_consumes_clipped_axis() { assert_eq!( From f01365031ccbc67b2f50e55b8bbbd795c62d92e2 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 21 Aug 2026 22:39:24 +0900 Subject: [PATCH 27/30] Release v0.2.17 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 +- src/viewer.rs | 98 +++++++++++++------ 5 files changed, 76 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 63da7b6..8065da6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.16" +version = "0.2.17" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7d0d6bd..f8171e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.16" +version = "0.2.17" 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 24fc3f9..9f7a740 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 a48cbc4..8e60347 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.16-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.17-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.16" -git tag -a v0.2.16 -m "Comicviewer v0.2.16" +git commit -m "Release v0.2.17" +git tag -a v0.2.17 -m "Comicviewer v0.2.17" git push origin main -git push origin v0.2.16 +git push origin v0.2.17 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 042f5fb..d0c5fbc 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -2337,16 +2337,9 @@ impl MediaViewerWindow { gtk::Video::new() }; video.set_autoplay(false); + video.set_loop(false); video.set_hexpand(true); video.set_vexpand(true); - let video_for_loop = video.clone(); - loop_check.connect_toggled(move |check| { - let active = check.is_active(); - video_for_loop.set_loop(active); - if let Some(stream) = video_for_loop.media_stream() { - stream.set_loop(active); - } - }); stack.add_named(&video, Some("video")); let status_label = gtk::Label::builder() .label(&source.display_name) @@ -2435,31 +2428,33 @@ impl MediaViewerWindow { navigation_message_generation: Rc::new(Cell::new(0)), }); if let Some(stream) = controller.video.media_stream() { + stream.set_loop(false); let status_label = controller.status_label.clone(); stream.connect_error_notify(move |stream| { if let Some(error) = stream.error() { status_label.set_text(&format!("동영상을 재생할 수 없습니다: {error}")); } }); - let loop_check = loop_check.downgrade(); + let restart_pending = Rc::new(Cell::new(false)); + let loop_check_for_end = loop_check.downgrade(); + let restart_pending_for_end = Rc::clone(&restart_pending); stream.connect_ended_notify(move |stream| { - let Some(check) = loop_check.upgrade() else { + let Some(check) = loop_check_for_end.upgrade() else { return; }; - if !video_loop_restart_needed(stream.is_ended(), check.is_active()) { + schedule_video_loop_restart(stream, &check, &restart_pending_for_end); + }); + let loop_check_for_timestamp = loop_check.downgrade(); + let restart_pending_for_timestamp = Rc::clone(&restart_pending); + stream.connect_timestamp_notify(move |stream| { + let Some(check) = loop_check_for_timestamp.upgrade() else { return; - } - let stream = stream.clone(); - let loop_check = loop_check.clone(); - glib::timeout_add_local_once(Duration::from_millis(50), move || { - let Some(check) = loop_check.upgrade() else { - return; - }; - if video_loop_restart_needed(stream.is_ended(), check.is_active()) { - stream.seek(0); - stream.play(); - } - }); + }; + schedule_video_loop_restart(stream, &check, &restart_pending_for_timestamp); + }); + let stream_for_loop_check = stream.clone(); + loop_check.connect_toggled(move |check| { + schedule_video_loop_restart(&stream_for_loop_check, check, &restart_pending); }); } controller.connect_signals(); @@ -4084,8 +4079,52 @@ fn video_volume_target(volume: f64, delta: f64) -> f64 { (volume + delta).clamp(0.0, 1.0) } -fn video_loop_restart_needed(ended: bool, loop_active: bool) -> bool { - ended && loop_active +fn video_loop_restart_needed( + ended: bool, + timestamp: i64, + duration: i64, + loop_active: bool, +) -> bool { + loop_active && (ended || (duration > 0 && timestamp >= duration)) +} + +fn schedule_video_loop_restart( + stream: >k::MediaStream, + check: >k::CheckButton, + pending: &Rc>, +) { + if !video_loop_restart_needed( + stream.is_ended(), + stream.timestamp(), + stream.duration(), + check.is_active(), + ) { + pending.set(false); + return; + } + if pending.replace(true) { + return; + } + let stream = stream.clone(); + let check = check.downgrade(); + let pending = Rc::clone(pending); + glib::timeout_add_local_once(Duration::from_millis(50), move || { + let Some(check) = check.upgrade() else { + return; + }; + if video_loop_restart_needed( + stream.is_ended(), + stream.timestamp(), + stream.duration(), + check.is_active(), + ) { + stream.pause(); + stream.seek(0); + stream.play(); + } else { + pending.set(false); + } + }); } fn initialize_video_volume(stream: >k::MediaStream) { @@ -4246,10 +4285,11 @@ mod tests { #[test] fn video_loop_restarts_only_after_ending_when_enabled() { - assert!(video_loop_restart_needed(true, true)); - assert!(!video_loop_restart_needed(false, true)); - assert!(!video_loop_restart_needed(true, false)); - assert!(!video_loop_restart_needed(false, false)); + assert!(video_loop_restart_needed(true, 0, 10, true)); + assert!(video_loop_restart_needed(false, 11, 10, true)); + assert!(!video_loop_restart_needed(false, 9, 10, true)); + assert!(!video_loop_restart_needed(false, 11, 0, true)); + assert!(!video_loop_restart_needed(true, 10, 10, false)); } #[test] From 8f0578c0d3d81a518484a6e414af40c16e1970a4 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 21 Aug 2026 22:49:27 +0900 Subject: [PATCH 28/30] Release v0.2.18 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/RELEASING.ko.md | 8 +-- src/viewer.rs | 50 ++++++++++++++++--- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8065da6..9f8a707 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f8171e4..24c75e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.17" +version = "0.2.18" 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 9f7a740..b977e04 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 8e60347..6ee436d 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.17-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.18-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.17" -git tag -a v0.2.17 -m "Comicviewer v0.2.17" +git commit -m "Release v0.2.18" +git tag -a v0.2.18 -m "Comicviewer v0.2.18" git push origin main -git push origin v0.2.17 +git push origin v0.2.18 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index d0c5fbc..f7af34b 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -28,6 +28,7 @@ const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller"; const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-viewer-controller"; const VIEWER_FILE_SEARCH_KEY: &str = "comicviewer-file-search"; const VIEWER_FILE_SCROLL_KEY: &str = "comicviewer-file-scroll"; +const VIEWER_VIDEO_LOOP_KEY: &str = "comicviewer-video-loop"; const PRESERVE_SEEK_CLASS: &str = "viewer-preserve-seek"; fn clear_viewer_controller(window: >k::ApplicationWindow) { @@ -2277,6 +2278,7 @@ impl MediaViewerWindow { .as_ref() .map(viewer_file_scroll_position) .unwrap_or_default(); + let video_loop_active = reuse_window.as_ref().is_some_and(viewer_video_loop_enabled); if let Some(window) = reuse_window.as_ref() { clear_viewer_controller(window); } @@ -2300,6 +2302,7 @@ impl MediaViewerWindow { 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_active(video_loop_active); loop_check.set_visible(kind == EntryKind::Video); let image_mode_dropdown = gtk::DropDown::from_strings(&["100%", "폭 맞춤", "높이 맞춤"]); let (image_mode_selected, image_mode_change) = @@ -2453,7 +2456,9 @@ impl MediaViewerWindow { schedule_video_loop_restart(stream, &check, &restart_pending_for_timestamp); }); let stream_for_loop_check = stream.clone(); + let window_for_loop_check = controller.window.clone(); loop_check.connect_toggled(move |check| { + store_viewer_video_loop(&window_for_loop_check, check.is_active()); schedule_video_loop_restart(&stream_for_loop_check, check, &restart_pending); }); } @@ -3626,6 +3631,20 @@ fn store_viewer_file_scroll_position(window: >k::ApplicationWindow, position: } } +fn viewer_video_loop_enabled(window: >k::ApplicationWindow) -> bool { + unsafe { + window + .data::(VIEWER_VIDEO_LOOP_KEY) + .is_some_and(|active| *active.as_ref()) + } +} + +fn store_viewer_video_loop(window: >k::ApplicationWindow, active: bool) { + unsafe { + window.set_data(VIEWER_VIDEO_LOOP_KEY, active); + } +} + fn restore_viewer_file_scroll(scrolled: >k::ScrolledWindow, position: f64) { let scrolled = scrolled.clone(); glib::idle_add_local_once(move || { @@ -4093,16 +4112,13 @@ fn schedule_video_loop_restart( check: >k::CheckButton, pending: &Rc>, ) { - if !video_loop_restart_needed( + let restart_needed = video_loop_restart_needed( stream.is_ended(), stream.timestamp(), stream.duration(), check.is_active(), - ) { - pending.set(false); - return; - } - if pending.replace(true) { + ); + if !begin_video_loop_restart(pending, restart_needed) { return; } let stream = stream.clone(); @@ -4110,6 +4126,7 @@ fn schedule_video_loop_restart( let pending = Rc::clone(pending); glib::timeout_add_local_once(Duration::from_millis(50), move || { let Some(check) = check.upgrade() else { + pending.set(false); return; }; if video_loop_restart_needed( @@ -4121,12 +4138,19 @@ fn schedule_video_loop_restart( stream.pause(); stream.seek(0); stream.play(); - } else { - pending.set(false); } + pending.set(false); }); } +fn begin_video_loop_restart(pending: &Cell, restart_needed: bool) -> bool { + if !restart_needed { + pending.set(false); + return false; + } + !pending.replace(true) +} + fn initialize_video_volume(stream: >k::MediaStream) { let volume = stream.volume().clamp(0.0, 1.0); // Force the realized playback backend and GTK controls to receive the same initial value. @@ -4292,6 +4316,16 @@ mod tests { assert!(!video_loop_restart_needed(true, 10, 10, false)); } + #[test] + fn video_loop_can_restart_more_than_once() { + let pending = Cell::new(false); + assert!(begin_video_loop_restart(&pending, true)); + assert!(!begin_video_loop_restart(&pending, true)); + pending.set(false); + assert!(begin_video_loop_restart(&pending, true)); + assert!(!begin_video_loop_restart(&pending, false)); + } + #[test] fn image_pan_moves_at_most_100_pixels_and_consumes_clipped_axis() { assert_eq!( From bae869967491c726a1061aa57ab8bb1b9c1c6220 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 21 Aug 2026 23:24:30 +0900 Subject: [PATCH 29/30] Release v0.2.19 --- Cargo.lock | 2 +- Cargo.toml | 2 +- ...ithub.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 2 + docs/RELEASING.ko.md | 8 +- src/config.rs | 24 ++ src/window.rs | 401 +++++++++++++++--- 7 files changed, 371 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f8a707..6cf6008 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.18" +version = "0.2.19" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 24c75e5..4f974a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.18" +version = "0.2.19" 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 b977e04..2afcad9 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 c0bb076..63f7d66 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -84,6 +84,8 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다. - 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다. - 메인 창 검색은 현재 표시 중인 폴더의 파일명을 필터링하며, 입력을 지우면 전체 목록을 복원합니다. +- 위치 목록의 `최근 열어본 파일`은 성공적으로 연 ZIP·이미지·동영상을 최신순으로 최대 100개 보관합니다. +- 최근 파일 기록에는 원격 위치 재접속 정보가 함께 저장되며 `최근 기록 비우기`로 전체 기록만 삭제할 수 있습니다. ## 뷰어 조작 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index 6ee436d..a1e85bc 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.18-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.19-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.18" -git tag -a v0.2.18 -m "Comicviewer v0.2.18" +git commit -m "Release v0.2.19" +git tag -a v0.2.19 -m "Comicviewer v0.2.19" git push origin main -git push origin v0.2.18 +git push origin v0.2.19 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/config.rs b/src/config.rs index 90c3473..905b382 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,7 @@ pub struct AppConfig { pub two_page_right_to_left: bool, pub locations: Vec, pub favorites: Vec, + pub recent_files: Vec, } impl Default for AppConfig { @@ -27,6 +28,7 @@ impl Default for AppConfig { two_page_right_to_left: true, locations: Vec::new(), favorites: Vec::new(), + recent_files: Vec::new(), } } } @@ -52,6 +54,13 @@ pub struct FavoriteEntry { pub profile: LocationProfile, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentFileEntry { + pub name: String, + pub uri: String, + pub profile: LocationProfile, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LocationKind { @@ -203,6 +212,18 @@ mod tests { domain: String::new(), }, }], + recent_files: vec![RecentFileEntry { + name: "최근 영상.mp4".into(), + uri: "sftp://example.invalid/comics/recent.mp4".into(), + profile: LocationProfile { + name: "최근 서버".into(), + kind: LocationKind::Sftp, + address: "sftp://example.invalid/comics".into(), + username: "recent-reader".into(), + password: "recent-password".into(), + domain: String::new(), + }, + }], }; let encoded = toml::to_string(&config).unwrap(); @@ -210,6 +231,8 @@ mod tests { assert_eq!(decoded.locations[0].password, "plain-password"); assert_eq!(decoded.favorites[0].profile.username, "favorite-reader"); assert_eq!(decoded.favorites[0].profile.password, "favorite-password"); + assert_eq!(decoded.recent_files[0].profile.username, "recent-reader"); + assert_eq!(decoded.recent_files[0].profile.password, "recent-password"); } #[test] @@ -220,6 +243,7 @@ mod tests { assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert!(decoded.two_page_right_to_left); assert!(decoded.favorites.is_empty()); + assert!(decoded.recent_files.is_empty()); } #[test] diff --git a/src/window.rs b/src/window.rs index 31a28b0..838b684 100644 --- a/src/window.rs +++ b/src/window.rs @@ -21,7 +21,9 @@ use tar::Archive; use crate::browser::{ BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, }; -use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile, ViewerMode}; +use crate::config::{ + AppConfig, FavoriteEntry, LocationKind, LocationProfile, RecentFileEntry, ViewerMode, +}; use crate::database::Database; use crate::source::{ display_remote_uri, ensure_mounted, enumerate_directory, enumerate_tree_entries, @@ -36,6 +38,7 @@ use crate::viewer::{ use crate::zip_cache::ZipCache; const MOUSE_BACK_BUTTON: u32 = 8; +const RECENT_FILE_LIMIT: usize = 100; const UPDATE_BUNDLE_URL: &str = "https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz"; const UPDATE_CHECKSUM_URL: &str = "https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256"; @@ -83,11 +86,14 @@ impl ComicWindow { edit_location_button.set_sensitive(false); let delete_location_button = gtk::Button::with_label("삭제"); delete_location_button.set_sensitive(false); + let clear_recent_button = gtk::Button::with_label("최근 기록 비우기"); + clear_recent_button.set_visible(false); locations.append(>k::Label::new(Some("위치"))); locations.append(&location_dropdown); locations.append(&add_location_button); locations.append(&edit_location_button); locations.append(&delete_location_button); + locations.append(&clear_recent_button); let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let up_button = gtk::Button::builder() @@ -149,6 +155,7 @@ impl ComicWindow { location_dropdown, edit_location_button, delete_location_button, + clear_recent_button: clear_recent_button.clone(), path_entry, search_entry, sort_dropdown, @@ -159,7 +166,7 @@ impl ComicWindow { state: RefCell::new(BrowserState { profile_index: 0, favorite_profile: None, - browsing_favorites: false, + mode: BrowserMode::Directory, root_uri: root_uri.clone(), current_uri: root_uri, entries: Vec::new(), @@ -206,7 +213,7 @@ impl ComicWindow { struct BrowserState { profile_index: usize, favorite_profile: Option, - browsing_favorites: bool, + mode: BrowserMode, root_uri: String, current_uri: String, entries: Vec, @@ -218,6 +225,13 @@ struct BrowserState { typeahead_last_input: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BrowserMode { + Directory, + Favorites, + Recent, +} + struct BrowserController { window: gtk::ApplicationWindow, about_button: gtk::Button, @@ -225,6 +239,7 @@ struct BrowserController { location_dropdown: gtk::DropDown, edit_location_button: gtk::Button, delete_location_button: gtk::Button, + clear_recent_button: gtk::Button, path_entry: gtk::Entry, search_entry: gtk::SearchEntry, sort_dropdown: gtk::DropDown, @@ -298,8 +313,10 @@ impl BrowserController { let Some(controller) = controller.upgrade() else { return; }; - if is_favorites_selection(item.position(), controller.profiles().len()) { - controller.show_favorites(); + match special_browser_mode(item.position(), controller.profiles().len()) { + Some(BrowserMode::Favorites) => controller.show_favorites(), + Some(BrowserMode::Recent) => controller.show_recent(), + _ => {} } }); label.add_controller(click); @@ -406,6 +423,9 @@ impl BrowserController { let controller = Rc::clone(self); self.clear_cache_button .connect_clicked(move |_| controller.show_clear_cache_dialog()); + let controller = Rc::clone(self); + self.clear_recent_button + .connect_clicked(move |_| controller.show_clear_recent_dialog()); } fn profiles(&self) -> Vec { @@ -431,6 +451,7 @@ impl BrowserController { .map(|profile| profile.name.clone()) .collect(); names.push("즐겨찾기".into()); + names.push("최근 열어본 파일".into()); let names: Vec<_> = names.iter().map(String::as_str).collect(); self.location_dropdown .set_model(Some(>k::StringList::new(&names))); @@ -439,9 +460,16 @@ impl BrowserController { fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; let profiles = self.profiles(); - if is_favorites_selection(self.location_dropdown.selected(), profiles.len()) { - self.show_favorites(); - return; + match special_browser_mode(self.location_dropdown.selected(), profiles.len()) { + Some(BrowserMode::Favorites) => { + self.show_favorites(); + return; + } + Some(BrowserMode::Recent) => { + self.show_recent(); + return; + } + _ => {} } let editable = profile_index > 0 && profile_index < profiles.len(); self.edit_location_button.set_sensitive(editable); @@ -455,10 +483,11 @@ impl BrowserController { let mut state = self.state.borrow_mut(); state.profile_index = profile_index; state.favorite_profile = None; - state.browsing_favorites = false; + state.mode = BrowserMode::Directory; state.root_uri = root_uri.clone(); state.current_uri = root_uri; drop(state); + self.update_mode_controls(BrowserMode::Directory); self.path_entry.set_editable(true); self.search_entry.set_text(""); self.refresh(); @@ -484,24 +513,58 @@ impl BrowserController { state.generation = state.generation.wrapping_add(1); state.typeahead_query.clear(); state.typeahead_last_input = None; - state.browsing_favorites = true; + state.mode = BrowserMode::Favorites; state.favorite_profile = None; state.entries = entries; drop(state); + self.update_mode_controls(BrowserMode::Favorites); self.search_entry.set_text(""); self.render(); } + fn show_recent(&self) { + self.cancel_listing(); + self.edit_location_button.set_sensitive(false); + self.delete_location_button.set_sensitive(false); + self.path_entry.set_text("최근 열어본 파일"); + self.path_entry.set_editable(false); + let entries = self + .config + .borrow() + .recent_files + .iter() + .map(recent_file_browser_entry) + .collect(); + let mut state = self.state.borrow_mut(); + state.generation = state.generation.wrapping_add(1); + state.typeahead_query.clear(); + state.typeahead_last_input = None; + state.mode = BrowserMode::Recent; + state.favorite_profile = None; + state.entries = entries; + drop(state); + self.update_mode_controls(BrowserMode::Recent); + self.search_entry.set_text(""); + self.render(); + } + + fn update_mode_controls(&self, mode: BrowserMode) { + let recent = mode == BrowserMode::Recent; + self.clear_recent_button.set_visible(recent); + self.sort_dropdown.set_sensitive(!recent); + self.direction_button.set_sensitive(!recent); + } + fn open_favorite(self: &Rc, favorite: FavoriteEntry) { let Ok(root) = root_file(&favorite.profile) else { self.status_label - .set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다."); + .set_text("저장된 파일의 위치 정보가 올바르지 않습니다."); return; }; let favorite_file = gio::File::for_uri(&favorite.uri); if !is_within_root(&favorite_file, &root) { self.status_label - .set_text("즐겨찾기가 저장된 위치의 루트 밖을 가리킵니다."); + .set_text("저장된 파일이 위치의 루트 밖을 가리킵니다."); return; } if favorite.is_directory { @@ -511,17 +574,18 @@ impl BrowserController { self.delete_location_button.set_sensitive(false); let mut state = self.state.borrow_mut(); state.favorite_profile = Some(favorite.profile.clone()); - state.browsing_favorites = false; + state.mode = BrowserMode::Directory; state.root_uri = root.uri().into(); state.current_uri = favorite.uri; drop(state); + self.update_mode_controls(BrowserMode::Directory); self.search_entry.set_text(""); self.refresh(); return; } self.cancel_listing(); - self.status_label.set_text("즐겨찾기에 연결하는 중..."); + self.status_label.set_text("저장된 파일에 연결하는 중..."); let operation = mount_operation(&favorite.profile, &self.window); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { @@ -554,7 +618,7 @@ impl BrowserController { if !kind.is_viewable() { controller .status_label - .set_text("이 즐겨찾기 파일 형식은 열 수 없습니다."); + .set_text("이 파일 형식은 열 수 없습니다."); return; } let entry = folder_entries @@ -591,8 +655,7 @@ impl BrowserController { } fn open_entered_path(self: &Rc) { - let browsing_favorites = self.state.borrow().browsing_favorites; - if browsing_favorites { + if self.state.borrow().mode != BrowserMode::Directory { return; } let profile = self.current_profile(); @@ -609,8 +672,7 @@ impl BrowserController { } fn go_up(self: &Rc) { - let browsing_favorites = self.state.borrow().browsing_favorites; - if browsing_favorites { + if self.state.borrow().mode != BrowserMode::Directory { return; } let state = self.state.borrow(); @@ -624,10 +686,17 @@ impl BrowserController { } fn refresh(self: &Rc) { - let browsing_favorites = self.state.borrow().browsing_favorites; - if browsing_favorites { - self.show_favorites(); - return; + let mode = self.state.borrow().mode; + match mode { + BrowserMode::Favorites => { + self.show_favorites(); + return; + } + BrowserMode::Recent => { + self.show_recent(); + return; + } + BrowserMode::Directory => {} } self.cancel_listing(); let (profile, uri, generation) = { @@ -658,7 +727,8 @@ impl BrowserController { let Some(controller) = progress_controller.upgrade() else { return; }; - if controller.state.borrow().generation == generation { + let state = controller.state.borrow(); + if state.generation == generation && state.mode == BrowserMode::Directory { controller.status_label.set_text(&format!( "폴더를 불러오는 중... ({count}개의 파일/폴더를 읽음)" )); @@ -669,7 +739,7 @@ impl BrowserController { return; }; let state = controller.state.borrow(); - let stale = state.generation != generation || state.browsing_favorites; + let stale = state.generation != generation || state.mode != BrowserMode::Directory; drop(state); if stale { return; @@ -701,12 +771,14 @@ impl BrowserController { .filter(|entry| browser_search_match(entry, query.as_str())) .cloned() .collect(); - sort_entries( - &mut visible_entries, - state.sort_field, - state.sort_direction, - case_insensitive, - ); + if state.mode != BrowserMode::Recent { + sort_entries( + &mut visible_entries, + state.sort_field, + state.sort_direction, + case_insensitive, + ); + } let count = visible_entries.len(); let names = visible_entries .iter() @@ -828,19 +900,40 @@ impl BrowserController { else { return; }; - let browsing_favorites = self.state.borrow().browsing_favorites; - if browsing_favorites { - let favorite = self - .config - .borrow() - .favorites - .iter() - .find(|favorite| favorite.uri == entry.uri) - .cloned(); - if let Some(favorite) = favorite { - self.open_favorite(favorite); + let mode = self.state.borrow().mode; + match mode { + BrowserMode::Favorites => { + let favorite = self + .config + .borrow() + .favorites + .iter() + .find(|favorite| favorite.uri == entry.uri) + .cloned(); + if let Some(favorite) = favorite { + self.open_favorite(favorite); + } + return; } - return; + BrowserMode::Recent => { + let recent = self + .config + .borrow() + .recent_files + .iter() + .find(|recent| recent.uri == entry.uri) + .cloned(); + if let Some(recent) = recent { + self.open_favorite(FavoriteEntry { + name: recent.name, + uri: recent.uri, + is_directory: false, + profile: recent.profile, + }); + } + return; + } + BrowserMode::Directory => {} } match entry.kind { EntryKind::Directory => { @@ -985,9 +1078,9 @@ impl BrowserController { state.sort_direction, case_insensitive, ); - let browsing_favorites = state.browsing_favorites; + let mode = state.mode; drop(state); - if !browsing_favorites && entries.iter().any(|entry| entry.uri == current_uri) { + if mode == BrowserMode::Directory && entries.iter().any(|entry| entry.uri == current_uri) { Rc::new(entries) } else { Rc::clone(fallback) @@ -1001,6 +1094,8 @@ impl BrowserController { profile: LocationProfile, reuse_window: Option, ) { + let recent_entry = entry.clone(); + let recent_profile = profile.clone(); self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); let progress_label = reuse_window.as_ref().map_or_else( || self.status_label.clone(), @@ -1027,6 +1122,7 @@ impl BrowserController { } match result { Ok(source) => { + controller.record_recent_file(&recent_entry, &recent_profile); controller.status_label.set_text("다운로드 완료"); result_label.set_text("ZIP 파일을 여는 중..."); let Some(application) = controller.window.application() else { @@ -1068,6 +1164,8 @@ impl BrowserController { profile: LocationProfile, reuse_window: Option, ) { + let recent_entry = entry.clone(); + let recent_profile = profile.clone(); let kind = entry.kind; self.status_label .set_text("미디어 파일 다운로드 준비 중..."); @@ -1096,6 +1194,7 @@ impl BrowserController { } match result { Ok(source) => { + controller.record_recent_file(&recent_entry, &recent_profile); controller.status_label.set_text("다운로드 완료"); result_label.set_text("미디어 파일을 여는 중..."); let Some(application) = controller.window.application() else { @@ -1208,16 +1307,32 @@ impl BrowserController { } fn profile_for_entry(&self, entry: &BrowserEntry) -> LocationProfile { - if self.state.borrow().browsing_favorites - && let Some(profile) = self - .config - .borrow() - .favorites - .iter() - .find(|favorite| favorite.uri == entry.uri) - .map(|favorite| favorite.profile.clone()) - { - return profile; + match self.state.borrow().mode { + BrowserMode::Favorites => { + if let Some(profile) = self + .config + .borrow() + .favorites + .iter() + .find(|favorite| favorite.uri == entry.uri) + .map(|favorite| favorite.profile.clone()) + { + return profile; + } + } + BrowserMode::Recent => { + if let Some(profile) = self + .config + .borrow() + .recent_files + .iter() + .find(|recent| recent.uri == entry.uri) + .map(|recent| recent.profile.clone()) + { + return profile; + } + } + BrowserMode::Directory => {} } self.current_profile() } @@ -1449,13 +1564,14 @@ impl BrowserController { let uri: String = directory.uri().into(); let mut state = self.state.borrow_mut(); state.favorite_profile = Some(profile); - state.browsing_favorites = false; + state.mode = BrowserMode::Directory; state.root_uri = uri.clone(); state.current_uri = uri.clone(); state.entries = entries; state.typeahead_query.clear(); state.typeahead_last_input = None; drop(state); + self.update_mode_controls(BrowserMode::Directory); self.path_entry.set_text(&display_uri(&uri)); self.search_entry.set_text(""); self.render(); @@ -1525,8 +1641,7 @@ impl BrowserController { return was_active; } *self.config.borrow_mut() = updated; - let browsing_favorites = self.state.borrow().browsing_favorites; - if browsing_favorites { + if self.state.borrow().mode == BrowserMode::Favorites { self.show_favorites(); } self.status_label.set_text(if active { @@ -1537,6 +1652,28 @@ impl BrowserController { active } + fn record_recent_file(&self, entry: &BrowserEntry, profile: &LocationProfile) { + let mut updated = self.config.borrow().clone(); + record_recent_file(&mut updated, entry, profile); + if let Err(error) = updated.save(&self.config_path) { + self.status_label + .set_text(&format!("최근 파일 기록을 저장할 수 없습니다: {error:#}")); + return; + } + *self.config.borrow_mut() = updated; + if self.state.borrow().mode == BrowserMode::Recent { + let entries = self + .config + .borrow() + .recent_files + .iter() + .map(recent_file_browser_entry) + .collect(); + self.state.borrow_mut().entries = entries; + self.render(); + } + } + fn choose_download_destination( self: &Rc, entry: BrowserEntry, @@ -1973,6 +2110,58 @@ impl BrowserController { }); dialog.present(); } + + fn show_clear_recent_dialog(self: &Rc) { + let dialog = gtk::Window::builder() + .title("최근 기록 비우기") + .transient_for(&self.window) + .modal(true) + .resizable(false) + .default_width(420) + .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( + "최근 열어본 파일 기록을 모두 삭제하시겠습니까?\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 |_| { + let mut updated = controller.config.borrow().clone(); + updated.recent_files.clear(); + if let Err(error) = updated.save(&controller.config_path) { + controller + .status_label + .set_text(&format!("최근 기록을 삭제할 수 없습니다: {error:#}")); + return; + } + *controller.config.borrow_mut() = updated; + controller.show_recent(); + controller + .status_label + .set_text("최근 기록을 삭제했습니다."); + dialog_to_close.close(); + }); + dialog.present(); + } } fn install_latest_update() -> Result<()> { @@ -2258,8 +2447,15 @@ fn browser_search_match(entry: &BrowserEntry, lowercase_query: &str) -> bool { lowercase_query.is_empty() || entry.name.to_lowercase().contains(lowercase_query) } -fn is_favorites_selection(selected: u32, profile_count: usize) -> bool { - selected != gtk::INVALID_LIST_POSITION && selected as usize == profile_count +fn special_browser_mode(selected: u32, profile_count: usize) -> Option { + if selected == gtk::INVALID_LIST_POSITION { + return None; + } + match selected as usize { + index if index == profile_count => Some(BrowserMode::Favorites), + index if index == profile_count + 1 => Some(BrowserMode::Recent), + _ => None, + } } fn sorted_navigable_entries( @@ -2313,6 +2509,32 @@ fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { } } +fn recent_file_browser_entry(recent: &RecentFileEntry) -> BrowserEntry { + BrowserEntry { + uri: recent.uri.clone(), + name: recent.name.clone(), + list_name: None, + kind: classify_file(Path::new(&recent.name)), + size: None, + modified: None, + created: None, + etag: None, + } +} + +fn record_recent_file(config: &mut AppConfig, entry: &BrowserEntry, profile: &LocationProfile) { + config.recent_files.retain(|recent| recent.uri != entry.uri); + config.recent_files.insert( + 0, + RecentFileEntry { + name: entry.name.clone(), + uri: entry.uri.clone(), + profile: profile.clone(), + }, + ); + config.recent_files.truncate(RECENT_FILE_LIMIT); +} + fn show_viewer_loading( window: >k::ApplicationWindow, file_name: &str, @@ -2603,9 +2825,62 @@ mod tests { assert_eq!(folder.kind, EntryKind::Directory); assert_eq!(archive.kind, EntryKind::ZipArchive); - assert!(is_favorites_selection(3, 3)); - assert!(!is_favorites_selection(2, 3)); - assert!(!is_favorites_selection(gtk::INVALID_LIST_POSITION, 3)); + assert_eq!(special_browser_mode(3, 3), Some(BrowserMode::Favorites)); + assert_eq!(special_browser_mode(4, 3), Some(BrowserMode::Recent)); + assert_eq!(special_browser_mode(2, 3), None); + assert_eq!(special_browser_mode(gtk::INVALID_LIST_POSITION, 3), None); + } + + #[test] + fn recent_files_move_to_front_and_keep_only_100() { + let profile = LocationProfile { + name: "원격".into(), + kind: LocationKind::Sftp, + address: "sftp://example.invalid/comics".into(), + username: "reader".into(), + password: "password".into(), + domain: String::new(), + }; + let mut config = AppConfig::default(); + for index in 0..=RECENT_FILE_LIMIT { + let entry = media_entry(&format!("page{index}.jpg")); + record_recent_file(&mut config, &entry, &profile); + } + assert_eq!(config.recent_files.len(), RECENT_FILE_LIMIT); + assert_eq!(config.recent_files[0].name, "page100.jpg"); + assert!( + !config + .recent_files + .iter() + .any(|recent| recent.name == "page0.jpg") + ); + + let entry = media_entry("page50.jpg"); + record_recent_file(&mut config, &entry, &profile); + assert_eq!(config.recent_files.len(), RECENT_FILE_LIMIT); + assert_eq!(config.recent_files[0].name, "page50.jpg"); + assert_eq!(config.recent_files[0].profile.password, "password"); + } + + #[test] + fn recent_files_support_all_viewer_file_types() { + let profile = home_profile(); + let mut config = AppConfig::default(); + for name in ["book.zip", "page.jpg", "clip.mp4"] { + let mut entry = media_entry(name); + entry.kind = classify_file(Path::new(name)); + record_recent_file(&mut config, &entry, &profile); + } + let kinds = config + .recent_files + .iter() + .map(recent_file_browser_entry) + .map(|entry| entry.kind) + .collect::>(); + assert_eq!( + kinds, + [EntryKind::Video, EntryKind::Image, EntryKind::ZipArchive] + ); } #[test] From 10574773c61d41e6af0ed42104d1d333b02c72f5 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Fri, 21 Aug 2026 23:35:49 +0900 Subject: [PATCH 30/30] Release v0.2.20 --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- ...github.comicviewer.Comicviewer.metainfo.xml | 1 + docs/INSTALL.ko.md | 2 +- docs/RELEASING.ko.md | 8 ++++---- src/viewer.rs | 18 +++++++++--------- src/window.rs | 7 +++++-- 7 files changed, 23 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cf6008..44ba40a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.19" +version = "0.2.20" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4f974a9..3b72bf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.19" +version = "0.2.20" edition = "2024" description = "A comic archive viewer for local and remote locations" license = "GPL-3.0-or-later" @@ -8,7 +8,7 @@ license = "GPL-3.0-or-later" [dependencies] anyhow = "1.0" chrono = { version = "0.4", default-features = false, features = ["clock"] } -gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] } +gtk = { package = "gtk4", version = "0.10", features = ["v4_12"] } image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] } filetime = "0.2" flate2 = "1.0" diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml index 2afcad9..1949cfa 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 63f7d66..4c2b483 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -14,7 +14,7 @@ sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl \ ``` - `fuse2`: AppImage 마운트 및 실행 -- `gtk4`, `dav1d`: 화면 표시와 이미지 디코딩 +- `gtk4` 4.12 이상, `dav1d`: 화면 표시와 이미지 디코딩 - `gvfs`: FTP와 SFTP 접근 - `gvfs-smb`: SMB 공유 접근 - `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index a1e85bc..b124f8f 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.19-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.20-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.19" -git tag -a v0.2.19 -m "Comicviewer v0.2.19" +git commit -m "Release v0.2.20" +git tag -a v0.2.20 -m "Comicviewer v0.2.20" git push origin main -git push origin v0.2.19 +git push origin v0.2.20 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index f7af34b..eb0ee7c 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1872,8 +1872,8 @@ impl ViewerController { if (zoom - 1.0).abs() >= f64::EPSILON { let (width, height) = zoomed_picture_request( self.view_mode(), - self.single_scrolled.allocated_width().max(1), - self.single_scrolled.allocated_height().max(1), + self.single_scrolled.width().max(1), + self.single_scrolled.height().max(1), texture_width, texture_height, zoom, @@ -1891,7 +1891,7 @@ impl ViewerController { self.single_picture.set_size_request(width, height); } else if let Some((width, height)) = single_picture_request( self.view_mode(), - self.stack.allocated_width().max(1), + self.stack.width().max(1), texture_width, texture_height, ) { @@ -1935,8 +1935,8 @@ impl ViewerController { }; drop(state); let count = visible.len().max(1) as i32; - let viewport_width = (self.spread_scrolled.allocated_width().max(1) - 8) / count; - let viewport_height = self.spread_scrolled.allocated_height().max(1); + let viewport_width = (self.spread_scrolled.width().max(1) - 8) / count; + let viewport_height = self.spread_scrolled.height().max(1); self.spread_box.set_homogeneous(false); self.spread_box.set_halign(gtk::Align::Center); self.spread_box.set_valign(gtk::Align::Center); @@ -2177,7 +2177,7 @@ impl ViewerController { } fn continuous_content_width(&self) -> i32 { - continuous_content_width(self.stack.allocated_width()) + continuous_content_width(self.stack.width()) } fn evict_cache(&self) { @@ -3071,8 +3071,8 @@ impl MediaViewerController { if (zoom - 1.0).abs() >= f64::EPSILON { let (width, height) = zoomed_picture_request( self.image_view_mode(), - self.image_scrolled.allocated_width().max(1), - self.image_scrolled.allocated_height().max(1), + self.image_scrolled.width().max(1), + self.image_scrolled.height().max(1), texture_width, texture_height, zoom, @@ -3090,7 +3090,7 @@ impl MediaViewerController { self.picture.set_size_request(width, height); } else if let Some((width, height)) = single_picture_request( self.image_view_mode(), - self.stack.allocated_width().max(1), + self.stack.width().max(1), texture_width, texture_height, ) { diff --git a/src/window.rs b/src/window.rs index 838b684..f4830f5 100644 --- a/src/window.rs +++ b/src/window.rs @@ -848,8 +848,11 @@ impl BrowserController { drop(state); if let Some(index) = found { - self.list_selection.set_selected(index as u32); - let _ = self.list_view.grab_focus(); + self.list_view.scroll_to( + index as u32, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); self.status_label.set_text(&format!( "'{query}'로 이동: {}", found_name.unwrap_or_default()