Release v0.2.6
This commit is contained in:
parent
b7a212c991
commit
9255700c7b
6 changed files with 473 additions and 42 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
|||
|
||||
[[package]]
|
||||
name = "comicviewer"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
</provides>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.2.6" date="2026-07-23" />
|
||||
<release version="0.2.5" date="2026-07-23" />
|
||||
<release version="0.2.4" date="2026-07-23" />
|
||||
<release version="0.2.3" date="2026-07-23" />
|
||||
|
|
|
|||
|
|
@ -89,7 +89,11 @@ Comicviewer는 XDG 표준 경로를 사용합니다.
|
|||
- `Home`과 `End`: 폴더의 첫 번째/마지막 파일 열기
|
||||
- 동영상에서 `←`와 `→`: 5초 뒤/앞으로 이동
|
||||
- 동영상에서 `↑`와 `↓`: 음량을 5%씩 조절
|
||||
- `파일 목록`: 우측 폴더 목록 표시 또는 숨기기
|
||||
- `L`: 우측 파일 목록 표시 또는 숨기기
|
||||
- 이미지에서 `+`와 `-`: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
|
||||
- 이미지에서 `Ctrl` + 마우스 휠: 현재 화면의 이미지를 임시로 10%씩 확대 또는 축소
|
||||
- 전체 화면에서 마우스 클릭: 숨겨진 컨트롤을 5초 동안 표시
|
||||
- 전체 화면에서 `←`, `→`, `↑`, `↓`: 컨트롤을 즉시 숨기고 해당 키 동작 실행
|
||||
- 파일 목록의 더블클릭 또는 선택 후 `Enter`: 해당 파일 열기
|
||||
- 파일 목록의 우클릭: 즐겨찾기 추가·제거 또는 다운로드
|
||||
- 메인 창이나 뷰어에 로컬 파일 드롭: 부모 폴더를 열고 새 뷰어에서 해당 파일 열기
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
|
||||
|
|
|
|||
496
src/viewer.rs
496
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<Option<gtk::EventControllerKey>>,
|
||||
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
|
||||
fullscreen_key_controller: RefCell<Option<gtk::EventControllerKey>>,
|
||||
fullscreen_click_controller: RefCell<Option<gtk::GestureClick>>,
|
||||
fullscreen_ui_hide_timer: RefCell<Option<glib::SourceId>>,
|
||||
fullscreen_ui_visible: Cell<bool>,
|
||||
fullscreen_notify_handler: RefCell<Option<glib::SignalHandlerId>>,
|
||||
image_zoom: Cell<f64>,
|
||||
navigation_message: gtk::Box,
|
||||
navigation_message_label: gtk::Label,
|
||||
navigation_message_generation: Rc<Cell<u64>>,
|
||||
|
|
@ -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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>, 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<Self>) {
|
||||
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<Rc<dyn Fn(u32)>>,
|
||||
image_dimensions: Cell<Option<(i32, i32)>>,
|
||||
image_zoom: Cell<f64>,
|
||||
image_animation_generation: Rc<AnimationState>,
|
||||
video: gtk::Video,
|
||||
stack: gtk::Stack,
|
||||
|
|
@ -2092,6 +2310,7 @@ struct MediaViewerController {
|
|||
key_controller: RefCell<Option<gtk::EventControllerKey>>,
|
||||
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
|
||||
fullscreen_key_controller: RefCell<Option<gtk::EventControllerKey>>,
|
||||
fullscreen_click_controller: RefCell<Option<gtk::GestureClick>>,
|
||||
fullscreen_ui_hide_timer: RefCell<Option<glib::SourceId>>,
|
||||
fullscreen_ui_visible: Cell<bool>,
|
||||
fullscreen_notify_handler: RefCell<Option<glib::SignalHandlerId>>,
|
||||
|
|
@ -2102,6 +2321,20 @@ struct MediaViewerController {
|
|||
|
||||
impl MediaViewerController {
|
||||
fn connect_signals(self: &Rc<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<usize> {
|
||||
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<CachedAnimation>, 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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue