Release v0.2.12
This commit is contained in:
parent
7d6202e0a2
commit
77d6e3b68b
7 changed files with 294 additions and 86 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "comicviewer"
|
name = "comicviewer"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "comicviewer"
|
name = "comicviewer"
|
||||||
version = "0.2.11"
|
version = "0.2.12"
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
</provides>
|
</provides>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.2.12" date="2026-07-29" />
|
||||||
<release version="0.2.11" date="2026-07-24" />
|
<release version="0.2.11" date="2026-07-24" />
|
||||||
<release version="0.2.10" 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" />
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
|
||||||
|
|
||||||
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
|
스크립트는 `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`
|
||||||
- `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.11"
|
git commit -m "Release v0.2.12"
|
||||||
git tag -a v0.2.11 -m "Comicviewer v0.2.11"
|
git tag -a v0.2.12 -m "Comicviewer v0.2.12"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin v0.2.11
|
git push origin v0.2.12
|
||||||
```
|
```
|
||||||
|
|
||||||
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,32 @@ 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,
|
||||||
|
|
@ -112,7 +138,7 @@ where
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
let mut scanned = 0;
|
let mut enumeration = EnumerationProgress::default();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let batch = enumerator
|
let batch = enumerator
|
||||||
|
|
@ -121,19 +147,21 @@ where
|
||||||
if batch.is_empty() {
|
if batch.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
scanned += batch.len();
|
enumeration.begin_batch();
|
||||||
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(),
|
||||||
|
|
@ -153,6 +181,10 @@ where
|
||||||
.map(Into::into),
|
.map(Into::into),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if enumeration.repeated_batch() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
progress(enumeration.len());
|
||||||
}
|
}
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
@ -449,6 +481,26 @@ 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();
|
||||||
|
|
|
||||||
119
src/viewer.rs
119
src/viewer.rs
|
|
@ -26,6 +26,7 @@ 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 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";
|
const PRESERVE_SEEK_CLASS: &str = "viewer-preserve-seek";
|
||||||
|
|
||||||
fn clear_viewer_controller(window: >k::ApplicationWindow) {
|
fn clear_viewer_controller(window: >k::ApplicationWindow) {
|
||||||
|
|
@ -289,6 +290,10 @@ impl ViewerWindow {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(viewer_file_search_query)
|
.map(viewer_file_search_query)
|
||||||
.unwrap_or_default();
|
.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() {
|
if let Some(window) = reuse_window.as_ref() {
|
||||||
clear_viewer_controller(window);
|
clear_viewer_controller(window);
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +310,7 @@ impl ViewerWindow {
|
||||||
set_viewer_loading(&window, false);
|
set_viewer_loading(&window, false);
|
||||||
window.set_title(Some(&title));
|
window.set_title(Some(&title));
|
||||||
let header = gtk::HeaderBar::new();
|
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 favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
|
||||||
let download_button = gtk::Button::with_label("파일 다운로드");
|
let download_button = gtk::Button::with_label("파일 다운로드");
|
||||||
let file_list_button = gtk::ToggleButton::with_label("파일 목록");
|
let file_list_button = gtk::ToggleButton::with_label("파일 목록");
|
||||||
|
|
@ -454,8 +459,10 @@ impl ViewerWindow {
|
||||||
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_search: file_list.search,
|
||||||
|
file_list_scrolled: file_list.scrolled,
|
||||||
file_list: file_list.list,
|
file_list: file_list.list,
|
||||||
file_list_files: RefCell::new(Vec::new()),
|
file_list_files: RefCell::new(Vec::new()),
|
||||||
|
file_list_scroll_restore: Cell::new(Some(file_list_scroll)),
|
||||||
favorite_button,
|
favorite_button,
|
||||||
favorite_toggle: favorite_control.toggle,
|
favorite_toggle: favorite_control.toggle,
|
||||||
download_button,
|
download_button,
|
||||||
|
|
@ -625,8 +632,10 @@ struct ViewerController {
|
||||||
file_list_button: gtk::ToggleButton,
|
file_list_button: gtk::ToggleButton,
|
||||||
file_list_panel: gtk::Box,
|
file_list_panel: gtk::Box,
|
||||||
file_list_search: gtk::SearchEntry,
|
file_list_search: gtk::SearchEntry,
|
||||||
|
file_list_scrolled: gtk::ScrolledWindow,
|
||||||
file_list: gtk::ListBox,
|
file_list: gtk::ListBox,
|
||||||
file_list_files: RefCell<Vec<SiblingFile>>,
|
file_list_files: RefCell<Vec<SiblingFile>>,
|
||||||
|
file_list_scroll_restore: Cell<Option<f64>>,
|
||||||
favorite_button: gtk::Button,
|
favorite_button: gtk::Button,
|
||||||
favorite_toggle: Rc<dyn Fn() -> bool>,
|
favorite_toggle: Rc<dyn Fn() -> bool>,
|
||||||
download_button: gtk::Button,
|
download_button: gtk::Button,
|
||||||
|
|
@ -752,6 +761,16 @@ impl ViewerController {
|
||||||
controller.refresh_file_list();
|
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_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);
|
||||||
|
|
@ -1073,6 +1092,11 @@ impl ViewerController {
|
||||||
if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) {
|
if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) {
|
||||||
self.file_list.select_row(Some(&row));
|
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) {
|
fn open_file_list_row(&self, index: i32) {
|
||||||
|
|
@ -1264,6 +1288,11 @@ impl ViewerController {
|
||||||
self.status_label.set_visible(true);
|
self.status_label.set_visible(true);
|
||||||
self.file_list_panel
|
self.file_list_panel
|
||||||
.set_visible(self.file_list_button.is_active());
|
.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.status_label.set_visible(visible);
|
||||||
self.file_list_panel
|
self.file_list_panel
|
||||||
.set_visible(visible && self.file_list_button.is_active());
|
.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();
|
self.window.queue_allocate();
|
||||||
let controller = Rc::downgrade(self);
|
let controller = Rc::downgrade(self);
|
||||||
glib::idle_add_local_once(move || {
|
glib::idle_add_local_once(move || {
|
||||||
|
|
@ -2209,6 +2243,10 @@ impl MediaViewerWindow {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(viewer_file_search_query)
|
.map(viewer_file_search_query)
|
||||||
.unwrap_or_default();
|
.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() {
|
if let Some(window) = reuse_window.as_ref() {
|
||||||
clear_viewer_controller(window);
|
clear_viewer_controller(window);
|
||||||
}
|
}
|
||||||
|
|
@ -2225,7 +2263,7 @@ impl MediaViewerWindow {
|
||||||
set_viewer_loading(&window, false);
|
set_viewer_loading(&window, false);
|
||||||
window.set_title(Some(&title));
|
window.set_title(Some(&title));
|
||||||
let header = gtk::HeaderBar::new();
|
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 favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
|
||||||
let download_button = gtk::Button::with_label("파일 다운로드");
|
let download_button = gtk::Button::with_label("파일 다운로드");
|
||||||
let file_list_button = gtk::ToggleButton::with_label("파일 목록");
|
let file_list_button = gtk::ToggleButton::with_label("파일 목록");
|
||||||
|
|
@ -2335,8 +2373,10 @@ impl MediaViewerWindow {
|
||||||
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_search: file_list.search,
|
||||||
|
file_list_scrolled: file_list.scrolled,
|
||||||
file_list: file_list.list,
|
file_list: file_list.list,
|
||||||
file_list_files: RefCell::new(Vec::new()),
|
file_list_files: RefCell::new(Vec::new()),
|
||||||
|
file_list_scroll_restore: Cell::new(Some(file_list_scroll)),
|
||||||
favorite_button,
|
favorite_button,
|
||||||
favorite_toggle: favorite_control.toggle,
|
favorite_toggle: favorite_control.toggle,
|
||||||
download_button,
|
download_button,
|
||||||
|
|
@ -2422,8 +2462,10 @@ struct MediaViewerController {
|
||||||
file_list_button: gtk::ToggleButton,
|
file_list_button: gtk::ToggleButton,
|
||||||
file_list_panel: gtk::Box,
|
file_list_panel: gtk::Box,
|
||||||
file_list_search: gtk::SearchEntry,
|
file_list_search: gtk::SearchEntry,
|
||||||
|
file_list_scrolled: gtk::ScrolledWindow,
|
||||||
file_list: gtk::ListBox,
|
file_list: gtk::ListBox,
|
||||||
file_list_files: RefCell<Vec<SiblingFile>>,
|
file_list_files: RefCell<Vec<SiblingFile>>,
|
||||||
|
file_list_scroll_restore: Cell<Option<f64>>,
|
||||||
favorite_button: gtk::Button,
|
favorite_button: gtk::Button,
|
||||||
favorite_toggle: Rc<dyn Fn() -> bool>,
|
favorite_toggle: Rc<dyn Fn() -> bool>,
|
||||||
download_button: gtk::Button,
|
download_button: gtk::Button,
|
||||||
|
|
@ -2653,6 +2695,16 @@ impl MediaViewerController {
|
||||||
controller.refresh_file_list();
|
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_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);
|
||||||
|
|
@ -2779,6 +2831,11 @@ impl MediaViewerController {
|
||||||
if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) {
|
if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) {
|
||||||
self.file_list.select_row(Some(&row));
|
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) {
|
fn open_file_list_row(&self, index: i32) {
|
||||||
|
|
@ -3191,6 +3248,11 @@ impl MediaViewerController {
|
||||||
self.status_label.set_visible(true);
|
self.status_label.set_visible(true);
|
||||||
self.file_list_panel
|
self.file_list_panel
|
||||||
.set_visible(self.file_list_button.is_active());
|
.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.status_label.set_visible(visible);
|
||||||
self.file_list_panel
|
self.file_list_panel
|
||||||
.set_visible(visible && self.file_list_button.is_active());
|
.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();
|
self.window.queue_allocate();
|
||||||
let controller = Rc::downgrade(self);
|
let controller = Rc::downgrade(self);
|
||||||
glib::idle_add_local_once(move || {
|
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 {
|
struct ViewerFileList {
|
||||||
panel: gtk::Box,
|
panel: gtk::Box,
|
||||||
search: gtk::SearchEntry,
|
search: gtk::SearchEntry,
|
||||||
|
scrolled: gtk::ScrolledWindow,
|
||||||
list: gtk::ListBox,
|
list: gtk::ListBox,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3430,6 +3507,7 @@ fn viewer_file_list(query: &str) -> ViewerFileList {
|
||||||
ViewerFileList {
|
ViewerFileList {
|
||||||
panel,
|
panel,
|
||||||
search,
|
search,
|
||||||
|
scrolled,
|
||||||
list,
|
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::<f64>(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<Option<f64>>,
|
||||||
|
) {
|
||||||
|
if !panel.is_visible() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(position) = pending.take() {
|
||||||
|
restore_viewer_file_scroll(scrolled, position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn widget_contains_focus(widget: &impl IsA<gtk::Widget>, window: >k::ApplicationWindow) -> bool {
|
fn widget_contains_focus(widget: &impl IsA<gtk::Widget>, window: >k::ApplicationWindow) -> bool {
|
||||||
gtk::prelude::GtkWindowExt::focus(window).is_some_and(|focus| {
|
gtk::prelude::GtkWindowExt::focus(window).is_some_and(|focus| {
|
||||||
let widget = widget.as_ref();
|
let widget = widget.as_ref();
|
||||||
|
|
|
||||||
180
src/window.rs
180
src/window.rs
|
|
@ -125,12 +125,13 @@ impl ComicWindow {
|
||||||
controls.append(>k::Label::new(Some("빠른 이미지 로딩")));
|
controls.append(>k::Label::new(Some("빠른 이미지 로딩")));
|
||||||
controls.append(&fast_loading_switch);
|
controls.append(&fast_loading_switch);
|
||||||
|
|
||||||
let list_box = gtk::ListBox::new();
|
let list_selection = gtk::SingleSelection::new(None::<gio::ListModel>);
|
||||||
list_box.set_selection_mode(gtk::SelectionMode::Single);
|
let list_view =
|
||||||
list_box.set_activate_on_single_click(false);
|
gtk::ListView::new(Some(list_selection.clone()), None::<gtk::ListItemFactory>);
|
||||||
|
list_view.set_single_click_activate(false);
|
||||||
let scrolled = gtk::ScrolledWindow::builder()
|
let scrolled = gtk::ScrolledWindow::builder()
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.child(&list_box)
|
.child(&list_view)
|
||||||
.build();
|
.build();
|
||||||
let status_label = gtk::Label::builder().xalign(0.0).build();
|
let status_label = gtk::Label::builder().xalign(0.0).build();
|
||||||
|
|
||||||
|
|
@ -152,7 +153,8 @@ impl ComicWindow {
|
||||||
search_entry,
|
search_entry,
|
||||||
sort_dropdown,
|
sort_dropdown,
|
||||||
direction_button,
|
direction_button,
|
||||||
list_box,
|
list_view,
|
||||||
|
list_selection,
|
||||||
status_label,
|
status_label,
|
||||||
state: RefCell::new(BrowserState {
|
state: RefCell::new(BrowserState {
|
||||||
profile_index: 0,
|
profile_index: 0,
|
||||||
|
|
@ -227,7 +229,8 @@ struct BrowserController {
|
||||||
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_box: gtk::ListBox,
|
list_view: gtk::ListView,
|
||||||
|
list_selection: gtk::SingleSelection,
|
||||||
status_label: gtk::Label,
|
status_label: gtk::Label,
|
||||||
state: RefCell<BrowserState>,
|
state: RefCell<BrowserState>,
|
||||||
config: RefCell<AppConfig>,
|
config: RefCell<AppConfig>,
|
||||||
|
|
@ -328,16 +331,50 @@ 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 controller = Rc::clone(self);
|
let factory = gtk::SignalListItemFactory::new();
|
||||||
self.list_box
|
let controller = Rc::downgrade(self);
|
||||||
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
|
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 context_click = gtk::GestureClick::builder().button(3).build();
|
||||||
let controller = Rc::clone(self);
|
let item = item.downgrade();
|
||||||
context_click.connect_pressed(move |_, _, x, y| {
|
let controller = controller.clone();
|
||||||
controller.show_context_menu(x, y);
|
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);
|
||||||
});
|
});
|
||||||
self.list_box.add_controller(context_click);
|
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);
|
||||||
|
self.list_view
|
||||||
|
.connect_activate(move |_, position| controller.activate_row(position));
|
||||||
|
|
||||||
let key_controller = gtk::EventControllerKey::new();
|
let key_controller = gtk::EventControllerKey::new();
|
||||||
let controller = Rc::clone(self);
|
let controller = Rc::clone(self);
|
||||||
|
|
@ -349,7 +386,7 @@ impl BrowserController {
|
||||||
controller.typeahead(key, modifiers)
|
controller.typeahead(key, modifiers)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
self.list_box.add_controller(key_controller);
|
self.list_view.add_controller(key_controller);
|
||||||
|
|
||||||
let mouse_back = gtk::GestureClick::builder()
|
let mouse_back = gtk::GestureClick::builder()
|
||||||
.button(MOUSE_BACK_BUTTON)
|
.button(MOUSE_BACK_BUTTON)
|
||||||
|
|
@ -609,9 +646,7 @@ impl BrowserController {
|
||||||
state.generation,
|
state.generation,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
while let Some(child) = self.list_box.first_child() {
|
self.list_selection.set_model(None::<&gio::ListModel>);
|
||||||
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개의 파일/폴더를 읽음)");
|
||||||
|
|
@ -657,9 +692,6 @@ 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();
|
||||||
|
|
@ -675,11 +707,15 @@ impl BrowserController {
|
||||||
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}개 항목"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -712,7 +748,8 @@ impl BrowserController {
|
||||||
return glib::Propagation::Proceed;
|
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 now = Instant::now();
|
||||||
let mut state = self.state.borrow_mut();
|
let mut state = self.state.borrow_mut();
|
||||||
if state
|
if state
|
||||||
|
|
@ -738,11 +775,9 @@ 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 {
|
||||||
&& let Some(row) = self.list_box.row_at_index(index as i32)
|
self.list_selection.set_selected(index as u32);
|
||||||
{
|
let _ = self.list_view.grab_focus();
|
||||||
self.list_box.select_row(Some(&row));
|
|
||||||
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()
|
||||||
|
|
@ -783,7 +818,7 @@ impl BrowserController {
|
||||||
self.render();
|
self.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn activate_row(self: &Rc<Self>, index: i32) {
|
fn activate_row(self: &Rc<Self>, index: u32) {
|
||||||
let Some(entry) = self
|
let Some(entry) = self
|
||||||
.state
|
.state
|
||||||
.borrow()
|
.borrow()
|
||||||
|
|
@ -1091,27 +1126,16 @@ impl BrowserController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_context_menu(self: &Rc<Self>, x: f64, y: f64) {
|
fn show_context_menu(
|
||||||
let Some(widget) = self.list_box.pick(x, y, gtk::PickFlags::DEFAULT) else {
|
self: &Rc<Self>,
|
||||||
return;
|
position: u32,
|
||||||
};
|
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(row.index() as usize)
|
.get(position as usize)
|
||||||
.cloned()
|
.cloned()
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1121,9 +1145,9 @@ impl BrowserController {
|
||||||
if !can_favorite && !can_download {
|
if !can_favorite && !can_download {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.list_box.select_row(Some(&row));
|
self.list_selection.set_selected(position);
|
||||||
let popover = gtk::Popover::new();
|
let popover = gtk::Popover::new();
|
||||||
popover.set_parent(&row);
|
popover.set_parent(parent);
|
||||||
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);
|
||||||
|
|
@ -2154,37 +2178,53 @@ fn display_uri(uri: &str) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow {
|
fn entry_content() -> gtk::Box {
|
||||||
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);
|
||||||
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::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",
|
||||||
};
|
};
|
||||||
content.append(>k::Image::from_icon_name(icon));
|
let Some(icon) = content.first_child().and_downcast::<gtk::Image>() else {
|
||||||
content.append(
|
return;
|
||||||
>k::Label::builder()
|
};
|
||||||
.label(entry.display_name())
|
icon.set_icon_name(Some(icon_name));
|
||||||
.hexpand(true)
|
let Some(name) = icon.next_sibling().and_downcast::<gtk::Label>() else {
|
||||||
.xalign(0.0)
|
return;
|
||||||
.ellipsize(gtk::pango::EllipsizeMode::Middle)
|
};
|
||||||
.build(),
|
name.set_text(entry.display_name());
|
||||||
);
|
let Some(modified) = name.next_sibling().and_downcast::<gtk::Label>() else {
|
||||||
for time in [entry.modified, entry.created] {
|
return;
|
||||||
let label = gtk::Label::new(Some(&format_time(time)));
|
};
|
||||||
label.set_width_chars(17);
|
modified.set_text(&format_time(entry.modified));
|
||||||
label.set_xalign(1.0);
|
let Some(created) = modified.next_sibling().and_downcast::<gtk::Label>() else {
|
||||||
content.append(&label);
|
return;
|
||||||
}
|
};
|
||||||
row.set_child(Some(&content));
|
created.set_text(&format_time(entry.created));
|
||||||
row
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_time(time: Option<std::time::SystemTime>) -> String {
|
fn format_time(time: Option<std::time::SystemTime>) -> String {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue