3806 lines
134 KiB
Rust
3806 lines
134 KiB
Rust
use std::cell::{Cell, RefCell};
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::rc::{Rc, Weak};
|
|
use std::sync::mpsc::{self, TryRecvError};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use gtk::gdk;
|
|
use gtk::glib;
|
|
use gtk::prelude::*;
|
|
|
|
use crate::archive::{ArchivePage, DecodedPage, decode_image_file, decode_page, list_pages};
|
|
use crate::browser::EntryKind;
|
|
use crate::database::Database;
|
|
use crate::zip_cache::ArchiveSource;
|
|
|
|
const CACHE_LIMIT_BYTES: usize = 256 * 1024 * 1024;
|
|
const CACHE_LIMIT_PAGES: usize = 16;
|
|
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";
|
|
|
|
fn clear_viewer_controller(window: >k::ApplicationWindow) {
|
|
unsafe {
|
|
if let Some(controller) = window.steal_data::<Rc<ViewerController>>(VIEWER_CONTROLLER_KEY) {
|
|
controller.detach_window_controllers();
|
|
}
|
|
if let Some(controller) =
|
|
window.steal_data::<Rc<MediaViewerController>>(MEDIA_VIEWER_CONTROLLER_KEY)
|
|
{
|
|
controller.prepare_for_navigation();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ensure_viewer_close_handler(window: >k::ApplicationWindow) {
|
|
if window.has_css_class("viewer-close-handler") {
|
|
return;
|
|
}
|
|
window.add_css_class("viewer-close-handler");
|
|
window.connect_close_request(|window| {
|
|
clear_viewer_controller(window);
|
|
glib::Propagation::Proceed
|
|
});
|
|
}
|
|
|
|
pub(crate) fn set_viewer_loading(window: >k::ApplicationWindow, loading: bool) {
|
|
if !window.has_css_class("viewer-loading-key-handler") {
|
|
window.add_css_class("viewer-loading-key-handler");
|
|
let keys = gtk::EventControllerKey::new();
|
|
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
|
|
let weak_window = window.downgrade();
|
|
keys.connect_key_pressed(move |_, key, _, _| {
|
|
let Some(window) = weak_window.upgrade() else {
|
|
return glib::Propagation::Proceed;
|
|
};
|
|
if !window.has_css_class("viewer-loading") || !window.is_fullscreen() {
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
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
|
|
});
|
|
window.add_controller(keys);
|
|
}
|
|
if loading {
|
|
window.add_css_class("viewer-loading");
|
|
} else {
|
|
window.remove_css_class("viewer-loading");
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_viewer_header(
|
|
window: >k::ApplicationWindow,
|
|
header: >k::HeaderBar,
|
|
) -> gtk::Box {
|
|
let host = window
|
|
.titlebar()
|
|
.and_downcast::<gtk::Box>()
|
|
.unwrap_or_else(|| {
|
|
let host = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
host.set_hexpand(true);
|
|
window.set_titlebar(Some(&host));
|
|
host
|
|
});
|
|
while let Some(child) = host.first_child() {
|
|
host.remove(&child);
|
|
}
|
|
header.set_hexpand(true);
|
|
host.append(header);
|
|
host.set_visible(!window.is_fullscreen());
|
|
host
|
|
}
|
|
|
|
pub struct ViewerWindow;
|
|
|
|
pub struct SiblingNavigator {
|
|
position: Rc<dyn Fn() -> Option<(usize, usize)>>,
|
|
navigate: Rc<dyn Fn(SiblingTarget, gtk::ApplicationWindow) -> bool>,
|
|
files: Rc<dyn Fn() -> Vec<SiblingFile>>,
|
|
actions: Rc<SiblingActionResolver>,
|
|
}
|
|
|
|
type SiblingActionResolver = dyn Fn(&str) -> Option<SiblingFileActions>;
|
|
|
|
#[derive(Clone)]
|
|
pub struct SiblingFile {
|
|
pub uri: String,
|
|
pub name: String,
|
|
pub kind: EntryKind,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct SiblingFileActions {
|
|
pub favorite: FavoriteControl,
|
|
pub download: FileDownloadControl,
|
|
}
|
|
|
|
impl SiblingNavigator {
|
|
pub fn new(
|
|
position: Rc<dyn Fn() -> Option<(usize, usize)>>,
|
|
navigate: Rc<dyn Fn(SiblingTarget, gtk::ApplicationWindow) -> bool>,
|
|
files: Rc<dyn Fn() -> Vec<SiblingFile>>,
|
|
actions: Rc<SiblingActionResolver>,
|
|
) -> Self {
|
|
Self {
|
|
position,
|
|
navigate,
|
|
files,
|
|
actions,
|
|
}
|
|
}
|
|
|
|
fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool {
|
|
(self.navigate)(SiblingTarget::Relative(forward), window)
|
|
}
|
|
|
|
fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool {
|
|
(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)()
|
|
}
|
|
|
|
fn files(&self) -> Vec<SiblingFile> {
|
|
(self.files)()
|
|
}
|
|
|
|
fn open_uri(&self, uri: &str, window: gtk::ApplicationWindow) -> bool {
|
|
let files = self.files();
|
|
let Some(index) = files.iter().position(|file| file.uri == uri) else {
|
|
return false;
|
|
};
|
|
self.open_index(index, window)
|
|
}
|
|
|
|
fn actions(&self, uri: &str) -> Option<SiblingFileActions> {
|
|
(self.actions)(uri)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum SiblingTarget {
|
|
Relative(bool),
|
|
Index(usize),
|
|
First,
|
|
Last,
|
|
}
|
|
|
|
impl SiblingTarget {
|
|
pub fn resolve(self, current: usize, count: usize) -> Option<usize> {
|
|
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),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct FavoriteControl {
|
|
pub active: bool,
|
|
pub toggle: Rc<dyn Fn() -> bool>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct FileDownloadControl {
|
|
pub download: Rc<dyn Fn(gtk::Window, gtk::Label)>,
|
|
}
|
|
|
|
pub struct ViewModeControl {
|
|
pub selected: u32,
|
|
pub change: Rc<dyn Fn(u32)>,
|
|
}
|
|
|
|
pub struct FileViewerControls {
|
|
pub sibling_navigator: Option<SiblingNavigator>,
|
|
pub favorite_control: FavoriteControl,
|
|
pub download_control: FileDownloadControl,
|
|
pub file_drop_control: FileDropControl,
|
|
pub reuse_window: Option<gtk::ApplicationWindow>,
|
|
pub view_mode_control: Option<ViewModeControl>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct FileDropControl {
|
|
pub handle: Rc<dyn Fn(Vec<gtk::gio::File>, gtk::Window)>,
|
|
}
|
|
|
|
pub(crate) fn install_file_drop_target(window: >k::ApplicationWindow, control: FileDropControl) {
|
|
if window.has_css_class("comicviewer-file-drop-target") {
|
|
return;
|
|
}
|
|
window.add_css_class("comicviewer-file-drop-target");
|
|
let target = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::COPY);
|
|
let weak_window = window.downgrade();
|
|
target.connect_drop(move |_, value, _, _| {
|
|
let Ok(file_list) = value.get::<gdk::FileList>() else {
|
|
return false;
|
|
};
|
|
let Some(window) = weak_window.upgrade() else {
|
|
return false;
|
|
};
|
|
(control.handle)(file_list.files(), window.upcast());
|
|
true
|
|
});
|
|
window.add_controller(target);
|
|
}
|
|
|
|
impl ViewerWindow {
|
|
pub fn open(
|
|
application: >k::Application,
|
|
source: ArchiveSource,
|
|
database: Rc<Database>,
|
|
fast_image_loading: bool,
|
|
case_insensitive_sort: bool,
|
|
view_mode_control: ViewModeControl,
|
|
controls: FileViewerControls,
|
|
) {
|
|
let FileViewerControls {
|
|
sibling_navigator,
|
|
favorite_control,
|
|
download_control,
|
|
file_drop_control,
|
|
reuse_window,
|
|
view_mode_control: _,
|
|
} = controls;
|
|
let title = source.display_name.clone();
|
|
if let Some(window) = reuse_window.as_ref() {
|
|
clear_viewer_controller(window);
|
|
}
|
|
let window = reuse_window.unwrap_or_else(|| {
|
|
gtk::ApplicationWindow::builder()
|
|
.application(application)
|
|
.title(&title)
|
|
.default_width(1100)
|
|
.default_height(850)
|
|
.build()
|
|
});
|
|
install_file_drop_target(&window, file_drop_control);
|
|
ensure_viewer_close_handler(&window);
|
|
set_viewer_loading(&window, false);
|
|
window.set_title(Some(&title));
|
|
let header = gtk::HeaderBar::new();
|
|
header.set_title_widget(Some(>k::Label::new(Some(&title))));
|
|
let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
|
|
let download_button = gtk::Button::with_label("파일 다운로드");
|
|
let file_list_button = gtk::ToggleButton::with_label("파일 목록");
|
|
file_list_button.set_sensitive(sibling_navigator.is_some());
|
|
file_list_button.set_active(window.has_css_class("viewer-file-list-visible"));
|
|
header.pack_end(&download_button);
|
|
header.pack_end(&favorite_button);
|
|
header.pack_end(&file_list_button);
|
|
let titlebar_host = set_viewer_header(&window, &header);
|
|
|
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
|
toolbar.set_margin_top(8);
|
|
toolbar.set_margin_bottom(8);
|
|
toolbar.set_margin_start(8);
|
|
toolbar.set_margin_end(8);
|
|
let previous_button = gtk::Button::builder()
|
|
.icon_name("go-previous-symbolic")
|
|
.tooltip_text("이전")
|
|
.build();
|
|
let next_button = gtk::Button::builder()
|
|
.icon_name("go-next-symbolic")
|
|
.tooltip_text("다음")
|
|
.build();
|
|
let shift_back_button = gtk::Button::with_label("한 장 이전");
|
|
let shift_forward_button = gtk::Button::with_label("한 장 다음");
|
|
let page_label = gtk::Label::new(Some("- / -"));
|
|
page_label.set_hexpand(true);
|
|
let direction_dropdown = gtk::DropDown::from_strings(&["좌→우", "우→좌"]);
|
|
direction_dropdown.set_selected(1);
|
|
let mode_dropdown = gtk::DropDown::from_strings(&[
|
|
"100%",
|
|
"폭 맞춤",
|
|
"높이 맞춤",
|
|
"2페이지",
|
|
"이어서 보기",
|
|
]);
|
|
mode_dropdown.set_selected(view_mode_control.selected);
|
|
toolbar.append(&previous_button);
|
|
toolbar.append(&shift_back_button);
|
|
toolbar.append(&shift_forward_button);
|
|
toolbar.append(&next_button);
|
|
toolbar.append(&page_label);
|
|
toolbar.append(&direction_dropdown);
|
|
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_box)
|
|
.build();
|
|
let left_picture = page_picture();
|
|
let right_picture = page_picture();
|
|
let spread_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
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);
|
|
let continuous_scrolled = gtk::ScrolledWindow::builder()
|
|
.hexpand(true)
|
|
.vexpand(true)
|
|
.child(&continuous_box)
|
|
.build();
|
|
let stack = gtk::Stack::new();
|
|
stack.set_hexpand(true);
|
|
stack.set_vexpand(true);
|
|
stack.add_named(&single_scrolled, Some("single"));
|
|
stack.add_named(&spread_scrolled, Some("spread"));
|
|
stack.add_named(&continuous_scrolled, Some("continuous"));
|
|
|
|
let spinner = gtk::Spinner::new();
|
|
spinner.set_halign(gtk::Align::Center);
|
|
spinner.set_valign(gtk::Align::Center);
|
|
let overlay = gtk::Overlay::new();
|
|
overlay.set_child(Some(&stack));
|
|
overlay.add_overlay(&spinner);
|
|
let status_label = gtk::Label::builder()
|
|
.label("ZIP 파일을 읽는 중...")
|
|
.xalign(0.0)
|
|
.margin_top(6)
|
|
.margin_bottom(6)
|
|
.margin_start(8)
|
|
.margin_end(8)
|
|
.build();
|
|
let page_seek = navigation_scale();
|
|
let page_seek_row = navigation_scale_row(&page_seek);
|
|
page_seek.set_sensitive(false);
|
|
root.append(&toolbar);
|
|
root.append(&overlay);
|
|
root.append(&page_seek_row);
|
|
root.append(&status_label);
|
|
let window_overlay = gtk::Overlay::new();
|
|
window_overlay.set_child(Some(&root));
|
|
let (navigation_message, navigation_message_label) = navigation_message();
|
|
window_overlay.add_overlay(&navigation_message);
|
|
window_overlay.set_hexpand(true);
|
|
window_overlay.set_vexpand(true);
|
|
let file_list = viewer_file_list();
|
|
file_list.panel.set_visible(file_list_button.is_active());
|
|
let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
viewer_layout.append(&window_overlay);
|
|
viewer_layout.append(&file_list.panel);
|
|
window.set_child(Some(&viewer_layout));
|
|
|
|
let controller = Rc::new(ViewerController {
|
|
window,
|
|
titlebar_host,
|
|
source,
|
|
database,
|
|
stack,
|
|
single_scrolled,
|
|
single_box,
|
|
single_picture,
|
|
left_picture,
|
|
right_picture,
|
|
spread_box,
|
|
spread_scrolled,
|
|
continuous_box,
|
|
continuous_scrolled,
|
|
spinner,
|
|
page_label,
|
|
page_seek,
|
|
page_seek_row,
|
|
toolbar,
|
|
status_label,
|
|
previous_button,
|
|
next_button,
|
|
shift_back_button,
|
|
shift_forward_button,
|
|
mode_dropdown,
|
|
direction_dropdown,
|
|
fast_image_loading,
|
|
case_insensitive_sort,
|
|
sibling_navigator,
|
|
file_list_button,
|
|
file_list_panel: file_list.panel,
|
|
file_list: file_list.list,
|
|
file_list_files: RefCell::new(Vec::new()),
|
|
favorite_button,
|
|
favorite_toggle: favorite_control.toggle,
|
|
download_button,
|
|
file_download: download_control.download,
|
|
view_mode_change: view_mode_control.change,
|
|
seek_hide_timer: RefCell::new(None),
|
|
last_pointer_position: Cell::new(None),
|
|
key_controller: RefCell::new(None),
|
|
motion_controller: RefCell::new(None),
|
|
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)),
|
|
continuous_programmatic_scroll: Cell::new(false),
|
|
view_layout_generation: Cell::new(0),
|
|
single_animation_generation: Rc::new(AnimationState::default()),
|
|
left_animation_generation: Rc::new(AnimationState::default()),
|
|
right_animation_generation: Rc::new(AnimationState::default()),
|
|
state: RefCell::new(ViewerState {
|
|
pages: Vec::new(),
|
|
anchor: 0,
|
|
cache: HashMap::new(),
|
|
requested: HashSet::new(),
|
|
pending: VecDeque::new(),
|
|
in_flight: 0,
|
|
failed: HashMap::new(),
|
|
access_clock: 0,
|
|
continuous_rows: Vec::new(),
|
|
continuous_bound: HashSet::new(),
|
|
}),
|
|
});
|
|
controller.connect_signals();
|
|
controller.fullscreen_changed();
|
|
if controller.file_list_button.is_active() {
|
|
controller.refresh_file_list();
|
|
}
|
|
unsafe {
|
|
controller
|
|
.window
|
|
.set_data(VIEWER_CONTROLLER_KEY, Rc::clone(&controller));
|
|
}
|
|
controller.apply_view_mode();
|
|
controller.window.present();
|
|
controller.load_archive();
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum ViewMode {
|
|
ActualSize,
|
|
FitWidth,
|
|
FitHeight,
|
|
TwoPage,
|
|
Continuous,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct CachedFrame {
|
|
texture: gdk::MemoryTexture,
|
|
delay: Duration,
|
|
}
|
|
|
|
struct CachedAnimation {
|
|
frames: Vec<CachedFrame>,
|
|
loop_count: Option<u32>,
|
|
}
|
|
|
|
impl std::ops::Deref for CachedAnimation {
|
|
type Target = [CachedFrame];
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.frames
|
|
}
|
|
}
|
|
|
|
struct CachedPage {
|
|
frames: Rc<CachedAnimation>,
|
|
cost: usize,
|
|
last_used: u64,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct AnimationState {
|
|
generation: Cell<u64>,
|
|
timer: RefCell<Option<glib::SourceId>>,
|
|
frames: RefCell<Option<Weak<CachedAnimation>>>,
|
|
}
|
|
|
|
impl AnimationState {
|
|
fn cancel(&self) -> u64 {
|
|
if let Some(timer) = self.timer.borrow_mut().take() {
|
|
timer.remove();
|
|
}
|
|
self.frames.borrow_mut().take();
|
|
let generation = self.generation.get().wrapping_add(1);
|
|
self.generation.set(generation);
|
|
generation
|
|
}
|
|
}
|
|
|
|
struct ContinuousRow {
|
|
frame: gtk::AspectFrame,
|
|
picture: gtk::Picture,
|
|
ratio: f32,
|
|
animation_generation: Rc<AnimationState>,
|
|
}
|
|
|
|
struct ViewerState {
|
|
pages: Vec<ArchivePage>,
|
|
anchor: usize,
|
|
cache: HashMap<usize, CachedPage>,
|
|
requested: HashSet<usize>,
|
|
pending: VecDeque<usize>,
|
|
in_flight: usize,
|
|
failed: HashMap<usize, String>,
|
|
access_clock: u64,
|
|
continuous_rows: Vec<ContinuousRow>,
|
|
continuous_bound: HashSet<usize>,
|
|
}
|
|
|
|
struct ViewerController {
|
|
window: gtk::ApplicationWindow,
|
|
titlebar_host: gtk::Box,
|
|
source: ArchiveSource,
|
|
database: Rc<Database>,
|
|
stack: gtk::Stack,
|
|
single_scrolled: gtk::ScrolledWindow,
|
|
single_box: gtk::Box,
|
|
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,
|
|
page_label: gtk::Label,
|
|
page_seek: gtk::Scale,
|
|
page_seek_row: gtk::Box,
|
|
toolbar: gtk::Box,
|
|
status_label: gtk::Label,
|
|
previous_button: gtk::Button,
|
|
next_button: gtk::Button,
|
|
shift_back_button: gtk::Button,
|
|
shift_forward_button: gtk::Button,
|
|
mode_dropdown: gtk::DropDown,
|
|
direction_dropdown: gtk::DropDown,
|
|
fast_image_loading: bool,
|
|
case_insensitive_sort: bool,
|
|
sibling_navigator: Option<SiblingNavigator>,
|
|
file_list_button: gtk::ToggleButton,
|
|
file_list_panel: gtk::ScrolledWindow,
|
|
file_list: gtk::ListBox,
|
|
file_list_files: RefCell<Vec<SiblingFile>>,
|
|
favorite_button: gtk::Button,
|
|
favorite_toggle: Rc<dyn Fn() -> bool>,
|
|
download_button: gtk::Button,
|
|
file_download: Rc<dyn Fn(gtk::Window, gtk::Label)>,
|
|
view_mode_change: Rc<dyn Fn(u32)>,
|
|
seek_hide_timer: RefCell<Option<glib::SourceId>>,
|
|
last_pointer_position: Cell<Option<(f64, f64)>>,
|
|
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>>,
|
|
continuous_programmatic_scroll: Cell<bool>,
|
|
view_layout_generation: Cell<u64>,
|
|
single_animation_generation: Rc<AnimationState>,
|
|
left_animation_generation: Rc<AnimationState>,
|
|
right_animation_generation: Rc<AnimationState>,
|
|
state: RefCell<ViewerState>,
|
|
}
|
|
|
|
impl ViewerController {
|
|
fn connect_signals(self: &Rc<Self>) {
|
|
let controller = Rc::downgrade(self);
|
|
self.previous_button.connect_clicked(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.navigate(false);
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.next_button.connect_clicked(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.navigate(true);
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.shift_back_button.connect_clicked(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.shift_pairing(-1);
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.shift_forward_button.connect_clicked(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.shift_pairing(1);
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
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();
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.direction_dropdown.connect_selected_notify(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.render();
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.favorite_button.connect_clicked(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
let active = (controller.favorite_toggle)();
|
|
controller.favorite_button.set_label(favorite_label(active));
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.download_button.connect_clicked(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
(controller.file_download)(
|
|
controller.window.clone().upcast(),
|
|
controller.status_label.clone(),
|
|
);
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.file_list_button.connect_toggled(move |button| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.file_list_panel.set_visible(
|
|
button.is_active()
|
|
&& (!controller.window.is_fullscreen()
|
|
|| controller.fullscreen_ui_visible.get()),
|
|
);
|
|
if button.is_active() {
|
|
controller.window.add_css_class("viewer-file-list-visible");
|
|
} else {
|
|
controller
|
|
.window
|
|
.remove_css_class("viewer-file-list-visible");
|
|
}
|
|
if button.is_active() {
|
|
controller.refresh_file_list();
|
|
}
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.file_list.connect_row_activated(move |_, row| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.open_file_list_row(row.index());
|
|
}
|
|
});
|
|
let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default()));
|
|
let file_list_keys = gtk::EventControllerKey::new();
|
|
let controller = Rc::downgrade(self);
|
|
file_list_keys.connect_key_pressed(move |_, key, _, modifiers| {
|
|
let Some(controller) = controller.upgrade() else {
|
|
return glib::Propagation::Proceed;
|
|
};
|
|
file_list_typeahead_select(
|
|
&controller.file_list,
|
|
&controller.file_list_files.borrow(),
|
|
&mut file_list_typeahead.borrow_mut(),
|
|
key,
|
|
modifiers,
|
|
)
|
|
});
|
|
self.file_list.add_controller(file_list_keys);
|
|
let file_list_click = gtk::GestureClick::builder().button(3).build();
|
|
let controller = Rc::downgrade(self);
|
|
file_list_click.connect_pressed(move |_, _, x, y| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.show_file_list_context_menu(x, y);
|
|
}
|
|
});
|
|
self.file_list.add_controller(file_list_click);
|
|
let controller = Rc::downgrade(self);
|
|
self.page_seek.connect_value_changed(move |scale| {
|
|
let Some(controller) = controller.upgrade() else {
|
|
return;
|
|
};
|
|
let page_count = controller.state.borrow().pages.len();
|
|
if let Some(index) = page_seek_index(scale.value(), page_count) {
|
|
controller.seek_page(index);
|
|
}
|
|
});
|
|
|
|
let adjustment = self.continuous_scrolled.vadjustment();
|
|
let controller = Rc::downgrade(self);
|
|
adjustment.connect_value_changed(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.continuous_scrolled();
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
adjustment.connect_changed(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.continuous_scrolled();
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.continuous_scrolled
|
|
.connect_notify_local(Some("width"), move |_, _| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.resize_continuous_rows();
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.single_scrolled
|
|
.connect_notify_local(Some("width"), move |_, _| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.resize_single_picture();
|
|
}
|
|
});
|
|
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 |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
|
|
) || delta_y == 0.0
|
|
{
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
controller.navigate(delta_y > 0.0);
|
|
glib::Propagation::Stop
|
|
});
|
|
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);
|
|
motion_controller.connect_motion(move |_, x, y| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.pointer_moved(x, y);
|
|
}
|
|
});
|
|
self.window.add_controller(motion_controller.clone());
|
|
self.motion_controller.replace(Some(motion_controller));
|
|
|
|
let fullscreen_keys = gtk::EventControllerKey::new();
|
|
fullscreen_keys.set_propagation_phase(gtk::PropagationPhase::Capture);
|
|
let controller = Rc::downgrade(self);
|
|
fullscreen_keys.connect_key_pressed(move |_, key, _, _| {
|
|
if let Some(controller) = controller.upgrade()
|
|
&& controller.window.is_fullscreen()
|
|
&& fullscreen_direction_key(key)
|
|
{
|
|
controller.hide_fullscreen_ui_now();
|
|
}
|
|
glib::Propagation::Proceed
|
|
});
|
|
self.window.add_controller(fullscreen_keys.clone());
|
|
self.fullscreen_key_controller
|
|
.replace(Some(fullscreen_keys));
|
|
|
|
let key_controller = gtk::EventControllerKey::new();
|
|
let controller = Rc::downgrade(self);
|
|
key_controller.connect_key_pressed(move |_, key, _, modifiers| {
|
|
let Some(controller) = controller.upgrade() else {
|
|
return glib::Propagation::Proceed;
|
|
};
|
|
if !controller.window.is_fullscreen() {
|
|
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 | gdk::Key::KP_Up
|
|
if matches!(
|
|
controller.view_mode(),
|
|
ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous
|
|
) =>
|
|
{
|
|
controller.scroll_vertically(false);
|
|
glib::Propagation::Stop
|
|
}
|
|
gdk::Key::Down | gdk::Key::KP_Down
|
|
if matches!(
|
|
controller.view_mode(),
|
|
ViewMode::ActualSize | ViewMode::FitWidth | ViewMode::Continuous
|
|
) =>
|
|
{
|
|
controller.scroll_vertically(true);
|
|
glib::Propagation::Stop
|
|
}
|
|
gdk::Key::Left | gdk::Key::KP_Left | gdk::Key::Page_Up => {
|
|
controller.navigate(false);
|
|
glib::Propagation::Stop
|
|
}
|
|
gdk::Key::Right | gdk::Key::KP_Right | gdk::Key::Page_Down | gdk::Key::space => {
|
|
controller.navigate(true);
|
|
glib::Propagation::Stop
|
|
}
|
|
_ if key.to_unicode() == Some('[') => {
|
|
controller.open_sibling(false);
|
|
glib::Propagation::Stop
|
|
}
|
|
_ if key.to_unicode() == Some(']') => {
|
|
controller.open_sibling(true);
|
|
glib::Propagation::Stop
|
|
}
|
|
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')) =>
|
|
{
|
|
controller.toggle_fullscreen();
|
|
glib::Propagation::Stop
|
|
}
|
|
gdk::Key::Escape if controller.window.is_fullscreen() => {
|
|
controller.window.unfullscreen();
|
|
glib::Propagation::Stop
|
|
}
|
|
_ => glib::Propagation::Proceed,
|
|
}
|
|
});
|
|
self.window.add_controller(key_controller.clone());
|
|
self.key_controller.replace(Some(key_controller));
|
|
let controller = Rc::downgrade(self);
|
|
let handler = self
|
|
.window
|
|
.connect_notify_local(Some("fullscreened"), move |_, _| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.fullscreen_changed();
|
|
}
|
|
});
|
|
self.fullscreen_notify_handler.replace(Some(handler));
|
|
}
|
|
|
|
fn open_sibling(&self, forward: bool) {
|
|
if let Some(navigate) = &self.sibling_navigator
|
|
&& navigate.open_relative(forward, self.window.clone())
|
|
{
|
|
self.detach_window_controllers();
|
|
} else {
|
|
self.show_navigation_error(forward);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
let files = self
|
|
.sibling_navigator
|
|
.as_ref()
|
|
.map(SiblingNavigator::files)
|
|
.unwrap_or_default();
|
|
let current = files
|
|
.iter()
|
|
.position(|file| file.uri == self.source.source_uri);
|
|
for file in &files {
|
|
self.file_list.append(&viewer_file_row(file));
|
|
}
|
|
*self.file_list_files.borrow_mut() = files;
|
|
if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) {
|
|
self.file_list.select_row(Some(&row));
|
|
}
|
|
}
|
|
|
|
fn open_file_list_row(&self, index: i32) {
|
|
let Some(uri) = self
|
|
.file_list_files
|
|
.borrow()
|
|
.get(index as usize)
|
|
.map(|file| file.uri.clone())
|
|
else {
|
|
return;
|
|
};
|
|
if uri == self.source.source_uri {
|
|
return;
|
|
}
|
|
if let Some(navigator) = &self.sibling_navigator
|
|
&& navigator.open_uri(&uri, self.window.clone())
|
|
{
|
|
self.detach_window_controllers();
|
|
}
|
|
}
|
|
|
|
fn show_file_list_context_menu(&self, x: f64, y: f64) {
|
|
let Some((row, file)) = selected_viewer_file(&self.file_list, &self.file_list_files, x, y)
|
|
else {
|
|
return;
|
|
};
|
|
self.file_list.select_row(Some(&row));
|
|
let Some(actions) = self
|
|
.sibling_navigator
|
|
.as_ref()
|
|
.and_then(|navigator| navigator.actions(&file.uri))
|
|
else {
|
|
return;
|
|
};
|
|
show_viewer_file_context_menu(
|
|
&row,
|
|
&file,
|
|
actions,
|
|
&self.source.source_uri,
|
|
&self.favorite_button,
|
|
&self.window,
|
|
&self.status_label,
|
|
);
|
|
}
|
|
|
|
fn show_navigation_error(&self, forward: bool) {
|
|
self.navigation_message_label
|
|
.set_text(navigation_error_message(forward));
|
|
self.navigation_message.set_visible(true);
|
|
let message = self.navigation_message.clone();
|
|
let generation = self.navigation_message_generation.get().wrapping_add(1);
|
|
self.navigation_message_generation.set(generation);
|
|
let current_generation = Rc::clone(&self.navigation_message_generation);
|
|
glib::timeout_add_local_once(Duration::from_secs(2), move || {
|
|
if current_generation.get() == generation {
|
|
message.set_visible(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn detach_window_controllers(&self) {
|
|
self.stop_animations();
|
|
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
|
|
timer.remove();
|
|
}
|
|
self.cancel_fullscreen_ui_timer();
|
|
if let Some(handler) = self.fullscreen_notify_handler.borrow_mut().take() {
|
|
self.window.disconnect(handler);
|
|
}
|
|
if let Some(controller) = self.key_controller.borrow_mut().take() {
|
|
self.window.remove_controller(&controller);
|
|
}
|
|
if let Some(controller) = self.motion_controller.borrow_mut().take() {
|
|
self.window.remove_controller(&controller);
|
|
}
|
|
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) {
|
|
self.single_animation_generation.cancel();
|
|
self.left_animation_generation.cancel();
|
|
self.right_animation_generation.cancel();
|
|
for row in &self.state.borrow().continuous_rows {
|
|
row.animation_generation.cancel();
|
|
}
|
|
}
|
|
|
|
fn show_seek_temporarily(self: &Rc<Self>) {
|
|
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
|
|
timer.remove();
|
|
}
|
|
self.set_seek_visible(true);
|
|
let controller = Rc::downgrade(self);
|
|
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.seek_hide_timer.borrow_mut().take();
|
|
controller.set_seek_visible(false);
|
|
}
|
|
});
|
|
self.seek_hide_timer.replace(Some(timer));
|
|
}
|
|
|
|
fn pointer_moved(self: &Rc<Self>, x: f64, y: f64) {
|
|
if self.last_pointer_position.get() == Some((x, y)) {
|
|
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 {
|
|
self.show_seek_temporarily();
|
|
}
|
|
}
|
|
|
|
fn hide_seek(self: &Rc<Self>) {
|
|
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
|
|
timer.remove();
|
|
}
|
|
self.set_seek_visible(false);
|
|
}
|
|
|
|
fn set_seek_visible(self: &Rc<Self>, visible: bool) {
|
|
if self.page_seek_row.is_visible() == visible {
|
|
return;
|
|
}
|
|
self.page_seek_row.set_visible(visible);
|
|
self.window.queue_allocate();
|
|
let controller = Rc::downgrade(self);
|
|
glib::idle_add_local_once(move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.stack.queue_allocate();
|
|
controller.resize_continuous_rows();
|
|
controller.render();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn toggle_fullscreen(&self) {
|
|
if self.window.is_fullscreen() {
|
|
self.window.unfullscreen();
|
|
} else {
|
|
self.window.fullscreen();
|
|
}
|
|
}
|
|
|
|
fn fullscreen_changed(self: &Rc<Self>) {
|
|
self.cancel_fullscreen_ui_timer();
|
|
self.hide_seek();
|
|
if self.window.is_fullscreen() {
|
|
self.set_fullscreen_ui_visible(false);
|
|
} else {
|
|
self.fullscreen_ui_visible.set(true);
|
|
self.titlebar_host.set_visible(true);
|
|
self.toolbar.set_visible(true);
|
|
self.status_label.set_visible(true);
|
|
self.file_list_panel
|
|
.set_visible(self.file_list_button.is_active());
|
|
}
|
|
}
|
|
|
|
fn show_fullscreen_ui_temporarily(self: &Rc<Self>) {
|
|
if !self.window.is_fullscreen() {
|
|
return;
|
|
}
|
|
self.cancel_fullscreen_ui_timer();
|
|
self.set_fullscreen_ui_visible(true);
|
|
let controller = Rc::downgrade(self);
|
|
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.fullscreen_ui_hide_timer.borrow_mut().take();
|
|
controller.set_fullscreen_ui_visible(false);
|
|
}
|
|
});
|
|
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();
|
|
}
|
|
}
|
|
|
|
fn set_fullscreen_ui_visible(self: &Rc<Self>, visible: bool) {
|
|
self.fullscreen_ui_visible.set(visible);
|
|
self.titlebar_host.set_visible(visible);
|
|
self.toolbar.set_visible(visible);
|
|
self.page_seek_row.set_visible(visible);
|
|
self.status_label.set_visible(visible);
|
|
self.file_list_panel
|
|
.set_visible(visible && self.file_list_button.is_active());
|
|
self.window.queue_allocate();
|
|
let controller = Rc::downgrade(self);
|
|
glib::idle_add_local_once(move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.stack.queue_allocate();
|
|
controller.resize_continuous_rows();
|
|
controller.render();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn load_archive(self: &Rc<Self>) {
|
|
self.spinner.start();
|
|
let path = self.source.local_path.clone();
|
|
let case_insensitive_sort = self.case_insensitive_sort;
|
|
let (sender, receiver) = mpsc::channel();
|
|
thread::spawn(move || {
|
|
let _ = sender.send(
|
|
list_pages(&path, case_insensitive_sort).map_err(|error| format!("{error:#}")),
|
|
);
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
glib::timeout_add_local(Duration::from_millis(20), move || {
|
|
match receiver.try_recv() {
|
|
Ok(result) => {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.finish_archive_load(result);
|
|
}
|
|
glib::ControlFlow::Break
|
|
}
|
|
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
|
|
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
|
|
}
|
|
});
|
|
}
|
|
|
|
fn finish_archive_load(self: &Rc<Self>, result: Result<Vec<ArchivePage>, String>) {
|
|
match result {
|
|
Ok(pages) if pages.is_empty() => {
|
|
self.spinner.stop();
|
|
self.status_label
|
|
.set_text("지원하는 이미지가 ZIP 파일에 없습니다.");
|
|
}
|
|
Ok(pages) => {
|
|
let page_count = pages.len();
|
|
let saved = self
|
|
.database
|
|
.load_progress(&self.source.source_uri)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or(0)
|
|
.min(pages.len() - 1);
|
|
let mut state = self.state.borrow_mut();
|
|
state.pages = pages;
|
|
state.anchor = saved;
|
|
drop(state);
|
|
self.page_seek
|
|
.set_range(0.0, navigation_scale_upper(page_count));
|
|
self.page_seek.set_sensitive(page_count > 1);
|
|
self.build_continuous_rows();
|
|
self.render();
|
|
}
|
|
Err(error) => {
|
|
self.spinner.stop();
|
|
self.status_label.set_text(&error);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_continuous_rows(self: &Rc<Self>) {
|
|
while let Some(child) = self.continuous_box.first_child() {
|
|
self.continuous_box.remove(&child);
|
|
}
|
|
let count = self.state.borrow().pages.len();
|
|
let mut rows = Vec::with_capacity(count);
|
|
let default_ratio = 2.0 / 3.0;
|
|
let width = self.continuous_content_width();
|
|
for _ in 0..count {
|
|
let picture = gtk::Picture::new();
|
|
picture.set_content_fit(gtk::ContentFit::Contain);
|
|
picture.set_hexpand(true);
|
|
picture.set_vexpand(false);
|
|
picture.set_can_shrink(true);
|
|
picture.set_size_request(1, 1);
|
|
let frame = gtk::AspectFrame::new(0.5, 0.5, default_ratio, false);
|
|
frame.set_hexpand(true);
|
|
frame.set_size_request(-1, row_height(width, default_ratio));
|
|
frame.set_child(Some(&picture));
|
|
self.continuous_box.append(&frame);
|
|
rows.push(ContinuousRow {
|
|
frame,
|
|
picture,
|
|
ratio: default_ratio,
|
|
animation_generation: Rc::new(AnimationState::default()),
|
|
});
|
|
}
|
|
let mut state = self.state.borrow_mut();
|
|
state.continuous_rows = rows;
|
|
state.continuous_bound.clear();
|
|
}
|
|
|
|
fn view_mode(&self) -> ViewMode {
|
|
match self.mode_dropdown.selected() {
|
|
0 => ViewMode::ActualSize,
|
|
2 => ViewMode::FitHeight,
|
|
3 => ViewMode::TwoPage,
|
|
4 => ViewMode::Continuous,
|
|
_ => ViewMode::FitWidth,
|
|
}
|
|
}
|
|
|
|
fn apply_view_mode(self: &Rc<Self>) {
|
|
let mode = self.view_mode();
|
|
self.shift_back_button
|
|
.set_visible(mode == ViewMode::TwoPage);
|
|
self.shift_forward_button
|
|
.set_visible(mode == ViewMode::TwoPage);
|
|
self.direction_dropdown
|
|
.set_visible(mode == ViewMode::TwoPage);
|
|
match mode {
|
|
ViewMode::TwoPage => self.stack.set_visible_child_name("spread"),
|
|
ViewMode::Continuous => self.stack.set_visible_child_name("continuous"),
|
|
_ => self.stack.set_visible_child_name("single"),
|
|
}
|
|
match mode {
|
|
ViewMode::Continuous => {
|
|
set_picture_animation(
|
|
&self.single_picture,
|
|
None,
|
|
&self.single_animation_generation,
|
|
);
|
|
set_picture_animation(&self.left_picture, None, &self.left_animation_generation);
|
|
set_picture_animation(&self.right_picture, None, &self.right_animation_generation);
|
|
}
|
|
ViewMode::TwoPage => {
|
|
set_picture_animation(
|
|
&self.single_picture,
|
|
None,
|
|
&self.single_animation_generation,
|
|
);
|
|
self.clear_continuous_paintables();
|
|
}
|
|
_ => {
|
|
set_picture_animation(&self.left_picture, None, &self.left_animation_generation);
|
|
set_picture_animation(&self.right_picture, None, &self.right_animation_generation);
|
|
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 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);
|
|
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<Self>, forward: bool) {
|
|
let step = if self.view_mode() == ViewMode::TwoPage {
|
|
2
|
|
} else {
|
|
1
|
|
};
|
|
let mut state = self.state.borrow_mut();
|
|
if state.pages.is_empty() {
|
|
return;
|
|
}
|
|
let next = if forward {
|
|
state.anchor.saturating_add(step).min(state.pages.len() - 1)
|
|
} else {
|
|
state.anchor.saturating_sub(step)
|
|
};
|
|
if next == state.anchor {
|
|
return;
|
|
}
|
|
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<Self>, 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<Self>, offset: isize) {
|
|
let mut state = self.state.borrow_mut();
|
|
if state.pages.is_empty() {
|
|
return;
|
|
}
|
|
state.anchor = state
|
|
.anchor
|
|
.saturating_add_signed(offset)
|
|
.min(state.pages.len() - 1);
|
|
drop(state);
|
|
self.render();
|
|
}
|
|
|
|
fn seek_page(self: &Rc<Self>, index: usize) {
|
|
let mut state = self.state.borrow_mut();
|
|
if state.pages.is_empty() {
|
|
return;
|
|
}
|
|
let index = index.min(state.pages.len() - 1);
|
|
if index == state.anchor {
|
|
return;
|
|
}
|
|
state.anchor = index;
|
|
drop(state);
|
|
self.reset_single_scroll();
|
|
self.render();
|
|
if self.view_mode() == ViewMode::Continuous {
|
|
self.schedule_scroll_to_anchor();
|
|
}
|
|
}
|
|
|
|
fn render(self: &Rc<Self>) {
|
|
let visible = self.visible_indices();
|
|
if visible.is_empty() {
|
|
return;
|
|
}
|
|
match self.view_mode() {
|
|
ViewMode::TwoPage => self.render_spread(&visible),
|
|
ViewMode::Continuous => self.render_continuous(&visible),
|
|
_ => self.render_single(visible[0]),
|
|
}
|
|
self.update_navigation();
|
|
self.request_pages(self.prefetch_indices(&visible));
|
|
let state = self.state.borrow();
|
|
let _ =
|
|
self.database
|
|
.save_progress(&self.source.source_uri, state.anchor, state.pages.len());
|
|
}
|
|
|
|
fn visible_indices(&self) -> Vec<usize> {
|
|
let state = self.state.borrow();
|
|
if state.pages.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
if self.view_mode() == ViewMode::TwoPage {
|
|
spread_indices(state.anchor, state.pages.len()).to_vec()
|
|
} else if self.view_mode() == ViewMode::Continuous {
|
|
drop(state);
|
|
self.continuous_visible_indices()
|
|
} else {
|
|
vec![state.anchor]
|
|
}
|
|
}
|
|
|
|
fn prefetch_indices(&self, visible: &[usize]) -> Vec<usize> {
|
|
let count = self.state.borrow().pages.len();
|
|
prefetch_window(visible, count, self.fast_image_loading)
|
|
}
|
|
|
|
fn request_pages(self: &Rc<Self>, indices: Vec<usize>) {
|
|
let mut state = self.state.borrow_mut();
|
|
let mut prioritized = VecDeque::new();
|
|
for index in indices {
|
|
if index >= state.pages.len()
|
|
|| state.cache.contains_key(&index)
|
|
|| state.failed.contains_key(&index)
|
|
{
|
|
continue;
|
|
}
|
|
if state.requested.contains(&index) {
|
|
if state.pending.contains(&index) {
|
|
prioritized.push_back(index);
|
|
}
|
|
} else {
|
|
state.requested.insert(index);
|
|
prioritized.push_back(index);
|
|
}
|
|
}
|
|
let prioritized_set: HashSet<_> = prioritized.iter().copied().collect();
|
|
state
|
|
.pending
|
|
.retain(|index| !prioritized_set.contains(index));
|
|
prioritized.append(&mut state.pending);
|
|
state.pending = prioritized;
|
|
drop(state);
|
|
self.pump_requests();
|
|
self.update_spinner();
|
|
}
|
|
|
|
fn pump_requests(self: &Rc<Self>) {
|
|
let concurrency = if self.fast_image_loading { 4 } else { 2 };
|
|
while let Some((index, page)) = {
|
|
let mut state = self.state.borrow_mut();
|
|
if state.in_flight >= concurrency {
|
|
None
|
|
} else {
|
|
state.pending.pop_front().map(|index| {
|
|
state.in_flight += 1;
|
|
(index, state.pages[index].clone())
|
|
})
|
|
}
|
|
} {
|
|
let path = self.source.local_path.clone();
|
|
let (sender, receiver) = mpsc::channel();
|
|
thread::spawn(move || {
|
|
let result = decode_page(&path, &page).map_err(|error| format!("{error:#}"));
|
|
let _ = sender.send(result);
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
glib::timeout_add_local(Duration::from_millis(20), move || {
|
|
match receiver.try_recv() {
|
|
Ok(result) => {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.finish_page_load(index, result);
|
|
}
|
|
glib::ControlFlow::Break
|
|
}
|
|
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
|
|
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn finish_page_load(self: &Rc<Self>, index: usize, result: Result<DecodedPage, String>) {
|
|
let mut state = self.state.borrow_mut();
|
|
state.requested.remove(&index);
|
|
state.in_flight = state.in_flight.saturating_sub(1);
|
|
let mut decoded_ratio = None;
|
|
match result {
|
|
Ok(page) => {
|
|
let ratio = page.width as f32 / page.height as f32;
|
|
decoded_ratio = Some(ratio);
|
|
let (frames, cost) = texture_frames(page);
|
|
state.access_clock = state.access_clock.wrapping_add(1);
|
|
let last_used = state.access_clock;
|
|
state.cache.insert(
|
|
index,
|
|
CachedPage {
|
|
frames,
|
|
cost,
|
|
last_used,
|
|
},
|
|
);
|
|
}
|
|
Err(error) => {
|
|
state.failed.insert(index, error);
|
|
}
|
|
}
|
|
drop(state);
|
|
if let Some(ratio) = decoded_ratio {
|
|
let frame = {
|
|
let mut state = self.state.borrow_mut();
|
|
state.continuous_rows.get_mut(index).map(|row| {
|
|
row.ratio = ratio;
|
|
row.frame.clone()
|
|
})
|
|
};
|
|
if let Some(frame) = frame {
|
|
frame.set_ratio(ratio);
|
|
self.resize_continuous_rows();
|
|
}
|
|
}
|
|
self.evict_cache();
|
|
if self.visible_indices().contains(&index) {
|
|
self.render_loaded_pages();
|
|
}
|
|
self.update_spinner();
|
|
self.pump_requests();
|
|
}
|
|
|
|
fn render_loaded_pages(&self) {
|
|
let visible = self.visible_indices();
|
|
match self.view_mode() {
|
|
ViewMode::TwoPage => self.render_spread(&visible),
|
|
ViewMode::Continuous => self.render_continuous(&visible),
|
|
_ if !visible.is_empty() => self.render_single(visible[0]),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn render_single(&self, index: usize) {
|
|
let state = self.state.borrow();
|
|
let frames = state.cache.get(&index).map(|page| Rc::clone(&page.frames));
|
|
self.status_label.set_text(&state.pages[index].name);
|
|
drop(state);
|
|
set_picture_animation(
|
|
&self.single_picture,
|
|
frames,
|
|
&self.single_animation_generation,
|
|
);
|
|
self.resize_single_picture();
|
|
}
|
|
|
|
fn resize_single_picture(&self) {
|
|
let dimensions = {
|
|
let state = self.state.borrow();
|
|
state
|
|
.cache
|
|
.get(&state.anchor)
|
|
.and_then(|page| page.frames.first())
|
|
.map(|frame| (frame.texture.width(), frame.texture.height()))
|
|
};
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
|
|
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]) {
|
|
let state = self.state.borrow();
|
|
let first = indices
|
|
.first()
|
|
.and_then(|index| state.cache.get(index))
|
|
.map(|page| Rc::clone(&page.frames));
|
|
let second = indices
|
|
.get(1)
|
|
.and_then(|index| state.cache.get(index))
|
|
.map(|page| Rc::clone(&page.frames));
|
|
let status = indices
|
|
.iter()
|
|
.map(|index| state.pages[*index].name.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" · ");
|
|
drop(state);
|
|
if self.direction_dropdown.selected() == 1 {
|
|
set_picture_animation(&self.left_picture, second, &self.left_animation_generation);
|
|
set_picture_animation(&self.right_picture, first, &self.right_animation_generation);
|
|
} else {
|
|
set_picture_animation(&self.left_picture, first, &self.left_animation_generation);
|
|
set_picture_animation(
|
|
&self.right_picture,
|
|
second,
|
|
&self.right_animation_generation,
|
|
);
|
|
}
|
|
self.status_label.set_text(&status);
|
|
self.resize_spread_pictures();
|
|
}
|
|
|
|
fn render_continuous(&self, visible: &[usize]) {
|
|
let new_bound: HashSet<_> = visible.iter().copied().collect();
|
|
let mut state = self.state.borrow_mut();
|
|
let old_bound = state.continuous_bound.clone();
|
|
let mut updates = Vec::new();
|
|
for index in old_bound.difference(&new_bound) {
|
|
let row = &state.continuous_rows[*index];
|
|
updates.push((
|
|
row.picture.clone(),
|
|
None,
|
|
Rc::clone(&row.animation_generation),
|
|
));
|
|
}
|
|
for index in &new_bound {
|
|
state.access_clock = state.access_clock.wrapping_add(1);
|
|
let clock = state.access_clock;
|
|
let frames = state.cache.get_mut(index).map(|page| {
|
|
page.last_used = clock;
|
|
Rc::clone(&page.frames)
|
|
});
|
|
let row = &state.continuous_rows[*index];
|
|
if !old_bound.contains(index) || row.picture.paintable().is_none() {
|
|
updates.push((
|
|
row.picture.clone(),
|
|
frames,
|
|
Rc::clone(&row.animation_generation),
|
|
));
|
|
}
|
|
}
|
|
state.continuous_bound = new_bound;
|
|
let status = visible
|
|
.first()
|
|
.map(|index| state.pages[*index].name.clone());
|
|
drop(state);
|
|
for (picture, frames, generation) in updates {
|
|
set_picture_animation(&picture, frames, &generation);
|
|
}
|
|
if let Some(status) = status {
|
|
self.status_label.set_text(&status);
|
|
}
|
|
}
|
|
|
|
fn continuous_scrolled(self: &Rc<Self>) {
|
|
let pages_empty = self.state.borrow().pages.is_empty();
|
|
if self.view_mode() != ViewMode::Continuous || pages_empty {
|
|
return;
|
|
}
|
|
let visible = self.visible_indices();
|
|
let new_anchor = visible.first().copied();
|
|
let update_anchor = !self.continuous_programmatic_scroll.get();
|
|
let changed =
|
|
update_anchor && new_anchor.is_some_and(|index| index != self.state.borrow().anchor);
|
|
if let Some(index) = new_anchor.filter(|_| update_anchor) {
|
|
self.state.borrow_mut().anchor = index;
|
|
}
|
|
self.render_continuous(&visible);
|
|
self.request_pages(self.prefetch_indices(&visible));
|
|
self.update_navigation();
|
|
self.update_spinner();
|
|
if changed {
|
|
let state = self.state.borrow();
|
|
let _ = self.database.save_progress(
|
|
&self.source.source_uri,
|
|
state.anchor,
|
|
state.pages.len(),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn continuous_visible_indices(&self) -> Vec<usize> {
|
|
let state = self.state.borrow();
|
|
if state.pages.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let adjustment = self.continuous_scrolled.vadjustment();
|
|
let top = adjustment.value();
|
|
let bottom = top + adjustment.page_size();
|
|
if adjustment.page_size() <= 0.0 {
|
|
return vec![state.anchor];
|
|
}
|
|
let bounds: Vec<_> = state
|
|
.continuous_rows
|
|
.iter()
|
|
.map(|row| {
|
|
row.frame
|
|
.compute_bounds(&self.continuous_box)
|
|
.map(|bounds| {
|
|
let row_top = f64::from(bounds.y());
|
|
(row_top, row_top + f64::from(bounds.height()))
|
|
})
|
|
})
|
|
.collect();
|
|
let mut visible = intersecting_rows(&bounds, top, bottom);
|
|
if visible.is_empty() {
|
|
visible.push(state.anchor);
|
|
}
|
|
visible
|
|
}
|
|
|
|
fn schedule_scroll_to_anchor(self: &Rc<Self>) {
|
|
if self.view_mode() != ViewMode::Continuous || self.state.borrow().pages.is_empty() {
|
|
return;
|
|
}
|
|
let controller = Rc::downgrade(self);
|
|
glib::idle_add_local_once(move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.scroll_to_anchor();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn scroll_to_anchor(&self) {
|
|
let frame = {
|
|
let state = self.state.borrow();
|
|
let Some(row) = state.continuous_rows.get(state.anchor) else {
|
|
return;
|
|
};
|
|
row.frame.clone()
|
|
};
|
|
let Some(bounds) = frame.compute_bounds(&self.continuous_box) else {
|
|
return;
|
|
};
|
|
let adjustment = self.continuous_scrolled.vadjustment();
|
|
let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower());
|
|
let target = centered_scroll_value(
|
|
f64::from(bounds.y()),
|
|
f64::from(bounds.height()),
|
|
adjustment.page_size(),
|
|
adjustment.lower(),
|
|
maximum,
|
|
);
|
|
self.continuous_programmatic_scroll.set(true);
|
|
adjustment.set_value(target);
|
|
self.continuous_programmatic_scroll.set(false);
|
|
}
|
|
|
|
fn clear_continuous_paintables(&self) {
|
|
let mut state = self.state.borrow_mut();
|
|
let indices: Vec<_> = state.continuous_bound.drain().collect();
|
|
let pictures: Vec<_> = indices
|
|
.into_iter()
|
|
.map(|index| {
|
|
let row = &state.continuous_rows[index];
|
|
(row.picture.clone(), Rc::clone(&row.animation_generation))
|
|
})
|
|
.collect();
|
|
drop(state);
|
|
for (picture, generation) in pictures {
|
|
set_picture_animation(&picture, None, &generation);
|
|
}
|
|
}
|
|
|
|
fn resize_continuous_rows(&self) {
|
|
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()
|
|
.continuous_rows
|
|
.iter()
|
|
.map(|row| (row.frame.clone(), row.ratio))
|
|
.collect();
|
|
for (frame, ratio) in rows {
|
|
frame.set_hexpand(!zoomed);
|
|
frame.set_size_request(if zoomed { width } else { -1 }, row_height(width, ratio));
|
|
}
|
|
}
|
|
|
|
fn continuous_content_width(&self) -> i32 {
|
|
continuous_content_width(self.stack.allocated_width())
|
|
}
|
|
|
|
fn evict_cache(&self) {
|
|
let protected: HashSet<_> = self.visible_indices().into_iter().collect();
|
|
let (byte_limit, page_limit) = if self.fast_image_loading {
|
|
(FAST_CACHE_LIMIT_BYTES, FAST_CACHE_LIMIT_PAGES)
|
|
} else {
|
|
(CACHE_LIMIT_BYTES, CACHE_LIMIT_PAGES)
|
|
};
|
|
let mut state = self.state.borrow_mut();
|
|
loop {
|
|
let bytes: usize = state.cache.values().map(|page| page.cost).sum();
|
|
if bytes <= byte_limit && state.cache.len() <= page_limit {
|
|
break;
|
|
}
|
|
let candidate = state
|
|
.cache
|
|
.iter()
|
|
.filter(|(index, _)| !protected.contains(index))
|
|
.min_by_key(|(_, page)| page.last_used)
|
|
.map(|(index, _)| *index);
|
|
let Some(candidate) = candidate else {
|
|
break;
|
|
};
|
|
state.cache.remove(&candidate);
|
|
}
|
|
}
|
|
|
|
fn update_spinner(&self) {
|
|
let state = self.state.borrow();
|
|
let waiting = self
|
|
.visible_indices()
|
|
.iter()
|
|
.any(|index| !state.cache.contains_key(index) && !state.failed.contains_key(index));
|
|
if waiting {
|
|
self.spinner.start();
|
|
} else {
|
|
self.spinner.stop();
|
|
}
|
|
}
|
|
|
|
fn update_navigation(&self) {
|
|
let (count, anchor) = {
|
|
let state = self.state.borrow();
|
|
(state.pages.len(), state.anchor)
|
|
};
|
|
if count == 0 {
|
|
self.page_label.set_text("- / -");
|
|
return;
|
|
}
|
|
let visible = self.visible_indices();
|
|
let end = visible.last().copied().unwrap_or(anchor) + 1;
|
|
if visible.len() > 1 && self.view_mode() == ViewMode::TwoPage {
|
|
self.page_label
|
|
.set_text(&format!("{}-{end} / {count}", anchor + 1));
|
|
} else {
|
|
self.page_label
|
|
.set_text(&format!("{} / {count}", anchor + 1));
|
|
}
|
|
self.previous_button.set_sensitive(anchor > 0);
|
|
self.next_button.set_sensitive(end < count);
|
|
self.shift_back_button.set_sensitive(anchor > 0);
|
|
self.shift_forward_button.set_sensitive(anchor + 1 < count);
|
|
if (self.page_seek.value() - anchor as f64).abs() >= f64::EPSILON {
|
|
self.page_seek.set_value(anchor as f64);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct MediaViewerWindow;
|
|
|
|
impl MediaViewerWindow {
|
|
pub fn open(
|
|
application: >k::Application,
|
|
source: ArchiveSource,
|
|
kind: EntryKind,
|
|
controls: FileViewerControls,
|
|
) {
|
|
let FileViewerControls {
|
|
sibling_navigator,
|
|
favorite_control,
|
|
download_control,
|
|
file_drop_control,
|
|
reuse_window,
|
|
view_mode_control,
|
|
} = controls;
|
|
let title = source.display_name.clone();
|
|
if let Some(window) = reuse_window.as_ref() {
|
|
clear_viewer_controller(window);
|
|
}
|
|
let window = reuse_window.unwrap_or_else(|| {
|
|
gtk::ApplicationWindow::builder()
|
|
.application(application)
|
|
.title(&title)
|
|
.default_width(1100)
|
|
.default_height(850)
|
|
.build()
|
|
});
|
|
install_file_drop_target(&window, file_drop_control);
|
|
ensure_viewer_close_handler(&window);
|
|
set_viewer_loading(&window, false);
|
|
window.set_title(Some(&title));
|
|
let header = gtk::HeaderBar::new();
|
|
header.set_title_widget(Some(>k::Label::new(Some(&title))));
|
|
let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
|
|
let download_button = gtk::Button::with_label("파일 다운로드");
|
|
let file_list_button = gtk::ToggleButton::with_label("파일 목록");
|
|
file_list_button.set_sensitive(sibling_navigator.is_some());
|
|
file_list_button.set_active(window.has_css_class("viewer-file-list-visible"));
|
|
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);
|
|
header.pack_end(&file_list_button);
|
|
let titlebar_host = set_viewer_header(&window, &header);
|
|
|
|
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
let stack = gtk::Stack::new();
|
|
stack.set_hexpand(true);
|
|
stack.set_vexpand(true);
|
|
let picture = page_picture();
|
|
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 {
|
|
gtk::Video::new()
|
|
};
|
|
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)
|
|
.xalign(0.0)
|
|
.margin_top(6)
|
|
.margin_bottom(6)
|
|
.margin_start(8)
|
|
.margin_end(8)
|
|
.build();
|
|
let media_seek = navigation_scale();
|
|
let media_seek_row = navigation_scale_row(&media_seek);
|
|
if let Some((current, count)) = sibling_navigator
|
|
.as_ref()
|
|
.and_then(SiblingNavigator::position)
|
|
{
|
|
media_seek.set_range(0.0, navigation_scale_upper(count));
|
|
media_seek.set_value(current as f64);
|
|
media_seek.set_sensitive(count > 1);
|
|
} else {
|
|
media_seek.set_sensitive(false);
|
|
}
|
|
root.append(&stack);
|
|
root.append(&media_seek_row);
|
|
root.append(&status_label);
|
|
let window_overlay = gtk::Overlay::new();
|
|
window_overlay.set_child(Some(&root));
|
|
let (navigation_message, navigation_message_label) = navigation_message();
|
|
window_overlay.add_overlay(&navigation_message);
|
|
window_overlay.set_hexpand(true);
|
|
window_overlay.set_vexpand(true);
|
|
let file_list = viewer_file_list();
|
|
file_list.panel.set_visible(file_list_button.is_active());
|
|
let viewer_layout = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
viewer_layout.append(&window_overlay);
|
|
viewer_layout.append(&file_list.panel);
|
|
window.set_child(Some(&viewer_layout));
|
|
|
|
let controller = Rc::new(MediaViewerController {
|
|
window,
|
|
titlebar_host,
|
|
source,
|
|
kind,
|
|
picture,
|
|
image_box,
|
|
image_scrolled,
|
|
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,
|
|
status_label,
|
|
media_seek,
|
|
media_seek_row,
|
|
sibling_navigator,
|
|
file_list_button,
|
|
file_list_panel: file_list.panel,
|
|
file_list: file_list.list,
|
|
file_list_files: RefCell::new(Vec::new()),
|
|
favorite_button,
|
|
favorite_toggle: favorite_control.toggle,
|
|
download_button,
|
|
file_download: download_control.download,
|
|
seek_generation: Cell::new(0),
|
|
seek_opening: Cell::new(false),
|
|
seek_hide_timer: RefCell::new(None),
|
|
last_pointer_position: Cell::new(None),
|
|
key_controller: RefCell::new(None),
|
|
motion_controller: RefCell::new(None),
|
|
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),
|
|
navigation_message,
|
|
navigation_message_label,
|
|
navigation_message_generation: Rc::new(Cell::new(0)),
|
|
});
|
|
if let Some(stream) = controller.video.media_stream() {
|
|
let status_label = controller.status_label.clone();
|
|
stream.connect_error_notify(move |stream| {
|
|
if let Some(error) = stream.error() {
|
|
status_label.set_text(&format!("동영상을 재생할 수 없습니다: {error}"));
|
|
}
|
|
});
|
|
}
|
|
controller.connect_signals();
|
|
controller.fullscreen_changed();
|
|
if controller.file_list_button.is_active() {
|
|
controller.refresh_file_list();
|
|
}
|
|
unsafe {
|
|
controller
|
|
.window
|
|
.set_data(MEDIA_VIEWER_CONTROLLER_KEY, Rc::clone(&controller));
|
|
}
|
|
controller.apply_image_view_mode();
|
|
controller.window.present();
|
|
match kind {
|
|
EntryKind::Image => controller.load_image(),
|
|
EntryKind::Video => {
|
|
controller.stack.set_visible_child_name("video");
|
|
if let Some(stream) = controller.video.media_stream() {
|
|
stream.play();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MediaViewerController {
|
|
window: gtk::ApplicationWindow,
|
|
titlebar_host: gtk::Box,
|
|
source: ArchiveSource,
|
|
kind: EntryKind,
|
|
picture: gtk::Picture,
|
|
image_box: gtk::Box,
|
|
image_scrolled: gtk::ScrolledWindow,
|
|
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,
|
|
status_label: gtk::Label,
|
|
media_seek: gtk::Scale,
|
|
media_seek_row: gtk::Box,
|
|
sibling_navigator: Option<SiblingNavigator>,
|
|
file_list_button: gtk::ToggleButton,
|
|
file_list_panel: gtk::ScrolledWindow,
|
|
file_list: gtk::ListBox,
|
|
file_list_files: RefCell<Vec<SiblingFile>>,
|
|
favorite_button: gtk::Button,
|
|
favorite_toggle: Rc<dyn Fn() -> bool>,
|
|
download_button: gtk::Button,
|
|
file_download: Rc<dyn Fn(gtk::Window, gtk::Label)>,
|
|
seek_generation: Cell<u64>,
|
|
seek_opening: Cell<bool>,
|
|
seek_hide_timer: RefCell<Option<glib::SourceId>>,
|
|
last_pointer_position: Cell<Option<(f64, f64)>>,
|
|
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>>,
|
|
navigation_message: gtk::Box,
|
|
navigation_message_label: gtk::Label,
|
|
navigation_message_generation: Rc<Cell<u64>>,
|
|
}
|
|
|
|
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);
|
|
motion_controller.connect_motion(move |_, x, y| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.pointer_moved(x, y);
|
|
}
|
|
});
|
|
self.window.add_controller(motion_controller.clone());
|
|
self.motion_controller.replace(Some(motion_controller));
|
|
|
|
let fullscreen_keys = gtk::EventControllerKey::new();
|
|
fullscreen_keys.set_propagation_phase(gtk::PropagationPhase::Capture);
|
|
let controller = Rc::downgrade(self);
|
|
fullscreen_keys.connect_key_pressed(move |_, key, _, _| {
|
|
if let Some(controller) = controller.upgrade()
|
|
&& controller.window.is_fullscreen()
|
|
&& fullscreen_direction_key(key)
|
|
{
|
|
controller.hide_fullscreen_ui_now();
|
|
}
|
|
glib::Propagation::Proceed
|
|
});
|
|
self.window.add_controller(fullscreen_keys.clone());
|
|
self.fullscreen_key_controller
|
|
.replace(Some(fullscreen_keys));
|
|
|
|
let keys = gtk::EventControllerKey::new();
|
|
let controller = Rc::downgrade(self);
|
|
keys.connect_key_pressed(move |_, key, _, modifiers| {
|
|
let Some(controller) = controller.upgrade() else {
|
|
return glib::Propagation::Proceed;
|
|
};
|
|
if modifiers.intersects(
|
|
gdk::ModifierType::CONTROL_MASK
|
|
| gdk::ModifierType::ALT_MASK
|
|
| gdk::ModifierType::SUPER_MASK,
|
|
) {
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
if !controller.window.is_fullscreen() {
|
|
controller.hide_seek();
|
|
}
|
|
match key {
|
|
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 | gdk::Key::KP_Right if controller.kind == EntryKind::Video => {
|
|
controller.seek_video(VIDEO_SEEK_STEP_US);
|
|
glib::Propagation::Stop
|
|
}
|
|
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 | gdk::Key::KP_Down if controller.kind == EntryKind::Video => {
|
|
controller.adjust_video_volume(-VIDEO_VOLUME_STEP);
|
|
glib::Propagation::Stop
|
|
}
|
|
gdk::Key::Home => {
|
|
controller.open_edge(false);
|
|
glib::Propagation::Stop
|
|
}
|
|
gdk::Key::End => {
|
|
controller.open_edge(true);
|
|
glib::Propagation::Stop
|
|
}
|
|
gdk::Key::Up | gdk::Key::KP_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 | gdk::Key::KP_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
|
|
}
|
|
_ if key.to_unicode() == Some(']') => {
|
|
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')) =>
|
|
{
|
|
controller.toggle_fullscreen();
|
|
glib::Propagation::Stop
|
|
}
|
|
gdk::Key::Escape if controller.window.is_fullscreen() => {
|
|
controller.window.unfullscreen();
|
|
glib::Propagation::Stop
|
|
}
|
|
_ => glib::Propagation::Proceed,
|
|
}
|
|
});
|
|
self.window.add_controller(keys.clone());
|
|
self.key_controller.replace(Some(keys));
|
|
let controller = Rc::downgrade(self);
|
|
let handler = self
|
|
.window
|
|
.connect_notify_local(Some("fullscreened"), move |_, _| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.fullscreen_changed();
|
|
}
|
|
});
|
|
self.fullscreen_notify_handler.replace(Some(handler));
|
|
let controller = Rc::downgrade(self);
|
|
self.favorite_button.connect_clicked(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
let active = (controller.favorite_toggle)();
|
|
controller.favorite_button.set_label(favorite_label(active));
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.download_button.connect_clicked(move |_| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
(controller.file_download)(
|
|
controller.window.clone().upcast(),
|
|
controller.status_label.clone(),
|
|
);
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.file_list_button.connect_toggled(move |button| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.file_list_panel.set_visible(
|
|
button.is_active()
|
|
&& (!controller.window.is_fullscreen()
|
|
|| controller.fullscreen_ui_visible.get()),
|
|
);
|
|
if button.is_active() {
|
|
controller.window.add_css_class("viewer-file-list-visible");
|
|
} else {
|
|
controller
|
|
.window
|
|
.remove_css_class("viewer-file-list-visible");
|
|
}
|
|
if button.is_active() {
|
|
controller.refresh_file_list();
|
|
}
|
|
}
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
self.file_list.connect_row_activated(move |_, row| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.open_file_list_row(row.index());
|
|
}
|
|
});
|
|
let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default()));
|
|
let file_list_keys = gtk::EventControllerKey::new();
|
|
let controller = Rc::downgrade(self);
|
|
file_list_keys.connect_key_pressed(move |_, key, _, modifiers| {
|
|
let Some(controller) = controller.upgrade() else {
|
|
return glib::Propagation::Proceed;
|
|
};
|
|
file_list_typeahead_select(
|
|
&controller.file_list,
|
|
&controller.file_list_files.borrow(),
|
|
&mut file_list_typeahead.borrow_mut(),
|
|
key,
|
|
modifiers,
|
|
)
|
|
});
|
|
self.file_list.add_controller(file_list_keys);
|
|
let file_list_click = gtk::GestureClick::builder().button(3).build();
|
|
let controller = Rc::downgrade(self);
|
|
file_list_click.connect_pressed(move |_, _, x, y| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.show_file_list_context_menu(x, y);
|
|
}
|
|
});
|
|
self.file_list.add_controller(file_list_click);
|
|
if let Some(change) = self.image_mode_change.clone() {
|
|
let controller = Rc::downgrade(self);
|
|
self.image_mode_dropdown
|
|
.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();
|
|
}
|
|
});
|
|
}
|
|
let controller = Rc::downgrade(self);
|
|
self.image_scrolled
|
|
.connect_notify_local(Some("width"), move |_, _| {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.resize_image_picture();
|
|
}
|
|
});
|
|
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;
|
|
};
|
|
let Some(navigator) = &controller.sibling_navigator else {
|
|
return;
|
|
};
|
|
let Some((_, count)) = navigator.position() else {
|
|
return;
|
|
};
|
|
let Some(index) = page_seek_index(scale.value(), count) else {
|
|
return;
|
|
};
|
|
controller.schedule_seek(index);
|
|
});
|
|
}
|
|
|
|
fn refresh_file_list(&self) {
|
|
while let Some(child) = self.file_list.first_child() {
|
|
self.file_list.remove(&child);
|
|
}
|
|
let files = self
|
|
.sibling_navigator
|
|
.as_ref()
|
|
.map(SiblingNavigator::files)
|
|
.unwrap_or_default();
|
|
let current = files
|
|
.iter()
|
|
.position(|file| file.uri == self.source.source_uri);
|
|
for file in &files {
|
|
self.file_list.append(&viewer_file_row(file));
|
|
}
|
|
*self.file_list_files.borrow_mut() = files;
|
|
if let Some(row) = current.and_then(|index| self.file_list.row_at_index(index as i32)) {
|
|
self.file_list.select_row(Some(&row));
|
|
}
|
|
}
|
|
|
|
fn open_file_list_row(&self, index: i32) {
|
|
let Some(uri) = self
|
|
.file_list_files
|
|
.borrow()
|
|
.get(index as usize)
|
|
.map(|file| file.uri.clone())
|
|
else {
|
|
return;
|
|
};
|
|
if uri == self.source.source_uri {
|
|
return;
|
|
}
|
|
if let Some(navigator) = &self.sibling_navigator
|
|
&& navigator.open_uri(&uri, self.window.clone())
|
|
{
|
|
self.prepare_for_navigation();
|
|
}
|
|
}
|
|
|
|
fn show_file_list_context_menu(&self, x: f64, y: f64) {
|
|
let Some((row, file)) = selected_viewer_file(&self.file_list, &self.file_list_files, x, y)
|
|
else {
|
|
return;
|
|
};
|
|
self.file_list.select_row(Some(&row));
|
|
let Some(actions) = self
|
|
.sibling_navigator
|
|
.as_ref()
|
|
.and_then(|navigator| navigator.actions(&file.uri))
|
|
else {
|
|
return;
|
|
};
|
|
show_viewer_file_context_menu(
|
|
&row,
|
|
&file,
|
|
actions,
|
|
&self.source.source_uri,
|
|
&self.favorite_button,
|
|
&self.window,
|
|
&self.status_label,
|
|
);
|
|
}
|
|
|
|
fn image_view_mode(&self) -> ViewMode {
|
|
match self.image_mode_dropdown.selected() {
|
|
0 => ViewMode::ActualSize,
|
|
2 => ViewMode::FitHeight,
|
|
_ => ViewMode::FitWidth,
|
|
}
|
|
}
|
|
|
|
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
|
|
.media_stream()
|
|
.filter(|stream| stream.is_seekable())
|
|
else {
|
|
return;
|
|
};
|
|
stream.seek(video_seek_target(
|
|
stream.timestamp(),
|
|
stream.duration(),
|
|
delta,
|
|
));
|
|
}
|
|
|
|
fn adjust_video_volume(&self, delta: f64) {
|
|
let Some(stream) = self.video.media_stream() else {
|
|
return;
|
|
};
|
|
stream.set_volume(video_volume_target(stream.volume(), delta));
|
|
}
|
|
|
|
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 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;
|
|
};
|
|
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,
|
|
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())
|
|
{
|
|
self.prepare_for_navigation();
|
|
} else {
|
|
self.show_navigation_error(forward);
|
|
}
|
|
}
|
|
|
|
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));
|
|
self.navigation_message.set_visible(true);
|
|
let message = self.navigation_message.clone();
|
|
let generation = self.navigation_message_generation.get().wrapping_add(1);
|
|
self.navigation_message_generation.set(generation);
|
|
let current_generation = Rc::clone(&self.navigation_message_generation);
|
|
glib::timeout_add_local_once(Duration::from_secs(2), move || {
|
|
if current_generation.get() == generation {
|
|
message.set_visible(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn show_seek_temporarily(self: &Rc<Self>) {
|
|
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
|
|
timer.remove();
|
|
}
|
|
if let Some((current, count)) = self
|
|
.sibling_navigator
|
|
.as_ref()
|
|
.and_then(SiblingNavigator::position)
|
|
{
|
|
self.media_seek
|
|
.set_range(0.0, navigation_scale_upper(count));
|
|
self.media_seek.set_sensitive(count > 1);
|
|
self.media_seek.set_value(current as f64);
|
|
}
|
|
self.set_seek_visible(true);
|
|
let controller = Rc::downgrade(self);
|
|
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.seek_hide_timer.borrow_mut().take();
|
|
controller.set_seek_visible(false);
|
|
}
|
|
});
|
|
self.seek_hide_timer.replace(Some(timer));
|
|
}
|
|
|
|
fn pointer_moved(self: &Rc<Self>, x: f64, y: f64) {
|
|
if self.last_pointer_position.get() == Some((x, y)) {
|
|
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 {
|
|
self.show_seek_temporarily();
|
|
}
|
|
}
|
|
|
|
fn hide_seek(self: &Rc<Self>) {
|
|
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
|
|
timer.remove();
|
|
}
|
|
self.set_seek_visible(false);
|
|
}
|
|
|
|
fn set_seek_visible(self: &Rc<Self>, visible: bool) {
|
|
if self.media_seek_row.is_visible() == visible {
|
|
return;
|
|
}
|
|
self.media_seek_row.set_visible(visible);
|
|
self.window.queue_allocate();
|
|
let controller = Rc::downgrade(self);
|
|
glib::idle_add_local_once(move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.stack.queue_allocate();
|
|
controller.picture.queue_allocate();
|
|
controller.video.queue_allocate();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn schedule_seek(self: &Rc<Self>, index: usize) {
|
|
let Some(navigator) = &self.sibling_navigator else {
|
|
return;
|
|
};
|
|
if navigator
|
|
.position()
|
|
.is_none_or(|(current, _)| index == current)
|
|
|| self.seek_opening.get()
|
|
{
|
|
return;
|
|
}
|
|
let generation = self.seek_generation.get().wrapping_add(1);
|
|
self.seek_generation.set(generation);
|
|
let controller = Rc::downgrade(self);
|
|
glib::timeout_add_local_once(Duration::from_millis(180), move || {
|
|
let Some(controller) = controller.upgrade() else {
|
|
return;
|
|
};
|
|
if controller.seek_generation.get() != generation || controller.seek_opening.get() {
|
|
return;
|
|
}
|
|
let Some(navigator) = &controller.sibling_navigator else {
|
|
return;
|
|
};
|
|
if navigator.open_index(index, controller.window.clone()) {
|
|
controller.seek_opening.set(true);
|
|
controller.prepare_for_navigation();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn prepare_for_navigation(&self) {
|
|
self.stop_playback();
|
|
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
|
|
timer.remove();
|
|
}
|
|
self.cancel_fullscreen_ui_timer();
|
|
if let Some(handler) = self.fullscreen_notify_handler.borrow_mut().take() {
|
|
self.window.disconnect(handler);
|
|
}
|
|
if let Some(controller) = self.key_controller.borrow_mut().take() {
|
|
self.window.remove_controller(&controller);
|
|
}
|
|
if let Some(controller) = self.motion_controller.borrow_mut().take() {
|
|
self.window.remove_controller(&controller);
|
|
}
|
|
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) {
|
|
self.image_animation_generation.cancel();
|
|
if let Some(stream) = self.video.media_stream() {
|
|
stream.pause();
|
|
}
|
|
}
|
|
|
|
fn toggle_fullscreen(&self) {
|
|
if self.window.is_fullscreen() {
|
|
self.window.unfullscreen();
|
|
} else {
|
|
self.window.fullscreen();
|
|
}
|
|
}
|
|
|
|
fn fullscreen_changed(self: &Rc<Self>) {
|
|
self.cancel_fullscreen_ui_timer();
|
|
self.hide_seek();
|
|
if self.window.is_fullscreen() {
|
|
self.set_fullscreen_ui_visible(false);
|
|
} else {
|
|
self.fullscreen_ui_visible.set(true);
|
|
self.titlebar_host.set_visible(true);
|
|
self.status_label.set_visible(true);
|
|
self.file_list_panel
|
|
.set_visible(self.file_list_button.is_active());
|
|
}
|
|
}
|
|
|
|
fn show_fullscreen_ui_temporarily(self: &Rc<Self>) {
|
|
if !self.window.is_fullscreen() {
|
|
return;
|
|
}
|
|
self.cancel_fullscreen_ui_timer();
|
|
self.set_fullscreen_ui_visible(true);
|
|
let controller = Rc::downgrade(self);
|
|
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.fullscreen_ui_hide_timer.borrow_mut().take();
|
|
controller.set_fullscreen_ui_visible(false);
|
|
}
|
|
});
|
|
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();
|
|
}
|
|
}
|
|
|
|
fn set_fullscreen_ui_visible(self: &Rc<Self>, visible: bool) {
|
|
self.fullscreen_ui_visible.set(visible);
|
|
self.titlebar_host.set_visible(visible);
|
|
self.media_seek_row.set_visible(visible);
|
|
self.status_label.set_visible(visible);
|
|
self.file_list_panel
|
|
.set_visible(visible && self.file_list_button.is_active());
|
|
self.window.queue_allocate();
|
|
let controller = Rc::downgrade(self);
|
|
glib::idle_add_local_once(move || {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.stack.queue_allocate();
|
|
controller.picture.queue_allocate();
|
|
controller.video.queue_allocate();
|
|
controller.resize_image_picture();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn load_image(self: &Rc<Self>) {
|
|
self.stack.set_visible_child_name("image");
|
|
self.status_label.set_text("이미지를 읽는 중...");
|
|
let path = self.source.local_path.clone();
|
|
let display_name = self.source.display_name.clone();
|
|
let (sender, receiver) = mpsc::channel();
|
|
thread::spawn(move || {
|
|
let result =
|
|
decode_image_file(&path, &display_name).map_err(|error| format!("{error:#}"));
|
|
let _ = sender.send(result);
|
|
});
|
|
let controller = Rc::downgrade(self);
|
|
glib::timeout_add_local(Duration::from_millis(20), move || {
|
|
match receiver.try_recv() {
|
|
Ok(result) => {
|
|
if let Some(controller) = controller.upgrade() {
|
|
controller.finish_image_load(result);
|
|
}
|
|
glib::ControlFlow::Break
|
|
}
|
|
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
|
|
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
|
|
}
|
|
});
|
|
}
|
|
|
|
fn finish_image_load(&self, result: Result<DecodedPage, String>) {
|
|
match result {
|
|
Ok(page) => {
|
|
self.image_dimensions
|
|
.set(Some((page.width as i32, page.height as i32)));
|
|
let (frames, _) = texture_frames(page);
|
|
set_picture_animation(
|
|
&self.picture,
|
|
Some(frames),
|
|
&self.image_animation_generation,
|
|
);
|
|
self.apply_image_view_mode();
|
|
self.status_label.set_text(&self.source.display_name);
|
|
}
|
|
Err(error) => self.status_label.set_text(&error),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn favorite_label(active: bool) -> &'static str {
|
|
if active {
|
|
"즐겨찾기 제거"
|
|
} else {
|
|
"즐겨찾기 추가"
|
|
}
|
|
}
|
|
|
|
struct ViewerFileList {
|
|
panel: gtk::ScrolledWindow,
|
|
list: gtk::ListBox,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct TypeaheadBuffer {
|
|
query: String,
|
|
last_input: Option<Instant>,
|
|
}
|
|
|
|
fn file_list_typeahead_select(
|
|
list: >k::ListBox,
|
|
files: &[SiblingFile],
|
|
state: &mut TypeaheadBuffer,
|
|
key: gdk::Key,
|
|
modifiers: gdk::ModifierType,
|
|
) -> glib::Propagation {
|
|
let blocked = gdk::ModifierType::CONTROL_MASK
|
|
| gdk::ModifierType::ALT_MASK
|
|
| gdk::ModifierType::SUPER_MASK;
|
|
if modifiers.intersects(blocked) {
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
if key == gdk::Key::Escape {
|
|
state.query.clear();
|
|
state.last_input = None;
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
if matches!(key, gdk::Key::space)
|
|
|| key.to_unicode().is_some_and(|character| {
|
|
character.eq_ignore_ascii_case(&'f')
|
|
|| character.eq_ignore_ascii_case(&'l')
|
|
|| matches!(character, '[' | ']' | '+' | '-')
|
|
})
|
|
{
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
let now = Instant::now();
|
|
if state
|
|
.last_input
|
|
.is_none_or(|last| now.duration_since(last) > Duration::from_millis(1200))
|
|
{
|
|
state.query.clear();
|
|
}
|
|
if key == gdk::Key::BackSpace {
|
|
return if state.query.pop().is_some() {
|
|
state.last_input = Some(now);
|
|
glib::Propagation::Stop
|
|
} else {
|
|
glib::Propagation::Proceed
|
|
};
|
|
}
|
|
let Some(character) = key.to_unicode().filter(|character| !character.is_control()) else {
|
|
return glib::Propagation::Proceed;
|
|
};
|
|
let input = character.to_lowercase().collect::<String>();
|
|
let repeated_single = state.query.chars().count() == 1 && state.query == input;
|
|
if !repeated_single {
|
|
state.query.push_str(&input);
|
|
}
|
|
state.last_input = Some(now);
|
|
let selected = list.selected_row().map(|row| row.index() as usize);
|
|
let start = if repeated_single {
|
|
selected.map_or(0, |index| index + 1)
|
|
} else {
|
|
0
|
|
};
|
|
let found = find_sibling_prefix(files, &state.query, start);
|
|
if let Some(row) = found.and_then(|index| list.row_at_index(index as i32)) {
|
|
list.select_row(Some(&row));
|
|
let _ = row.grab_focus();
|
|
}
|
|
glib::Propagation::Stop
|
|
}
|
|
|
|
fn fullscreen_direction_key(key: gdk::Key) -> bool {
|
|
matches!(
|
|
key,
|
|
gdk::Key::Left
|
|
| gdk::Key::Right
|
|
| gdk::Key::Up
|
|
| gdk::Key::Down
|
|
| gdk::Key::KP_Left
|
|
| gdk::Key::KP_Right
|
|
| gdk::Key::KP_Up
|
|
| gdk::Key::KP_Down
|
|
)
|
|
}
|
|
|
|
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;
|
|
}
|
|
let query = query.to_lowercase();
|
|
(0..files.len())
|
|
.map(|offset| (start + offset) % files.len())
|
|
.find(|index| files[*index].name.to_lowercase().starts_with(&query))
|
|
}
|
|
|
|
fn viewer_file_list() -> ViewerFileList {
|
|
let list = gtk::ListBox::new();
|
|
list.set_selection_mode(gtk::SelectionMode::Single);
|
|
list.set_activate_on_single_click(false);
|
|
let panel = gtk::ScrolledWindow::builder()
|
|
.width_request(280)
|
|
.vexpand(true)
|
|
.child(&list)
|
|
.build();
|
|
panel.add_css_class("sidebar");
|
|
panel.set_visible(false);
|
|
ViewerFileList { panel, list }
|
|
}
|
|
|
|
fn viewer_file_row(file: &SiblingFile) -> gtk::ListBoxRow {
|
|
let row = gtk::ListBoxRow::new();
|
|
let content = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
content.set_margin_top(7);
|
|
content.set_margin_bottom(7);
|
|
content.set_margin_start(8);
|
|
content.set_margin_end(8);
|
|
let icon_name = match file.kind {
|
|
EntryKind::ZipArchive => "package-x-generic-symbolic",
|
|
EntryKind::Video => "video-x-generic-symbolic",
|
|
_ => "image-x-generic-symbolic",
|
|
};
|
|
let icon = gtk::Image::from_icon_name(icon_name);
|
|
let label = gtk::Label::builder()
|
|
.label(&file.name)
|
|
.xalign(0.0)
|
|
.hexpand(true)
|
|
.ellipsize(gtk::pango::EllipsizeMode::Middle)
|
|
.tooltip_text(&file.name)
|
|
.build();
|
|
content.append(&icon);
|
|
content.append(&label);
|
|
row.set_child(Some(&content));
|
|
row
|
|
}
|
|
|
|
fn selected_viewer_file(
|
|
list: >k::ListBox,
|
|
files: &RefCell<Vec<SiblingFile>>,
|
|
x: f64,
|
|
y: f64,
|
|
) -> Option<(gtk::ListBoxRow, SiblingFile)> {
|
|
let widget = list.pick(x, y, gtk::PickFlags::DEFAULT)?;
|
|
let row = widget
|
|
.clone()
|
|
.downcast::<gtk::ListBoxRow>()
|
|
.ok()
|
|
.or_else(|| {
|
|
widget
|
|
.ancestor(gtk::ListBoxRow::static_type())
|
|
.and_then(|widget| widget.downcast::<gtk::ListBoxRow>().ok())
|
|
})?;
|
|
let file = files.borrow().get(row.index() as usize)?.clone();
|
|
Some((row, file))
|
|
}
|
|
|
|
fn show_viewer_file_context_menu(
|
|
row: >k::ListBoxRow,
|
|
file: &SiblingFile,
|
|
actions: SiblingFileActions,
|
|
current_uri: &str,
|
|
current_favorite_button: >k::Button,
|
|
window: >k::ApplicationWindow,
|
|
status_label: >k::Label,
|
|
) {
|
|
let popover = gtk::Popover::new();
|
|
popover.set_parent(row);
|
|
let buttons = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
|
buttons.set_margin_top(6);
|
|
buttons.set_margin_bottom(6);
|
|
buttons.set_margin_start(6);
|
|
buttons.set_margin_end(6);
|
|
let favorite_button = gtk::Button::with_label(favorite_label(actions.favorite.active));
|
|
let download_button = gtk::Button::with_label("파일 다운로드...");
|
|
buttons.append(&favorite_button);
|
|
buttons.append(&download_button);
|
|
popover.set_child(Some(&buttons));
|
|
|
|
let toggle = actions.favorite.toggle;
|
|
let update_current = file.uri == current_uri;
|
|
let current_favorite_button = current_favorite_button.clone();
|
|
let popover_to_close = popover.clone();
|
|
favorite_button.connect_clicked(move |_| {
|
|
let active = toggle();
|
|
if update_current {
|
|
current_favorite_button.set_label(favorite_label(active));
|
|
}
|
|
popover_to_close.popdown();
|
|
});
|
|
let download = actions.download.download;
|
|
let window: gtk::Window = window.clone().upcast();
|
|
let status_label = status_label.clone();
|
|
let popover_to_close = popover.clone();
|
|
download_button.connect_clicked(move |_| {
|
|
popover_to_close.popdown();
|
|
download(window.clone(), status_label.clone());
|
|
});
|
|
popover.popup();
|
|
}
|
|
|
|
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);
|
|
picture.set_hexpand(true);
|
|
picture.set_vexpand(true);
|
|
picture
|
|
}
|
|
|
|
fn navigation_scale() -> gtk::Scale {
|
|
let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
|
|
scale.set_draw_value(false);
|
|
scale.set_hexpand(true);
|
|
scale.set_height_request(32);
|
|
scale.set_valign(gtk::Align::Center);
|
|
scale.set_focusable(false);
|
|
scale.set_margin_top(2);
|
|
scale.set_margin_bottom(8);
|
|
scale.set_margin_start(8);
|
|
scale.set_margin_end(8);
|
|
scale
|
|
}
|
|
|
|
fn navigation_message() -> (gtk::Box, gtk::Label) {
|
|
let message = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
message.add_css_class("osd");
|
|
message.set_halign(gtk::Align::Center);
|
|
message.set_valign(gtk::Align::Center);
|
|
message.set_can_target(false);
|
|
message.set_margin_top(12);
|
|
message.set_margin_bottom(12);
|
|
message.set_margin_start(18);
|
|
message.set_margin_end(18);
|
|
message.set_visible(false);
|
|
let label = gtk::Label::new(None);
|
|
label.add_css_class("title-2");
|
|
message.append(&label);
|
|
(message, label)
|
|
}
|
|
|
|
fn navigation_error_message(forward: bool) -> &'static str {
|
|
if forward {
|
|
"더 이상 다음 파일이 없습니다."
|
|
} else {
|
|
"더 이상 이전 파일이 없습니다."
|
|
}
|
|
}
|
|
|
|
fn navigation_scale_row(scale: >k::Scale) -> gtk::Box {
|
|
let row = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
row.set_height_request(42);
|
|
row.set_hexpand(true);
|
|
row.append(scale);
|
|
row.set_visible(false);
|
|
row
|
|
}
|
|
|
|
fn navigation_scale_upper(count: usize) -> f64 {
|
|
count.saturating_sub(1).max(1) as f64
|
|
}
|
|
|
|
fn spread_indices(anchor: usize, count: usize) -> Vec<usize> {
|
|
if count == 0 {
|
|
return Vec::new();
|
|
}
|
|
let anchor = anchor.min(count - 1);
|
|
let mut pages = vec![anchor];
|
|
if anchor + 1 < count {
|
|
pages.push(anchor + 1);
|
|
}
|
|
pages
|
|
}
|
|
|
|
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 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;
|
|
let loop_count = page.loop_count;
|
|
let mut cost = 0;
|
|
let frames = page
|
|
.frames
|
|
.into_iter()
|
|
.map(|frame| {
|
|
cost += frame.rgba.len();
|
|
let bytes = glib::Bytes::from_owned(frame.rgba);
|
|
CachedFrame {
|
|
texture: gdk::MemoryTexture::new(
|
|
width as i32,
|
|
height as i32,
|
|
gdk::MemoryFormat::R8g8b8a8,
|
|
&bytes,
|
|
width as usize * 4,
|
|
),
|
|
delay: frame.delay,
|
|
}
|
|
})
|
|
.collect();
|
|
(Rc::new(CachedAnimation { frames, loop_count }), cost)
|
|
}
|
|
|
|
fn set_picture_animation(
|
|
picture: >k::Picture,
|
|
frames: Option<Rc<CachedAnimation>>,
|
|
state: &Rc<AnimationState>,
|
|
) {
|
|
if let Some(frames) = frames.as_ref()
|
|
&& state
|
|
.frames
|
|
.borrow()
|
|
.as_ref()
|
|
.and_then(Weak::upgrade)
|
|
.is_some_and(|current| Rc::ptr_eq(¤t, frames))
|
|
{
|
|
return;
|
|
}
|
|
let current = state.cancel();
|
|
let Some(frames) = frames.filter(|frames| !frames.is_empty()) else {
|
|
picture.set_paintable(gdk::Paintable::NONE);
|
|
return;
|
|
};
|
|
state.frames.replace(Some(Rc::downgrade(&frames)));
|
|
show_animation_frame(picture.downgrade(), frames, Rc::clone(state), current, 0, 0);
|
|
}
|
|
|
|
fn show_animation_frame(
|
|
picture: glib::WeakRef<gtk::Picture>,
|
|
frames: Rc<CachedAnimation>,
|
|
state: Rc<AnimationState>,
|
|
expected_generation: u64,
|
|
index: usize,
|
|
completed_loops: u32,
|
|
) {
|
|
if state.generation.get() != expected_generation {
|
|
return;
|
|
}
|
|
let Some(picture_widget) = picture.upgrade() else {
|
|
return;
|
|
};
|
|
let frame = &frames[index];
|
|
picture_widget.set_paintable(Some(&frame.texture));
|
|
if frames.len() == 1 {
|
|
return;
|
|
}
|
|
let delay = frame.delay;
|
|
let next = (index + 1) % frames.len();
|
|
let completed_loops = completed_loops + u32::from(next == 0);
|
|
if next == 0
|
|
&& frames
|
|
.loop_count
|
|
.is_some_and(|loop_count| completed_loops >= loop_count)
|
|
{
|
|
return;
|
|
}
|
|
let callback_state = Rc::clone(&state);
|
|
let timer = glib::timeout_add_local_once(delay, move || {
|
|
callback_state.timer.borrow_mut().take();
|
|
show_animation_frame(
|
|
picture,
|
|
frames,
|
|
callback_state,
|
|
expected_generation,
|
|
next,
|
|
completed_loops,
|
|
);
|
|
});
|
|
state.timer.replace(Some(timer));
|
|
}
|
|
|
|
fn continuous_content_width(stack_width: i32) -> i32 {
|
|
(stack_width - 24).max(320)
|
|
}
|
|
|
|
fn prefetch_window(visible: &[usize], count: usize, fast: bool) -> Vec<usize> {
|
|
if visible.is_empty() || count == 0 {
|
|
return Vec::new();
|
|
}
|
|
let (ahead, behind) = if fast { (12, 4) } else { (2, 1) };
|
|
let mut indices = visible.to_vec();
|
|
let last = *visible.last().unwrap();
|
|
for offset in 1..=ahead {
|
|
let index = last + offset;
|
|
if index >= count {
|
|
break;
|
|
}
|
|
indices.push(index);
|
|
}
|
|
let first = visible[0];
|
|
for offset in 1..=behind.min(first) {
|
|
indices.push(first - offset);
|
|
}
|
|
indices
|
|
}
|
|
|
|
fn intersecting_rows(bounds: &[Option<(f64, f64)>], top: f64, bottom: f64) -> Vec<usize> {
|
|
bounds
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, bounds)| {
|
|
bounds
|
|
.filter(|(row_top, row_bottom)| *row_bottom > top && *row_top < bottom)
|
|
.map(|_| index)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn centered_scroll_value(
|
|
row_top: f64,
|
|
row_height: f64,
|
|
viewport_height: f64,
|
|
minimum: f64,
|
|
maximum: f64,
|
|
) -> f64 {
|
|
(row_top + row_height / 2.0 - viewport_height / 2.0).clamp(minimum, maximum)
|
|
}
|
|
|
|
fn page_seek_index(value: f64, count: usize) -> Option<usize> {
|
|
(count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1))
|
|
}
|
|
|
|
fn sibling_index(current: usize, count: usize, forward: bool) -> Option<usize> {
|
|
if forward {
|
|
(current + 1 < count).then_some(current + 1)
|
|
} else {
|
|
current.checked_sub(1)
|
|
}
|
|
}
|
|
|
|
fn video_seek_target(timestamp: i64, duration: i64, delta: i64) -> i64 {
|
|
let target = timestamp.saturating_add(delta).max(0);
|
|
if duration > 0 {
|
|
target.min(duration)
|
|
} else {
|
|
target
|
|
}
|
|
}
|
|
|
|
fn video_volume_target(volume: f64, delta: f64) -> f64 {
|
|
(volume + delta).clamp(0.0, 1.0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn spread_handles_last_odd_page() {
|
|
assert_eq!(spread_indices(0, 3), [0, 1]);
|
|
assert_eq!(spread_indices(2, 3), [2]);
|
|
}
|
|
|
|
#[test]
|
|
fn shifted_pairing_starts_at_requested_page() {
|
|
assert_eq!(spread_indices(1, 5), [1, 2]);
|
|
}
|
|
|
|
#[test]
|
|
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]
|
|
fn continuous_visibility_uses_real_unequal_row_bounds() {
|
|
let bounds = [
|
|
Some((0.0, 1800.0)),
|
|
Some((1808.0, 2608.0)),
|
|
Some((2616.0, 4416.0)),
|
|
];
|
|
assert_eq!(intersecting_rows(&bounds, 1000.0, 1900.0), [0, 1]);
|
|
assert_eq!(intersecting_rows(&bounds, 3000.0, 3800.0), [2]);
|
|
}
|
|
|
|
#[test]
|
|
fn continuous_seek_centers_the_selected_row() {
|
|
assert_eq!(
|
|
centered_scroll_value(1_000.0, 800.0, 600.0, 0.0, 5_000.0),
|
|
1_100.0
|
|
);
|
|
assert_eq!(centered_scroll_value(0.0, 400.0, 800.0, 0.0, 5_000.0), 0.0);
|
|
assert_eq!(
|
|
centered_scroll_value(5_000.0, 800.0, 600.0, 0.0, 5_000.0),
|
|
5_000.0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fast_loading_prefetches_a_larger_window() {
|
|
assert_eq!(prefetch_window(&[10], 30, false), [10, 11, 12, 9]);
|
|
assert_eq!(
|
|
prefetch_window(&[10], 30, true),
|
|
[
|
|
10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 9, 8, 7, 6
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn page_seek_rounds_and_clamps_to_archive() {
|
|
assert_eq!(page_seek_index(3.6, 10), Some(4));
|
|
assert_eq!(page_seek_index(20.0, 10), Some(9));
|
|
assert_eq!(page_seek_index(0.0, 0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_scale_always_has_a_valid_range() {
|
|
assert_eq!(navigation_scale_upper(0), 1.0);
|
|
assert_eq!(navigation_scale_upper(1), 1.0);
|
|
assert_eq!(navigation_scale_upper(2), 1.0);
|
|
assert_eq!(navigation_scale_upper(10), 9.0);
|
|
}
|
|
|
|
#[test]
|
|
fn sibling_navigation_stops_at_boundaries() {
|
|
assert_eq!(sibling_index(1, 3, false), Some(0));
|
|
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]
|
|
fn video_controls_clamp_seek_and_volume() {
|
|
assert_eq!(video_seek_target(3_000_000, 20_000_000, -5_000_000), 0);
|
|
assert_eq!(
|
|
video_seek_target(18_000_000, 20_000_000, 5_000_000),
|
|
20_000_000
|
|
);
|
|
assert_eq!(video_seek_target(18_000_000, 0, 5_000_000), 23_000_000);
|
|
assert_eq!(video_volume_target(0.02, -0.05), 0.0);
|
|
assert_eq!(video_volume_target(0.98, 0.05), 1.0);
|
|
assert_eq!(video_volume_target(0.5, 0.05), 0.55);
|
|
}
|
|
|
|
#[test]
|
|
fn viewer_file_typeahead_wraps_by_display_name() {
|
|
let files = ["Alpha.zip", "beta.jpg", "Bravo.mp4"].map(|name| SiblingFile {
|
|
uri: format!("file:///{name}"),
|
|
name: name.into(),
|
|
kind: EntryKind::Image,
|
|
});
|
|
|
|
assert_eq!(find_sibling_prefix(&files, "br", 0), Some(2));
|
|
assert_eq!(find_sibling_prefix(&files, "b", 2), Some(2));
|
|
assert_eq!(find_sibling_prefix(&files, "b", 3), Some(1));
|
|
}
|
|
|
|
#[test]
|
|
fn fullscreen_direction_keys_hide_controls() {
|
|
for key in [
|
|
gdk::Key::Left,
|
|
gdk::Key::Right,
|
|
gdk::Key::Up,
|
|
gdk::Key::Down,
|
|
gdk::Key::KP_Left,
|
|
gdk::Key::KP_Right,
|
|
gdk::Key::KP_Up,
|
|
gdk::Key::KP_Down,
|
|
] {
|
|
assert!(fullscreen_direction_key(key));
|
|
}
|
|
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]
|
|
fn navigation_boundary_messages_match_direction() {
|
|
assert_eq!(
|
|
navigation_error_message(false),
|
|
"더 이상 이전 파일이 없습니다."
|
|
);
|
|
assert_eq!(
|
|
navigation_error_message(true),
|
|
"더 이상 다음 파일이 없습니다."
|
|
);
|
|
}
|
|
}
|