Release v0.2.8

This commit is contained in:
burnintuna 2026-07-24 21:55:41 +09:00
parent f4a0c484ff
commit 98e659c5b8
9 changed files with 151 additions and 400 deletions

View file

@ -10,7 +10,7 @@ standalone images, and videos.
- Rust - Rust
- GTK4 with GIO/GVfs for desktop and remote filesystem integration - 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 - AppImage as the primary distribution format
- Korean-first UI with all user-facing strings kept translatable - 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 - Support ascending and descending sorting by name, modification time, and
creation time. Treat unavailable creation times as unknown rather than creation time. Treat unavailable creation times as unknown rather than
inventing values. inventing values.
- Search folder and ZIP names recursively below the current location using the - Filter the currently displayed browser directory by filename and restore all
cached index. 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. 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 - 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 or Enter opens the selected viewable file; its context menu provides favorite
and download actions. 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. - 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 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. parent folder and open the exact naturally sorted item in a new viewer window.

2
Cargo.lock generated
View file

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

View file

@ -1,6 +1,6 @@
[package] [package]
name = "comicviewer" name = "comicviewer"
version = "0.2.7" version = "0.2.8"
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"

View file

@ -26,6 +26,7 @@
</provides> </provides>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<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" />
<release version="0.2.6" date="2026-07-23" /> <release version="0.2.6" date="2026-07-23" />
<release version="0.2.5" date="2026-07-23" /> <release version="0.2.5" date="2026-07-23" />

View file

@ -82,6 +82,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다. - 기본 영구 ZIP 캐시 한도는 10GB이며 오래된 파일부터 자동으로 삭제됩니다.
- 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다. - 메인 창의 `캐시 비우기` 버튼으로 현재 열려 있지 않은 ZIP 캐시를 직접 삭제할 수 있습니다.
- 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다. - 캐시 한도는 설정 파일의 `cache_quota_gb`로 변경하며, 0은 영구 캐시를 사용하지 않는 값입니다.
- 메인 창 검색은 현재 표시 중인 폴더의 파일명을 필터링하며, 입력을 지우면 전체 목록을 복원합니다.
## 뷰어 조작 ## 뷰어 조작
@ -90,6 +91,7 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동 - 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동
- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
- `L`: 우측 파일 목록 표시 또는 숨기기 - `L`: 우측 파일 목록 표시 또는 숨기기
- 파일 목록 상단 검색: 현재 재생 목록을 임시로 필터링하며, 입력을 지우면 전체 목록 복원
- 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
- 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 - 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
- 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 - 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시

View file

@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. 스크립트는 `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`
- `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.7" git commit -m "Release v0.2.8"
git tag -a v0.2.7 -m "Comicviewer v0.2.7" git tag -a v0.2.8 -m "Comicviewer v0.2.8"
git push origin main git push origin main
git push origin v0.2.7 git push origin v0.2.8
``` ```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새

View file

@ -2,11 +2,8 @@ use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use gtk::gio::prelude::*;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use crate::browser::{BrowserEntry, EntryKind};
pub struct Database { pub struct Database {
connection: Connection, connection: Connection,
} }
@ -108,124 +105,6 @@ impl Database {
)?; )?;
Ok(()) Ok(())
} }
pub fn load_cached_tree(&self, location_id: &str, root_uri: &str) -> Result<Vec<BrowserEntry>> {
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<i64>>(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<i64>>(5)?.and_then(system_time_from_i64),
created: row.get::<_, Option<i64>>(6)?.and_then(system_time_from_i64),
etag: row.get(7)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.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<SystemTime> {
u64::try_from(seconds)
.ok()
.map(|seconds| UNIX_EPOCH + std::time::Duration::from_secs(seconds))
} }
#[cfg(test)] #[cfg(test)]
@ -243,51 +122,4 @@ mod tests {
database.save_progress("test.zip", 9, 20).unwrap(); database.save_progress("test.zip", 9, 20).unwrap();
assert_eq!(database.load_progress("test.zip").unwrap(), Some(9)); 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
);
}
} }

View file

@ -25,6 +25,7 @@ const IMAGE_ZOOM_MIN: f64 = 0.1;
const IMAGE_ZOOM_MAX: f64 = 4.0; const IMAGE_ZOOM_MAX: f64 = 4.0;
const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller"; const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller";
const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-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"; const PRESERVE_SEEK_CLASS: &str = "viewer-preserve-seek";
fn clear_viewer_controller(window: &gtk::ApplicationWindow) { fn clear_viewer_controller(window: &gtk::ApplicationWindow) {
@ -275,6 +276,10 @@ impl ViewerWindow {
view_mode_control: _, view_mode_control: _,
} = controls; } = controls;
let title = source.display_name.clone(); 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() { if let Some(window) = reuse_window.as_ref() {
clear_viewer_controller(window); clear_viewer_controller(window);
} }
@ -399,7 +404,7 @@ impl ViewerWindow {
window_overlay.add_overlay(&navigation_message); window_overlay.add_overlay(&navigation_message);
window_overlay.set_hexpand(true); window_overlay.set_hexpand(true);
window_overlay.set_vexpand(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()); file_list.panel.set_visible(file_list_button.is_active());
let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0); let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0);
viewer_layout.append(&window_overlay); viewer_layout.append(&window_overlay);
@ -438,6 +443,7 @@ impl ViewerWindow {
sibling_navigator, sibling_navigator,
file_list_button, file_list_button,
file_list_panel: file_list.panel, file_list_panel: file_list.panel,
file_list_search: file_list.search,
file_list: file_list.list, file_list: file_list.list,
file_list_files: RefCell::new(Vec::new()), file_list_files: RefCell::new(Vec::new()),
favorite_button, favorite_button,
@ -605,7 +611,8 @@ struct ViewerController {
case_insensitive_sort: bool, case_insensitive_sort: bool,
sibling_navigator: Option<SiblingNavigator>, sibling_navigator: Option<SiblingNavigator>,
file_list_button: gtk::ToggleButton, 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: gtk::ListBox,
file_list_files: RefCell<Vec<SiblingFile>>, file_list_files: RefCell<Vec<SiblingFile>>,
favorite_button: gtk::Button, favorite_button: gtk::Button,
@ -717,6 +724,13 @@ impl ViewerController {
controller.open_file_list_row(row.index()); 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_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default()));
let file_list_keys = gtk::EventControllerKey::new(); let file_list_keys = gtk::EventControllerKey::new();
let controller = Rc::downgrade(self); let controller = Rc::downgrade(self);
@ -890,6 +904,7 @@ impl ViewerController {
let controller = Rc::downgrade(self); let controller = Rc::downgrade(self);
fullscreen_keys.connect_key_pressed(move |_, key, _, _| { fullscreen_keys.connect_key_pressed(move |_, key, _, _| {
if let Some(controller) = controller.upgrade() if let Some(controller) = controller.upgrade()
&& !widget_contains_focus(&controller.file_list_search, &controller.window)
&& controller.window.is_fullscreen() && controller.window.is_fullscreen()
&& fullscreen_direction_key(key) && fullscreen_direction_key(key)
{ {
@ -907,6 +922,9 @@ impl ViewerController {
let Some(controller) = controller.upgrade() else { let Some(controller) = controller.upgrade() else {
return glib::Propagation::Proceed; return glib::Propagation::Proceed;
}; };
if widget_contains_focus(&controller.file_list_search, &controller.window) {
return glib::Propagation::Proceed;
}
if !controller.window.is_fullscreen() { if !controller.window.is_fullscreen() {
controller.hide_seek(); controller.hide_seek();
} }
@ -1026,6 +1044,7 @@ impl ViewerController {
.as_ref() .as_ref()
.map(SiblingNavigator::files) .map(SiblingNavigator::files)
.unwrap_or_default(); .unwrap_or_default();
let files = filter_sibling_files(files, self.file_list_search.text().as_str());
let current = files let current = files
.iter() .iter()
.position(|file| file.uri == self.source.source_uri); .position(|file| file.uri == self.source.source_uri);
@ -2167,6 +2186,10 @@ impl MediaViewerWindow {
view_mode_control, view_mode_control,
} = controls; } = controls;
let title = source.display_name.clone(); 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() { if let Some(window) = reuse_window.as_ref() {
clear_viewer_controller(window); clear_viewer_controller(window);
} }
@ -2263,7 +2286,7 @@ impl MediaViewerWindow {
window_overlay.add_overlay(&navigation_message); window_overlay.add_overlay(&navigation_message);
window_overlay.set_hexpand(true); window_overlay.set_hexpand(true);
window_overlay.set_vexpand(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()); file_list.panel.set_visible(file_list_button.is_active());
let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0); let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0);
viewer_layout.append(&window_overlay); viewer_layout.append(&window_overlay);
@ -2292,6 +2315,7 @@ impl MediaViewerWindow {
sibling_navigator, sibling_navigator,
file_list_button, file_list_button,
file_list_panel: file_list.panel, file_list_panel: file_list.panel,
file_list_search: file_list.search,
file_list: file_list.list, file_list: file_list.list,
file_list_files: RefCell::new(Vec::new()), file_list_files: RefCell::new(Vec::new()),
favorite_button, favorite_button,
@ -2377,7 +2401,8 @@ struct MediaViewerController {
seek_files: RefCell<Vec<SiblingFile>>, seek_files: RefCell<Vec<SiblingFile>>,
sibling_navigator: Option<SiblingNavigator>, sibling_navigator: Option<SiblingNavigator>,
file_list_button: gtk::ToggleButton, 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: gtk::ListBox,
file_list_files: RefCell<Vec<SiblingFile>>, file_list_files: RefCell<Vec<SiblingFile>>,
favorite_button: gtk::Button, favorite_button: gtk::Button,
@ -2434,6 +2459,7 @@ impl MediaViewerController {
let controller = Rc::downgrade(self); let controller = Rc::downgrade(self);
fullscreen_keys.connect_key_pressed(move |_, key, _, _| { fullscreen_keys.connect_key_pressed(move |_, key, _, _| {
if let Some(controller) = controller.upgrade() if let Some(controller) = controller.upgrade()
&& !widget_contains_focus(&controller.file_list_search, &controller.window)
&& controller.window.is_fullscreen() && controller.window.is_fullscreen()
&& fullscreen_direction_key(key) && fullscreen_direction_key(key)
{ {
@ -2451,6 +2477,9 @@ impl MediaViewerController {
let Some(controller) = controller.upgrade() else { let Some(controller) = controller.upgrade() else {
return glib::Propagation::Proceed; return glib::Propagation::Proceed;
}; };
if widget_contains_focus(&controller.file_list_search, &controller.window) {
return glib::Propagation::Proceed;
}
if modifiers.intersects( if modifiers.intersects(
gdk::ModifierType::CONTROL_MASK gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK | gdk::ModifierType::ALT_MASK
@ -2596,6 +2625,13 @@ impl MediaViewerController {
controller.open_file_list_row(row.index()); 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_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default()));
let file_list_keys = gtk::EventControllerKey::new(); let file_list_keys = gtk::EventControllerKey::new();
let controller = Rc::downgrade(self); let controller = Rc::downgrade(self);
@ -2716,6 +2752,7 @@ impl MediaViewerController {
.as_ref() .as_ref()
.map(SiblingNavigator::files) .map(SiblingNavigator::files)
.unwrap_or_default(); .unwrap_or_default();
let files = filter_sibling_files(files, self.file_list_search.text().as_str());
let current = files let current = files
.iter() .iter()
.position(|file| file.uri == self.source.source_uri); .position(|file| file.uri == self.source.source_uri);
@ -3254,7 +3291,8 @@ fn favorite_label(active: bool) -> &'static str {
} }
struct ViewerFileList { struct ViewerFileList {
panel: gtk::ScrolledWindow, panel: gtk::Box,
search: gtk::SearchEntry,
list: gtk::ListBox, 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)) .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(); let list = gtk::ListBox::new();
list.set_selection_mode(gtk::SelectionMode::Single); list.set_selection_mode(gtk::SelectionMode::Single);
list.set_activate_on_single_click(false); list.set_activate_on_single_click(false);
let panel = gtk::ScrolledWindow::builder() let scrolled = gtk::ScrolledWindow::builder()
.width_request(280)
.vexpand(true) .vexpand(true)
.child(&list) .child(&list)
.build(); .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.add_css_class("sidebar");
panel.set_visible(false); panel.set_visible(false);
ViewerFileList { panel, list } ViewerFileList {
panel,
search,
list,
}
}
fn viewer_file_search_query(window: &gtk::ApplicationWindow) -> String {
unsafe {
window
.data::<String>(VIEWER_FILE_SEARCH_KEY)
.map(|query| query.as_ref().clone())
.unwrap_or_default()
}
}
fn store_viewer_file_search_query(window: &gtk::ApplicationWindow, query: &str) {
unsafe {
window.set_data(VIEWER_FILE_SEARCH_KEY, query.to_owned());
}
}
fn widget_contains_focus(widget: &impl IsA<gtk::Widget>, window: &gtk::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 { fn viewer_file_row(file: &SiblingFile) -> gtk::ListBoxRow {
@ -3774,6 +3850,14 @@ fn seek_sibling_files(
files files
} }
fn filter_sibling_files(mut files: Vec<SiblingFile>, query: &str) -> Vec<SiblingFile> {
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<usize> { fn seek_position(files: &[SiblingFile], current_uri: &str) -> Option<usize> {
files.iter().position(|file| file.uri == current_uri) 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); 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] #[test]
fn file_list_shortcut_rejects_command_modifiers() { fn file_list_shortcut_rejects_command_modifiers() {
assert!(!shortcut_modifiers_blocked(gdk::ModifierType::SHIFT_MASK)); assert!(!shortcut_modifiers_blocked(gdk::ModifierType::SHIFT_MASK));

View file

@ -1,4 +1,4 @@
use std::cell::{Cell, RefCell}; use std::cell::RefCell;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
@ -97,7 +97,7 @@ impl ComicWindow {
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6); let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let search_entry = gtk::SearchEntry::builder() let search_entry = gtk::SearchEntry::builder()
.placeholder_text("현재 위치 아래 폴더와 ZIP 검색") .placeholder_text("현재 폴더에서 검색")
.hexpand(true) .hexpand(true)
.build(); .build();
let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]);
@ -152,10 +152,6 @@ impl ComicWindow {
current_uri: root_uri, current_uri: root_uri,
entries: Vec::new(), entries: Vec::new(),
visible_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_field: SortField::Name,
sort_direction: SortDirection::Ascending, sort_direction: SortDirection::Ascending,
generation: 0, generation: 0,
@ -167,8 +163,6 @@ impl ComicWindow {
database: Rc::new(database), database: Rc::new(database),
zip_cache, zip_cache,
listing_task: RefCell::new(None), listing_task: RefCell::new(None),
search_task: RefCell::new(None),
search_generation: Cell::new(0),
}); });
controller.rebuild_profiles(); controller.rebuild_profiles();
@ -204,10 +198,6 @@ struct BrowserState {
current_uri: String, current_uri: String,
entries: Vec<BrowserEntry>, entries: Vec<BrowserEntry>,
visible_entries: Vec<BrowserEntry>, visible_entries: Vec<BrowserEntry>,
recursive_entries: Vec<BrowserEntry>,
recursive_root_uri: Option<String>,
recursive_ready: bool,
recursive_scanning: bool,
sort_field: SortField, sort_field: SortField,
sort_direction: SortDirection, sort_direction: SortDirection,
generation: u64, generation: u64,
@ -234,8 +224,6 @@ struct BrowserController {
database: Rc<Database>, database: Rc<Database>,
zip_cache: Rc<ZipCache>, zip_cache: Rc<ZipCache>,
listing_task: RefCell<Option<glib::JoinHandle<()>>>, listing_task: RefCell<Option<glib::JoinHandle<()>>>,
search_task: RefCell<Option<glib::JoinHandle<()>>>,
search_generation: Cell<u64>,
} }
impl BrowserController { impl BrowserController {
@ -317,7 +305,7 @@ impl BrowserController {
.set_list_factory(Some(&location_factory)); .set_list_factory(Some(&location_factory));
let controller = Rc::clone(self); let controller = Rc::clone(self);
self.search_entry self.search_entry
.connect_search_changed(move |_| controller.search_changed()); .connect_search_changed(move |_| controller.render());
let controller = Rc::clone(self); let controller = Rc::clone(self);
self.sort_dropdown self.sort_dropdown
.connect_selected_notify(move |_| controller.change_sort_field()); .connect_selected_notify(move |_| controller.change_sort_field());
@ -432,7 +420,6 @@ impl BrowserController {
fn show_favorites(&self) { fn show_favorites(&self) {
self.cancel_listing(); self.cancel_listing();
self.cancel_recursive_search();
self.edit_location_button.set_sensitive(false); self.edit_location_button.set_sensitive(false);
self.delete_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false);
self.path_entry.set_text("즐겨찾기"); self.path_entry.set_text("즐겨찾기");
@ -594,12 +581,13 @@ impl BrowserController {
return; return;
} }
self.cancel_listing(); self.cancel_listing();
self.cancel_recursive_search();
let (profile, uri, generation) = { let (profile, uri, generation) = {
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
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.entries.clear();
state.visible_entries.clear();
( (
state state
.favorite_profile .favorite_profile
@ -609,6 +597,9 @@ impl BrowserController {
state.generation, 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.path_entry.set_text(&display_uri(&uri));
self.status_label self.status_label
.set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)"); .set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)");
@ -640,9 +631,6 @@ impl BrowserController {
Ok(entries) => { Ok(entries) => {
controller.state.borrow_mut().entries = entries; controller.state.borrow_mut().entries = entries;
controller.render(); controller.render();
if !controller.search_entry.text().is_empty() {
controller.start_recursive_search();
}
} }
Err(error) => controller.status_label.set_text(&format!("{error:#}")), 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<Self>) {
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<Self>) {
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) { fn render(&self) {
while let Some(child) = self.list_box.first_child() { while let Some(child) = self.list_box.first_child() {
self.list_box.remove(&child); self.list_box.remove(&child);
@ -799,17 +651,10 @@ impl BrowserController {
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();
let recursive_ready = !query.is_empty() let mut visible_entries: Vec<_> = state
&& state.recursive_ready .entries
&& 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() .iter()
.filter(|entry| query.is_empty() || recursive_search_match(entry, query.as_str())) .filter(|entry| browser_search_match(entry, query.as_str()))
.cloned() .cloned()
.collect(); .collect();
sort_entries( sort_entries(
@ -823,14 +668,7 @@ impl BrowserController {
} }
let count = visible_entries.len(); let count = visible_entries.len();
state.visible_entries = visible_entries; state.visible_entries = visible_entries;
let status = if query.is_empty() { self.status_label.set_text(&format!("{count}개 항목"));
format!("{count}개 항목")
} else if state.recursive_scanning {
format!("현재 폴더 결과 {count}개 · 하위 폴더 색인 생성 중...")
} else {
format!("하위 폴더 검색 결과 {count}개")
};
self.status_label.set_text(&status);
} }
fn typeahead( fn typeahead(
@ -964,23 +802,6 @@ impl BrowserController {
self.refresh(); self.refresh();
} }
EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { 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::<Vec<_>>();
self.open_viewable(
entry.clone(),
Rc::new(siblings),
self.current_profile(),
None,
);
return;
}
let current_kind = entry.kind; let current_kind = entry.kind;
let case_insensitive = self.config.borrow().case_insensitive_sort; let case_insensitive = self.config.borrow().case_insensitive_sort;
let siblings = { let siblings = {
@ -1363,7 +1184,6 @@ impl BrowserController {
} }
fn open_folder_recursive(self: &Rc<Self>, entry: BrowserEntry) { fn open_folder_recursive(self: &Rc<Self>, entry: BrowserEntry) {
self.cancel_recursive_search();
let profile = self.profile_for_entry(&entry); let profile = self.profile_for_entry(&entry);
let Ok(profile_root) = root_file(&profile) else { let Ok(profile_root) = root_file(&profile) else {
self.status_label self.status_label
@ -1491,7 +1311,6 @@ impl BrowserController {
} }
let file = file.clone(); let file = file.clone();
self.cancel_listing(); self.cancel_listing();
self.cancel_recursive_search();
let generation = { let generation = {
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
state.generation = state.generation.wrapping_add(1); 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() 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 { fn is_favorites_selection(selected: u32, profile_count: usize) -> bool {
selected != gtk::INVALID_LIST_POSITION && selected as usize == profile_count selected != gtk::INVALID_LIST_POSITION && selected as usize == profile_count
} }
@ -2193,14 +2016,6 @@ fn sorted_navigable_selection(
Some((entries, selected)) 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 { fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry {
BrowserEntry { BrowserEntry {
uri: favorite.uri.clone(), uri: favorite.uri.clone(),
@ -2380,19 +2195,6 @@ mod tests {
assert_eq!(siblings[selected].name, "page10.jpg"); 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] #[test]
fn mouse_back_button_matches_standard_x11_button() { fn mouse_back_button_matches_standard_x11_button() {
assert_eq!(MOUSE_BACK_BUTTON, 8); assert_eq!(MOUSE_BACK_BUTTON, 8);
@ -2411,6 +2213,20 @@ mod tests {
assert!(profile.password.is_empty()); 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] #[test]
fn favorites_are_rendered_as_browser_paths_and_files() { fn favorites_are_rendered_as_browser_paths_and_files() {
let profile = home_profile(); let profile = home_profile();