Compare commits

..

No commits in common. "main" and "v0.2.9" have entirely different histories.
main ... v0.2.9

11 changed files with 281 additions and 1110 deletions

1
.gitignore vendored
View file

@ -9,4 +9,3 @@
/dist/*.tar.gz.part-* /dist/*.tar.gz.part-*
/dist/*.tar.gz.parts.sha256 /dist/*.tar.gz.parts.sha256
/dist/*-install.sh /dist/*-install.sh
/.release-credentials

View file

@ -42,8 +42,7 @@ standalone images, and videos.
entries when the search text is cleared. entries when the search text is cleared.
- Provide 100%, fit-width, fit-height, two-page, and continuous vertical viewing. - 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 - Support both right-to-left and left-to-right two-page layouts, plus controls
that advance by one page to adjust page pairing. Persist the last selected that advance by one page to adjust page pairing.
two-page direction and restore it in newly opened archive viewers.
- Reuse viewer windows when moving between ZIP, image, and video files. Preserve - Reuse viewer windows when moving between ZIP, image, and video files. Preserve
fullscreen state and keep the registered GTK titlebar widget stable while the fullscreen state and keep the registered GTK titlebar widget stable while the
displayed header changes. displayed header changes.

2
Cargo.lock generated
View file

@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "comicviewer" name = "comicviewer"
version = "0.2.20" version = "0.2.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "comicviewer" name = "comicviewer"
version = "0.2.20" version = "0.2.9"
edition = "2024" edition = "2024"
description = "A comic archive viewer for local and remote locations" description = "A comic archive viewer for local and remote locations"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@ -8,7 +8,7 @@ license = "GPL-3.0-or-later"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
gtk = { package = "gtk4", version = "0.10", features = ["v4_12"] } gtk = { package = "gtk4", version = "0.10", features = ["v4_10"] }
image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] } image = { version = "0.25", default-features = false, features = ["avif-native", "bmp", "gif", "jpeg", "png", "webp"] }
filetime = "0.2" filetime = "0.2"
flate2 = "1.0" flate2 = "1.0"

View file

@ -26,17 +26,6 @@
</provides> </provides>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.2.20" date="2026-08-21" />
<release version="0.2.19" date="2026-08-21" />
<release version="0.2.18" date="2026-08-21" />
<release version="0.2.17" date="2026-08-21" />
<release version="0.2.16" date="2026-08-21" />
<release version="0.2.15" date="2026-08-21" />
<release version="0.2.14" date="2026-08-21" />
<release version="0.2.13" date="2026-08-21" />
<release version="0.2.12" date="2026-07-29" />
<release version="0.2.11" date="2026-07-24" />
<release version="0.2.10" date="2026-07-24" />
<release version="0.2.9" date="2026-07-24" /> <release version="0.2.9" date="2026-07-24" />
<release version="0.2.8" date="2026-07-24" /> <release version="0.2.8" date="2026-07-24" />
<release version="0.2.7" date="2026-07-24" /> <release version="0.2.7" date="2026-07-24" />

View file

@ -14,7 +14,7 @@ sudo pacman -S fuse2 gtk4 dav1d gvfs gvfs-smb gstreamer curl \
``` ```
- `fuse2`: AppImage 마운트 및 실행 - `fuse2`: AppImage 마운트 및 실행
- `gtk4` 4.12 이상, `dav1d`: 화면 표시와 이미지 디코딩 - `gtk4`, `dav1d`: 화면 표시와 이미지 디코딩
- `gvfs`: FTP와 SFTP 접근 - `gvfs`: FTP와 SFTP 접근
- `gvfs-smb`: SMB 공유 접근 - `gvfs-smb`: SMB 공유 접근
- `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생 - `gstreamer`, `gst-plugins-*`, `gst-libav`: MP4 등 동영상 재생
@ -84,8 +84,6 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다. - 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다.
- 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다. - 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다.
- 메인 창 검색은 현재 표시 중인 폴더의 파일명을 필터링하며, 입력을 지우면 전체 목록을 복원합니다. - 메인 창 검색은 현재 표시 중인 폴더의 파일명을 필터링하며, 입력을 지우면 전체 목록을 복원합니다.
- 위치 목록의 `최근 열어본 파일`은 성공적으로 연 ZIP·이미지·동영상을 최신순으로 최대 100개 보관합니다.
- 최근 파일 기록에는 원격 위치 재접속 정보가 함께 저장되며 `최근 기록 비우기`로 전체 기록만 삭제할 수 있습니다.
## 뷰어 조작 ## 뷰어 조작
@ -93,14 +91,11 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기 - `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기
- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동 - 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동
- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
- 이미지에서 `1`, `2`, `3`, `4`: 폭 맞춤, 높이 맞춤, 2페이지, 100%로 전환 (`3`은 ZIP 뷰어에서 사용)
- 창보다 크게 표시된 이미지에서 `←`, `→`, `↑`, `↓`: 해당 방향으로 최대 100px 이동
- `L`: 우측 파일 목록 표시 또는 숨기기 - `L`: 우측 파일 목록 표시 또는 숨기기
- 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원 - 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원
- About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치 - About 창의 `업데이트`: Cafe24에서 최신 번들을 내려받아 검증 후 사용자 영역에 설치
- 이미지에서 `+`/`=`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
- 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
- 2페이지의 `좌→우`/`우→좌` 방향은 마지막 선택을 저장해 다음 뷰어에서 복원
- 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 - 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시
- 전체 화면에서 `←`, `→`, `↑`, `↓`: 컨트롤을 즉시 숨기고 해당 키 동작 실행 - 전체 화면에서 `←`, `→`, `↑`, `↓`: 컨트롤을 즉시 숨기고 해당 키 동작 실행
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기 - 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기

View file

@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
- `dist/Comicviewer-0.2.20-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`
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256` - `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
```sh ```sh
git add Cargo.toml Cargo.lock data packaging README.md docs git add Cargo.toml Cargo.lock data packaging README.md docs
git commit -m "Release v0.2.20" git commit -m "Release v0.2.9"
git tag -a v0.2.20 -m "Comicviewer v0.2.20" git tag -a v0.2.9 -m "Comicviewer v0.2.9"
git push origin main git push origin main
git push origin v0.2.20 git push origin v0.2.9
``` ```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새

View file

@ -12,10 +12,8 @@ pub struct AppConfig {
pub fast_image_loading: bool, pub fast_image_loading: bool,
pub case_insensitive_sort: bool, pub case_insensitive_sort: bool,
pub viewer_mode: ViewerMode, pub viewer_mode: ViewerMode,
pub two_page_right_to_left: bool,
pub locations: Vec<LocationProfile>, pub locations: Vec<LocationProfile>,
pub favorites: Vec<FavoriteEntry>, pub favorites: Vec<FavoriteEntry>,
pub recent_files: Vec<RecentFileEntry>,
} }
impl Default for AppConfig { impl Default for AppConfig {
@ -25,10 +23,8 @@ impl Default for AppConfig {
fast_image_loading: true, fast_image_loading: true,
case_insensitive_sort: true, case_insensitive_sort: true,
viewer_mode: ViewerMode::FitWidth, viewer_mode: ViewerMode::FitWidth,
two_page_right_to_left: true,
locations: Vec::new(), locations: Vec::new(),
favorites: Vec::new(), favorites: Vec::new(),
recent_files: Vec::new(),
} }
} }
} }
@ -54,13 +50,6 @@ pub struct FavoriteEntry {
pub profile: LocationProfile, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum LocationKind { pub enum LocationKind {
@ -190,7 +179,6 @@ mod tests {
fast_image_loading: true, fast_image_loading: true,
case_insensitive_sort: true, case_insensitive_sort: true,
viewer_mode: ViewerMode::FitWidth, viewer_mode: ViewerMode::FitWidth,
two_page_right_to_left: true,
locations: vec![LocationProfile { locations: vec![LocationProfile {
name: "테스트".into(), name: "테스트".into(),
kind: LocationKind::Sftp, kind: LocationKind::Sftp,
@ -212,18 +200,6 @@ mod tests {
domain: String::new(), 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(); let encoded = toml::to_string(&config).unwrap();
@ -231,8 +207,6 @@ mod tests {
assert_eq!(decoded.locations[0].password, "plain-password"); assert_eq!(decoded.locations[0].password, "plain-password");
assert_eq!(decoded.favorites[0].profile.username, "favorite-reader"); assert_eq!(decoded.favorites[0].profile.username, "favorite-reader");
assert_eq!(decoded.favorites[0].profile.password, "favorite-password"); 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] #[test]
@ -241,9 +215,7 @@ mod tests {
assert!(decoded.fast_image_loading); assert!(decoded.fast_image_loading);
assert!(decoded.case_insensitive_sort); assert!(decoded.case_insensitive_sort);
assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth);
assert!(decoded.two_page_right_to_left);
assert!(decoded.favorites.is_empty()); assert!(decoded.favorites.is_empty());
assert!(decoded.recent_files.is_empty());
} }
#[test] #[test]
@ -259,17 +231,4 @@ mod tests {
} }
assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth); 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);
}
} }

View file

@ -16,32 +16,6 @@ const RECURSIVE_ATTRIBUTES: &str = "standard::display-name,standard::name,standa
const MAX_RECURSIVE_DIRECTORIES: usize = 10_000; const MAX_RECURSIVE_DIRECTORIES: usize = 10_000;
const MAX_RECURSIVE_ENTRIES: usize = 100_000; const MAX_RECURSIVE_ENTRIES: usize = 100_000;
#[derive(Default)]
struct EnumerationProgress {
seen: HashSet<String>,
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)] #[derive(Clone, Copy, Default)]
pub struct RecursiveProgress { pub struct RecursiveProgress {
pub directories: usize, pub directories: usize,
@ -138,7 +112,7 @@ where
} }
}; };
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut enumeration = EnumerationProgress::default(); let mut scanned = 0;
loop { loop {
let batch = enumerator let batch = enumerator
@ -147,21 +121,19 @@ where
if batch.is_empty() { if batch.is_empty() {
break; break;
} }
enumeration.begin_batch(); scanned += batch.len();
progress(scanned);
for info in 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() { let kind = match info.file_type() {
gio::FileType::Directory => EntryKind::Directory, gio::FileType::Directory => EntryKind::Directory,
gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory, gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory,
gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())), gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())),
_ => continue, _ => 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 { entries.push(BrowserEntry {
uri, uri,
name: info.display_name().into(), name: info.display_name().into(),
@ -181,10 +153,6 @@ where
.map(Into::into), .map(Into::into),
}); });
} }
if enumeration.repeated_batch() {
break;
}
progress(enumeration.len());
} }
Ok(entries) Ok(entries)
} }
@ -481,26 +449,6 @@ mod tests {
assert!(encoded_input.equal(&encoded)); 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] #[test]
fn recursively_lists_viewable_files_without_following_symlinks() { fn recursively_lists_viewable_files_without_following_symlinks() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();

File diff suppressed because it is too large Load diff

View file

@ -21,9 +21,7 @@ use tar::Archive;
use crate::browser::{ use crate::browser::{
BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries,
}; };
use crate::config::{ use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile, ViewerMode};
AppConfig, FavoriteEntry, LocationKind, LocationProfile, RecentFileEntry, ViewerMode,
};
use crate::database::Database; use crate::database::Database;
use crate::source::{ use crate::source::{
display_remote_uri, ensure_mounted, enumerate_directory, enumerate_tree_entries, display_remote_uri, ensure_mounted, enumerate_directory, enumerate_tree_entries,
@ -32,13 +30,12 @@ use crate::source::{
}; };
use crate::viewer::{ use crate::viewer::{
FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow, FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow,
SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, TwoPageDirectionControl, SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl,
ViewModeControl, ViewerWindow, install_file_drop_target, set_viewer_header, set_viewer_loading, ViewerWindow, install_file_drop_target, set_viewer_header, set_viewer_loading,
}; };
use crate::zip_cache::ZipCache; use crate::zip_cache::ZipCache;
const MOUSE_BACK_BUTTON: u32 = 8; const MOUSE_BACK_BUTTON: u32 = 8;
const RECENT_FILE_LIMIT: usize = 100;
const UPDATE_BUNDLE_URL: &str = const UPDATE_BUNDLE_URL: &str =
"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";
const UPDATE_CHECKSUM_URL: &str = "https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256"; const UPDATE_CHECKSUM_URL: &str = "https://burningtuna.cafe24.com/www/Downloads/Comicviewer/Comicviewer-latest-x86_64.tar.gz.sha256";
@ -86,14 +83,11 @@ impl ComicWindow {
edit_location_button.set_sensitive(false); edit_location_button.set_sensitive(false);
let delete_location_button = gtk::Button::with_label("삭제"); let delete_location_button = gtk::Button::with_label("삭제");
delete_location_button.set_sensitive(false); delete_location_button.set_sensitive(false);
let clear_recent_button = gtk::Button::with_label("최근 기록 비우기");
clear_recent_button.set_visible(false);
locations.append(&gtk::Label::new(Some("위치"))); locations.append(&gtk::Label::new(Some("위치")));
locations.append(&location_dropdown); locations.append(&location_dropdown);
locations.append(&add_location_button); locations.append(&add_location_button);
locations.append(&edit_location_button); locations.append(&edit_location_button);
locations.append(&delete_location_button); locations.append(&delete_location_button);
locations.append(&clear_recent_button);
let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let up_button = gtk::Button::builder() let up_button = gtk::Button::builder()
@ -131,13 +125,12 @@ impl ComicWindow {
controls.append(&gtk::Label::new(Some("빠른 이미지 로딩"))); controls.append(&gtk::Label::new(Some("빠른 이미지 로딩")));
controls.append(&fast_loading_switch); controls.append(&fast_loading_switch);
let list_selection = gtk::SingleSelection::new(None::<gio::ListModel>); let list_box = gtk::ListBox::new();
let list_view = list_box.set_selection_mode(gtk::SelectionMode::Single);
gtk::ListView::new(Some(list_selection.clone()), None::<gtk::ListItemFactory>); list_box.set_activate_on_single_click(false);
list_view.set_single_click_activate(false);
let scrolled = gtk::ScrolledWindow::builder() let scrolled = gtk::ScrolledWindow::builder()
.vexpand(true) .vexpand(true)
.child(&list_view) .child(&list_box)
.build(); .build();
let status_label = gtk::Label::builder().xalign(0.0).build(); let status_label = gtk::Label::builder().xalign(0.0).build();
@ -155,18 +148,16 @@ impl ComicWindow {
location_dropdown, location_dropdown,
edit_location_button, edit_location_button,
delete_location_button, delete_location_button,
clear_recent_button: clear_recent_button.clone(),
path_entry, path_entry,
search_entry, search_entry,
sort_dropdown, sort_dropdown,
direction_button, direction_button,
list_view, list_box,
list_selection,
status_label, status_label,
state: RefCell::new(BrowserState { state: RefCell::new(BrowserState {
profile_index: 0, profile_index: 0,
favorite_profile: None, favorite_profile: None,
mode: BrowserMode::Directory, browsing_favorites: false,
root_uri: root_uri.clone(), root_uri: root_uri.clone(),
current_uri: root_uri, current_uri: root_uri,
entries: Vec::new(), entries: Vec::new(),
@ -213,7 +204,7 @@ impl ComicWindow {
struct BrowserState { struct BrowserState {
profile_index: usize, profile_index: usize,
favorite_profile: Option<LocationProfile>, favorite_profile: Option<LocationProfile>,
mode: BrowserMode, browsing_favorites: bool,
root_uri: String, root_uri: String,
current_uri: String, current_uri: String,
entries: Vec<BrowserEntry>, entries: Vec<BrowserEntry>,
@ -225,13 +216,6 @@ struct BrowserState {
typeahead_last_input: Option<Instant>, typeahead_last_input: Option<Instant>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BrowserMode {
Directory,
Favorites,
Recent,
}
struct BrowserController { struct BrowserController {
window: gtk::ApplicationWindow, window: gtk::ApplicationWindow,
about_button: gtk::Button, about_button: gtk::Button,
@ -239,13 +223,11 @@ struct BrowserController {
location_dropdown: gtk::DropDown, location_dropdown: gtk::DropDown,
edit_location_button: gtk::Button, edit_location_button: gtk::Button,
delete_location_button: gtk::Button, delete_location_button: gtk::Button,
clear_recent_button: gtk::Button,
path_entry: gtk::Entry, path_entry: gtk::Entry,
search_entry: gtk::SearchEntry, search_entry: gtk::SearchEntry,
sort_dropdown: gtk::DropDown, sort_dropdown: gtk::DropDown,
direction_button: gtk::ToggleButton, direction_button: gtk::ToggleButton,
list_view: gtk::ListView, list_box: gtk::ListBox,
list_selection: gtk::SingleSelection,
status_label: gtk::Label, status_label: gtk::Label,
state: RefCell<BrowserState>, state: RefCell<BrowserState>,
config: RefCell<AppConfig>, config: RefCell<AppConfig>,
@ -313,10 +295,8 @@ impl BrowserController {
let Some(controller) = controller.upgrade() else { let Some(controller) = controller.upgrade() else {
return; return;
}; };
match special_browser_mode(item.position(), controller.profiles().len()) { if is_favorites_selection(item.position(), controller.profiles().len()) {
Some(BrowserMode::Favorites) => controller.show_favorites(), controller.show_favorites();
Some(BrowserMode::Recent) => controller.show_recent(),
_ => {}
} }
}); });
label.add_controller(click); label.add_controller(click);
@ -348,50 +328,16 @@ impl BrowserController {
case_insensitive_check.connect_toggled(move |check| { case_insensitive_check.connect_toggled(move |check| {
controller.set_case_insensitive_sort(check); controller.set_case_insensitive_sort(check);
}); });
let factory = gtk::SignalListItemFactory::new();
let controller = Rc::downgrade(self);
factory.connect_setup(move |_, item| {
let Some(item) = item.downcast_ref::<gtk::ListItem>() 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);
});
let controller = Rc::downgrade(self);
factory.connect_bind(move |_, item| {
let Some(item) = item.downcast_ref::<gtk::ListItem>() else {
return;
};
let Some(content) = item.child().and_downcast::<gtk::Box>() 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); let controller = Rc::clone(self);
self.list_view self.list_box
.connect_activate(move |_, position| controller.activate_row(position)); .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);
});
self.list_box.add_controller(context_click);
let key_controller = gtk::EventControllerKey::new(); let key_controller = gtk::EventControllerKey::new();
let controller = Rc::clone(self); let controller = Rc::clone(self);
@ -403,7 +349,7 @@ impl BrowserController {
controller.typeahead(key, modifiers) controller.typeahead(key, modifiers)
} }
}); });
self.list_view.add_controller(key_controller); self.list_box.add_controller(key_controller);
let mouse_back = gtk::GestureClick::builder() let mouse_back = gtk::GestureClick::builder()
.button(MOUSE_BACK_BUTTON) .button(MOUSE_BACK_BUTTON)
@ -423,9 +369,6 @@ impl BrowserController {
let controller = Rc::clone(self); let controller = Rc::clone(self);
self.clear_cache_button self.clear_cache_button
.connect_clicked(move |_| controller.show_clear_cache_dialog()); .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<LocationProfile> { fn profiles(&self) -> Vec<LocationProfile> {
@ -451,7 +394,6 @@ impl BrowserController {
.map(|profile| profile.name.clone()) .map(|profile| profile.name.clone())
.collect(); .collect();
names.push("즐겨찾기".into()); names.push("즐겨찾기".into());
names.push("최근 열어본 파일".into());
let names: Vec<_> = names.iter().map(String::as_str).collect(); let names: Vec<_> = names.iter().map(String::as_str).collect();
self.location_dropdown self.location_dropdown
.set_model(Some(&gtk::StringList::new(&names))); .set_model(Some(&gtk::StringList::new(&names)));
@ -460,16 +402,9 @@ impl BrowserController {
fn select_profile(self: &Rc<Self>) { fn select_profile(self: &Rc<Self>) {
let profile_index = self.location_dropdown.selected() as usize; let profile_index = self.location_dropdown.selected() as usize;
let profiles = self.profiles(); let profiles = self.profiles();
match special_browser_mode(self.location_dropdown.selected(), profiles.len()) { if is_favorites_selection(self.location_dropdown.selected(), profiles.len()) {
Some(BrowserMode::Favorites) => { self.show_favorites();
self.show_favorites(); return;
return;
}
Some(BrowserMode::Recent) => {
self.show_recent();
return;
}
_ => {}
} }
let editable = profile_index > 0 && profile_index < profiles.len(); let editable = profile_index > 0 && profile_index < profiles.len();
self.edit_location_button.set_sensitive(editable); self.edit_location_button.set_sensitive(editable);
@ -483,11 +418,10 @@ impl BrowserController {
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
state.profile_index = profile_index; state.profile_index = profile_index;
state.favorite_profile = None; state.favorite_profile = None;
state.mode = BrowserMode::Directory; state.browsing_favorites = false;
state.root_uri = root_uri.clone(); state.root_uri = root_uri.clone();
state.current_uri = root_uri; state.current_uri = root_uri;
drop(state); drop(state);
self.update_mode_controls(BrowserMode::Directory);
self.path_entry.set_editable(true); self.path_entry.set_editable(true);
self.search_entry.set_text(""); self.search_entry.set_text("");
self.refresh(); self.refresh();
@ -513,58 +447,24 @@ impl BrowserController {
state.generation = state.generation.wrapping_add(1); state.generation = state.generation.wrapping_add(1);
state.typeahead_query.clear(); state.typeahead_query.clear();
state.typeahead_last_input = None; state.typeahead_last_input = None;
state.mode = BrowserMode::Favorites; state.browsing_favorites = true;
state.favorite_profile = None; state.favorite_profile = None;
state.entries = entries; state.entries = entries;
drop(state); drop(state);
self.update_mode_controls(BrowserMode::Favorites);
self.search_entry.set_text(""); self.search_entry.set_text("");
self.render(); 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<Self>, favorite: FavoriteEntry) { fn open_favorite(self: &Rc<Self>, favorite: FavoriteEntry) {
let Ok(root) = root_file(&favorite.profile) else { let Ok(root) = root_file(&favorite.profile) else {
self.status_label self.status_label
.set_text("저장된 파일의 위치 정보가 올바르지 않습니다."); .set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다.");
return; return;
}; };
let favorite_file = gio::File::for_uri(&favorite.uri); let favorite_file = gio::File::for_uri(&favorite.uri);
if !is_within_root(&favorite_file, &root) { if !is_within_root(&favorite_file, &root) {
self.status_label self.status_label
.set_text("저장된 파일이 위치의 루트 밖을 가리킵니다."); .set_text("즐겨찾기가 저장된 위치의 루트 밖을 가리킵니다.");
return; return;
} }
if favorite.is_directory { if favorite.is_directory {
@ -574,18 +474,17 @@ impl BrowserController {
self.delete_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false);
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
state.favorite_profile = Some(favorite.profile.clone()); state.favorite_profile = Some(favorite.profile.clone());
state.mode = BrowserMode::Directory; state.browsing_favorites = false;
state.root_uri = root.uri().into(); state.root_uri = root.uri().into();
state.current_uri = favorite.uri; state.current_uri = favorite.uri;
drop(state); drop(state);
self.update_mode_controls(BrowserMode::Directory);
self.search_entry.set_text(""); self.search_entry.set_text("");
self.refresh(); self.refresh();
return; return;
} }
self.cancel_listing(); self.cancel_listing();
self.status_label.set_text("저장된 파일에 연결하는 중..."); self.status_label.set_text("즐겨찾기에 연결하는 중...");
let operation = mount_operation(&favorite.profile, &self.window); let operation = mount_operation(&favorite.profile, &self.window);
let controller = Rc::downgrade(self); let controller = Rc::downgrade(self);
let task = glib::MainContext::default().spawn_local(async move { let task = glib::MainContext::default().spawn_local(async move {
@ -618,7 +517,7 @@ impl BrowserController {
if !kind.is_viewable() { if !kind.is_viewable() {
controller controller
.status_label .status_label
.set_text("이 파일 형식은 열 수 없습니다."); .set_text("이 즐겨찾기 파일 형식은 열 수 없습니다.");
return; return;
} }
let entry = folder_entries let entry = folder_entries
@ -655,7 +554,8 @@ impl BrowserController {
} }
fn open_entered_path(self: &Rc<Self>) { fn open_entered_path(self: &Rc<Self>) {
if self.state.borrow().mode != BrowserMode::Directory { let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
return; return;
} }
let profile = self.current_profile(); let profile = self.current_profile();
@ -672,7 +572,8 @@ impl BrowserController {
} }
fn go_up(self: &Rc<Self>) { fn go_up(self: &Rc<Self>) {
if self.state.borrow().mode != BrowserMode::Directory { let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
return; return;
} }
let state = self.state.borrow(); let state = self.state.borrow();
@ -686,17 +587,10 @@ impl BrowserController {
} }
fn refresh(self: &Rc<Self>) { fn refresh(self: &Rc<Self>) {
let mode = self.state.borrow().mode; let browsing_favorites = self.state.borrow().browsing_favorites;
match mode { if browsing_favorites {
BrowserMode::Favorites => { self.show_favorites();
self.show_favorites(); return;
return;
}
BrowserMode::Recent => {
self.show_recent();
return;
}
BrowserMode::Directory => {}
} }
self.cancel_listing(); self.cancel_listing();
let (profile, uri, generation) = { let (profile, uri, generation) = {
@ -715,7 +609,9 @@ impl BrowserController {
state.generation, state.generation,
) )
}; };
self.list_selection.set_model(None::<&gio::ListModel>); while let Some(child) = self.list_box.first_child() {
self.list_box.remove(&child);
}
self.path_entry.set_text(&display_uri(&uri)); self.path_entry.set_text(&display_uri(&uri));
self.status_label self.status_label
.set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)"); .set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)");
@ -727,8 +623,7 @@ impl BrowserController {
let Some(controller) = progress_controller.upgrade() else { let Some(controller) = progress_controller.upgrade() else {
return; return;
}; };
let state = controller.state.borrow(); if controller.state.borrow().generation == generation {
if state.generation == generation && state.mode == BrowserMode::Directory {
controller.status_label.set_text(&format!( controller.status_label.set_text(&format!(
"폴더를 불러오는 중... ({count}개의 파일/폴더를 읽음)" "폴더를 불러오는 중... ({count}개의 파일/폴더를 읽음)"
)); ));
@ -739,7 +634,7 @@ impl BrowserController {
return; return;
}; };
let state = controller.state.borrow(); let state = controller.state.borrow();
let stale = state.generation != generation || state.mode != BrowserMode::Directory; let stale = state.generation != generation || state.browsing_favorites;
drop(state); drop(state);
if stale { if stale {
return; return;
@ -762,6 +657,9 @@ impl BrowserController {
} }
fn render(&self) { 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 query = self.search_entry.text().to_lowercase();
let case_insensitive = self.config.borrow().case_insensitive_sort; let case_insensitive = self.config.borrow().case_insensitive_sort;
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
@ -771,23 +669,17 @@ impl BrowserController {
.filter(|entry| browser_search_match(entry, query.as_str())) .filter(|entry| browser_search_match(entry, query.as_str()))
.cloned() .cloned()
.collect(); .collect();
if state.mode != BrowserMode::Recent { sort_entries(
sort_entries( &mut visible_entries,
&mut visible_entries, state.sort_field,
state.sort_field, state.sort_direction,
state.sort_direction, case_insensitive,
case_insensitive, );
); for entry in &visible_entries {
self.list_box.append(&entry_row(entry));
} }
let count = visible_entries.len(); let count = visible_entries.len();
let names = visible_entries
.iter()
.map(BrowserEntry::display_name)
.collect::<Vec<_>>();
let model = gtk::StringList::new(&names);
state.visible_entries = visible_entries; state.visible_entries = visible_entries;
drop(state);
self.list_selection.set_model(Some(&model));
self.status_label.set_text(&format!("{count}개 항목")); self.status_label.set_text(&format!("{count}개 항목"));
} }
@ -820,8 +712,7 @@ impl BrowserController {
return glib::Propagation::Proceed; return glib::Propagation::Proceed;
}; };
let selected = (self.list_selection.selected() != gtk::INVALID_LIST_POSITION) let selected = self.list_box.selected_row().map(|row| row.index() as usize);
.then(|| self.list_selection.selected() as usize);
let now = Instant::now(); let now = Instant::now();
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
if state if state
@ -847,12 +738,11 @@ impl BrowserController {
let found_name = found.map(|index| state.visible_entries[index].display_name().to_owned()); let found_name = found.map(|index| state.visible_entries[index].display_name().to_owned());
drop(state); drop(state);
if let Some(index) = found { if let Some(index) = found
self.list_view.scroll_to( && let Some(row) = self.list_box.row_at_index(index as i32)
index as u32, {
gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, self.list_box.select_row(Some(&row));
None, let _ = row.grab_focus();
);
self.status_label.set_text(&format!( self.status_label.set_text(&format!(
"'{query}'로 이동: {}", "'{query}'로 이동: {}",
found_name.unwrap_or_default() found_name.unwrap_or_default()
@ -893,7 +783,7 @@ impl BrowserController {
self.render(); self.render();
} }
fn activate_row(self: &Rc<Self>, index: u32) { fn activate_row(self: &Rc<Self>, index: i32) {
let Some(entry) = self let Some(entry) = self
.state .state
.borrow() .borrow()
@ -903,40 +793,19 @@ impl BrowserController {
else { else {
return; return;
}; };
let mode = self.state.borrow().mode; let browsing_favorites = self.state.borrow().browsing_favorites;
match mode { if browsing_favorites {
BrowserMode::Favorites => { let favorite = self
let favorite = self .config
.config .borrow()
.borrow() .favorites
.favorites .iter()
.iter() .find(|favorite| favorite.uri == entry.uri)
.find(|favorite| favorite.uri == entry.uri) .cloned();
.cloned(); if let Some(favorite) = favorite {
if let Some(favorite) = favorite { self.open_favorite(favorite);
self.open_favorite(favorite);
}
return;
} }
BrowserMode::Recent => { return;
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 { match entry.kind {
EntryKind::Directory => { EntryKind::Directory => {
@ -1081,9 +950,9 @@ impl BrowserController {
state.sort_direction, state.sort_direction,
case_insensitive, case_insensitive,
); );
let mode = state.mode; let browsing_favorites = state.browsing_favorites;
drop(state); drop(state);
if mode == BrowserMode::Directory && entries.iter().any(|entry| entry.uri == current_uri) { if !browsing_favorites && entries.iter().any(|entry| entry.uri == current_uri) {
Rc::new(entries) Rc::new(entries)
} else { } else {
Rc::clone(fallback) Rc::clone(fallback)
@ -1097,8 +966,6 @@ impl BrowserController {
profile: LocationProfile, profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>, reuse_window: Option<gtk::ApplicationWindow>,
) { ) {
let recent_entry = entry.clone();
let recent_profile = profile.clone();
self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
let progress_label = reuse_window.as_ref().map_or_else( let progress_label = reuse_window.as_ref().map_or_else(
|| self.status_label.clone(), || self.status_label.clone(),
@ -1125,13 +992,11 @@ impl BrowserController {
} }
match result { match result {
Ok(source) => { Ok(source) => {
controller.record_recent_file(&recent_entry, &recent_profile);
controller.status_label.set_text("다운로드 완료"); controller.status_label.set_text("다운로드 완료");
result_label.set_text("ZIP 파일을 여는 중..."); result_label.set_text("ZIP 파일을 여는 중...");
let Some(application) = controller.window.application() else { let Some(application) = controller.window.application() else {
return; return;
}; };
let direction_control = controller.two_page_direction_control();
ViewerWindow::open( ViewerWindow::open(
&application, &application,
source, source,
@ -1146,7 +1011,6 @@ impl BrowserController {
file_drop_control: controller.file_drop_control(), file_drop_control: controller.file_drop_control(),
reuse_window, reuse_window,
view_mode_control: None, view_mode_control: None,
two_page_direction_control: Some(direction_control),
}, },
); );
} }
@ -1167,8 +1031,6 @@ impl BrowserController {
profile: LocationProfile, profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>, reuse_window: Option<gtk::ApplicationWindow>,
) { ) {
let recent_entry = entry.clone();
let recent_profile = profile.clone();
let kind = entry.kind; let kind = entry.kind;
self.status_label self.status_label
.set_text("미디어 파일 다운로드 준비 중..."); .set_text("미디어 파일 다운로드 준비 중...");
@ -1197,7 +1059,6 @@ impl BrowserController {
} }
match result { match result {
Ok(source) => { Ok(source) => {
controller.record_recent_file(&recent_entry, &recent_profile);
controller.status_label.set_text("다운로드 완료"); controller.status_label.set_text("다운로드 완료");
result_label.set_text("미디어 파일을 여는 중..."); result_label.set_text("미디어 파일을 여는 중...");
let Some(application) = controller.window.application() else { let Some(application) = controller.window.application() else {
@ -1214,7 +1075,6 @@ impl BrowserController {
file_drop_control: controller.file_drop_control(), file_drop_control: controller.file_drop_control(),
reuse_window, reuse_window,
view_mode_control, view_mode_control,
two_page_direction_control: None,
}, },
); );
} }
@ -1228,16 +1088,27 @@ impl BrowserController {
); );
} }
fn show_context_menu( fn show_context_menu(self: &Rc<Self>, x: f64, y: f64) {
self: &Rc<Self>, let Some(widget) = self.list_box.pick(x, y, gtk::PickFlags::DEFAULT) else {
position: u32, return;
parent: &impl glib::object::IsA<gtk::Widget>, };
) { let Some(row) = widget
.clone()
.downcast::<gtk::ListBoxRow>()
.ok()
.or_else(|| {
widget
.ancestor(gtk::ListBoxRow::static_type())
.and_then(|widget| widget.downcast::<gtk::ListBoxRow>().ok())
})
else {
return;
};
let Some(entry) = self let Some(entry) = self
.state .state
.borrow() .borrow()
.visible_entries .visible_entries
.get(position as usize) .get(row.index() as usize)
.cloned() .cloned()
else { else {
return; return;
@ -1247,9 +1118,9 @@ impl BrowserController {
if !can_favorite && !can_download { if !can_favorite && !can_download {
return; return;
} }
self.list_selection.set_selected(position); self.list_box.select_row(Some(&row));
let popover = gtk::Popover::new(); let popover = gtk::Popover::new();
popover.set_parent(parent); popover.set_parent(&row);
let actions = gtk::Box::new(gtk::Orientation::Vertical, 4); let actions = gtk::Box::new(gtk::Orientation::Vertical, 4);
actions.set_margin_top(6); actions.set_margin_top(6);
actions.set_margin_bottom(6); actions.set_margin_bottom(6);
@ -1310,32 +1181,16 @@ impl BrowserController {
} }
fn profile_for_entry(&self, entry: &BrowserEntry) -> LocationProfile { fn profile_for_entry(&self, entry: &BrowserEntry) -> LocationProfile {
match self.state.borrow().mode { if self.state.borrow().browsing_favorites
BrowserMode::Favorites => { && let Some(profile) = self
if let Some(profile) = self .config
.config .borrow()
.borrow() .favorites
.favorites .iter()
.iter() .find(|favorite| favorite.uri == entry.uri)
.find(|favorite| favorite.uri == entry.uri) .map(|favorite| favorite.profile.clone())
.map(|favorite| favorite.profile.clone()) {
{ return profile;
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() self.current_profile()
} }
@ -1567,14 +1422,13 @@ impl BrowserController {
let uri: String = directory.uri().into(); let uri: String = directory.uri().into();
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
state.favorite_profile = Some(profile); state.favorite_profile = Some(profile);
state.mode = BrowserMode::Directory; state.browsing_favorites = false;
state.root_uri = uri.clone(); state.root_uri = uri.clone();
state.current_uri = uri.clone(); state.current_uri = uri.clone();
state.entries = entries; state.entries = entries;
state.typeahead_query.clear(); state.typeahead_query.clear();
state.typeahead_last_input = None; state.typeahead_last_input = None;
drop(state); drop(state);
self.update_mode_controls(BrowserMode::Directory);
self.path_entry.set_text(&display_uri(&uri)); self.path_entry.set_text(&display_uri(&uri));
self.search_entry.set_text(""); self.search_entry.set_text("");
self.render(); self.render();
@ -1607,33 +1461,6 @@ impl BrowserController {
} }
} }
fn two_page_direction_control(self: &Rc<Self>) -> 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 { fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool {
let mut updated = self.config.borrow().clone(); let mut updated = self.config.borrow().clone();
let was_active = self.is_favorite(&entry.uri); let was_active = self.is_favorite(&entry.uri);
@ -1644,7 +1471,8 @@ impl BrowserController {
return was_active; return was_active;
} }
*self.config.borrow_mut() = updated; *self.config.borrow_mut() = updated;
if self.state.borrow().mode == BrowserMode::Favorites { let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
self.show_favorites(); self.show_favorites();
} }
self.status_label.set_text(if active { self.status_label.set_text(if active {
@ -1655,28 +1483,6 @@ impl BrowserController {
active 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( fn choose_download_destination(
self: &Rc<Self>, self: &Rc<Self>,
entry: BrowserEntry, entry: BrowserEntry,
@ -2113,58 +1919,6 @@ impl BrowserController {
}); });
dialog.present(); dialog.present();
} }
fn show_clear_recent_dialog(self: &Rc<Self>) {
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<()> { fn install_latest_update() -> Result<()> {
@ -2370,53 +2124,37 @@ fn display_uri(uri: &str) -> String {
) )
} }
fn entry_content() -> gtk::Box { fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow {
let row = gtk::ListBoxRow::new();
let content = gtk::Box::new(gtk::Orientation::Horizontal, 12); let content = gtk::Box::new(gtk::Orientation::Horizontal, 12);
content.set_margin_top(8); content.set_margin_top(8);
content.set_margin_bottom(8); content.set_margin_bottom(8);
content.set_margin_start(10); content.set_margin_start(10);
content.set_margin_end(10); content.set_margin_end(10);
content.append(&gtk::Image::new()); let icon = match entry.kind {
content.append(
&gtk::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: &gtk::Box, entry: &BrowserEntry) {
let icon_name = match entry.kind {
EntryKind::Directory => "folder-symbolic", EntryKind::Directory => "folder-symbolic",
EntryKind::ZipArchive => "package-x-generic-symbolic", EntryKind::ZipArchive => "package-x-generic-symbolic",
EntryKind::Image => "image-x-generic-symbolic", EntryKind::Image => "image-x-generic-symbolic",
EntryKind::Video => "video-x-generic-symbolic", EntryKind::Video => "video-x-generic-symbolic",
EntryKind::OtherFile => "text-x-generic-symbolic", EntryKind::OtherFile => "text-x-generic-symbolic",
}; };
let Some(icon) = content.first_child().and_downcast::<gtk::Image>() else { content.append(&gtk::Image::from_icon_name(icon));
return; content.append(
}; &gtk::Label::builder()
icon.set_icon_name(Some(icon_name)); .label(entry.display_name())
let Some(name) = icon.next_sibling().and_downcast::<gtk::Label>() else { .hexpand(true)
return; .xalign(0.0)
}; .ellipsize(gtk::pango::EllipsizeMode::Middle)
name.set_text(entry.display_name()); .build(),
let Some(modified) = name.next_sibling().and_downcast::<gtk::Label>() else { );
return; for time in [entry.modified, entry.created] {
}; let label = gtk::Label::new(Some(&format_time(time)));
modified.set_text(&format_time(entry.modified)); label.set_width_chars(17);
let Some(created) = modified.next_sibling().and_downcast::<gtk::Label>() else { label.set_xalign(1.0);
return; content.append(&label);
}; }
created.set_text(&format_time(entry.created)); row.set_child(Some(&content));
row
} }
fn format_time(time: Option<std::time::SystemTime>) -> String { fn format_time(time: Option<std::time::SystemTime>) -> String {
@ -2450,15 +2188,8 @@ fn browser_search_match(entry: &BrowserEntry, lowercase_query: &str) -> bool {
lowercase_query.is_empty() || entry.name.to_lowercase().contains(lowercase_query) lowercase_query.is_empty() || entry.name.to_lowercase().contains(lowercase_query)
} }
fn special_browser_mode(selected: u32, profile_count: usize) -> Option<BrowserMode> { fn is_favorites_selection(selected: u32, profile_count: usize) -> bool {
if selected == gtk::INVALID_LIST_POSITION { selected != gtk::INVALID_LIST_POSITION && selected as usize == profile_count
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( fn sorted_navigable_entries(
@ -2512,32 +2243,6 @@ 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( fn show_viewer_loading(
window: &gtk::ApplicationWindow, window: &gtk::ApplicationWindow,
file_name: &str, file_name: &str,
@ -2828,62 +2533,9 @@ mod tests {
assert_eq!(folder.kind, EntryKind::Directory); assert_eq!(folder.kind, EntryKind::Directory);
assert_eq!(archive.kind, EntryKind::ZipArchive); assert_eq!(archive.kind, EntryKind::ZipArchive);
assert_eq!(special_browser_mode(3, 3), Some(BrowserMode::Favorites)); assert!(is_favorites_selection(3, 3));
assert_eq!(special_browser_mode(4, 3), Some(BrowserMode::Recent)); assert!(!is_favorites_selection(2, 3));
assert_eq!(special_browser_mode(2, 3), None); assert!(!is_favorites_selection(gtk::INVALID_LIST_POSITION, 3));
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::<Vec<_>>();
assert_eq!(
kinds,
[EntryKind::Video, EntryKind::Image, EntryKind::ZipArchive]
);
} }
#[test] #[test]