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 IMAGE_PAN_STEP: f64 = 100.0; const VIEWER_CONTROLLER_KEY: &str = "comicviewer-viewer-controller"; const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-viewer-controller"; const VIEWER_FILE_SEARCH_KEY: &str = "comicviewer-file-search"; const VIEWER_FILE_SCROLL_KEY: &str = "comicviewer-file-scroll"; const PRESERVE_SEEK_CLASS: &str = "viewer-preserve-seek"; fn clear_viewer_controller(window: >k::ApplicationWindow) { unsafe { if let Some(controller) = window.steal_data::>(VIEWER_CONTROLLER_KEY) { controller.detach_window_controllers(); } if let Some(controller) = window.steal_data::>(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::() .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 { navigate: Rc bool>, files: Rc Vec>, actions: Rc, recursive: bool, } type SiblingActionResolver = dyn Fn(&str) -> Option; #[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( navigate: Rc bool>, files: Rc Vec>, actions: Rc, recursive: bool, ) -> Self { Self { navigate, files, actions, recursive, } } 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 files(&self) -> Vec { (self.files)() } fn seek_files(&self, current_kind: EntryKind) -> Vec { seek_sibling_files(self.files(), current_kind, self.recursive) } 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 { (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 { 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 bool>, } #[derive(Clone)] pub struct FileDownloadControl { pub download: Rc, } pub struct ViewModeControl { pub selected: u32, pub change: Rc, } pub struct TwoPageDirectionControl { pub selected: u32, pub change: Rc bool>, } pub struct FileViewerControls { pub sibling_navigator: Option, pub favorite_control: FavoriteControl, pub download_control: FileDownloadControl, pub file_drop_control: FileDropControl, pub reuse_window: Option, pub view_mode_control: Option, pub two_page_direction_control: Option, } #[derive(Clone)] pub struct FileDropControl { pub handle: Rc, 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::() 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, 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: _, two_page_direction_control, } = controls; let direction_control = two_page_direction_control .expect("archive viewer requires a two-page direction control"); let title_files = sibling_navigator .as_ref() .map(SiblingNavigator::files) .unwrap_or_default(); let title = viewer_file_title(&source.display_name, &source.source_uri, &title_files); let file_list_query = reuse_window .as_ref() .map(viewer_file_search_query) .unwrap_or_default(); let file_list_scroll = reuse_window .as_ref() .map(viewer_file_scroll_position) .unwrap_or_default(); 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(&viewer_title_label(&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(&["좌→우", "우→좌"]); let direction_selected = direction_control.selected.min(1); direction_dropdown.set_selected(direction_selected); 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_query); 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_search: file_list.search, file_list_scrolled: file_list.scrolled, file_list: file_list.list, file_list_files: RefCell::new(Vec::new()), file_list_scroll_restore: Cell::new(Some(file_list_scroll)), favorite_button, favorite_toggle: favorite_control.toggle, download_button, file_download: download_control.download, view_mode_change: view_mode_control.change, direction_change: direction_control.change, direction_selected: Cell::new(direction_selected), seek_hide_timer: RefCell::new(None), seek_interacting: Cell::new(false), 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.window.has_css_class(PRESERVE_SEEK_CLASS) { controller.window.remove_css_class(PRESERVE_SEEK_CLASS); if controller.window.is_fullscreen() { controller.show_fullscreen_ui_temporarily(); } else { controller.show_seek_temporarily(); } } 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, loop_count: Option, } impl std::ops::Deref for CachedAnimation { type Target = [CachedFrame]; fn deref(&self) -> &Self::Target { &self.frames } } struct CachedPage { frames: Rc, cost: usize, last_used: u64, } #[derive(Default)] struct AnimationState { generation: Cell, timer: RefCell>, frames: RefCell>>, } 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, } struct ViewerState { pages: Vec, anchor: usize, cache: HashMap, requested: HashSet, pending: VecDeque, in_flight: usize, failed: HashMap, access_clock: u64, continuous_rows: Vec, continuous_bound: HashSet, } struct ViewerController { window: gtk::ApplicationWindow, titlebar_host: gtk::Box, source: ArchiveSource, database: Rc, stack: gtk::Stack, single_scrolled: gtk::ScrolledWindow, single_box: gtk::Box, single_picture: gtk::Picture, left_picture: gtk::Picture, right_picture: gtk::Picture, 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, file_list_button: gtk::ToggleButton, file_list_panel: gtk::Box, file_list_search: gtk::SearchEntry, file_list_scrolled: gtk::ScrolledWindow, file_list: gtk::ListBox, file_list_files: RefCell>, file_list_scroll_restore: Cell>, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, file_download: Rc, view_mode_change: Rc, direction_change: Rc bool>, direction_selected: Cell, seek_hide_timer: RefCell>, seek_interacting: Cell, last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, fullscreen_key_controller: RefCell>, fullscreen_click_controller: RefCell>, fullscreen_ui_hide_timer: RefCell>, fullscreen_ui_visible: Cell, fullscreen_notify_handler: RefCell>, image_zoom: Cell, navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, continuous_programmatic_scroll: Cell, view_layout_generation: Cell, single_animation_generation: Rc, left_animation_generation: Rc, right_animation_generation: Rc, state: RefCell, } impl ViewerController { fn connect_signals(self: &Rc) { let controller = Rc::downgrade(self); self.previous_button.connect_clicked(move |_| { if let Some(controller) = controller.upgrade() { controller.navigate_from_button(false); } }); let controller = Rc::downgrade(self); self.next_button.connect_clicked(move |_| { if let Some(controller) = controller.upgrade() { controller.navigate_from_button(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 |dropdown| { if let Some(controller) = controller.upgrade() { let selected = dropdown.selected().min(1); if (controller.direction_change)(selected) { controller.direction_selected.set(selected); controller.render(); } else { dropdown.set_selected(controller.direction_selected.get()); } } }); 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 controller = Rc::downgrade(self); self.file_list_search.connect_search_changed(move |search| { if let Some(controller) = controller.upgrade() { store_viewer_file_search_query(&controller.window, search.text().as_str()); controller.refresh_file_list(); } }); let controller = Rc::downgrade(self); self.file_list_scrolled .vadjustment() .connect_value_changed(move |adjustment| { if let Some(controller) = controller.upgrade() && controller.file_list_scroll_restore.get().is_none() { store_viewer_file_scroll_position(&controller.window, adjustment.value()); } }); 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 seek_events = gtk::EventControllerLegacy::new(); seek_events.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); seek_events.connect_event(move |_, event| { if event .downcast_ref::() .is_some_and(|event| event.button() == gdk::BUTTON_PRIMARY) && let Some(controller) = controller.upgrade() { match event.event_type() { gdk::EventType::ButtonPress => controller.begin_seek_interaction(), gdk::EventType::ButtonRelease => controller.end_seek_interaction(), _ => {} } } glib::Propagation::Proceed }); self.page_seek.add_controller(seek_events); 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::EventControllerLegacy::new(); fullscreen_click.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); fullscreen_click.connect_event(move |_, event| { if event.downcast_ref::().is_some() && event.event_type() == gdk::EventType::ButtonPress && let Some(controller) = controller.upgrade() { controller.controls_clicked(); } glib::Propagation::Proceed }); 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() && !widget_contains_focus(&controller.file_list_search, &controller.window) && 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 widget_contains_focus(&controller.file_list_search, &controller.window) { 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 controller.pan_image(false, false) => { glib::Propagation::Stop } gdk::Key::Down | gdk::Key::KP_Down if controller.pan_image(false, true) => { glib::Propagation::Stop } gdk::Key::Left | gdk::Key::KP_Left if controller.pan_image(true, false) => { glib::Propagation::Stop } gdk::Key::Right | gdk::Key::KP_Right if controller.pan_image(true, 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::equal | 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 } gdk::Key::_1 | gdk::Key::KP_1 => { controller.select_view_mode(1); glib::Propagation::Stop } gdk::Key::_2 | gdk::Key::KP_2 => { controller.select_view_mode(2); glib::Propagation::Stop } gdk::Key::_3 | gdk::Key::KP_3 => { controller.select_view_mode(3); glib::Propagation::Stop } gdk::Key::_4 | gdk::Key::KP_4 => { controller.select_view_mode(0); 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 files = filter_sibling_files(files, self.file_list_search.text().as_str()); 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)); } restore_pending_viewer_file_scroll( &self.file_list_panel, &self.file_list_scrolled, &self.file_list_scroll_restore, ); } 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) { 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(); if !controller.seek_interacting.get() { controller.set_seek_visible(false); } } }); self.seek_hide_timer.replace(Some(timer)); } fn begin_seek_interaction(self: &Rc) { self.seek_interacting.set(true); if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } self.cancel_fullscreen_ui_timer(); if self.window.is_fullscreen() { self.set_fullscreen_ui_visible(true); } else { self.set_seek_visible(true); } } fn end_seek_interaction(self: &Rc) { self.seek_interacting.set(false); if self.window.is_fullscreen() { self.show_fullscreen_ui_temporarily(); } else { self.show_seek_temporarily(); } } fn pointer_moved(self: &Rc, x: f64, y: f64) { if self.last_pointer_position.get() == Some((x, y)) { return; } self.last_pointer_position.set(Some((x, y))); } fn controls_clicked(self: &Rc) { if self.window.is_fullscreen() { self.show_fullscreen_ui_temporarily(); } else { self.show_seek_temporarily(); } } fn hide_seek(self: &Rc) { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } if !self.seek_interacting.get() { self.set_seek_visible(false); } } fn set_seek_visible(self: &Rc, visible: bool) { if self.page_seek_row.is_visible() == visible { return; } self.page_seek_row.set_visible(visible); self.window.queue_allocate(); let controller = Rc::downgrade(self); glib::idle_add_local_once(move || { if let Some(controller) = controller.upgrade() { controller.stack.queue_allocate(); controller.resize_continuous_rows(); controller.render(); } }); } fn toggle_fullscreen(&self) { if self.window.is_fullscreen() { self.window.unfullscreen(); } else { self.window.fullscreen(); } } fn fullscreen_changed(self: &Rc) { 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()); restore_pending_viewer_file_scroll( &self.file_list_panel, &self.file_list_scrolled, &self.file_list_scroll_restore, ); } } fn show_fullscreen_ui_temporarily(self: &Rc) { 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) { if self.window.is_fullscreen() && !self.seek_interacting.get() { 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, 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()); restore_pending_viewer_file_scroll( &self.file_list_panel, &self.file_list_scrolled, &self.file_list_scroll_restore, ); 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.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, result: Result, 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) { 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) { 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, 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 select_view_mode(self: &Rc, index: u32) { if self.mode_dropdown.selected() != index { self.mode_dropdown.set_selected(index); } else { self.image_zoom.set(1.0); self.apply_view_mode(); } } fn schedule_view_mode_refresh(self: &Rc) { let generation = self.view_layout_generation.get().wrapping_add(1); self.view_layout_generation.set(generation); self.stack.queue_allocate(); self.window.queue_allocate(); let controller = Rc::downgrade(self); glib::idle_add_local_once(move || { let Some(controller) = controller.upgrade() else { return; }; if controller.view_layout_generation.get() != generation { return; } match controller.view_mode() { ViewMode::FitWidth => controller.resize_single_picture(), ViewMode::Continuous => controller.resize_continuous_rows(), _ => {} } controller.render(); if controller.view_mode() == ViewMode::Continuous { controller.schedule_scroll_to_anchor(); } }); } fn navigate_from_button(self: &Rc, forward: bool) { if self.navigate(forward) && (self.image_zoom.replace(1.0) - 1.0).abs() >= f64::EPSILON { self.apply_view_mode(); } } fn navigate(self: &Rc, forward: bool) -> 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 false; } 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 false; } state.anchor = next; drop(state); self.reset_single_scroll(); self.render(); if self.view_mode() == ViewMode::Continuous { self.schedule_scroll_to_anchor(); } true } fn seek_edge(self: &Rc, end: bool) { let count = self.state.borrow().pages.len(); if count == 0 { return; } self.seek_page(if end { count - 1 } else { 0 }); } fn pan_image(&self, horizontal: bool, forward: bool) -> bool { let scrolled = match self.view_mode() { ViewMode::TwoPage => &self.spread_scrolled, ViewMode::Continuous => &self.continuous_scrolled, _ => &self.single_scrolled, }; let adjustment = if horizontal { scrolled.hadjustment() } else { scrolled.vadjustment() }; pan_adjustment(&adjustment, forward) } fn shift_pairing(self: &Rc, 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, 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) { 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 { 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 { let count = self.state.borrow().pages.len(); prefetch_window(visible, count, self.fast_image_loading) } fn request_pages(self: &Rc, indices: Vec) { 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) { 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, index: usize, result: Result) { 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::>() .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) { 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 { 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) { 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, two_page_direction_control: _, } = controls; let title_files = sibling_navigator .as_ref() .map(SiblingNavigator::files) .unwrap_or_default(); let title = viewer_file_title(&source.display_name, &source.source_uri, &title_files); let file_list_query = reuse_window .as_ref() .map(viewer_file_search_query) .unwrap_or_default(); let file_list_scroll = reuse_window .as_ref() .map(viewer_file_scroll_position) .unwrap_or_default(); 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(&viewer_title_label(&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(false); video.set_loop(false); video.set_hexpand(true); video.set_vexpand(true); stack.add_named(&video, Some("video")); let status_label = gtk::Label::builder() .label(&source.display_name) .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); let seek_files = sibling_navigator .as_ref() .map(|navigator| navigator.seek_files(kind)) .unwrap_or_default(); if let Some(current) = seek_position(&seek_files, &source.source_uri) { let count = seek_files.len(); 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_query); 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, seek_files: RefCell::new(seek_files), sibling_navigator, file_list_button, file_list_panel: file_list.panel, file_list_search: file_list.search, file_list_scrolled: file_list.scrolled, file_list: file_list.list, file_list_files: RefCell::new(Vec::new()), file_list_scroll_restore: Cell::new(Some(file_list_scroll)), 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), seek_interacting: Cell::new(false), pending_seek_index: Cell::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() { stream.set_loop(false); 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}")); } }); let restart_pending = Rc::new(Cell::new(false)); let loop_check_for_end = loop_check.downgrade(); let restart_pending_for_end = Rc::clone(&restart_pending); stream.connect_ended_notify(move |stream| { let Some(check) = loop_check_for_end.upgrade() else { return; }; schedule_video_loop_restart(stream, &check, &restart_pending_for_end); }); let loop_check_for_timestamp = loop_check.downgrade(); let restart_pending_for_timestamp = Rc::clone(&restart_pending); stream.connect_timestamp_notify(move |stream| { let Some(check) = loop_check_for_timestamp.upgrade() else { return; }; schedule_video_loop_restart(stream, &check, &restart_pending_for_timestamp); }); let stream_for_loop_check = stream.clone(); loop_check.connect_toggled(move |check| { schedule_video_loop_restart(&stream_for_loop_check, check, &restart_pending); }); } controller.connect_signals(); controller.fullscreen_changed(); if controller.window.has_css_class(PRESERVE_SEEK_CLASS) { controller.window.remove_css_class(PRESERVE_SEEK_CLASS); if controller.window.is_fullscreen() { controller.show_fullscreen_ui_temporarily(); } else { controller.show_seek_temporarily(); } } 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() { initialize_video_volume(&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>, image_dimensions: Cell>, image_zoom: Cell, image_animation_generation: Rc, video: gtk::Video, stack: gtk::Stack, status_label: gtk::Label, media_seek: gtk::Scale, media_seek_row: gtk::Box, seek_files: RefCell>, sibling_navigator: Option, file_list_button: gtk::ToggleButton, file_list_panel: gtk::Box, file_list_search: gtk::SearchEntry, file_list_scrolled: gtk::ScrolledWindow, file_list: gtk::ListBox, file_list_files: RefCell>, file_list_scroll_restore: Cell>, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, file_download: Rc, seek_generation: Cell, seek_opening: Cell, seek_hide_timer: RefCell>, seek_interacting: Cell, pending_seek_index: Cell>, last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, fullscreen_key_controller: RefCell>, fullscreen_click_controller: RefCell>, fullscreen_ui_hide_timer: RefCell>, fullscreen_ui_visible: Cell, fullscreen_notify_handler: RefCell>, navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, } impl MediaViewerController { fn connect_signals(self: &Rc) { let fullscreen_click = gtk::EventControllerLegacy::new(); fullscreen_click.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); fullscreen_click.connect_event(move |_, event| { if event.downcast_ref::().is_some() && event.event_type() == gdk::EventType::ButtonPress && let Some(controller) = controller.upgrade() { controller.controls_clicked(); } glib::Propagation::Proceed }); 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() && !widget_contains_focus(&controller.file_list_search, &controller.window) && 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 widget_contains_focus(&controller.file_list_search, &controller.window) { 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 && controller.pan_image(false, false) => { glib::Propagation::Stop } gdk::Key::Down | gdk::Key::KP_Down if controller.kind == EntryKind::Image && controller.pan_image(false, true) => { glib::Propagation::Stop } gdk::Key::Left | gdk::Key::KP_Left if controller.kind == EntryKind::Image && controller.pan_image(true, false) => { glib::Propagation::Stop } gdk::Key::Right | gdk::Key::KP_Right if controller.kind == EntryKind::Image && controller.pan_image(true, 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::equal | 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 } gdk::Key::_1 | gdk::Key::KP_1 if controller.kind == EntryKind::Image => { controller.select_image_view_mode(1); glib::Propagation::Stop } gdk::Key::_2 | gdk::Key::KP_2 if controller.kind == EntryKind::Image => { controller.select_image_view_mode(2); glib::Propagation::Stop } gdk::Key::_4 | gdk::Key::KP_4 if controller.kind == EntryKind::Image => { controller.select_image_view_mode(0); 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 controller = Rc::downgrade(self); self.file_list_search.connect_search_changed(move |search| { if let Some(controller) = controller.upgrade() { store_viewer_file_search_query(&controller.window, search.text().as_str()); controller.refresh_file_list(); } }); let controller = Rc::downgrade(self); self.file_list_scrolled .vadjustment() .connect_value_changed(move |adjustment| { if let Some(controller) = controller.upgrade() && controller.file_list_scroll_restore.get().is_none() { store_viewer_file_scroll_position(&controller.window, adjustment.value()); } }); 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 count = controller.seek_files.borrow().len(); let Some(index) = page_seek_index(scale.value(), count) else { return; }; if controller.seek_interacting.get() { controller.pending_seek_index.set(Some(index)); } else { controller.schedule_seek(index); } }); let seek_events = gtk::EventControllerLegacy::new(); seek_events.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::downgrade(self); seek_events.connect_event(move |_, event| { if event .downcast_ref::() .is_some_and(|event| event.button() == gdk::BUTTON_PRIMARY) && let Some(controller) = controller.upgrade() { match event.event_type() { gdk::EventType::ButtonPress => controller.begin_seek_interaction(), gdk::EventType::ButtonRelease => controller.end_seek_interaction(), _ => {} } } glib::Propagation::Proceed }); self.media_seek.add_controller(seek_events); } 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 files = filter_sibling_files(files, self.file_list_search.text().as_str()); 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)); } restore_pending_viewer_file_scroll( &self.file_list_panel, &self.file_list_scrolled, &self.file_list_scroll_restore, ); } 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 select_image_view_mode(&self, index: u32) { if self.image_mode_dropdown.selected() != index { self.image_mode_dropdown.set_selected(index); } else { self.image_zoom.set(1.0); self.apply_image_view_mode(); } } 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 pan_image(&self, horizontal: bool, forward: bool) -> bool { let adjustment = if horizontal { self.image_scrolled.hadjustment() } else { self.image_scrolled.vadjustment() }; pan_adjustment(&adjustment, forward) } 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) { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } let seek_files = self.seek_files.borrow(); if let Some(current) = seek_position(&seek_files, &self.source.source_uri) { let count = seek_files.len(); 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(); if !controller.seek_interacting.get() { controller.set_seek_visible(false); } } }); self.seek_hide_timer.replace(Some(timer)); } fn begin_seek_interaction(self: &Rc) { self.seek_interacting.set(true); self.pending_seek_index.set(None); self.seek_generation .set(self.seek_generation.get().wrapping_add(1)); if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } self.cancel_fullscreen_ui_timer(); if self.window.is_fullscreen() { self.set_fullscreen_ui_visible(true); } else { self.set_seek_visible(true); } } fn end_seek_interaction(self: &Rc) { if !self.seek_interacting.replace(false) { return; } if self.window.is_fullscreen() { self.show_fullscreen_ui_temporarily(); } else { self.show_seek_temporarily(); } if let Some(index) = self.pending_seek_index.take() { self.schedule_seek(index); } } fn pointer_moved(self: &Rc, x: f64, y: f64) { if self.last_pointer_position.get() == Some((x, y)) { return; } self.last_pointer_position.set(Some((x, y))); } fn controls_clicked(self: &Rc) { if self.window.is_fullscreen() { self.show_fullscreen_ui_temporarily(); } else { self.show_seek_temporarily(); } } fn hide_seek(self: &Rc) { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } if !self.seek_interacting.get() { self.set_seek_visible(false); } } fn set_seek_visible(self: &Rc, visible: bool) { if self.media_seek_row.is_visible() == visible { return; } self.media_seek_row.set_visible(visible); self.window.queue_allocate(); let controller = Rc::downgrade(self); glib::idle_add_local_once(move || { if let Some(controller) = controller.upgrade() { controller.stack.queue_allocate(); controller.picture.queue_allocate(); controller.video.queue_allocate(); } }); } fn schedule_seek(self: &Rc, index: usize) { if self.sibling_navigator.is_none() { return; } let Some(file) = self.seek_files.borrow().get(index).cloned() else { return; }; if file.uri == self.source.source_uri || 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; }; let Some(uri) = controller .seek_files .borrow() .get(index) .map(|file| file.uri.clone()) else { return; }; controller.seek_opening.set(true); controller.window.add_css_class(PRESERVE_SEEK_CLASS); if navigator.open_uri(&uri, controller.window.clone()) { controller.prepare_for_navigation(); } else { controller.seek_opening.set(false); controller.window.remove_css_class(PRESERVE_SEEK_CLASS); } }); } fn prepare_for_navigation(&self) { self.seek_generation .set(self.seek_generation.get().wrapping_add(1)); 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.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()); restore_pending_viewer_file_scroll( &self.file_list_panel, &self.file_list_scrolled, &self.file_list_scroll_restore, ); } } fn show_fullscreen_ui_temporarily(self: &Rc) { 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) { if self.window.is_fullscreen() && !self.seek_interacting.get() { 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, 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()); restore_pending_viewer_file_scroll( &self.file_list_panel, &self.file_list_scrolled, &self.file_list_scroll_restore, ); 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.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) { 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 { "즐겨찾기 추가" } } fn viewer_title_label(title: &str) -> gtk::Label { gtk::Label::builder() .label(title) .hexpand(true) .ellipsize(gtk::pango::EllipsizeMode::Middle) .tooltip_text(title) .build() } fn viewer_file_title(display_name: &str, current_uri: &str, files: &[SiblingFile]) -> String { seek_position(files, current_uri).map_or_else( || display_name.to_owned(), |index| format!("{display_name} ({}/{})", index + 1, files.len()), ) } struct ViewerFileList { panel: gtk::Box, search: gtk::SearchEntry, scrolled: gtk::ScrolledWindow, list: gtk::ListBox, } #[derive(Default)] struct TypeaheadBuffer { query: String, last_input: Option, } 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::(); 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 { 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(query: &str) -> ViewerFileList { let search = gtk::SearchEntry::builder() .placeholder_text("재생 목록 검색") .margin_top(8) .margin_bottom(8) .margin_start(8) .margin_end(8) .build(); search.set_text(query); let list = gtk::ListBox::new(); list.set_selection_mode(gtk::SelectionMode::Single); list.set_activate_on_single_click(false); let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) .child(&list) .build(); let panel = gtk::Box::new(gtk::Orientation::Vertical, 0); panel.set_width_request(280); panel.set_vexpand(true); panel.append(&search); panel.append(&scrolled); panel.add_css_class("sidebar"); panel.set_visible(false); ViewerFileList { panel, search, scrolled, list, } } fn viewer_file_search_query(window: >k::ApplicationWindow) -> String { unsafe { window .data::(VIEWER_FILE_SEARCH_KEY) .map(|query| query.as_ref().clone()) .unwrap_or_default() } } fn store_viewer_file_search_query(window: >k::ApplicationWindow, query: &str) { unsafe { window.set_data(VIEWER_FILE_SEARCH_KEY, query.to_owned()); } } fn viewer_file_scroll_position(window: >k::ApplicationWindow) -> f64 { unsafe { window .data::(VIEWER_FILE_SCROLL_KEY) .map(|position| *position.as_ref()) .unwrap_or_default() } } fn store_viewer_file_scroll_position(window: >k::ApplicationWindow, position: f64) { unsafe { window.set_data(VIEWER_FILE_SCROLL_KEY, position.max(0.0)); } } fn restore_viewer_file_scroll(scrolled: >k::ScrolledWindow, position: f64) { let scrolled = scrolled.clone(); glib::idle_add_local_once(move || { let adjustment = scrolled.vadjustment(); let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); adjustment.set_value(position.clamp(adjustment.lower(), maximum)); }); } fn restore_pending_viewer_file_scroll( panel: >k::Box, scrolled: >k::ScrolledWindow, pending: &Cell>, ) { if !panel.is_visible() { return; } if let Some(position) = pending.take() { restore_viewer_file_scroll(scrolled, position); } } fn widget_contains_focus(widget: &impl IsA, window: >k::ApplicationWindow) -> bool { gtk::prelude::GtkWindowExt::focus(window).is_some_and(|focus| { let widget = widget.as_ref(); focus == *widget || focus.is_ancestor(widget) }) } 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>, x: f64, y: f64, ) -> Option<(gtk::ListBoxRow, SiblingFile)> { let widget = list.pick(x, y, gtk::PickFlags::DEFAULT)?; let row = widget .clone() .downcast::() .ok() .or_else(|| { widget .ancestor(gtk::ListBoxRow::static_type()) .and_then(|widget| widget.downcast::().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 { 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 multiplier = if zoom_in { 1.0 + IMAGE_ZOOM_STEP } else { 1.0 - IMAGE_ZOOM_STEP }; ((current * multiplier * 10_000.0).round() / 10_000.0).clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX) } fn scaled_dimension(value: i32, zoom: f64) -> i32 { ((value.max(1) as f64 * zoom).round() as i32).clamp(1, 30_000) } fn zoomed_picture_request( mode: ViewMode, viewport_width: i32, viewport_height: i32, texture_width: i32, texture_height: i32, zoom: f64, ) -> (i32, i32) { let viewport_width = viewport_width.max(1); let viewport_height = viewport_height.max(1); let texture_width = texture_width.max(1); let texture_height = texture_height.max(1); let (base_width, base_height) = match mode { ViewMode::ActualSize => (texture_width, texture_height), ViewMode::FitWidth | ViewMode::Continuous => ( viewport_width, row_height(viewport_width, texture_width as f32 / texture_height as f32), ), ViewMode::FitHeight | ViewMode::TwoPage => { let scale = (viewport_width as f64 / texture_width as f64) .min(viewport_height as f64 / texture_height as f64); ( (texture_width as f64 * scale).round() as i32, (texture_height as f64 * scale).round() as i32, ) } }; ( scaled_dimension(base_width, zoom.clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX)), scaled_dimension(base_height, zoom.clamp(IMAGE_ZOOM_MIN, IMAGE_ZOOM_MAX)), ) } fn texture_frames(page: DecodedPage) -> (Rc, usize) { let width = page.width; let height = page.height; 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>, state: &Rc, ) { 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, frames: Rc, state: Rc, 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 { 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 { 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 { (count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1)) } fn sibling_index(current: usize, count: usize, forward: bool) -> Option { if forward { (current + 1 < count).then_some(current + 1) } else { current.checked_sub(1) } } fn seek_sibling_files( mut files: Vec, current_kind: EntryKind, recursive: bool, ) -> Vec { if current_kind == EntryKind::Image && !recursive { files.retain(|file| file.kind != EntryKind::ZipArchive); } files } fn filter_sibling_files(mut files: Vec, query: &str) -> Vec { let query = query.to_lowercase(); if !query.is_empty() { files.retain(|file| file.name.to_lowercase().contains(&query)); } files } fn seek_position(files: &[SiblingFile], current_uri: &str) -> Option { files.iter().position(|file| file.uri == current_uri) } 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) } fn video_loop_restart_needed( ended: bool, timestamp: i64, duration: i64, loop_active: bool, ) -> bool { loop_active && (ended || (duration > 0 && timestamp >= duration)) } fn schedule_video_loop_restart( stream: >k::MediaStream, check: >k::CheckButton, pending: &Rc>, ) { if !video_loop_restart_needed( stream.is_ended(), stream.timestamp(), stream.duration(), check.is_active(), ) { pending.set(false); return; } if pending.replace(true) { return; } let stream = stream.clone(); let check = check.downgrade(); let pending = Rc::clone(pending); glib::timeout_add_local_once(Duration::from_millis(50), move || { let Some(check) = check.upgrade() else { return; }; if video_loop_restart_needed( stream.is_ended(), stream.timestamp(), stream.duration(), check.is_active(), ) { stream.pause(); stream.seek(0); stream.play(); } else { pending.set(false); } }); } fn initialize_video_volume(stream: >k::MediaStream) { let volume = stream.volume().clamp(0.0, 1.0); // Force the realized playback backend and GTK controls to receive the same initial value. stream.set_volume(if volume > 0.0 { 0.0 } else { 1.0 }); stream.set_volume(volume); } fn pan_adjustment(adjustment: >k::Adjustment, forward: bool) -> bool { let Some(value) = image_pan_target( adjustment.value(), adjustment.lower(), adjustment.upper(), adjustment.page_size(), forward, ) else { return false; }; adjustment.set_value(value); true } fn image_pan_target( value: f64, lower: f64, upper: f64, page_size: f64, forward: bool, ) -> Option { let maximum = (upper - page_size).max(lower); if maximum <= lower { return None; } let delta = if forward { IMAGE_PAN_STEP } else { -IMAGE_PAN_STEP }; Some((value + delta).clamp(lower, maximum)) } #[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 video_loop_restarts_only_after_ending_when_enabled() { assert!(video_loop_restart_needed(true, 0, 10, true)); assert!(video_loop_restart_needed(false, 11, 10, true)); assert!(!video_loop_restart_needed(false, 9, 10, true)); assert!(!video_loop_restart_needed(false, 11, 0, true)); assert!(!video_loop_restart_needed(true, 10, 10, false)); } #[test] fn image_pan_moves_at_most_100_pixels_and_consumes_clipped_axis() { assert_eq!( image_pan_target(250.0, 0.0, 1_000.0, 400.0, true), Some(350.0) ); assert_eq!( image_pan_target(600.0, 0.0, 1_000.0, 400.0, true), Some(600.0) ); assert_eq!(image_pan_target(0.0, 0.0, 400.0, 400.0, true), None); } #[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(1.1, true), 1.21); assert_eq!(next_image_zoom(1.1, false), 0.99); 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 ordinary_image_seek_excludes_zip_but_recursive_seek_keeps_it() { let files = vec![ SiblingFile { uri: "file:///book.zip".into(), name: "book.zip".into(), kind: EntryKind::ZipArchive, }, SiblingFile { uri: "file:///page.jpg".into(), name: "page.jpg".into(), kind: EntryKind::Image, }, SiblingFile { uri: "file:///clip.mp4".into(), name: "clip.mp4".into(), kind: EntryKind::Video, }, ]; let ordinary = seek_sibling_files(files.clone(), EntryKind::Image, false); assert_eq!(ordinary.len(), 2); assert!( !ordinary .iter() .any(|file| file.kind == EntryKind::ZipArchive) ); assert_eq!(seek_position(&ordinary, "file:///page.jpg"), Some(0)); assert_eq!(seek_position(&ordinary, "file:///clip.mp4"), Some(1)); assert_eq!(seek_sibling_files(files, EntryKind::Image, true).len(), 3); } #[test] fn viewer_title_includes_current_file_index() { let files = ["first.zip", "second.jpg", "third.mp4"].map(|name| SiblingFile { uri: format!("file:///{name}"), name: name.into(), kind: EntryKind::Image, }); assert_eq!( viewer_file_title("second.jpg", "file:///second.jpg", &files), "second.jpg (2/3)" ); assert_eq!( viewer_file_title("missing.jpg", "file:///missing.jpg", &files), "missing.jpg" ); assert_eq!( viewer_file_title("only.jpg", "file:///only.jpg", &[]), "only.jpg" ); } #[test] fn playlist_search_filters_and_empty_query_restores_files() { let files = ["Alpha.zip", "beta.jpg", "Bravo.mp4"].map(|name| SiblingFile { uri: format!("file:///{name}"), name: name.into(), kind: EntryKind::Image, }); let filtered = filter_sibling_files(files.to_vec(), "BRAV"); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "Bravo.mp4"); assert_eq!(filter_sibling_files(files.to_vec(), "").len(), 3); } #[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), "더 이상 다음 파일이 없습니다." ); } }