diff --git a/src/config.rs b/src/config.rs index 1775522..d84f5de 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; pub struct AppConfig { pub cache_quota_gb: u64, pub fast_image_loading: bool, + pub viewer_mode: ViewerMode, pub locations: Vec, pub favorites: Vec, } @@ -19,6 +20,7 @@ impl Default for AppConfig { Self { cache_quota_gb: 10, fast_image_loading: true, + viewer_mode: ViewerMode::FitWidth, locations: Vec::new(), favorites: Vec::new(), } @@ -55,6 +57,39 @@ pub enum LocationKind { Sftp, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ViewerMode { + ActualSize, + #[default] + FitWidth, + FitHeight, + TwoPage, + Continuous, +} + +impl ViewerMode { + pub fn dropdown_index(self) -> u32 { + match self { + Self::ActualSize => 0, + Self::FitWidth => 1, + Self::FitHeight => 2, + Self::TwoPage => 3, + Self::Continuous => 4, + } + } + + pub fn from_dropdown_index(index: u32) -> Self { + match index { + 0 => Self::ActualSize, + 2 => Self::FitHeight, + 3 => Self::TwoPage, + 4 => Self::Continuous, + _ => Self::FitWidth, + } + } +} + impl AppConfig { pub fn load_or_create(path: &Path) -> Result { if path.exists() { @@ -139,6 +174,7 @@ mod tests { let config = AppConfig { cache_quota_gb: 0, fast_image_loading: true, + viewer_mode: ViewerMode::FitWidth, locations: vec![LocationProfile { name: "테스트".into(), kind: LocationKind::Sftp, @@ -173,6 +209,21 @@ mod tests { fn fast_image_loading_is_enabled_for_existing_configs() { let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); assert!(decoded.fast_image_loading); + assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert!(decoded.favorites.is_empty()); } + + #[test] + fn viewer_modes_round_trip_through_dropdown_indices() { + for mode in [ + ViewerMode::ActualSize, + ViewerMode::FitWidth, + ViewerMode::FitHeight, + ViewerMode::TwoPage, + ViewerMode::Continuous, + ] { + assert_eq!(ViewerMode::from_dropdown_index(mode.dropdown_index()), mode); + } + assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth); + } } diff --git a/src/viewer.rs b/src/viewer.rs index 9fc5108..5217886 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -24,11 +24,15 @@ pub struct ViewerWindow; pub struct SiblingNavigator { current: usize, count: usize, - open: Rc bool>, + open: Rc bool>, } impl SiblingNavigator { - pub fn new(current: usize, count: usize, open: Rc bool>) -> Self { + pub fn new( + current: usize, + count: usize, + open: Rc bool>, + ) -> Self { Self { current, count, @@ -36,15 +40,15 @@ impl SiblingNavigator { } } - fn open_relative(&self, forward: bool) -> bool { + fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool { let Some(index) = sibling_index(self.current, self.count, forward) else { return false; }; - (self.open)(index) + (self.open)(index, window) } - fn open_index(&self, index: usize) -> bool { - index < self.count && index != self.current && (self.open)(index) + fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool { + index < self.count && index != self.current && (self.open)(index, window) } } @@ -57,23 +61,43 @@ pub struct FileDownloadControl { pub download: Rc, } +pub struct ViewModeControl { + pub selected: u32, + pub change: Rc, +} + +pub struct FileViewerControls { + pub sibling_navigator: Option, + pub favorite_control: FavoriteControl, + pub download_control: FileDownloadControl, + pub reuse_window: Option, +} + impl ViewerWindow { pub fn open( application: >k::Application, source: ArchiveSource, database: Rc, fast_image_loading: bool, - sibling_navigator: Option, - favorite_control: FavoriteControl, - download_control: FileDownloadControl, + view_mode_control: ViewModeControl, + controls: FileViewerControls, ) { + let FileViewerControls { + sibling_navigator, + favorite_control, + download_control, + reuse_window, + } = controls; let title = source.display_name.clone(); - let window = gtk::ApplicationWindow::builder() - .application(application) - .title(&title) - .default_width(1100) - .default_height(850) - .build(); + let window = reuse_window.unwrap_or_else(|| { + gtk::ApplicationWindow::builder() + .application(application) + .title(&title) + .default_width(1100) + .default_height(850) + .build() + }); + window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); @@ -109,7 +133,7 @@ impl ViewerWindow { "2페이지", "이어서 보기", ]); - mode_dropdown.set_selected(1); + mode_dropdown.set_selected(view_mode_control.selected); toolbar.append(&previous_button); toolbar.append(&shift_back_button); toolbar.append(&shift_forward_button); @@ -159,14 +183,12 @@ impl ViewerWindow { .margin_start(8) .margin_end(8) .build(); - let page_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); - page_seek.set_draw_value(false); + let page_seek = navigation_scale(); + let page_seek_row = navigation_scale_row(&page_seek); page_seek.set_sensitive(false); - page_seek.set_margin_start(8); - page_seek.set_margin_end(8); root.append(&toolbar); root.append(&overlay); - root.append(&page_seek); + root.append(&page_seek_row); root.append(&status_label); window.set_child(Some(&root)); @@ -183,6 +205,7 @@ impl ViewerWindow { spinner, page_label, page_seek, + page_seek_row, status_label, previous_button, next_button, @@ -196,6 +219,11 @@ impl ViewerWindow { favorite_toggle: favorite_control.toggle, download_button, file_download: download_control.download, + view_mode_change: view_mode_control.change, + seek_hide_timer: RefCell::new(None), + last_pointer_position: Cell::new(None), + key_controller: RefCell::new(None), + motion_controller: RefCell::new(None), state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -263,6 +291,7 @@ struct ViewerController { spinner: gtk::Spinner, page_label: gtk::Label, page_seek: gtk::Scale, + page_seek_row: gtk::Box, status_label: gtk::Label, previous_button: gtk::Button, next_button: gtk::Button, @@ -276,6 +305,11 @@ struct ViewerController { favorite_toggle: Rc bool>, download_button: gtk::Button, file_download: Rc, + view_mode_change: Rc, + seek_hide_timer: RefCell>, + last_pointer_position: Cell>, + key_controller: RefCell>, + motion_controller: RefCell>, state: RefCell, } @@ -294,8 +328,10 @@ impl ViewerController { self.shift_forward_button .connect_clicked(move |_| controller.shift_pairing(1)); let controller = Rc::clone(self); - self.mode_dropdown - .connect_selected_notify(move |_| controller.apply_view_mode()); + self.mode_dropdown.connect_selected_notify(move |dropdown| { + (controller.view_mode_change)(dropdown.selected()); + controller.apply_view_mode(); + }); let controller = Rc::clone(self); self.direction_dropdown .connect_selected_notify(move |_| controller.render()); @@ -313,9 +349,8 @@ impl ViewerController { }); let controller = Rc::clone(self); self.page_seek.connect_value_changed(move |scale| { - if let Some(index) = - page_seek_index(scale.value(), controller.state.borrow().pages.len()) - { + let page_count = controller.state.borrow().pages.len(); + if let Some(index) = page_seek_index(scale.value(), page_count) { controller.seek_page(index); } }); @@ -345,49 +380,115 @@ impl ViewerController { }); self.stack.add_controller(scroll_controller); + let motion_controller = gtk::EventControllerMotion::new(); + motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); + let controller = Rc::clone(self); + motion_controller.connect_motion(move |_, x, y| controller.pointer_moved(x, y)); + self.window.add_controller(motion_controller.clone()); + self.motion_controller.replace(Some(motion_controller)); + let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); - key_controller.connect_key_pressed(move |_, key, _, _| match key { - gdk::Key::Left | gdk::Key::Page_Up => { - controller.navigate(false); - glib::Propagation::Stop + key_controller.connect_key_pressed(move |_, key, _, _| { + controller.hide_seek(); + match key { + gdk::Key::Left | gdk::Key::Page_Up => { + controller.navigate(false); + glib::Propagation::Stop + } + gdk::Key::Right | gdk::Key::Page_Down | gdk::Key::space => { + controller.navigate(true); + glib::Propagation::Stop + } + _ if key.to_unicode() == Some('[') => { + controller.open_sibling(false); + glib::Propagation::Stop + } + _ if key.to_unicode() == Some(']') => { + controller.open_sibling(true); + glib::Propagation::Stop + } + _ if key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => + { + controller.toggle_fullscreen(); + glib::Propagation::Stop + } + gdk::Key::Escape if controller.window.is_fullscreen() => { + controller.window.unfullscreen(); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, } - gdk::Key::Right | gdk::Key::Page_Down | gdk::Key::space => { - controller.navigate(true); - glib::Propagation::Stop - } - _ if key.to_unicode() == Some('[') => { - controller.open_sibling(false); - glib::Propagation::Stop - } - _ if key.to_unicode() == Some(']') => { - controller.open_sibling(true); - glib::Propagation::Stop - } - _ if key - .to_unicode() - .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => - { - controller.toggle_fullscreen(); - glib::Propagation::Stop - } - gdk::Key::Escape if controller.window.is_fullscreen() => { - controller.window.unfullscreen(); - glib::Propagation::Stop - } - _ => glib::Propagation::Proceed, }); - self.window.add_controller(key_controller); + self.window.add_controller(key_controller.clone()); + self.key_controller.replace(Some(key_controller)); } fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator - && navigate.open_relative(forward) + && navigate.open_relative(forward, self.window.clone()) { - self.window.close(); + self.detach_window_controllers(); } } + fn detach_window_controllers(&self) { + if let Some(controller) = self.key_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } + if let Some(controller) = self.motion_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } + } + + fn show_seek_temporarily(self: &Rc) { + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.set_seek_visible(true); + let controller = Rc::downgrade(self); + let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { + if let Some(controller) = controller.upgrade() { + controller.seek_hide_timer.borrow_mut().take(); + controller.set_seek_visible(false); + } + }); + self.seek_hide_timer.replace(Some(timer)); + } + + fn pointer_moved(self: &Rc, x: f64, y: f64) { + if self.last_pointer_position.get() == Some((x, y)) { + return; + } + self.last_pointer_position.set(Some((x, y))); + self.show_seek_temporarily(); + } + + fn hide_seek(self: &Rc) { + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.set_seek_visible(false); + } + + fn set_seek_visible(self: &Rc, visible: bool) { + if self.page_seek_row.is_visible() == visible { + return; + } + self.page_seek_row.set_visible(visible); + self.window.queue_allocate(); + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + if let Some(controller) = controller.upgrade() { + controller.stack.queue_allocate(); + controller.resize_continuous_rows(); + controller.render(); + } + }); + } + fn toggle_fullscreen(&self) { if self.window.is_fullscreen() { self.window.unfullscreen(); @@ -438,7 +539,8 @@ impl ViewerController { state.pages = pages; state.anchor = saved; drop(state); - self.page_seek.set_range(0.0, (page_count - 1) as f64); + self.page_seek + .set_range(0.0, navigation_scale_upper(page_count)); self.page_seek.set_sensitive(page_count > 1); self.build_continuous_rows(); self.render(); @@ -986,17 +1088,24 @@ impl MediaViewerWindow { application: >k::Application, source: ArchiveSource, kind: EntryKind, - sibling_navigator: Option, - favorite_control: FavoriteControl, - download_control: FileDownloadControl, + controls: FileViewerControls, ) { + let FileViewerControls { + sibling_navigator, + favorite_control, + download_control, + reuse_window, + } = controls; let title = source.display_name.clone(); - let window = gtk::ApplicationWindow::builder() - .application(application) - .title(&title) - .default_width(1100) - .default_height(850) - .build(); + let window = reuse_window.unwrap_or_else(|| { + gtk::ApplicationWindow::builder() + .application(application) + .title(&title) + .default_width(1100) + .default_height(850) + .build() + }); + window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); @@ -1028,19 +1137,17 @@ impl MediaViewerWindow { .margin_start(8) .margin_end(8) .build(); - let media_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); - media_seek.set_draw_value(false); - media_seek.set_margin_start(8); - media_seek.set_margin_end(8); + let media_seek = navigation_scale(); + let media_seek_row = navigation_scale_row(&media_seek); if let Some(navigator) = &sibling_navigator { - media_seek.set_range(0.0, navigator.count.saturating_sub(1) as f64); + media_seek.set_range(0.0, navigation_scale_upper(navigator.count)); media_seek.set_value(navigator.current as f64); media_seek.set_sensitive(navigator.count > 1); } else { media_seek.set_sensitive(false); } root.append(&stack); - root.append(&media_seek); + root.append(&media_seek_row); root.append(&status_label); window.set_child(Some(&root)); @@ -1052,6 +1159,7 @@ impl MediaViewerWindow { stack, status_label, media_seek, + media_seek_row, sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, @@ -1059,6 +1167,10 @@ impl MediaViewerWindow { file_download: download_control.download, seek_generation: Cell::new(0), seek_opening: Cell::new(false), + seek_hide_timer: RefCell::new(None), + last_pointer_position: Cell::new(None), + key_controller: RefCell::new(None), + motion_controller: RefCell::new(None), }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); @@ -1091,6 +1203,7 @@ struct MediaViewerController { stack: gtk::Stack, status_label: gtk::Label, media_seek: gtk::Scale, + media_seek_row: gtk::Box, sibling_navigator: Option, favorite_button: gtk::Button, favorite_toggle: Rc bool>, @@ -1098,32 +1211,47 @@ struct MediaViewerController { file_download: Rc, seek_generation: Cell, seek_opening: Cell, + seek_hide_timer: RefCell>, + last_pointer_position: Cell>, + key_controller: RefCell>, + motion_controller: RefCell>, } impl MediaViewerController { fn connect_signals(self: &Rc) { + let motion_controller = gtk::EventControllerMotion::new(); + motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); + let controller = Rc::clone(self); + motion_controller.connect_motion(move |_, x, y| controller.pointer_moved(x, y)); + self.window.add_controller(motion_controller.clone()); + self.motion_controller.replace(Some(motion_controller)); + let keys = gtk::EventControllerKey::new(); let controller = Rc::clone(self); - keys.connect_key_pressed(move |_, key, _, _| match key.to_unicode() { - Some('[') => { - controller.open_sibling(false); - glib::Propagation::Stop + keys.connect_key_pressed(move |_, key, _, _| { + controller.hide_seek(); + match key.to_unicode() { + Some('[') => { + controller.open_sibling(false); + glib::Propagation::Stop + } + Some(']') => { + controller.open_sibling(true); + glib::Propagation::Stop + } + Some(character) if character.eq_ignore_ascii_case(&'f') => { + controller.toggle_fullscreen(); + glib::Propagation::Stop + } + _ if key == gdk::Key::Escape && controller.window.is_fullscreen() => { + controller.window.unfullscreen(); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, } - Some(']') => { - controller.open_sibling(true); - glib::Propagation::Stop - } - Some(character) if character.eq_ignore_ascii_case(&'f') => { - controller.toggle_fullscreen(); - glib::Propagation::Stop - } - _ if key == gdk::Key::Escape && controller.window.is_fullscreen() => { - controller.window.unfullscreen(); - glib::Propagation::Stop - } - _ => glib::Propagation::Proceed, }); - self.window.add_controller(keys); + self.window.add_controller(keys.clone()); + self.key_controller.replace(Some(keys)); let controller = Rc::clone(self); self.favorite_button.connect_clicked(move |_| { let active = (controller.favorite_toggle)(); @@ -1150,12 +1278,58 @@ impl MediaViewerController { fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator - && navigate.open_relative(forward) + && navigate.open_relative(forward, self.window.clone()) { - self.close_for_navigation(); + self.prepare_for_navigation(); } } + fn show_seek_temporarily(self: &Rc) { + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.set_seek_visible(true); + let controller = Rc::downgrade(self); + let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { + if let Some(controller) = controller.upgrade() { + controller.seek_hide_timer.borrow_mut().take(); + controller.set_seek_visible(false); + } + }); + self.seek_hide_timer.replace(Some(timer)); + } + + fn pointer_moved(self: &Rc, x: f64, y: f64) { + if self.last_pointer_position.get() == Some((x, y)) { + return; + } + self.last_pointer_position.set(Some((x, y))); + self.show_seek_temporarily(); + } + + fn hide_seek(self: &Rc) { + if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { + timer.remove(); + } + self.set_seek_visible(false); + } + + fn set_seek_visible(self: &Rc, visible: bool) { + if self.media_seek_row.is_visible() == visible { + return; + } + self.media_seek_row.set_visible(visible); + self.window.queue_allocate(); + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + if let Some(controller) = controller.upgrade() { + controller.stack.queue_allocate(); + controller.picture.queue_allocate(); + controller.video.queue_allocate(); + } + }); + } + fn schedule_seek(self: &Rc, index: usize) { let Some(navigator) = &self.sibling_navigator else { return; @@ -1176,18 +1350,23 @@ impl MediaViewerController { let Some(navigator) = &controller.sibling_navigator else { return; }; - if navigator.open_index(index) { + if navigator.open_index(index, controller.window.clone()) { controller.seek_opening.set(true); - controller.close_for_navigation(); + controller.prepare_for_navigation(); } }); } - fn close_for_navigation(&self) { + fn prepare_for_navigation(&self) { if let Some(stream) = self.video.media_stream() { stream.pause(); } - self.window.close(); + if let Some(controller) = self.key_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } + if let Some(controller) = self.motion_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } } fn toggle_fullscreen(&self) { @@ -1259,6 +1438,33 @@ fn page_picture() -> gtk::Picture { picture } +fn navigation_scale() -> gtk::Scale { + let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0); + scale.set_draw_value(false); + scale.set_hexpand(true); + scale.set_height_request(32); + scale.set_valign(gtk::Align::Center); + scale.set_focusable(false); + scale.set_margin_top(2); + scale.set_margin_bottom(8); + scale.set_margin_start(8); + scale.set_margin_end(8); + scale +} + +fn navigation_scale_row(scale: >k::Scale) -> gtk::Box { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 0); + row.set_height_request(42); + row.set_hexpand(true); + row.append(scale); + row.set_visible(false); + row +} + +fn navigation_scale_upper(count: usize) -> f64 { + count.saturating_sub(1).max(1) as f64 +} + fn spread_indices(anchor: usize, count: usize) -> Vec { if count == 0 { return Vec::new(); @@ -1370,6 +1576,14 @@ mod tests { assert_eq!(page_seek_index(0.0, 0), None); } + #[test] + fn navigation_scale_always_has_a_valid_range() { + assert_eq!(navigation_scale_upper(0), 1.0); + assert_eq!(navigation_scale_upper(1), 1.0); + assert_eq!(navigation_scale_upper(2), 1.0); + assert_eq!(navigation_scale_upper(10), 9.0); + } + #[test] fn sibling_navigation_stops_at_boundaries() { assert_eq!(sibling_index(1, 3, false), Some(0)); diff --git a/src/window.rs b/src/window.rs index d95852a..86730df 100644 --- a/src/window.rs +++ b/src/window.rs @@ -14,14 +14,15 @@ use gtk::prelude::*; use crate::browser::{ BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, }; -use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile}; +use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile, ViewerMode}; use crate::database::Database; use crate::source::{ display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root, mount_operation, parent_within_root, root_file, validate_profile, }; use crate::viewer::{ - FavoriteControl, FileDownloadControl, MediaViewerWindow, SiblingNavigator, ViewerWindow, + FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator, + ViewModeControl, ViewerWindow, }; use crate::zip_cache::ZipCache; @@ -70,12 +71,6 @@ impl ComicWindow { locations.append(&edit_location_button); locations.append(&delete_location_button); - let favorites = gtk::Box::new(gtk::Orientation::Horizontal, 6); - let favorite_dropdown = gtk::DropDown::from_strings(&["즐겨찾기 선택"]); - favorite_dropdown.set_hexpand(true); - favorites.append(>k::Label::new(Some("즐겨찾기"))); - favorites.append(&favorite_dropdown); - let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let up_button = gtk::Button::builder() .icon_name("go-up-symbolic") @@ -119,7 +114,6 @@ impl ComicWindow { let status_label = gtk::Label::builder().xalign(0.0).build(); root.append(&locations); - root.append(&favorites); root.append(&navigation); root.append(&controls); root.append(&scrolled); @@ -129,7 +123,6 @@ impl ComicWindow { let controller = Rc::new(BrowserController { window, location_dropdown, - favorite_dropdown, edit_location_button, delete_location_button, path_entry, @@ -141,6 +134,7 @@ impl ComicWindow { state: RefCell::new(BrowserState { profile_index: 0, favorite_profile: None, + browsing_favorites: false, root_uri: root_uri.clone(), current_uri: root_uri, entries: Vec::new(), @@ -158,7 +152,6 @@ impl ComicWindow { }); controller.rebuild_profiles(); - controller.rebuild_favorites(); controller.connect_signals( open_button, refresh_button, @@ -178,6 +171,7 @@ impl ComicWindow { struct BrowserState { profile_index: usize, favorite_profile: Option, + browsing_favorites: bool, root_uri: String, current_uri: String, entries: Vec, @@ -192,7 +186,6 @@ struct BrowserState { struct BrowserController { window: gtk::ApplicationWindow, location_dropdown: gtk::DropDown, - favorite_dropdown: gtk::DropDown, edit_location_button: gtk::Button, delete_location_button: gtk::Button, path_entry: gtk::Entry, @@ -244,9 +237,6 @@ impl BrowserController { self.location_dropdown .connect_selected_notify(move |_| controller.select_profile()); let controller = Rc::clone(self); - self.favorite_dropdown - .connect_selected_notify(move |_| controller.select_favorite()); - let controller = Rc::clone(self); self.search_entry .connect_search_changed(move |_| controller.render()); let controller = Rc::clone(self); @@ -261,8 +251,8 @@ impl BrowserController { let context_click = gtk::GestureClick::builder().button(3).build(); let controller = Rc::clone(self); - context_click.connect_pressed(move |_, _, _, y| { - controller.show_context_menu(y as i32); + context_click.connect_pressed(move |_, _, x, y| { + controller.show_context_menu(x, y); }); self.list_box.add_controller(context_click); @@ -296,38 +286,26 @@ impl BrowserController { fn rebuild_profiles(&self) { let profiles = self.profiles(); - let names: Vec<_> = profiles + let mut names: Vec<_> = profiles .iter() - .map(|profile| profile.name.as_str()) + .map(|profile| profile.name.clone()) .collect(); + names.push("즐겨찾기".into()); + let names: Vec<_> = names.iter().map(String::as_str).collect(); self.location_dropdown .set_model(Some(>k::StringList::new(&names))); } - fn rebuild_favorites(&self) { - let config = self.config.borrow(); - let mut names = Vec::with_capacity(config.favorites.len() + 1); - names.push("즐겨찾기 선택".to_owned()); - names.extend(config.favorites.iter().map(|favorite| { - let marker = if favorite.is_directory { - "폴더" - } else { - "파일" - }; - format!("[{marker}] {}", favorite.name) - })); - let names: Vec<_> = names.iter().map(String::as_str).collect(); - self.favorite_dropdown - .set_model(Some(>k::StringList::new(&names))); - self.favorite_dropdown.set_selected(0); - } - fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; - let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len(); + let profiles = self.profiles(); + if profile_index == profiles.len() { + self.show_favorites(); + return; + } + let editable = profile_index > 0 && profile_index < profiles.len(); self.edit_location_button.set_sensitive(editable); self.delete_location_button.set_sensitive(editable); - let profiles = self.profiles(); let Some(profile) = profiles.get(profile_index) else { return; }; @@ -337,9 +315,11 @@ impl BrowserController { let mut state = self.state.borrow_mut(); state.profile_index = profile_index; state.favorite_profile = None; + state.browsing_favorites = false; state.root_uri = root_uri.clone(); state.current_uri = root_uri; drop(state); + self.path_entry.set_editable(true); self.search_entry.set_text(""); self.refresh(); } @@ -347,15 +327,28 @@ impl BrowserController { } } - fn select_favorite(self: &Rc) { - let Some(index) = (self.favorite_dropdown.selected() as usize).checked_sub(1) else { - return; - }; - let Some(favorite) = self.config.borrow().favorites.get(index).cloned() else { - self.favorite_dropdown.set_selected(0); - return; - }; - self.favorite_dropdown.set_selected(0); + fn show_favorites(&self) { + self.edit_location_button.set_sensitive(false); + self.delete_location_button.set_sensitive(false); + self.path_entry.set_text("즐겨찾기"); + self.path_entry.set_editable(false); + self.search_entry.set_text(""); + let entries = self + .config + .borrow() + .favorites + .iter() + .map(favorite_browser_entry) + .collect(); + let mut state = self.state.borrow_mut(); + state.browsing_favorites = true; + state.favorite_profile = None; + state.entries = entries; + drop(state); + self.render(); + } + + fn open_favorite(self: &Rc, favorite: FavoriteEntry) { let Ok(root) = root_file(&favorite.profile) else { self.status_label .set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다."); @@ -374,6 +367,7 @@ impl BrowserController { self.delete_location_button.set_sensitive(false); let mut state = self.state.borrow_mut(); state.favorite_profile = Some(favorite.profile.clone()); + state.browsing_favorites = false; state.root_uri = root.uri().into(); state.current_uri = favorite.uri; drop(state); @@ -412,11 +406,15 @@ impl BrowserController { etag: None, }; let siblings = Rc::new(vec![entry.clone()]); - controller.open_viewable(entry, siblings, 0, favorite.profile); + controller.open_viewable(entry, siblings, 0, favorite.profile, None); }); } fn open_entered_path(self: &Rc) { + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + return; + } let profile = self.current_profile(); let entered = self.path_entry.text(); let file = file_from_user_input(profile.kind, entered.as_str()); @@ -431,6 +429,10 @@ impl BrowserController { } fn go_up(self: &Rc) { + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + return; + } let state = self.state.borrow(); let current = gio::File::for_uri(&state.current_uri); let root = gio::File::for_uri(&state.root_uri); @@ -442,6 +444,11 @@ impl BrowserController { } fn refresh(self: &Rc) { + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + self.show_favorites(); + return; + } let (profile, uri, generation) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); @@ -602,6 +609,20 @@ impl BrowserController { else { return; }; + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + let favorite = self + .config + .borrow() + .favorites + .iter() + .find(|favorite| favorite.uri == entry.uri) + .cloned(); + if let Some(favorite) = favorite { + self.open_favorite(favorite); + } + return; + } match entry.kind { EntryKind::Directory => { self.state.borrow_mut().current_uri = entry.uri; @@ -623,7 +644,7 @@ impl BrowserController { }; let siblings = Rc::new(siblings); if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { - self.open_viewable(entry, siblings, current, self.current_profile()); + self.open_viewable(entry, siblings, current, self.current_profile(), None); } } EntryKind::OtherFile => {} @@ -636,9 +657,12 @@ impl BrowserController { siblings: Rc>, current: usize, profile: LocationProfile, + reuse_window: Option, ) { match entry.kind { - EntryKind::ZipArchive => self.open_archive(entry, siblings, current, profile), + EntryKind::ZipArchive => { + self.open_archive(entry, siblings, current, profile, reuse_window) + } EntryKind::Image | EntryKind::Video => { let media_siblings = Rc::new( siblings @@ -651,7 +675,7 @@ impl BrowserController { .iter() .position(|sibling| sibling.uri == entry.uri) { - self.open_media(entry, media_siblings, current, profile); + self.open_media(entry, media_siblings, current, profile, reuse_window); } } _ => {} @@ -666,7 +690,7 @@ impl BrowserController { ) -> SiblingNavigator { let controller = Rc::downgrade(self); let count = siblings.len(); - let open = Rc::new(move |next: usize| { + let open = Rc::new(move |next: usize, reuse_window: gtk::ApplicationWindow| { let Some(controller) = controller.upgrade() else { return false; }; @@ -681,6 +705,7 @@ impl BrowserController { Rc::clone(&siblings), next, profile.clone(), + Some(reuse_window), ); true }); @@ -693,13 +718,19 @@ impl BrowserController { siblings: Rc>, current: usize, profile: LocationProfile, + reuse_window: Option, ) { self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); - let progress_label = self.status_label.clone(); + let progress_label = reuse_window.as_ref().map_or_else( + || self.status_label.clone(), + |window| show_viewer_loading(window, &entry.name, "ZIP 파일을 준비하는 중..."), + ); + let result_label = progress_label.clone(); let controller = Rc::clone(self); let navigator = self.sibling_navigator(siblings, current, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); + let view_mode_control = self.view_mode_control(); self.zip_cache.prepare( entry, move |current, total| { @@ -709,6 +740,7 @@ impl BrowserController { move |result| match result { Ok(source) => { controller.status_label.set_text("다운로드 완료"); + result_label.set_text("ZIP 파일을 여는 중..."); let Some(application) = controller.window.application() else { return; }; @@ -717,12 +749,20 @@ impl BrowserController { source, Rc::clone(&controller.database), controller.config.borrow().fast_image_loading, - Some(navigator), - favorite_control, - download_control, + view_mode_control, + FileViewerControls { + sibling_navigator: Some(navigator), + favorite_control, + download_control, + reuse_window, + }, ); } - Err(error) => controller.status_label.set_text(&format!("{error:#}")), + Err(error) => { + let message = format!("{error:#}"); + controller.status_label.set_text(&message); + result_label.set_text(&message); + } }, ); } @@ -733,11 +773,16 @@ impl BrowserController { siblings: Rc>, current: usize, profile: LocationProfile, + reuse_window: Option, ) { let kind = entry.kind; self.status_label .set_text("미디어 파일 다운로드 준비 중..."); - let progress_label = self.status_label.clone(); + let progress_label = reuse_window.as_ref().map_or_else( + || self.status_label.clone(), + |window| show_viewer_loading(window, &entry.name, "미디어 파일을 준비하는 중..."), + ); + let result_label = progress_label.clone(); let controller = Rc::clone(self); let navigator = self.sibling_navigator(siblings, current, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); @@ -751,6 +796,7 @@ impl BrowserController { move |result| match result { Ok(source) => { controller.status_label.set_text("다운로드 완료"); + result_label.set_text("미디어 파일을 여는 중..."); let Some(application) = controller.window.application() else { return; }; @@ -758,18 +804,37 @@ impl BrowserController { &application, source, kind, - Some(navigator), - favorite_control, - download_control, + FileViewerControls { + sibling_navigator: Some(navigator), + favorite_control, + download_control, + reuse_window, + }, ); } - Err(error) => controller.status_label.set_text(&format!("{error:#}")), + Err(error) => { + let message = format!("{error:#}"); + controller.status_label.set_text(&message); + result_label.set_text(&message); + } }, ); } - fn show_context_menu(self: &Rc, y: i32) { - let Some(row) = self.list_box.row_at_y(y) else { + fn show_context_menu(self: &Rc, x: f64, y: f64) { + let Some(widget) = self.list_box.pick(x, y, gtk::PickFlags::DEFAULT) else { + return; + }; + let Some(row) = widget + .clone() + .downcast::() + .ok() + .or_else(|| { + widget + .ancestor(gtk::ListBoxRow::static_type()) + .and_then(|widget| widget.downcast::().ok()) + }) + else { return; }; let Some(entry) = self @@ -869,6 +934,32 @@ impl BrowserController { } } + fn view_mode_control(self: &Rc) -> ViewModeControl { + let selected = self.config.borrow().viewer_mode.dropdown_index(); + let controller = Rc::downgrade(self); + ViewModeControl { + selected, + change: Rc::new(move |index| { + let Some(controller) = controller.upgrade() else { + return; + }; + let viewer_mode = ViewerMode::from_dropdown_index(index); + if controller.config.borrow().viewer_mode == viewer_mode { + return; + } + let mut updated = controller.config.borrow().clone(); + updated.viewer_mode = viewer_mode; + if let Err(error) = updated.save(&controller.config_path) { + controller + .status_label + .set_text(&format!("보기 모드를 저장할 수 없습니다: {error:#}")); + return; + } + *controller.config.borrow_mut() = updated; + }), + } + } + fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { let mut updated = self.config.borrow().clone(); let was_active = self.is_favorite(&entry.uri); @@ -879,7 +970,10 @@ impl BrowserController { return was_active; } *self.config.borrow_mut() = updated; - self.rebuild_favorites(); + let browsing_favorites = self.state.borrow().browsing_favorites; + if browsing_favorites { + self.show_favorites(); + } self.status_label.set_text(if active { "즐겨찾기에 추가했습니다." } else { @@ -1245,6 +1339,51 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { } } +fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { + BrowserEntry { + uri: favorite.uri.clone(), + name: favorite.name.clone(), + kind: if favorite.is_directory { + EntryKind::Directory + } else { + classify_file(Path::new(&favorite.name)) + }, + size: None, + modified: None, + created: None, + etag: None, + } +} + +fn show_viewer_loading( + window: >k::ApplicationWindow, + file_name: &str, + status: &str, +) -> gtk::Label { + window.set_title(Some(file_name)); + let header = gtk::HeaderBar::new(); + header.set_title_widget(Some(>k::Label::new(Some(file_name)))); + window.set_titlebar(Some(&header)); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_halign(gtk::Align::Center); + content.set_valign(gtk::Align::Center); + content.set_hexpand(true); + content.set_vexpand(true); + let spinner = gtk::Spinner::new(); + spinner.set_spinning(true); + let name_label = gtk::Label::new(Some(file_name)); + name_label.add_css_class("title-2"); + name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + name_label.set_max_width_chars(60); + let status_label = gtk::Label::new(Some(status)); + content.append(&spinner); + content.append(&name_label); + content.append(&status_label); + window.set_child(Some(&content)); + status_label +} + fn download_part_path(final_path: &std::path::Path) -> PathBuf { final_path.with_file_name(format!( "{}.part", @@ -1315,6 +1454,26 @@ mod tests { assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); } + #[test] + fn favorites_are_rendered_as_browser_paths_and_files() { + let profile = home_profile(); + let folder = favorite_browser_entry(&FavoriteEntry { + name: "Comics".into(), + uri: "file:///tmp/Comics".into(), + is_directory: true, + profile: profile.clone(), + }); + let archive = favorite_browser_entry(&FavoriteEntry { + name: "book.zip".into(), + uri: "file:///tmp/book.zip".into(), + is_directory: false, + profile, + }); + + assert_eq!(folder.kind, EntryKind::Directory); + assert_eq!(archive.kind, EntryKind::ZipArchive); + } + #[test] fn incomplete_download_uses_distinct_suffix() { assert_eq!(