diff --git a/Cargo.lock b/Cargo.lock index 85f20c7..0088458 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "comicviewer" -version = "0.2.5" +version = "0.2.6" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0e39e2c..b37e02f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comicviewer" -version = "0.2.5" +version = "0.2.6" 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 7d8fb58..1025231 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/INSTALL.ko.md b/docs/INSTALL.ko.md index 1094e7f..bf92e93 100644 --- a/docs/INSTALL.ko.md +++ b/docs/INSTALL.ko.md @@ -89,7 +89,11 @@ Comicviewer는 XDG 표준 경로를 사용합니다. - `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기 - 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동 - 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절 -- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기 +- `L`: 우측 파일 목록 표시 또는 숨기기 +- 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 +- 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소 +- 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시 +- 전체 화면에서 `←`, `→`, `↑`, `↓`: 컨트롤을 즉시 숨기고 해당 키 동작 실행 - 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기 - 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드 - 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기 diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md index 8437a92..15dedde 100644 --- a/docs/RELEASING.ko.md +++ b/docs/RELEASING.ko.md @@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz` 스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다. -- `dist/Comicviewer-0.2.5-x86_64.AppImage.sha256` +- `dist/Comicviewer-0.2.6-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.5" -git tag -a v0.2.5 -m "Comicviewer v0.2.5" +git commit -m "Release v0.2.6" +git tag -a v0.2.6 -m "Comicviewer v0.2.6" git push origin main -git push origin v0.2.5 +git push origin v0.2.6 ``` 이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새 diff --git a/src/viewer.rs b/src/viewer.rs index 05dfd18..29d68ad 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -20,6 +20,9 @@ const FAST_CACHE_LIMIT_BYTES: usize = 1024 * 1024 * 1024; const FAST_CACHE_LIMIT_PAGES: usize = 64; const VIDEO_SEEK_STEP_US: i64 = 5_000_000; const VIDEO_VOLUME_STEP: f64 = 0.05; +const IMAGE_ZOOM_STEP: f64 = 0.1; +const IMAGE_ZOOM_MIN: f64 = 0.1; +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"; @@ -60,15 +63,15 @@ pub(crate) fn set_viewer_loading(window: >k::ApplicationWindow, loading: bool) if !window.has_css_class("viewer-loading") || !window.is_fullscreen() { return glib::Propagation::Proceed; } - if let Some(titlebar) = window.titlebar() { - titlebar.set_visible(true); - } if key == gdk::Key::Escape || key .to_unicode() .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) { window.unfullscreen(); + if let Some(titlebar) = window.titlebar() { + titlebar.set_visible(true); + } return glib::Propagation::Stop; } glib::Propagation::Proceed @@ -348,6 +351,11 @@ impl ViewerWindow { spread_box.set_homogeneous(true); spread_box.append(&left_picture); spread_box.append(&right_picture); + let spread_scrolled = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .child(&spread_box) + .build(); let continuous_box = gtk::Box::new(gtk::Orientation::Vertical, 8); continuous_box.set_halign(gtk::Align::Fill); @@ -360,7 +368,7 @@ impl ViewerWindow { stack.set_hexpand(true); stack.set_vexpand(true); stack.add_named(&single_scrolled, Some("single")); - stack.add_named(&spread_box, Some("spread")); + stack.add_named(&spread_scrolled, Some("spread")); stack.add_named(&continuous_scrolled, Some("continuous")); let spinner = gtk::Spinner::new(); @@ -408,6 +416,8 @@ impl ViewerWindow { single_picture, left_picture, right_picture, + spread_box, + spread_scrolled, continuous_box, continuous_scrolled, spinner, @@ -439,9 +449,11 @@ impl ViewerWindow { key_controller: RefCell::new(None), motion_controller: RefCell::new(None), fullscreen_key_controller: RefCell::new(None), + fullscreen_click_controller: RefCell::new(None), fullscreen_ui_hide_timer: RefCell::new(None), fullscreen_ui_visible: Cell::new(false), fullscreen_notify_handler: RefCell::new(None), + image_zoom: Cell::new(1.0), navigation_message, navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), @@ -563,6 +575,8 @@ struct ViewerController { single_picture: gtk::Picture, left_picture: gtk::Picture, right_picture: gtk::Picture, + spread_box: gtk::Box, + spread_scrolled: gtk::ScrolledWindow, continuous_box: gtk::Box, continuous_scrolled: gtk::ScrolledWindow, spinner: gtk::Spinner, @@ -594,9 +608,11 @@ struct ViewerController { key_controller: RefCell>, motion_controller: RefCell>, fullscreen_key_controller: RefCell>, + fullscreen_click_controller: RefCell>, fullscreen_ui_hide_timer: RefCell>, fullscreen_ui_visible: Cell, fullscreen_notify_handler: RefCell>, + image_zoom: Cell, navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, @@ -638,6 +654,7 @@ impl ViewerController { self.mode_dropdown.connect_selected_notify(move |dropdown| { if let Some(controller) = controller.upgrade() { (controller.view_mode_change)(dropdown.selected()); + controller.image_zoom.set(1.0); controller.apply_view_mode(); } }); @@ -752,22 +769,51 @@ impl ViewerController { } }); let controller = Rc::downgrade(self); + self.single_scrolled + .connect_notify_local(Some("height"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.resize_single_picture(); + } + }); + let controller = Rc::downgrade(self); self.single_picture .connect_notify_local(Some("width"), move |_, _| { if let Some(controller) = controller.upgrade() { controller.resize_single_picture(); } }); + let controller = Rc::downgrade(self); + self.spread_scrolled + .connect_notify_local(Some("width"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.resize_spread_pictures(); + } + }); + let controller = Rc::downgrade(self); + self.spread_scrolled + .connect_notify_local(Some("height"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.resize_spread_pictures(); + } + }); let scroll_controller = gtk::EventControllerScroll::new( gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, ); scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); - scroll_controller.connect_scroll(move |_, _, delta_y| { + scroll_controller.connect_scroll(move |scroll, _, delta_y| { let Some(controller) = controller.upgrade() else { return glib::Propagation::Proceed; }; + if scroll + .current_event_state() + .contains(gdk::ModifierType::CONTROL_MASK) + && delta_y != 0.0 + { + controller.adjust_image_zoom(delta_y < 0.0); + return glib::Propagation::Stop; + } if matches!( controller.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous @@ -780,6 +826,20 @@ impl ViewerController { }); self.stack.add_controller(scroll_controller); + let fullscreen_click = gtk::GestureClick::builder() + .button(0) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + let controller = Rc::downgrade(self); + fullscreen_click.connect_pressed(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() { + controller.controls_clicked(); + } + }); + self.window.add_controller(fullscreen_click.clone()); + self.fullscreen_click_controller + .replace(Some(fullscreen_click)); + let motion_controller = gtk::EventControllerMotion::new(); motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); @@ -797,9 +857,9 @@ impl ViewerController { fullscreen_keys.connect_key_pressed(move |_, key, _, _| { if let Some(controller) = controller.upgrade() && controller.window.is_fullscreen() - && fullscreen_key_reveals_ui(key) + && fullscreen_direction_key(key) { - controller.show_fullscreen_ui_temporarily(); + controller.hide_fullscreen_ui_now(); } glib::Propagation::Proceed }); @@ -809,7 +869,7 @@ impl ViewerController { let key_controller = gtk::EventControllerKey::new(); let controller = Rc::downgrade(self); - key_controller.connect_key_pressed(move |_, key, _, _| { + key_controller.connect_key_pressed(move |_, key, _, modifiers| { let Some(controller) = controller.upgrade() else { return glib::Propagation::Proceed; }; @@ -825,7 +885,7 @@ impl ViewerController { controller.seek_edge(true); glib::Propagation::Stop } - gdk::Key::Up + gdk::Key::Up | gdk::Key::KP_Up if matches!( controller.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous @@ -834,7 +894,7 @@ impl ViewerController { controller.scroll_vertically(false); glib::Propagation::Stop } - gdk::Key::Down + gdk::Key::Down | gdk::Key::KP_Down if matches!( controller.view_mode(), ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous @@ -843,11 +903,11 @@ impl ViewerController { controller.scroll_vertically(true); glib::Propagation::Stop } - gdk::Key::Left | gdk::Key::Page_Up => { + gdk::Key::Left | gdk::Key::KP_Left | gdk::Key::Page_Up => { controller.navigate(false); glib::Propagation::Stop } - gdk::Key::Right | gdk::Key::Page_Down | gdk::Key::space => { + gdk::Key::Right | gdk::Key::KP_Right | gdk::Key::Page_Down | gdk::Key::space => { controller.navigate(true); glib::Propagation::Stop } @@ -859,6 +919,22 @@ impl ViewerController { controller.open_sibling(true); glib::Propagation::Stop } + gdk::Key::plus | gdk::Key::KP_Add => { + controller.adjust_image_zoom(true); + glib::Propagation::Stop + } + gdk::Key::minus | gdk::Key::KP_Subtract => { + controller.adjust_image_zoom(false); + glib::Propagation::Stop + } + _ if !shortcut_modifiers_blocked(modifiers) + && key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'l')) => + { + controller.toggle_file_list(); + glib::Propagation::Stop + } _ if key .to_unicode() .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => @@ -896,6 +972,17 @@ impl ViewerController { } } + fn toggle_file_list(&self) { + if !self.file_list_button.is_sensitive() { + return; + } + let active = !self.file_list_button.is_active(); + self.file_list_button.set_active(active); + if self.window.is_fullscreen() { + self.file_list_panel.set_visible(active); + } + } + fn refresh_file_list(&self) { while let Some(child) = self.file_list.first_child() { self.file_list.remove(&child); @@ -993,6 +1080,9 @@ impl ViewerController { if let Some(controller) = self.fullscreen_key_controller.borrow_mut().take() { self.window.remove_controller(&controller); } + if let Some(controller) = self.fullscreen_click_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } } fn stop_animations(&self) { @@ -1024,6 +1114,9 @@ impl ViewerController { return; } self.last_pointer_position.set(Some((x, y))); + } + + fn controls_clicked(self: &Rc) { if self.window.is_fullscreen() { self.show_fullscreen_ui_temporarily(); } else { @@ -1093,6 +1186,13 @@ impl ViewerController { self.fullscreen_ui_hide_timer.replace(Some(timer)); } + fn hide_fullscreen_ui_now(self: &Rc) { + if self.window.is_fullscreen() { + self.cancel_fullscreen_ui_timer(); + self.set_fullscreen_ui_visible(false); + } + } + fn cancel_fullscreen_ui_timer(&self) { if let Some(timer) = self.fullscreen_ui_hide_timer.borrow_mut().take() { timer.remove(); @@ -1296,6 +1396,27 @@ impl ViewerController { self.schedule_view_mode_refresh(); } + fn adjust_image_zoom(self: &Rc, zoom_in: bool) { + let zoom = next_image_zoom(self.image_zoom.get(), zoom_in); + if (zoom - self.image_zoom.get()).abs() < f64::EPSILON { + return; + } + self.image_zoom.set(zoom); + if (zoom - 1.0).abs() < f64::EPSILON { + self.apply_view_mode(); + return; + } + match self.view_mode() { + ViewMode::TwoPage => self.resize_spread_pictures(), + ViewMode::Continuous => { + self.resize_continuous_rows(); + self.continuous_scrolled(); + } + _ => self.resize_single_picture(), + } + self.stack.queue_allocate(); + } + fn schedule_view_mode_refresh(self: &Rc) { let generation = self.view_layout_generation.get().wrapping_add(1); self.view_layout_generation.set(generation); @@ -1538,7 +1659,7 @@ impl ViewerController { }; if let Some(frame) = frame { frame.set_ratio(ratio); - frame.set_size_request(-1, row_height(self.continuous_content_width(), ratio)); + self.resize_continuous_rows(); } } self.evict_cache(); @@ -1581,15 +1702,100 @@ impl ViewerController { .and_then(|page| page.frames.first()) .map(|frame| (frame.texture.width(), frame.texture.height())) }; - if let Some((texture_width, texture_height)) = dimensions - && let Some((width, height)) = single_picture_request( + if let Some((texture_width, texture_height)) = dimensions { + let zoom = self.image_zoom.get(); + if (zoom - 1.0).abs() >= f64::EPSILON { + let (width, height) = zoomed_picture_request( + self.view_mode(), + self.single_scrolled.allocated_width().max(1), + self.single_scrolled.allocated_height().max(1), + texture_width, + texture_height, + zoom, + ); + self.single_box.set_hexpand(true); + self.single_box.set_vexpand(true); + self.single_box.set_halign(gtk::Align::Fill); + self.single_box.set_valign(gtk::Align::Fill); + self.single_picture.set_can_shrink(false); + self.single_picture.set_hexpand(false); + self.single_picture.set_vexpand(false); + self.single_picture.set_content_fit(gtk::ContentFit::Fill); + self.single_picture.set_halign(gtk::Align::Center); + self.single_picture.set_valign(gtk::Align::Center); + self.single_picture.set_size_request(width, height); + } else if 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 resize_spread_pictures(&self) { + let zoom = self.image_zoom.get(); + if (zoom - 1.0).abs() < f64::EPSILON { + self.spread_box.set_homogeneous(true); + self.spread_box.set_halign(gtk::Align::Fill); + self.spread_box.set_valign(gtk::Align::Fill); + for picture in [&self.left_picture, &self.right_picture] { + picture.set_can_shrink(true); + picture.set_hexpand(true); + picture.set_vexpand(true); + picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_size_request(-1, -1); + } + return; + } + let visible = self.visible_indices(); + let state = self.state.borrow(); + let page_dimensions = |position: usize| { + visible.get(position).and_then(|index| { + state + .cache + .get(index) + .and_then(|page| page.frames.first()) + .map(|frame| (frame.texture.width(), frame.texture.height())) + }) + }; + let first = page_dimensions(0); + let second = page_dimensions(1); + let dimensions = if self.direction_dropdown.selected() == 1 { + [second, first] + } else { + [first, second] + }; + drop(state); + let count = visible.len().max(1) as i32; + let viewport_width = (self.spread_scrolled.allocated_width().max(1) - 8) / count; + let viewport_height = self.spread_scrolled.allocated_height().max(1); + self.spread_box.set_homogeneous(false); + self.spread_box.set_halign(gtk::Align::Center); + self.spread_box.set_valign(gtk::Align::Center); + for (picture, dimensions) in [&self.left_picture, &self.right_picture] + .into_iter() + .zip(dimensions) { - self.single_picture.set_size_request(width, height); + let Some((texture_width, texture_height)) = dimensions else { + picture.set_size_request(1, 1); + continue; + }; + let (width, height) = zoomed_picture_request( + ViewMode::FitHeight, + viewport_width, + viewport_height, + texture_width, + texture_height, + zoom, + ); + picture.set_can_shrink(false); + picture.set_hexpand(false); + picture.set_vexpand(false); + picture.set_content_fit(gtk::ContentFit::Fill); + picture.set_size_request(width, height); } } @@ -1628,6 +1834,7 @@ impl ViewerController { ); } self.status_label.set_text(&status); + self.resize_spread_pictures(); } fn render_continuous(&self, visible: &[usize]) { @@ -1783,7 +1990,14 @@ impl ViewerController { } fn resize_continuous_rows(&self) { - let width = self.continuous_content_width(); + let zoom = self.image_zoom.get(); + let width = scaled_dimension(self.continuous_content_width(), zoom); + let zoomed = (zoom - 1.0).abs() >= f64::EPSILON; + self.continuous_box.set_halign(if zoomed { + gtk::Align::Center + } else { + gtk::Align::Fill + }); let rows: Vec<_> = self .state .borrow() @@ -1792,7 +2006,8 @@ impl ViewerController { .map(|row| (row.frame.clone(), row.ratio)) .collect(); for (frame, ratio) in rows { - frame.set_size_request(-1, row_height(width, ratio)); + frame.set_hexpand(!zoomed); + frame.set_size_request(if zoomed { width } else { -1 }, row_height(width, ratio)); } } @@ -1997,6 +2212,7 @@ impl MediaViewerWindow { image_mode_dropdown, image_mode_change, image_dimensions: Cell::new(None), + image_zoom: Cell::new(1.0), image_animation_generation: Rc::new(AnimationState::default()), video, stack, @@ -2019,6 +2235,7 @@ impl MediaViewerWindow { key_controller: RefCell::new(None), motion_controller: RefCell::new(None), fullscreen_key_controller: RefCell::new(None), + fullscreen_click_controller: RefCell::new(None), fullscreen_ui_hide_timer: RefCell::new(None), fullscreen_ui_visible: Cell::new(false), fullscreen_notify_handler: RefCell::new(None), @@ -2070,6 +2287,7 @@ struct MediaViewerController { image_mode_dropdown: gtk::DropDown, image_mode_change: Option>, image_dimensions: Cell>, + image_zoom: Cell, image_animation_generation: Rc, video: gtk::Video, stack: gtk::Stack, @@ -2092,6 +2310,7 @@ struct MediaViewerController { key_controller: RefCell>, motion_controller: RefCell>, fullscreen_key_controller: RefCell>, + fullscreen_click_controller: RefCell>, fullscreen_ui_hide_timer: RefCell>, fullscreen_ui_visible: Cell, fullscreen_notify_handler: RefCell>, @@ -2102,6 +2321,20 @@ struct MediaViewerController { impl MediaViewerController { fn connect_signals(self: &Rc) { + let fullscreen_click = gtk::GestureClick::builder() + .button(0) + .propagation_phase(gtk::PropagationPhase::Capture) + .build(); + let controller = Rc::downgrade(self); + fullscreen_click.connect_pressed(move |_, _, _, _| { + if let Some(controller) = controller.upgrade() { + controller.controls_clicked(); + } + }); + self.window.add_controller(fullscreen_click.clone()); + self.fullscreen_click_controller + .replace(Some(fullscreen_click)); + let motion_controller = gtk::EventControllerMotion::new(); motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); @@ -2119,9 +2352,9 @@ impl MediaViewerController { fullscreen_keys.connect_key_pressed(move |_, key, _, _| { if let Some(controller) = controller.upgrade() && controller.window.is_fullscreen() - && fullscreen_key_reveals_ui(key) + && fullscreen_direction_key(key) { - controller.show_fullscreen_ui_temporarily(); + controller.hide_fullscreen_ui_now(); } glib::Propagation::Proceed }); @@ -2146,19 +2379,19 @@ impl MediaViewerController { controller.hide_seek(); } match key { - gdk::Key::Left if controller.kind == EntryKind::Video => { + gdk::Key::Left | gdk::Key::KP_Left if controller.kind == EntryKind::Video => { controller.seek_video(-VIDEO_SEEK_STEP_US); glib::Propagation::Stop } - gdk::Key::Right if controller.kind == EntryKind::Video => { + gdk::Key::Right | gdk::Key::KP_Right if controller.kind == EntryKind::Video => { controller.seek_video(VIDEO_SEEK_STEP_US); glib::Propagation::Stop } - gdk::Key::Up if controller.kind == EntryKind::Video => { + gdk::Key::Up | gdk::Key::KP_Up if controller.kind == EntryKind::Video => { controller.adjust_video_volume(VIDEO_VOLUME_STEP); glib::Propagation::Stop } - gdk::Key::Down if controller.kind == EntryKind::Video => { + gdk::Key::Down | gdk::Key::KP_Down if controller.kind == EntryKind::Video => { controller.adjust_video_volume(-VIDEO_VOLUME_STEP); glib::Propagation::Stop } @@ -2170,7 +2403,7 @@ impl MediaViewerController { controller.open_edge(true); glib::Propagation::Stop } - gdk::Key::Up + gdk::Key::Up | gdk::Key::KP_Up if controller.kind == EntryKind::Image && matches!( controller.image_view_mode(), @@ -2180,7 +2413,7 @@ impl MediaViewerController { controller.scroll_image(false); glib::Propagation::Stop } - gdk::Key::Down + gdk::Key::Down | gdk::Key::KP_Down if controller.kind == EntryKind::Image && matches!( controller.image_view_mode(), @@ -2198,6 +2431,21 @@ impl MediaViewerController { controller.open_sibling(true); glib::Propagation::Stop } + gdk::Key::plus | gdk::Key::KP_Add if controller.kind == EntryKind::Image => { + controller.adjust_image_zoom(true); + glib::Propagation::Stop + } + gdk::Key::minus | gdk::Key::KP_Subtract if controller.kind == EntryKind::Image => { + controller.adjust_image_zoom(false); + glib::Propagation::Stop + } + _ if key + .to_unicode() + .is_some_and(|character| character.eq_ignore_ascii_case(&'l')) => + { + controller.toggle_file_list(); + glib::Propagation::Stop + } _ if key .to_unicode() .is_some_and(|character| character.eq_ignore_ascii_case(&'f')) => @@ -2295,6 +2543,7 @@ impl MediaViewerController { .connect_selected_notify(move |dropdown| { if let Some(controller) = controller.upgrade() { change(dropdown.selected()); + controller.image_zoom.set(1.0); controller.apply_image_view_mode(); } }); @@ -2307,6 +2556,35 @@ impl MediaViewerController { } }); let controller = Rc::downgrade(self); + self.image_scrolled + .connect_notify_local(Some("height"), move |_, _| { + if let Some(controller) = controller.upgrade() { + controller.resize_image_picture(); + } + }); + let image_scroll = gtk::EventControllerScroll::new( + gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, + ); + image_scroll.set_propagation_phase(gtk::PropagationPhase::Capture); + let controller = Rc::downgrade(self); + image_scroll.connect_scroll(move |scroll, _, delta_y| { + let Some(controller) = controller.upgrade() else { + return glib::Propagation::Proceed; + }; + if controller.kind == EntryKind::Image + && scroll + .current_event_state() + .contains(gdk::ModifierType::CONTROL_MASK) + && delta_y != 0.0 + { + controller.adjust_image_zoom(delta_y < 0.0); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + self.image_scrolled.add_controller(image_scroll); + let controller = Rc::downgrade(self); self.media_seek.connect_value_changed(move |scale| { let Some(controller) = controller.upgrade() else { return; @@ -2396,6 +2674,17 @@ impl MediaViewerController { } } + fn toggle_file_list(&self) { + if !self.file_list_button.is_sensitive() { + return; + } + let active = !self.file_list_button.is_active(); + self.file_list_button.set_active(active); + if self.window.is_fullscreen() { + self.file_list_panel.set_visible(active); + } + } + fn seek_video(&self, delta: i64) { let Some(stream) = self .video @@ -2461,11 +2750,49 @@ impl MediaViewerController { self.stack.queue_allocate(); } + fn adjust_image_zoom(&self, zoom_in: bool) { + if self.kind != EntryKind::Image { + return; + } + let zoom = next_image_zoom(self.image_zoom.get(), zoom_in); + if (zoom - self.image_zoom.get()).abs() < f64::EPSILON { + return; + } + self.image_zoom.set(zoom); + if (zoom - 1.0).abs() < f64::EPSILON { + self.apply_image_view_mode(); + } else { + 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( + let zoom = self.image_zoom.get(); + if (zoom - 1.0).abs() >= f64::EPSILON { + let (width, height) = zoomed_picture_request( + self.image_view_mode(), + self.image_scrolled.allocated_width().max(1), + self.image_scrolled.allocated_height().max(1), + texture_width, + texture_height, + zoom, + ); + self.image_box.set_hexpand(true); + self.image_box.set_vexpand(true); + self.image_box.set_halign(gtk::Align::Fill); + self.image_box.set_valign(gtk::Align::Fill); + self.picture.set_can_shrink(false); + self.picture.set_hexpand(false); + self.picture.set_vexpand(false); + self.picture.set_content_fit(gtk::ContentFit::Fill); + self.picture.set_halign(gtk::Align::Center); + self.picture.set_valign(gtk::Align::Center); + self.picture.set_size_request(width, height); + } else if let Some((width, height)) = single_picture_request( self.image_view_mode(), self.stack.allocated_width().max(1), texture_width, @@ -2548,6 +2875,9 @@ impl MediaViewerController { return; } self.last_pointer_position.set(Some((x, y))); + } + + fn controls_clicked(self: &Rc) { if self.window.is_fullscreen() { self.show_fullscreen_ui_temporarily(); } else { @@ -2627,6 +2957,9 @@ impl MediaViewerController { if let Some(controller) = self.fullscreen_key_controller.borrow_mut().take() { self.window.remove_controller(&controller); } + if let Some(controller) = self.fullscreen_click_controller.borrow_mut().take() { + self.window.remove_controller(&controller); + } } fn stop_playback(&self) { @@ -2674,6 +3007,13 @@ impl MediaViewerController { self.fullscreen_ui_hide_timer.replace(Some(timer)); } + fn hide_fullscreen_ui_now(self: &Rc) { + if self.window.is_fullscreen() { + self.cancel_fullscreen_ui_timer(); + self.set_fullscreen_ui_visible(false); + } + } + fn cancel_fullscreen_ui_timer(&self) { if let Some(timer) = self.fullscreen_ui_hide_timer.borrow_mut().take() { timer.remove(); @@ -2783,7 +3123,9 @@ fn file_list_typeahead_select( } if matches!(key, gdk::Key::space) || key.to_unicode().is_some_and(|character| { - character.eq_ignore_ascii_case(&'f') || matches!(character, '[' | ']') + character.eq_ignore_ascii_case(&'f') + || character.eq_ignore_ascii_case(&'l') + || matches!(character, '[' | ']' | '+' | '-') }) { return glib::Propagation::Proceed; @@ -2826,8 +3168,8 @@ fn file_list_typeahead_select( glib::Propagation::Stop } -fn fullscreen_key_reveals_ui(key: gdk::Key) -> bool { - !matches!( +fn fullscreen_direction_key(key: gdk::Key) -> bool { + matches!( key, gdk::Key::Left | gdk::Key::Right @@ -2840,6 +3182,14 @@ fn fullscreen_key_reveals_ui(key: gdk::Key) -> bool { ) } +fn shortcut_modifiers_blocked(modifiers: gdk::ModifierType) -> bool { + modifiers.intersects( + gdk::ModifierType::CONTROL_MASK + | gdk::ModifierType::ALT_MASK + | gdk::ModifierType::SUPER_MASK, + ) +} + fn find_sibling_prefix(files: &[SiblingFile], query: &str, start: usize) -> Option { if files.is_empty() { return None; @@ -3053,6 +3403,49 @@ fn single_picture_request( } } +fn next_image_zoom(current: f64, zoom_in: bool) -> f64 { + let direction = if zoom_in { 1.0 } else { -1.0 }; + ((((current + direction * IMAGE_ZOOM_STEP) * 10.0).round()) / 10.0) + .clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX) +} + +fn scaled_dimension(value: i32, zoom: f64) -> i32 { + ((value.max(1) as f64 * zoom).round() as i32).clamp(1, 30_000) +} + +fn zoomed_picture_request( + mode: ViewMode, + viewport_width: i32, + viewport_height: i32, + texture_width: i32, + texture_height: i32, + zoom: f64, +) -> (i32, i32) { + let viewport_width = viewport_width.max(1); + let viewport_height = viewport_height.max(1); + let texture_width = texture_width.max(1); + let texture_height = texture_height.max(1); + let (base_width, base_height) = match mode { + ViewMode::ActualSize => (texture_width, texture_height), + ViewMode::FitWidth | ViewMode::Continuous => ( + viewport_width, + row_height(viewport_width, texture_width as f32 / texture_height as f32), + ), + ViewMode::FitHeight | ViewMode::TwoPage => { + let scale = (viewport_width as f64 / texture_width as f64) + .min(viewport_height as f64 / texture_height as f64); + ( + (texture_width as f64 * scale).round() as i32, + (texture_height as f64 * scale).round() as i32, + ) + } + }; + ( + scaled_dimension(base_width, zoom.clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX)), + scaled_dimension(base_height, zoom.clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX)), + ) +} + fn texture_frames(page: DecodedPage) -> (Rc, usize) { let width = page.width; let height = page.height; @@ -3349,7 +3742,7 @@ mod tests { } #[test] - fn fullscreen_direction_keys_do_not_reveal_controls() { + fn fullscreen_direction_keys_hide_controls() { for key in [ gdk::Key::Left, gdk::Key::Right, @@ -3360,10 +3753,43 @@ mod tests { gdk::Key::KP_Up, gdk::Key::KP_Down, ] { - assert!(!fullscreen_key_reveals_ui(key)); + assert!(fullscreen_direction_key(key)); } - assert!(fullscreen_key_reveals_ui(gdk::Key::bracketleft)); - assert!(fullscreen_key_reveals_ui(gdk::Key::bracketright)); + assert!(!fullscreen_direction_key(gdk::Key::bracketleft)); + assert!(!fullscreen_direction_key(gdk::Key::bracketright)); + assert!(!fullscreen_direction_key(gdk::Key::f)); + } + + #[test] + fn image_zoom_changes_by_ten_percent_and_clamps() { + assert_eq!(next_image_zoom(1.0, true), 1.1); + assert_eq!(next_image_zoom(1.0, false), 0.9); + assert_eq!(next_image_zoom(IMAGE_ZOOM_MAX, true), IMAGE_ZOOM_MAX); + assert_eq!(next_image_zoom(IMAGE_ZOOM_MIN, false), IMAGE_ZOOM_MIN); + } + + #[test] + fn file_list_shortcut_rejects_command_modifiers() { + assert!(!shortcut_modifiers_blocked(gdk::ModifierType::SHIFT_MASK)); + assert!(shortcut_modifiers_blocked(gdk::ModifierType::CONTROL_MASK)); + assert!(shortcut_modifiers_blocked(gdk::ModifierType::ALT_MASK)); + assert!(shortcut_modifiers_blocked(gdk::ModifierType::SUPER_MASK)); + } + + #[test] + fn zoomed_image_request_preserves_aspect_ratio() { + assert_eq!( + zoomed_picture_request(ViewMode::ActualSize, 1000, 800, 800, 1600, 1.1), + (880, 1760) + ); + assert_eq!( + zoomed_picture_request(ViewMode::FitWidth, 1000, 800, 800, 1600, 0.9), + (900, 1800) + ); + assert_eq!( + zoomed_picture_request(ViewMode::FitHeight, 1000, 800, 800, 1600, 1.1), + (440, 880) + ); } #[test]