use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use std::sync::mpsc::{self, TryRecvError}; use std::thread; use std::time::Duration; 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; pub struct ViewerWindow; pub struct SiblingNavigator { position: Rc Option<(usize, usize)>>, navigate: Rc bool>, } impl SiblingNavigator { pub fn new( position: Rc Option<(usize, usize)>>, navigate: Rc bool>, ) -> Self { Self { position, navigate } } 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 position(&self) -> Option<(usize, usize)> { (self.position)() } } #[derive(Clone, Copy)] pub enum SiblingTarget { Relative(bool), Index(usize), } 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), } } } pub struct FavoriteControl { pub active: bool, pub toggle: Rc bool>, } pub struct FileDownloadControl { pub download: Rc, } pub struct ViewModeControl { pub selected: u32, pub change: Rc, } pub struct FileViewerControls { pub sibling_navigator: Option, pub favorite_control: FavoriteControl, pub download_control: FileDownloadControl, pub reuse_window: Option, } 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, reuse_window, } = controls; let title = source.display_name.clone(); let window = reuse_window.unwrap_or_else(|| { gtk::ApplicationWindow::builder() .application(application) .title(&title) .default_width(1100) .default_height(850) .build() }); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); let download_button = gtk::Button::with_label("파일 다운로드"); header.pack_end(&download_button); header.pack_end(&favorite_button); window.set_titlebar(Some(&header)); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6); toolbar.set_margin_top(8); toolbar.set_margin_bottom(8); toolbar.set_margin_start(8); toolbar.set_margin_end(8); let previous_button = gtk::Button::builder() .icon_name("go-previous-symbolic") .tooltip_text("이전") .build(); let next_button = gtk::Button::builder() .icon_name("go-next-symbolic") .tooltip_text("다음") .build(); let shift_back_button = gtk::Button::with_label("한 장 이전"); let shift_forward_button = gtk::Button::with_label("한 장 다음"); let page_label = gtk::Label::new(Some("- / -")); page_label.set_hexpand(true); let direction_dropdown = gtk::DropDown::from_strings(&["좌→우", "우→좌"]); direction_dropdown.set_selected(1); let mode_dropdown = gtk::DropDown::from_strings(&[ "100%", "폭 맞춤", "높이 맞춤", "2페이지", "이어서 보기", ]); mode_dropdown.set_selected(view_mode_control.selected); toolbar.append(&previous_button); toolbar.append(&shift_back_button); toolbar.append(&shift_forward_button); toolbar.append(&next_button); toolbar.append(&page_label); toolbar.append(&direction_dropdown); toolbar.append(&mode_dropdown); let single_picture = page_picture(); let single_scrolled = gtk::ScrolledWindow::builder() .hexpand(true) .vexpand(true) .child(&single_picture) .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 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_box, 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.set_child(Some(&window_overlay)); let controller = Rc::new(ViewerController { window, source, database, stack, single_picture, left_picture, right_picture, continuous_box, continuous_scrolled, spinner, page_label, page_seek, page_seek_row, status_label, previous_button, next_button, shift_back_button, shift_forward_button, mode_dropdown, direction_dropdown, fast_image_loading, case_insensitive_sort, sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, download_button, file_download: download_control.download, view_mode_change: view_mode_control.change, seek_hide_timer: RefCell::new(None), last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), navigation_message, navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), 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.apply_view_mode(); controller.window.present(); controller.load_archive(); } } #[derive(Clone, Copy, PartialEq, Eq)] enum ViewMode { ActualSize, FitWidth, FitHeight, TwoPage, Continuous, } struct CachedPage { texture: gdk::MemoryTexture, cost: usize, last_used: u64, } struct ContinuousRow { frame: gtk::AspectFrame, picture: gtk::Picture, ratio: f32, } 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, source: ArchiveSource, database: Rc, stack: gtk::Stack, single_picture: gtk::Picture, left_picture: gtk::Picture, right_picture: gtk::Picture, continuous_box: gtk::Box, continuous_scrolled: gtk::ScrolledWindow, spinner: gtk::Spinner, page_label: gtk::Label, page_seek: gtk::Scale, page_seek_row: 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, favorite_button: gtk::Button, favorite_toggle: Rc bool>, download_button: gtk::Button, file_download: Rc, view_mode_change: Rc, seek_hide_timer: RefCell>, last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, state: RefCell, } impl ViewerController { fn connect_signals(self: &Rc) { let controller = Rc::clone(self); self.previous_button .connect_clicked(move |_| controller.navigate(false)); let controller = Rc::clone(self); self.next_button .connect_clicked(move |_| controller.navigate(true)); let controller = Rc::clone(self); self.shift_back_button .connect_clicked(move |_| controller.shift_pairing(-1)); let controller = Rc::clone(self); self.shift_forward_button .connect_clicked(move |_| controller.shift_pairing(1)); let controller = Rc::clone(self); self.mode_dropdown.connect_selected_notify(move |dropdown| { (controller.view_mode_change)(dropdown.selected()); controller.apply_view_mode(); }); let controller = Rc::clone(self); self.direction_dropdown .connect_selected_notify(move |_| controller.render()); let controller = Rc::clone(self); self.favorite_button.connect_clicked(move |_| { let active = (controller.favorite_toggle)(); controller.favorite_button.set_label(favorite_label(active)); }); let controller = Rc::clone(self); self.download_button.connect_clicked(move |_| { (controller.file_download)( controller.window.clone().upcast(), controller.status_label.clone(), ); }); let controller = Rc::clone(self); self.page_seek.connect_value_changed(move |scale| { let page_count = controller.state.borrow().pages.len(); if let Some(index) = page_seek_index(scale.value(), page_count) { controller.seek_page(index); } }); let adjustment = self.continuous_scrolled.vadjustment(); let controller = Rc::clone(self); adjustment.connect_value_changed(move |_| controller.continuous_scrolled()); let controller = Rc::clone(self); adjustment.connect_changed(move |_| controller.continuous_scrolled()); let controller = Rc::clone(self); self.continuous_scrolled .connect_notify_local(Some("width"), move |_, _| { controller.resize_continuous_rows(); }); let scroll_controller = gtk::EventControllerScroll::new( gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE, ); scroll_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::clone(self); scroll_controller.connect_scroll(move |_, _, delta_y| { if controller.view_mode() == ViewMode::Continuous || delta_y == 0.0 { return glib::Propagation::Proceed; } controller.navigate(delta_y > 0.0); glib::Propagation::Stop }); self.stack.add_controller(scroll_controller); let motion_controller = gtk::EventControllerMotion::new(); motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::clone(self); motion_controller.connect_motion(move |_, x, y| controller.pointer_moved(x, y)); self.window.add_controller(motion_controller.clone()); self.motion_controller.replace(Some(motion_controller)); let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); key_controller.connect_key_pressed(move |_, key, _, _| { controller.hide_seek(); match key { gdk::Key::Left | gdk::Key::Page_Up => { controller.navigate(false); glib::Propagation::Stop } gdk::Key::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 } _ 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)); } 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 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) { 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); } } 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(); controller.set_seek_visible(false); } }); self.seek_hide_timer.replace(Some(timer)); } 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))); self.show_seek_temporarily(); } fn hide_seek(self: &Rc) { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } 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 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, }); } 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"), } if mode == ViewMode::Continuous { self.single_picture.set_paintable(gdk::Paintable::NONE); self.left_picture.set_paintable(gdk::Paintable::NONE); self.right_picture.set_paintable(gdk::Paintable::NONE); self.schedule_scroll_to_anchor(); } else { self.clear_continuous_paintables(); } let actual = mode == ViewMode::ActualSize; self.single_picture.set_can_shrink(!actual); self.single_picture.set_hexpand(mode == ViewMode::FitWidth); self.single_picture.set_vexpand(mode == ViewMode::FitHeight); self.render(); } fn navigate(self: &Rc, forward: bool) { let step = if self.view_mode() == ViewMode::TwoPage { 2 } else { 1 }; let mut state = self.state.borrow_mut(); if state.pages.is_empty() { return; } let next = if forward { state.anchor.saturating_add(step).min(state.pages.len() - 1) } else { state.anchor.saturating_sub(step) }; if next == state.anchor { return; } state.anchor = next; drop(state); self.render(); if self.view_mode() == ViewMode::Continuous { self.schedule_scroll_to_anchor(); } } 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.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 cost = page.rgba.len(); let bytes = glib::Bytes::from_owned(page.rgba); let texture = gdk::MemoryTexture::new( page.width as i32, page.height as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, page.width as usize * 4, ); state.access_clock = state.access_clock.wrapping_add(1); let last_used = state.access_clock; state.cache.insert( index, CachedPage { texture, 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); frame.set_size_request(-1, row_height(self.continuous_content_width(), ratio)); } } self.evict_cache(); 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(); self.single_picture .set_paintable(state.cache.get(&index).map(|page| &page.texture)); self.status_label.set_text(&state.pages[index].name); } fn render_spread(&self, indices: &[usize]) { let state = self.state.borrow(); let first = indices.first().and_then(|index| state.cache.get(index)); let second = indices.get(1).and_then(|index| state.cache.get(index)); if self.direction_dropdown.selected() == 1 { self.left_picture .set_paintable(second.map(|page| &page.texture)); self.right_picture .set_paintable(first.map(|page| &page.texture)); } else { self.left_picture .set_paintable(first.map(|page| &page.texture)); self.right_picture .set_paintable(second.map(|page| &page.texture)); } self.status_label.set_text( &indices .iter() .map(|index| state.pages[*index].name.as_str()) .collect::>() .join(" · "), ); } 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) { updates.push((state.continuous_rows[*index].picture.clone(), None)); } for index in &new_bound { state.access_clock = state.access_clock.wrapping_add(1); let clock = state.access_clock; let texture = state.cache.get_mut(index).map(|page| { page.last_used = clock; page.texture.clone() }); updates.push((state.continuous_rows[*index].picture.clone(), texture)); } state.continuous_bound = new_bound; let status = visible .first() .map(|index| state.pages[*index].name.clone()); drop(state); for (picture, texture) in updates { picture.set_paintable(texture.as_ref()); } if let Some(status) = status { self.status_label.set_text(&status); } } fn continuous_scrolled(self: &Rc) { if self.view_mode() != ViewMode::Continuous || self.state.borrow().pages.is_empty() { return; } let visible = self.visible_indices(); let new_anchor = visible.first().copied(); let changed = new_anchor.is_some_and(|index| index != self.state.borrow().anchor); if let Some(index) = new_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 state = self.state.borrow(); let Some(row) = state.continuous_rows.get(state.anchor) else { return; }; let Some(bounds) = row.frame.compute_bounds(&self.continuous_box) else { return; }; let adjustment = self.continuous_scrolled.vadjustment(); let maximum = (adjustment.upper() - adjustment.page_size()).max(adjustment.lower()); adjustment.set_value(f64::from(bounds.y()).clamp(adjustment.lower(), maximum)); } 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| state.continuous_rows[index].picture.clone()) .collect(); drop(state); for picture in pictures { picture.set_paintable(gdk::Paintable::NONE); } } fn resize_continuous_rows(&self) { let width = self.continuous_content_width(); let rows: Vec<_> = self .state .borrow() .continuous_rows .iter() .map(|row| (row.frame.clone(), row.ratio)) .collect(); for (frame, ratio) in rows { frame.set_size_request(-1, row_height(width, ratio)); } } fn continuous_content_width(&self) -> i32 { (self.continuous_scrolled.allocated_width() - 24).max(320) } 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, reuse_window, } = controls; let title = source.display_name.clone(); let window = reuse_window.unwrap_or_else(|| { gtk::ApplicationWindow::builder() .application(application) .title(&title) .default_width(1100) .default_height(850) .build() }); window.set_title(Some(&title)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); let download_button = gtk::Button::with_label("파일 다운로드"); header.pack_end(&download_button); header.pack_end(&favorite_button); window.set_titlebar(Some(&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(); stack.add_named(&picture, Some("image")); let video = if kind == EntryKind::Video { gtk::Video::for_filename(Some(&source.local_path)) } else { gtk::Video::new() }; video.set_autoplay(true); video.set_hexpand(true); video.set_vexpand(true); stack.add_named(&video, Some("video")); let status_label = gtk::Label::builder() .label(&title) .xalign(0.0) .margin_top(6) .margin_bottom(6) .margin_start(8) .margin_end(8) .build(); let media_seek = navigation_scale(); let media_seek_row = navigation_scale_row(&media_seek); if let Some((current, count)) = sibling_navigator .as_ref() .and_then(SiblingNavigator::position) { media_seek.set_range(0.0, navigation_scale_upper(count)); media_seek.set_value(current as f64); media_seek.set_sensitive(count > 1); } else { media_seek.set_sensitive(false); } root.append(&stack); root.append(&media_seek_row); root.append(&status_label); let window_overlay = gtk::Overlay::new(); window_overlay.set_child(Some(&root)); let (navigation_message, navigation_message_label) = navigation_message(); window_overlay.add_overlay(&navigation_message); window.set_child(Some(&window_overlay)); let controller = Rc::new(MediaViewerController { window, source, picture, video, stack, status_label, media_seek, media_seek_row, sibling_navigator, favorite_button, favorite_toggle: favorite_control.toggle, download_button, file_download: download_control.download, seek_generation: Cell::new(0), seek_opening: Cell::new(false), seek_hide_timer: RefCell::new(None), last_pointer_position: Cell::new(None), key_controller: RefCell::new(None), motion_controller: RefCell::new(None), navigation_message, navigation_message_label, navigation_message_generation: Rc::new(Cell::new(0)), }); if let Some(stream) = controller.video.media_stream() { let status_label = controller.status_label.clone(); stream.connect_error_notify(move |stream| { if let Some(error) = stream.error() { status_label.set_text(&format!("동영상을 재생할 수 없습니다: {error}")); } }); } controller.connect_signals(); controller.window.present(); match kind { EntryKind::Image => controller.load_image(), EntryKind::Video => { controller.stack.set_visible_child_name("video"); if let Some(stream) = controller.video.media_stream() { stream.play(); } } _ => {} } } } struct MediaViewerController { window: gtk::ApplicationWindow, source: ArchiveSource, picture: gtk::Picture, video: gtk::Video, stack: gtk::Stack, status_label: gtk::Label, media_seek: gtk::Scale, media_seek_row: gtk::Box, sibling_navigator: Option, 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>, last_pointer_position: Cell>, key_controller: RefCell>, motion_controller: RefCell>, navigation_message: gtk::Box, navigation_message_label: gtk::Label, navigation_message_generation: Rc>, } impl MediaViewerController { fn connect_signals(self: &Rc) { let motion_controller = gtk::EventControllerMotion::new(); motion_controller.set_propagation_phase(gtk::PropagationPhase::Capture); let controller = Rc::clone(self); motion_controller.connect_motion(move |_, x, y| controller.pointer_moved(x, y)); self.window.add_controller(motion_controller.clone()); self.motion_controller.replace(Some(motion_controller)); let keys = gtk::EventControllerKey::new(); let controller = Rc::clone(self); keys.connect_key_pressed(move |_, key, _, _| { controller.hide_seek(); match key.to_unicode() { Some('[') => { controller.open_sibling(false); glib::Propagation::Stop } Some(']') => { controller.open_sibling(true); glib::Propagation::Stop } Some(character) if character.eq_ignore_ascii_case(&'f') => { controller.toggle_fullscreen(); glib::Propagation::Stop } _ if key == gdk::Key::Escape && 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::clone(self); self.favorite_button.connect_clicked(move |_| { let active = (controller.favorite_toggle)(); controller.favorite_button.set_label(favorite_label(active)); }); let controller = Rc::clone(self); self.download_button.connect_clicked(move |_| { (controller.file_download)( controller.window.clone().upcast(), controller.status_label.clone(), ); }); let controller = Rc::clone(self); self.media_seek.connect_value_changed(move |scale| { let Some(navigator) = &controller.sibling_navigator else { return; }; let Some((_, count)) = navigator.position() else { return; }; let Some(index) = page_seek_index(scale.value(), count) else { return; }; controller.schedule_seek(index); }); } fn 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 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(); } if let Some((current, count)) = self .sibling_navigator .as_ref() .and_then(SiblingNavigator::position) { self.media_seek .set_range(0.0, navigation_scale_upper(count)); self.media_seek.set_sensitive(count > 1); self.media_seek.set_value(current as f64); } self.set_seek_visible(true); let controller = Rc::downgrade(self); let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || { if let Some(controller) = controller.upgrade() { controller.seek_hide_timer.borrow_mut().take(); controller.set_seek_visible(false); } }); self.seek_hide_timer.replace(Some(timer)); } fn pointer_moved(self: &Rc, x: f64, y: f64) { if self.last_pointer_position.get() == Some((x, y)) { return; } self.last_pointer_position.set(Some((x, y))); self.show_seek_temporarily(); } fn hide_seek(self: &Rc) { if let Some(timer) = self.seek_hide_timer.borrow_mut().take() { timer.remove(); } 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) { let Some(navigator) = &self.sibling_navigator else { return; }; if navigator .position() .is_none_or(|(current, _)| index == current) || self.seek_opening.get() { return; } let generation = self.seek_generation.get().wrapping_add(1); self.seek_generation.set(generation); let controller = Rc::downgrade(self); glib::timeout_add_local_once(Duration::from_millis(180), move || { let Some(controller) = controller.upgrade() else { return; }; if controller.seek_generation.get() != generation || controller.seek_opening.get() { return; } let Some(navigator) = &controller.sibling_navigator else { return; }; if navigator.open_index(index, controller.window.clone()) { controller.seek_opening.set(true); controller.prepare_for_navigation(); } }); } fn prepare_for_navigation(&self) { if let Some(stream) = self.video.media_stream() { stream.pause(); } 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); } } fn toggle_fullscreen(&self) { if self.window.is_fullscreen() { self.window.unfullscreen(); } else { self.window.fullscreen(); } } 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) => { let bytes = glib::Bytes::from_owned(page.rgba); let texture = gdk::MemoryTexture::new( page.width as i32, page.height as i32, gdk::MemoryFormat::R8g8b8a8, &bytes, page.width as usize * 4, ); self.picture.set_paintable(Some(&texture)); 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 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 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 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) } } #[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); } #[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 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); } #[test] fn navigation_boundary_messages_match_direction() { assert_eq!( navigation_error_message(false), "더 이상 이전 파일이 없습니다." ); assert_eq!( navigation_error_message(true), "더 이상 다음 파일이 없습니다." ); } }