From ffa8bc93bab83289b498bfb8dadea55abb4440d5 Mon Sep 17 00:00:00 2001 From: burnintuna Date: Wed, 22 Jul 2026 04:09:30 +0900 Subject: [PATCH] Improve viewer controls and navigation --- src/viewer.rs | 412 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/window.rs | 81 +++++++++- 2 files changed, 477 insertions(+), 16 deletions(-) diff --git a/src/viewer.rs b/src/viewer.rs index 6e601d6..ac33744 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -42,6 +42,17 @@ impl SiblingNavigator { (self.navigate)(SiblingTarget::Index(index), window) } + fn open_edge(&self, end: bool, window: gtk::ApplicationWindow) -> bool { + (self.navigate)( + if end { + SiblingTarget::Last + } else { + SiblingTarget::First + }, + window, + ) + } + fn position(&self) -> Option<(usize, usize)> { (self.position)() } @@ -51,6 +62,8 @@ impl SiblingNavigator { pub enum SiblingTarget { Relative(bool), Index(usize), + First, + Last, } impl SiblingTarget { @@ -58,6 +71,8 @@ impl SiblingTarget { match self { Self::Relative(forward) => sibling_index(current, count, forward), Self::Index(index) => (index < count && index != current).then_some(index), + Self::First => (count > 0 && current > 0).then_some(0), + Self::Last => count.checked_sub(1).filter(|last| current < *last), } } } @@ -81,6 +96,7 @@ pub struct FileViewerControls { pub favorite_control: FavoriteControl, pub download_control: FileDownloadControl, pub reuse_window: Option, + pub view_mode_control: Option, } impl ViewerWindow { @@ -98,6 +114,7 @@ impl ViewerWindow { favorite_control, download_control, reuse_window, + view_mode_control: _, } = controls; let title = source.display_name.clone(); let window = reuse_window.unwrap_or_else(|| { @@ -154,10 +171,12 @@ impl ViewerWindow { toolbar.append(&mode_dropdown); let single_picture = page_picture(); + let single_box = gtk::Box::new(gtk::Orientation::Horizontal, 0); + single_box.append(&single_picture); let single_scrolled = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(true) - .child(&single_picture) + .child(&single_box) .build(); let left_picture = page_picture(); let right_picture = page_picture(); @@ -212,6 +231,8 @@ impl ViewerWindow { source, database, stack, + single_scrolled, + single_box, single_picture, left_picture, right_picture, @@ -244,6 +265,7 @@ impl ViewerWindow { navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), continuous_programmatic_scroll: Cell::new(false), + view_layout_generation: Cell::new(0), state: RefCell::new(ViewerState { pages: Vec::new(), anchor: 0, @@ -303,6 +325,8 @@ struct ViewerController { source: ArchiveSource, database: Rc, stack: gtk::Stack, + single_scrolled: gtk::ScrolledWindow, + single_box: gtk::Box, single_picture: gtk::Picture, left_picture: gtk::Picture, right_picture: gtk::Picture, @@ -335,6 +359,7 @@ struct ViewerController { navigation_message_label: gtk::Label, navigation_message_generation: Rc>, continuous_programmatic_scroll: Cell, + view_layout_generation: Cell, state: RefCell, } @@ -390,6 +415,16 @@ impl ViewerController { .connect_notify_local(Some("width"), move |_, _| { controller.resize_continuous_rows(); }); + let controller = Rc::clone(self); + self.single_scrolled + .connect_notify_local(Some("width"), move |_, _| { + controller.resize_single_picture(); + }); + let controller = Rc::clone(self); + self.single_picture + .connect_notify_local(Some("width"), move |_, _| { + controller.resize_single_picture(); + }); let scroll_controller = gtk::EventControllerScroll::new( gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, @@ -397,7 +432,11 @@ impl ViewerController { scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::clone(self); scroll_controller.connect_scroll(move |_, _, delta_y| { - if controller.view_mode() == ViewMode::Continuous || delta_y == 0.0 { + if matches!( + controller.view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous + ) || delta_y == 0.0 + { return glib::Propagation::Proceed; } controller.navigate(delta_y > 0.0); @@ -417,6 +456,32 @@ impl ViewerController { key_controller.connect_key_pressed(move |_, key, _, _| { controller.hide_seek(); match key { + gdk::Key::Home => { + controller.seek_edge(false); + glib::Propagation::Stop + } + gdk::Key::End => { + controller.seek_edge(true); + glib::Propagation::Stop + } + gdk::Key::Up + if matches!( + controller.view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous + ) => + { + controller.scroll_vertically(false); + glib::Propagation::Stop + } + gdk::Key::Down + if matches!( + controller.view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous + ) => + { + controller.scroll_vertically(true); + glib::Propagation::Stop + } gdk::Key::Left | gdk::Key::Page_Up => { controller.navigate(false); glib::Propagation::Stop @@ -655,15 +720,73 @@ impl ViewerController { self.single_picture.set_paintable(gdk::Paintable::NONE); self.left_picture.set_paintable(gdk::Paintable::NONE); self.right_picture.set_paintable(gdk::Paintable::NONE); - self.schedule_scroll_to_anchor(); } else { self.clear_continuous_paintables(); } let actual = mode == ViewMode::ActualSize; + self.single_box.set_hexpand(!actual); + self.single_box.set_vexpand(mode == ViewMode::FitHeight); + self.single_box.set_halign(match mode { + ViewMode::ActualSize => gtk::Align::Start, + ViewMode::FitHeight => gtk::Align::Center, + _ => gtk::Align::Fill, + }); + self.single_box.set_valign(if actual { + gtk::Align::Start + } else { + gtk::Align::Fill + }); self.single_picture.set_can_shrink(!actual); self.single_picture.set_hexpand(mode == ViewMode::FitWidth); self.single_picture.set_vexpand(mode == ViewMode::FitHeight); + if matches!(mode, ViewMode::ActualSize | ViewMode::FitWidth) { + self.single_picture.set_content_fit(gtk::ContentFit::Fill); + self.single_picture.set_valign(gtk::Align::Start); + self.single_picture + .set_halign(if mode == ViewMode::FitWidth { + gtk::Align::Fill + } else { + gtk::Align::Start + }); + } else { + self.single_picture + .set_content_fit(gtk::ContentFit::Contain); + self.single_picture.set_valign(gtk::Align::Fill); + self.single_picture + .set_halign(if mode == ViewMode::FitHeight { + gtk::Align::Center + } else { + gtk::Align::Fill + }); + self.single_picture.set_size_request(-1, -1); + } self.render(); + self.schedule_view_mode_refresh(); + } + + fn schedule_view_mode_refresh(self: &Rc) { + let generation = self.view_layout_generation.get().wrapping_add(1); + self.view_layout_generation.set(generation); + self.stack.queue_allocate(); + self.window.queue_allocate(); + let controller = Rc::downgrade(self); + glib::idle_add_local_once(move || { + let Some(controller) = controller.upgrade() else { + return; + }; + if controller.view_layout_generation.get() != generation { + return; + } + match controller.view_mode() { + ViewMode::FitWidth => controller.resize_single_picture(), + ViewMode::Continuous => controller.resize_continuous_rows(), + _ => {} + } + controller.render(); + if controller.view_mode() == ViewMode::Continuous { + controller.schedule_scroll_to_anchor(); + } + }); } fn navigate(self: &Rc, forward: bool) { @@ -686,12 +809,33 @@ impl ViewerController { } state.anchor = next; drop(state); + self.reset_single_scroll(); self.render(); if self.view_mode() == ViewMode::Continuous { self.schedule_scroll_to_anchor(); } } + fn seek_edge(self: &Rc, end: bool) { + let count = self.state.borrow().pages.len(); + if count == 0 { + return; + } + self.seek_page(if end { count - 1 } else { 0 }); + } + + fn scroll_vertically(&self, down: bool) { + let adjustment = if self.view_mode() == ViewMode::Continuous { + self.continuous_scrolled.vadjustment() + } else { + self.single_scrolled.vadjustment() + }; + let step = adjustment.step_increment().max(64.0); + let delta = if down { step } else { -step }; + let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); + adjustment.set_value((adjustment.value() + delta).clamp(adjustment.lower(), maximum)); + } + fn shift_pairing(self: &Rc, offset: isize) { let mut state = self.state.borrow_mut(); if state.pages.is_empty() { @@ -716,6 +860,7 @@ impl ViewerController { } state.anchor = index; drop(state); + self.reset_single_scroll(); self.render(); if self.view_mode() == ViewMode::Continuous { self.schedule_scroll_to_anchor(); @@ -893,6 +1038,35 @@ impl ViewerController { self.single_picture .set_paintable(state.cache.get(&index).map(|page| &page.texture)); self.status_label.set_text(&state.pages[index].name); + drop(state); + self.resize_single_picture(); + } + + fn resize_single_picture(&self) { + let dimensions = { + let state = self.state.borrow(); + state + .cache + .get(&state.anchor) + .map(|page| (page.texture.width(), page.texture.height())) + }; + if let Some((texture_width, texture_height)) = dimensions + && let Some((width, height)) = single_picture_request( + self.view_mode(), + self.stack.allocated_width().max(1), + texture_width, + texture_height, + ) + { + self.single_picture.set_size_request(width, height); + } + } + + fn reset_single_scroll(&self) { + if matches!(self.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth) { + let adjustment = self.single_scrolled.vadjustment(); + adjustment.set_value(adjustment.lower()); + } } fn render_spread(&self, indices: &[usize]) { @@ -1071,7 +1245,7 @@ impl ViewerController { } fn continuous_content_width(&self) -> i32 { - (self.continuous_scrolled.allocated_width() - 24).max(320) + continuous_content_width(self.stack.allocated_width()) } fn evict_cache(&self) { @@ -1155,6 +1329,7 @@ impl MediaViewerWindow { favorite_control, download_control, reuse_window, + view_mode_control, } = controls; let title = source.display_name.clone(); let window = reuse_window.unwrap_or_else(|| { @@ -1170,6 +1345,20 @@ impl MediaViewerWindow { header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); let download_button = gtk::Button::with_label("파일 다운로드"); + let loop_check = gtk::CheckButton::with_label("반복 재생"); + loop_check.set_visible(kind == EntryKind::Video); + let image_mode_dropdown = gtk::DropDown::from_strings(&["100%", "폭 맞춤", "높이 맞춤"]); + let (image_mode_selected, image_mode_change) = + view_mode_control.map_or((1, None), |control| { + ( + standalone_mode_index(control.selected), + Some(control.change), + ) + }); + image_mode_dropdown.set_selected(image_mode_selected); + image_mode_dropdown.set_visible(kind == EntryKind::Image); + header.pack_start(&image_mode_dropdown); + header.pack_end(&loop_check); header.pack_end(&download_button); header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); @@ -1179,7 +1368,14 @@ impl MediaViewerWindow { stack.set_hexpand(true); stack.set_vexpand(true); let picture = page_picture(); - stack.add_named(&picture, Some("image")); + let image_box = gtk::Box::new(gtk::Orientation::Horizontal, 0); + image_box.append(&picture); + let image_scrolled = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .child(&image_box) + .build(); + stack.add_named(&image_scrolled, Some("image")); let video = if kind == EntryKind::Video { gtk::Video::for_filename(Some(&source.local_path)) } else { @@ -1188,6 +1384,8 @@ impl MediaViewerWindow { video.set_autoplay(true); video.set_hexpand(true); video.set_vexpand(true); + let video_for_loop = video.clone(); + loop_check.connect_toggled(move |check| video_for_loop.set_loop(check.is_active())); stack.add_named(&video, Some("video")); let status_label = gtk::Label::builder() .label(&title) @@ -1221,7 +1419,13 @@ impl MediaViewerWindow { let controller = Rc::new(MediaViewerController { window, source, + kind, picture, + image_box, + image_scrolled, + image_mode_dropdown, + image_mode_change, + image_dimensions: Cell::new(None), video, stack, status_label, @@ -1251,6 +1455,7 @@ impl MediaViewerWindow { }); } controller.connect_signals(); + controller.apply_image_view_mode(); controller.window.present(); match kind { EntryKind::Image => controller.load_image(), @@ -1268,7 +1473,13 @@ impl MediaViewerWindow { struct MediaViewerController { window: gtk::ApplicationWindow, source: ArchiveSource, + kind: EntryKind, picture: gtk::Picture, + image_box: gtk::Box, + image_scrolled: gtk::ScrolledWindow, + image_mode_dropdown: gtk::DropDown, + image_mode_change: Option>, + image_dimensions: Cell>, video: gtk::Video, stack: gtk::Stack, status_label: gtk::Label, @@ -1303,20 +1514,51 @@ impl MediaViewerController { let controller = Rc::clone(self); keys.connect_key_pressed(move |_, key, _, _| { controller.hide_seek(); - match key.to_unicode() { - Some('[') => { + match key { + gdk::Key::Home => { + controller.open_edge(false); + glib::Propagation::Stop + } + gdk::Key::End => { + controller.open_edge(true); + glib::Propagation::Stop + } + gdk::Key::Up + if controller.kind == EntryKind::Image + && matches!( + controller.image_view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth + ) => + { + controller.scroll_image(false); + glib::Propagation::Stop + } + gdk::Key::Down + if controller.kind == EntryKind::Image + && matches!( + controller.image_view_mode(), + ViewMode::ActualSize | ViewMode::FitWidth + ) => + { + controller.scroll_image(true); + glib::Propagation::Stop + } + _ if key.to_unicode() == Some('[') => { controller.open_sibling(false); glib::Propagation::Stop } - Some(']') => { + _ if key.to_unicode() == Some(']') => { controller.open_sibling(true); glib::Propagation::Stop } - Some(character) if character.eq_ignore_ascii_case(&'f') => { + _ if key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => + { controller.toggle_fullscreen(); glib::Propagation::Stop } - _ if key == gdk::Key::Escape && controller.window.is_fullscreen() => { + gdk::Key::Escape if controller.window.is_fullscreen() => { controller.window.unfullscreen(); glib::Propagation::Stop } @@ -1337,6 +1579,19 @@ impl MediaViewerController { controller.status_label.clone(), ); }); + if let Some(change) = self.image_mode_change.clone() { + let controller = Rc::clone(self); + self.image_mode_dropdown + .connect_selected_notify(move |dropdown| { + change(dropdown.selected()); + controller.apply_image_view_mode(); + }); + } + let controller = Rc::clone(self); + self.image_scrolled + .connect_notify_local(Some("width"), move |_, _| { + controller.resize_image_picture(); + }); let controller = Rc::clone(self); self.media_seek.connect_value_changed(move |scale| { let Some(navigator) = &controller.sibling_navigator else { @@ -1352,6 +1607,79 @@ impl MediaViewerController { }); } + fn image_view_mode(&self) -> ViewMode { + match self.image_mode_dropdown.selected() { + 0 => ViewMode::ActualSize, + 2 => ViewMode::FitHeight, + _ => ViewMode::FitWidth, + } + } + + fn apply_image_view_mode(&self) { + if self.kind != EntryKind::Image { + return; + } + let mode = self.image_view_mode(); + let actual = mode == ViewMode::ActualSize; + self.image_box.set_hexpand(!actual); + self.image_box.set_vexpand(mode == ViewMode::FitHeight); + self.image_box.set_halign(match mode { + ViewMode::ActualSize => gtk::Align::Start, + ViewMode::FitHeight => gtk::Align::Center, + _ => gtk::Align::Fill, + }); + self.image_box.set_valign(if actual { + gtk::Align::Start + } else { + gtk::Align::Fill + }); + self.picture.set_can_shrink(!actual); + self.picture.set_hexpand(mode == ViewMode::FitWidth); + self.picture.set_vexpand(mode == ViewMode::FitHeight); + if matches!(mode, ViewMode::ActualSize | ViewMode::FitWidth) { + self.picture.set_content_fit(gtk::ContentFit::Fill); + self.picture.set_halign(if actual { + gtk::Align::Start + } else { + gtk::Align::Fill + }); + self.picture.set_valign(gtk::Align::Start); + } else { + self.picture.set_content_fit(gtk::ContentFit::Contain); + self.picture.set_halign(if mode == ViewMode::FitHeight { + gtk::Align::Center + } else { + gtk::Align::Fill + }); + self.picture.set_valign(gtk::Align::Fill); + self.picture.set_size_request(-1, -1); + } + self.resize_image_picture(); + self.stack.queue_allocate(); + } + + fn resize_image_picture(&self) { + let Some((texture_width, texture_height)) = self.image_dimensions.get() else { + return; + }; + if let Some((width, height)) = single_picture_request( + self.image_view_mode(), + self.stack.allocated_width().max(1), + texture_width, + texture_height, + ) { + self.picture.set_size_request(width, height); + } + } + + fn scroll_image(&self, down: bool) { + let adjustment = self.image_scrolled.vadjustment(); + let step = adjustment.step_increment().max(64.0); + let delta = if down { step } else { -step }; + let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); + adjustment.set_value((adjustment.value() + delta).clamp(adjustment.lower(), maximum)); + } + fn open_sibling(&self, forward: bool) { if let Some(navigate) = &self.sibling_navigator && navigate.open_relative(forward, self.window.clone()) @@ -1362,6 +1690,16 @@ impl MediaViewerController { } } + fn open_edge(&self, end: bool) { + if let Some(navigate) = &self.sibling_navigator + && navigate.open_edge(end, self.window.clone()) + { + self.prepare_for_navigation(); + } else { + self.show_navigation_error(end); + } + } + fn show_navigation_error(&self, forward: bool) { self.navigation_message_label .set_text(navigation_error_message(forward)); @@ -1513,6 +1851,8 @@ impl MediaViewerController { fn finish_image_load(&self, result: Result) { match result { Ok(page) => { + self.image_dimensions + .set(Some((page.width as i32, page.height as i32))); let bytes = glib::Bytes::from_owned(page.rgba); let texture = gdk::MemoryTexture::new( page.width as i32, @@ -1522,6 +1862,7 @@ impl MediaViewerController { page.width as usize * 4, ); self.picture.set_paintable(Some(&texture)); + self.apply_image_view_mode(); self.status_label.set_text(&self.source.display_name); } Err(error) => self.status_label.set_text(&error), @@ -1537,6 +1878,13 @@ fn favorite_label(active: bool) -> &'static str { } } +fn standalone_mode_index(global_index: u32) -> u32 { + match global_index { + 0 | 2 => global_index, + _ => 1, + } +} + fn page_picture() -> gtk::Picture { let picture = gtk::Picture::new(); picture.set_content_fit(gtk::ContentFit::Contain); @@ -1613,6 +1961,26 @@ fn row_height(width: i32, ratio: f32) -> i32 { ((width as f32 / ratio).round() as i32).clamp(1, 30_000) } +fn single_picture_request( + mode: ViewMode, + viewport_width: i32, + texture_width: i32, + texture_height: i32, +) -> Option<(i32, i32)> { + match mode { + ViewMode::ActualSize => Some((texture_width, texture_height)), + ViewMode::FitWidth => Some(( + -1, + row_height(viewport_width, texture_width as f32 / texture_height as f32), + )), + _ => None, + } +} + +fn continuous_content_width(stack_width: i32) -> i32 { + (stack_width - 24).max(320) +} + fn prefetch_window(visible: &[usize], count: usize, fast: bool) -> Vec { if visible.is_empty() || count == 0 { return Vec::new(); @@ -1687,6 +2055,25 @@ mod tests { fn continuous_row_height_preserves_image_ratio() { assert_eq!(row_height(1200, 2.0 / 3.0), 1800); assert_eq!(row_height(1200, 1.5), 800); + assert_eq!(continuous_content_width(1200), 1176); + assert_eq!(continuous_content_width(0), 320); + assert_eq!( + single_picture_request(ViewMode::ActualSize, 1200, 800, 1600), + Some((800, 1600)) + ); + assert_eq!( + single_picture_request(ViewMode::FitWidth, 1200, 800, 1600), + Some((-1, 2400)) + ); + assert_eq!( + single_picture_request(ViewMode::FitHeight, 1200, 800, 1600), + None + ); + assert_eq!(standalone_mode_index(0), 0); + assert_eq!(standalone_mode_index(1), 1); + assert_eq!(standalone_mode_index(2), 2); + assert_eq!(standalone_mode_index(3), 1); + assert_eq!(standalone_mode_index(4), 1); } #[test] @@ -1745,6 +2132,11 @@ mod tests { assert_eq!(sibling_index(1, 3, true), Some(2)); assert_eq!(sibling_index(0, 3, false), None); assert_eq!(sibling_index(2, 3, true), None); + assert_eq!(SiblingTarget::First.resolve(2, 5), Some(0)); + assert_eq!(SiblingTarget::Last.resolve(1, 5), Some(4)); + assert_eq!(SiblingTarget::First.resolve(0, 5), None); + assert_eq!(SiblingTarget::Last.resolve(4, 5), None); + assert_eq!(SiblingTarget::Last.resolve(0, 0), None); } #[test] diff --git a/src/window.rs b/src/window.rs index effba41..476bfcf 100644 --- a/src/window.rs +++ b/src/window.rs @@ -248,6 +248,44 @@ impl BrowserController { let controller = Rc::clone(self); self.location_dropdown .connect_selected_notify(move |_| controller.select_profile()); + let location_factory = gtk::SignalListItemFactory::new(); + let controller = Rc::downgrade(self); + location_factory.connect_setup(move |_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let label = gtk::Label::builder().xalign(0.0).build(); + let click = gtk::GestureClick::builder().button(1).build(); + item.set_child(Some(&label)); + let item = item.downgrade(); + let controller = controller.clone(); + click.connect_released(move |_, _, _, _| { + let Some(item) = item.upgrade() else { + return; + }; + let Some(controller) = controller.upgrade() else { + return; + }; + if is_favorites_selection(item.position(), controller.profiles().len()) { + controller.show_favorites(); + } + }); + label.add_controller(click); + }); + location_factory.connect_bind(|_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let Some(label) = item.child().and_downcast::() else { + return; + }; + let Some(value) = item.item().and_downcast::() else { + return; + }; + label.set_text(value.string().as_str()); + }); + self.location_dropdown + .set_list_factory(Some(&location_factory)); let controller = Rc::clone(self); self.search_entry .connect_search_changed(move |_| controller.render()); @@ -274,8 +312,14 @@ impl BrowserController { let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); - key_controller - .connect_key_pressed(move |_, key, _, modifiers| controller.typeahead(key, modifiers)); + key_controller.connect_key_pressed(move |_, key, _, modifiers| { + if key == gtk::gdk::Key::BackSpace { + controller.go_up(); + glib::Propagation::Stop + } else { + controller.typeahead(key, modifiers) + } + }); self.list_box.add_controller(key_controller); let controller = Rc::clone(self); @@ -315,7 +359,7 @@ impl BrowserController { fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; let profiles = self.profiles(); - if profile_index == profiles.len() { + if is_favorites_selection(self.location_dropdown.selected(), profiles.len()) { self.show_favorites(); return; } @@ -348,7 +392,6 @@ impl BrowserController { 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() @@ -357,10 +400,14 @@ impl BrowserController { .map(favorite_browser_entry) .collect(); let mut state = self.state.borrow_mut(); + state.generation = state.generation.wrapping_add(1); + state.typeahead_query.clear(); + state.typeahead_last_input = None; state.browsing_favorites = true; state.favorite_profile = None; state.entries = entries; drop(state); + self.search_entry.set_text(""); self.render(); } @@ -500,7 +547,10 @@ impl BrowserController { let Some(controller) = controller.upgrade() else { return; }; - if controller.state.borrow().generation != generation { + let state = controller.state.borrow(); + let stale = state.generation != generation || state.browsing_favorites; + drop(state); + if stale { return; } match result { @@ -815,6 +865,7 @@ impl BrowserController { favorite_control, download_control, reuse_window, + view_mode_control: None, }, ); } @@ -846,6 +897,7 @@ impl BrowserController { let navigator = self.sibling_navigator(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); + let view_mode_control = (kind == EntryKind::Image).then(|| self.view_mode_control()); self.zip_cache.prepare_media( entry, move |current, total| { @@ -868,6 +920,7 @@ impl BrowserController { favorite_control, download_control, reuse_window, + view_mode_control, }, ); } @@ -1166,7 +1219,7 @@ impl BrowserController { .transient_for(&self.window) .modal(true) .resizable(false) - .default_width(360) + .default_width(520) .build(); let content = gtk::Box::new(gtk::Orientation::Vertical, 14); content.set_margin_top(24); @@ -1179,12 +1232,21 @@ impl BrowserController { ))); name.add_css_class("title-2"); let contact = gtk::Label::new(Some("개발자 연락처 : burningtuna@gmail.com")); + let libraries = gtk::Label::new(Some( + "사용한 라이브러리\n\ + GTK4 / GIO · GStreamer · image · zip · rusqlite / SQLite\n\ + serde · toml · anyhow · chrono · filetime · percent-encoding\n\ + sha2 · tempfile", + )); + libraries.set_xalign(0.0); + libraries.set_wrap(true); let close_button = gtk::Button::with_label("닫기"); close_button.set_halign(gtk::Align::End); let dialog_to_close = dialog.clone(); close_button.connect_clicked(move |_| dialog_to_close.close()); content.append(&name); content.append(&contact); + content.append(&libraries); content.append(&close_button); dialog.set_child(Some(&content)); dialog.present(); @@ -1449,6 +1511,10 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { } } +fn is_favorites_selection(selected: u32, profile_count: usize) -> bool { + selected != gtk::INVALID_LIST_POSITION && selected as usize == profile_count +} + fn sorted_navigable_entries( entries: &[BrowserEntry], current_kind: EntryKind, @@ -1634,6 +1700,9 @@ mod tests { assert_eq!(folder.kind, EntryKind::Directory); assert_eq!(archive.kind, EntryKind::ZipArchive); + assert!(is_favorites_selection(3, 3)); + assert!(!is_favorites_selection(2, 3)); + assert!(!is_favorites_selection(gtk::INVALID_LIST_POSITION, 3)); } #[test]