diff --git a/Cargo.lock b/Cargo.lock index 4e9026f..bd47889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 962f340..58af1a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.11" +version = "0.2.12" edition = "2024" description = "A comic archive viewer for local and remote locations" license = "GPL-3.0-or-later" diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml index 4d02a91..999b213 100644 --- a/data/io.github.comicviewer.Comicviewer.metainfo.xml +++ b/data/io.github.comicviewer.Comicviewer.metainfo.xml @@ -26,6 +26,7 @@ + diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index 905fc7a..ac64840 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.11-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.12-x86_64.AppImage.sha256` - `dist/Comicviewer-latest-x86_64.tar.gz` - `dist/Comicviewer-latest-x86_64.tar.gz.sha256` @@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256 ```sh git add Cargo.toml Cargo.lock data packaging README.md docs -git commit -m "Release v0.2.11" -git tag -a v0.2.11 -m "Comicviewer v0.2.11" +git commit -m "Release v0.2.12" +git tag -a v0.2.12 -m "Comicviewer v0.2.12" git push origin main -git push origin v0.2.11 +git push origin v0.2.12 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/source.rs b/src/source.rs index fc9b2c2..bf6b8e2 100644 --- a/src/source.rs +++ b/src/source.rs @@ -16,6 +16,32 @@ const RECURSIVE_ATTRIBUTES: &str = "standard::display-name,standard::name,standa const MAX_RECURSIVE_DIRECTORIES: usize = 10_000; const MAX_RECURSIVE_ENTRIES: usize = 100_000; +#[derive(Default)] +struct EnumerationProgress { + seen: HashSet, + new_in_batch: usize, +} + +impl EnumerationProgress { + fn begin_batch(&mut self) { + self.new_in_batch = 0; + } + + fn record(&mut self, uri: String) -> bool { + let inserted = self.seen.insert(uri); + self.new_in_batch += usize::from(inserted); + inserted + } + + fn repeated_batch(&self) -> bool { + self.new_in_batch == 0 + } + + fn len(&self) -> usize { + self.seen.len() + } +} + #[derive(Clone, Copy, Default)] pub struct RecursiveProgress { pub directories: usize, @@ -112,7 +138,7 @@ where } }; let mut entries = Vec::new(); - let mut scanned = 0; + let mut enumeration = EnumerationProgress::default(); loop { let batch = enumerator @@ -121,19 +147,21 @@ where if batch.is_empty() { break; } - scanned += batch.len(); - progress(scanned); + enumeration.begin_batch(); for info in batch { + let child = enumerator.child(&info); + let uri: String = info + .attribute_string("standard::target-uri") + .map_or_else(|| child.uri().into(), Into::into); + if !enumeration.record(uri.clone()) { + continue; + } let kind = match info.file_type() { gio::FileType::Directory => EntryKind::Directory, gio::FileType::Mountable | gio::FileType::Shortcut => EntryKind::Directory, gio::FileType::Regular => classify_file(Path::new(info.display_name().as_str())), _ => continue, }; - let child = enumerator.child(&info); - let uri = info - .attribute_string("standard::target-uri") - .map_or_else(|| child.uri().into(), Into::into); entries.push(BrowserEntry { uri, name: info.display_name().into(), @@ -153,6 +181,10 @@ where .map(Into::into), }); } + if enumeration.repeated_batch() { + break; + } + progress(enumeration.len()); } Ok(entries) } @@ -449,6 +481,26 @@ mod tests { assert!(encoded_input.equal(&encoded)); } + #[test] + fn enumeration_allows_large_unique_directories_and_stops_repeated_batches() { + let mut enumeration = EnumerationProgress::default(); + for batch_start in (0..330_001).step_by(128) { + enumeration.begin_batch(); + for index in batch_start..(batch_start + 128).min(330_001) { + assert!(enumeration.record(format!("smb://server/share/file-{index}"))); + } + assert!(!enumeration.repeated_batch()); + } + assert_eq!(enumeration.len(), 330_001); + + enumeration.begin_batch(); + for index in 0..128 { + assert!(!enumeration.record(format!("smb://server/share/file-{index}"))); + } + assert!(enumeration.repeated_batch()); + assert_eq!(enumeration.len(), 330_001); + } + #[test] fn recursively_lists_viewable_files_without_following_symlinks() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/viewer.rs b/src/viewer.rs index b4b6a94..861094e 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -26,6 +26,7 @@ const IMAGE_ZOOM_MAX: f64 = 4.0; const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller"; const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-viewer-controller"; const VIEWER_FILE_SEARCH_KEY: &str = "comicviewer-file-search"; +const VIEWER_FILE_SCROLL_KEY: &str = "comicviewer-file-scroll"; const PRESERVE_SEEK_CLASS: &str = "viewer-preserve-seek"; fn clear_viewer_controller(window: >k::ApplicationWindow) { @@ -289,6 +290,10 @@ impl ViewerWindow { .as_ref() .map(viewer_file_search_query) .unwrap_or_default(); + let file_list_scroll = reuse_window + .as_ref() + .map(viewer_file_scroll_position) + .unwrap_or_default(); if let Some(window) = reuse_window.as_ref() { clear_viewer_controller(window); } @@ -305,7 +310,7 @@ impl ViewerWindow { set_viewer_loading(&window, false); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); - header.set_title_widget(Some(>k::Label::new(Some(&title)))); + header.set_title_widget(Some(&viewer_title_label(&title))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); let download_button = gtk::Button::with_label("파일 다운로드"); let file_list_button = gtk::ToggleButton::with_label("파일 목록"); @@ -454,8 +459,10 @@ impl ViewerWindow { file_list_button, file_list_panel: file_list.panel, file_list_search: file_list.search, + file_list_scrolled: file_list.scrolled, file_list: file_list.list, file_list_files: RefCell::new(Vec::new()), + file_list_scroll_restore: Cell::new(Some(file_list_scroll)), favorite_button, favorite_toggle: favorite_control.toggle, download_button, @@ -625,8 +632,10 @@ struct ViewerController { file_list_button: gtk::ToggleButton, file_list_panel: gtk::Box, file_list_search: gtk::SearchEntry, + file_list_scrolled: gtk::ScrolledWindow, file_list: gtk::ListBox, file_list_files: RefCell>, + file_list_scroll_restore: Cell>, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, @@ -752,6 +761,16 @@ impl ViewerController { controller.refresh_file_list(); } }); + let controller = Rc::downgrade(self); + self.file_list_scrolled + .vadjustment() + .connect_value_changed(move |adjustment| { + if let Some(controller) = controller.upgrade() + && controller.file_list_scroll_restore.get().is_none() + { + store_viewer_file_scroll_position(&controller.window, adjustment.value()); + } + }); let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default())); let file_list_keys = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); @@ -1073,6 +1092,11 @@ impl ViewerController { if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) { self.file_list.select_row(Some(&row)); } + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); } fn open_file_list_row(&self, index: i32) { @@ -1264,6 +1288,11 @@ impl ViewerController { self.status_label.set_visible(true); self.file_list_panel .set_visible(self.file_list_button.is_active()); + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); } } @@ -1304,6 +1333,11 @@ impl ViewerController { self.status_label.set_visible(visible); self.file_list_panel .set_visible(visible && self.file_list_button.is_active()); + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); self.window.queue_allocate(); let controller = Rc::downgrade(self); glib::idle_add_local_once(move || { @@ -2209,6 +2243,10 @@ impl MediaViewerWindow { .as_ref() .map(viewer_file_search_query) .unwrap_or_default(); + let file_list_scroll = reuse_window + .as_ref() + .map(viewer_file_scroll_position) + .unwrap_or_default(); if let Some(window) = reuse_window.as_ref() { clear_viewer_controller(window); } @@ -2225,7 +2263,7 @@ impl MediaViewerWindow { set_viewer_loading(&window, false); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); - header.set_title_widget(Some(>k::Label::new(Some(&title)))); + header.set_title_widget(Some(&viewer_title_label(&title))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); let download_button = gtk::Button::with_label("파일 다운로드"); let file_list_button = gtk::ToggleButton::with_label("파일 목록"); @@ -2335,8 +2373,10 @@ impl MediaViewerWindow { file_list_button, file_list_panel: file_list.panel, file_list_search: file_list.search, + file_list_scrolled: file_list.scrolled, file_list: file_list.list, file_list_files: RefCell::new(Vec::new()), + file_list_scroll_restore: Cell::new(Some(file_list_scroll)), favorite_button, favorite_toggle: favorite_control.toggle, download_button, @@ -2422,8 +2462,10 @@ struct MediaViewerController { file_list_button: gtk::ToggleButton, file_list_panel: gtk::Box, file_list_search: gtk::SearchEntry, + file_list_scrolled: gtk::ScrolledWindow, file_list: gtk::ListBox, file_list_files: RefCell>, + file_list_scroll_restore: Cell>, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, @@ -2653,6 +2695,16 @@ impl MediaViewerController { controller.refresh_file_list(); } }); + let controller = Rc::downgrade(self); + self.file_list_scrolled + .vadjustment() + .connect_value_changed(move |adjustment| { + if let Some(controller) = controller.upgrade() + && controller.file_list_scroll_restore.get().is_none() + { + store_viewer_file_scroll_position(&controller.window, adjustment.value()); + } + }); let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default())); let file_list_keys = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); @@ -2779,6 +2831,11 @@ impl MediaViewerController { if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) { self.file_list.select_row(Some(&row)); } + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); } fn open_file_list_row(&self, index: i32) { @@ -3191,6 +3248,11 @@ impl MediaViewerController { self.status_label.set_visible(true); self.file_list_panel .set_visible(self.file_list_button.is_active()); + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); } } @@ -3230,6 +3292,11 @@ impl MediaViewerController { self.status_label.set_visible(visible); self.file_list_panel .set_visible(visible && self.file_list_button.is_active()); + restore_pending_viewer_file_scroll( + &self.file_list_panel, + &self.file_list_scrolled, + &self.file_list_scroll_restore, + ); self.window.queue_allocate(); let controller = Rc::downgrade(self); glib::idle_add_local_once(move || { @@ -3295,9 +3362,19 @@ fn favorite_label(active: bool) -> &'static str { } } +fn viewer_title_label(title: &str) -> gtk::Label { + gtk::Label::builder() + .label(title) + .hexpand(true) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .tooltip_text(title) + .build() +} + struct ViewerFileList { panel: gtk::Box, search: gtk::SearchEntry, + scrolled: gtk::ScrolledWindow, list: gtk::ListBox, } @@ -3430,6 +3507,7 @@ fn viewer_file_list(query: &str) -> ViewerFileList { ViewerFileList { panel, search, + scrolled, list, } } @@ -3449,6 +3527,43 @@ fn store_viewer_file_search_query(window: >k::ApplicationWindow, query: &str) } } +fn viewer_file_scroll_position(window: >k::ApplicationWindow) -> f64 { + unsafe { + window + .data::(VIEWER_FILE_SCROLL_KEY) + .map(|position| *position.as_ref()) + .unwrap_or_default() + } +} + +fn store_viewer_file_scroll_position(window: >k::ApplicationWindow, position: f64) { + unsafe { + window.set_data(VIEWER_FILE_SCROLL_KEY, position.max(0.0)); + } +} + +fn restore_viewer_file_scroll(scrolled: >k::ScrolledWindow, position: f64) { + let scrolled = scrolled.clone(); + glib::idle_add_local_once(move || { + let adjustment = scrolled.vadjustment(); + let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); + adjustment.set_value(position.clamp(adjustment.lower(), maximum)); + }); +} + +fn restore_pending_viewer_file_scroll( + panel: >k::Box, + scrolled: >k::ScrolledWindow, + pending: &Cell>, +) { + if !panel.is_visible() { + return; + } + if let Some(position) = pending.take() { + restore_viewer_file_scroll(scrolled, position); + } +} + fn widget_contains_focus(widget: &impl IsA, window: >k::ApplicationWindow) -> bool { gtk::prelude::GtkWindowExt::focus(window).is_some_and(|focus| { let widget = widget.as_ref(); diff --git a/src/window.rs b/src/window.rs index 57be341..31a28b0 100644 --- a/src/window.rs +++ b/src/window.rs @@ -125,12 +125,13 @@ impl ComicWindow { controls.append(>k::Label::new(Some("빠른 이미지 로딩"))); controls.append(&fast_loading_switch); - let list_box = gtk::ListBox::new(); - list_box.set_selection_mode(gtk::SelectionMode::Single); - list_box.set_activate_on_single_click(false); + let list_selection = gtk::SingleSelection::new(None::); + let list_view = + gtk::ListView::new(Some(list_selection.clone()), None::); + list_view.set_single_click_activate(false); let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) - .child(&list_box) + .child(&list_view) .build(); let status_label = gtk::Label::builder().xalign(0.0).build(); @@ -152,7 +153,8 @@ impl ComicWindow { search_entry, sort_dropdown, direction_button, - list_box, + list_view, + list_selection, status_label, state: RefCell::new(BrowserState { profile_index: 0, @@ -227,7 +229,8 @@ struct BrowserController { search_entry: gtk::SearchEntry, sort_dropdown: gtk::DropDown, direction_button: gtk::ToggleButton, - list_box: gtk::ListBox, + list_view: gtk::ListView, + list_selection: gtk::SingleSelection, status_label: gtk::Label, state: RefCell, config: RefCell, @@ -328,16 +331,50 @@ impl BrowserController { case_insensitive_check.connect_toggled(move |check| { controller.set_case_insensitive_sort(check); }); - let controller = Rc::clone(self); - self.list_box - .connect_row_activated(move |_, row| controller.activate_row(row.index())); - - let context_click = gtk::GestureClick::builder().button(3).build(); - let controller = Rc::clone(self); - context_click.connect_pressed(move |_, _, x, y| { - controller.show_context_menu(x, y); + let factory = gtk::SignalListItemFactory::new(); + let controller = Rc::downgrade(self); + factory.connect_setup(move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let content = entry_content(); + item.set_child(Some(&content)); + let context_click = gtk::GestureClick::builder().button(3).build(); + let item = item.downgrade(); + let controller = controller.clone(); + let menu_parent = content.clone(); + context_click.connect_pressed(move |_, _, _, _| { + let Some(item) = item.upgrade() else { + return; + }; + let Some(controller) = controller.upgrade() else { + return; + }; + controller.show_context_menu(item.position(), &menu_parent); + }); + content.add_controller(context_click); }); - self.list_box.add_controller(context_click); + let controller = Rc::downgrade(self); + factory.connect_bind(move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let Some(content) = item.child().and_downcast::() else { + return; + }; + let Some(controller) = controller.upgrade() else { + return; + }; + let state = controller.state.borrow(); + let Some(entry) = state.visible_entries.get(item.position() as usize) else { + return; + }; + bind_entry_content(&content, entry); + }); + self.list_view.set_factory(Some(&factory)); + let controller = Rc::clone(self); + self.list_view + .connect_activate(move |_, position| controller.activate_row(position)); let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); @@ -349,7 +386,7 @@ impl BrowserController { controller.typeahead(key, modifiers) } }); - self.list_box.add_controller(key_controller); + self.list_view.add_controller(key_controller); let mouse_back = gtk::GestureClick::builder() .button(MOUSE_BACK_BUTTON) @@ -609,9 +646,7 @@ impl BrowserController { state.generation, ) }; - while let Some(child) = self.list_box.first_child() { - self.list_box.remove(&child); - } + self.list_selection.set_model(None::<&gio::ListModel>); self.path_entry.set_text(&display_uri(&uri)); self.status_label .set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)"); @@ -657,9 +692,6 @@ impl BrowserController { } fn render(&self) { - while let Some(child) = self.list_box.first_child() { - self.list_box.remove(&child); - } let query = self.search_entry.text().to_lowercase(); let case_insensitive = self.config.borrow().case_insensitive_sort; let mut state = self.state.borrow_mut(); @@ -675,11 +707,15 @@ impl BrowserController { state.sort_direction, case_insensitive, ); - for entry in &visible_entries { - self.list_box.append(&entry_row(entry)); - } let count = visible_entries.len(); + let names = visible_entries + .iter() + .map(BrowserEntry::display_name) + .collect::>(); + let model = gtk::StringList::new(&names); state.visible_entries = visible_entries; + drop(state); + self.list_selection.set_model(Some(&model)); self.status_label.set_text(&format!("{count}개 항목")); } @@ -712,7 +748,8 @@ impl BrowserController { return glib::Propagation::Proceed; }; - let selected = self.list_box.selected_row().map(|row| row.index() as usize); + let selected = (self.list_selection.selected() != gtk::INVALID_LIST_POSITION) + .then(|| self.list_selection.selected() as usize); let now = Instant::now(); let mut state = self.state.borrow_mut(); if state @@ -738,11 +775,9 @@ impl BrowserController { let found_name = found.map(|index| state.visible_entries[index].display_name().to_owned()); drop(state); - if let Some(index) = found - && let Some(row) = self.list_box.row_at_index(index as i32) - { - self.list_box.select_row(Some(&row)); - let _ = row.grab_focus(); + if let Some(index) = found { + self.list_selection.set_selected(index as u32); + let _ = self.list_view.grab_focus(); self.status_label.set_text(&format!( "'{query}'로 이동: {}", found_name.unwrap_or_default() @@ -783,7 +818,7 @@ impl BrowserController { self.render(); } - fn activate_row(self: &Rc, index: i32) { + fn activate_row(self: &Rc, index: u32) { let Some(entry) = self .state .borrow() @@ -1091,27 +1126,16 @@ impl BrowserController { ); } - fn show_context_menu(self: &Rc, x: f64, y: f64) { - let Some(widget) = self.list_box.pick(x, y, gtk::PickFlags::DEFAULT) else { - return; - }; - let Some(row) = widget - .clone() - .downcast::() - .ok() - .or_else(|| { - widget - .ancestor(gtk::ListBoxRow::static_type()) - .and_then(|widget| widget.downcast::().ok()) - }) - else { - return; - }; + fn show_context_menu( + self: &Rc, + position: u32, + parent: &impl glib::object::IsA, + ) { let Some(entry) = self .state .borrow() .visible_entries - .get(row.index() as usize) + .get(position as usize) .cloned() else { return; @@ -1121,9 +1145,9 @@ impl BrowserController { if !can_favorite && !can_download { return; } - self.list_box.select_row(Some(&row)); + self.list_selection.set_selected(position); let popover = gtk::Popover::new(); - popover.set_parent(&row); + popover.set_parent(parent); let actions = gtk::Box::new(gtk::Orientation::Vertical, 4); actions.set_margin_top(6); actions.set_margin_bottom(6); @@ -2154,37 +2178,53 @@ fn display_uri(uri: &str) -> String { ) } -fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow { - let row = gtk::ListBoxRow::new(); +fn entry_content() -> gtk::Box { let content = gtk::Box::new(gtk::Orientation::Horizontal, 12); content.set_margin_top(8); content.set_margin_bottom(8); content.set_margin_start(10); content.set_margin_end(10); - let icon = match entry.kind { + content.append(>k::Image::new()); + content.append( + >k::Label::builder() + .hexpand(true) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .build(), + ); + for _ in 0..2 { + let label = gtk::Label::new(None); + label.set_width_chars(17); + label.set_xalign(1.0); + content.append(&label); + } + content +} + +fn bind_entry_content(content: >k::Box, entry: &BrowserEntry) { + let icon_name = match entry.kind { EntryKind::Directory => "folder-symbolic", EntryKind::ZipArchive => "package-x-generic-symbolic", EntryKind::Image => "image-x-generic-symbolic", EntryKind::Video => "video-x-generic-symbolic", EntryKind::OtherFile => "text-x-generic-symbolic", }; - content.append(>k::Image::from_icon_name(icon)); - content.append( - >k::Label::builder() - .label(entry.display_name()) - .hexpand(true) - .xalign(0.0) - .ellipsize(gtk::pango::EllipsizeMode::Middle) - .build(), - ); - for time in [entry.modified, entry.created] { - let label = gtk::Label::new(Some(&format_time(time))); - label.set_width_chars(17); - label.set_xalign(1.0); - content.append(&label); - } - row.set_child(Some(&content)); - row + let Some(icon) = content.first_child().and_downcast::() else { + return; + }; + icon.set_icon_name(Some(icon_name)); + let Some(name) = icon.next_sibling().and_downcast::() else { + return; + }; + name.set_text(entry.display_name()); + let Some(modified) = name.next_sibling().and_downcast::() else { + return; + }; + modified.set_text(&format_time(entry.modified)); + let Some(created) = modified.next_sibling().and_downcast::() else { + return; + }; + created.set_text(&format_time(entry.created)); } fn format_time(time: Option) -> String {