Comicviewer/src/viewer.rs

1721 lines
60 KiB
Rust

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<dyn Fn() -> Option<(usize, usize)>>,
navigate: Rc<dyn Fn(SiblingTarget, gtk::ApplicationWindow) -> bool>,
}
impl SiblingNavigator {
pub fn new(
position: Rc<dyn Fn() -> Option<(usize, usize)>>,
navigate: Rc<dyn Fn(SiblingTarget, gtk::ApplicationWindow) -> 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<usize> {
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<dyn Fn() -> bool>,
}
pub struct FileDownloadControl {
pub download: Rc<dyn Fn(gtk::Window, gtk::Label)>,
}
pub struct ViewModeControl {
pub selected: u32,
pub change: Rc<dyn Fn(u32)>,
}
pub struct FileViewerControls {
pub sibling_navigator: Option<SiblingNavigator>,
pub favorite_control: FavoriteControl,
pub download_control: FileDownloadControl,
pub reuse_window: Option<gtk::ApplicationWindow>,
}
impl ViewerWindow {
pub fn open(
application: &gtk::Application,
source: ArchiveSource,
database: Rc<Database>,
fast_image_loading: bool,
case_insensitive_sort: bool,
view_mode_control: ViewModeControl,
controls: FileViewerControls,
) {
let FileViewerControls {
sibling_navigator,
favorite_control,
download_control,
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(&gtk::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<ArchivePage>,
anchor: usize,
cache: HashMap<usize, CachedPage>,
requested: HashSet<usize>,
pending: VecDeque<usize>,
in_flight: usize,
failed: HashMap<usize, String>,
access_clock: u64,
continuous_rows: Vec<ContinuousRow>,
continuous_bound: HashSet<usize>,
}
struct ViewerController {
window: gtk::ApplicationWindow,
source: ArchiveSource,
database: Rc<Database>,
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<SiblingNavigator>,
favorite_button: gtk::Button,
favorite_toggle: Rc<dyn Fn() -> bool>,
download_button: gtk::Button,
file_download: Rc<dyn Fn(gtk::Window, gtk::Label)>,
view_mode_change: Rc<dyn Fn(u32)>,
seek_hide_timer: RefCell<Option<glib::SourceId>>,
last_pointer_position: Cell<Option<(f64, f64)>>,
key_controller: RefCell<Option<gtk::EventControllerKey>>,
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
navigation_message: gtk::Box,
navigation_message_label: gtk::Label,
navigation_message_generation: Rc<Cell<u64>>,
state: RefCell<ViewerState>,
}
impl ViewerController {
fn connect_signals(self: &Rc<Self>) {
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<Self>) {
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
timer.remove();
}
self.set_seek_visible(true);
let controller = Rc::downgrade(self);
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
if let Some(controller) = controller.upgrade() {
controller.seek_hide_timer.borrow_mut().take();
controller.set_seek_visible(false);
}
});
self.seek_hide_timer.replace(Some(timer));
}
fn pointer_moved(self: &Rc<Self>, x: f64, y: f64) {
if self.last_pointer_position.get() == Some((x, y)) {
return;
}
self.last_pointer_position.set(Some((x, y)));
self.show_seek_temporarily();
}
fn hide_seek(self: &Rc<Self>) {
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
timer.remove();
}
self.set_seek_visible(false);
}
fn set_seek_visible(self: &Rc<Self>, visible: bool) {
if self.page_seek_row.is_visible() == visible {
return;
}
self.page_seek_row.set_visible(visible);
self.window.queue_allocate();
let controller = Rc::downgrade(self);
glib::idle_add_local_once(move || {
if let Some(controller) = controller.upgrade() {
controller.stack.queue_allocate();
controller.resize_continuous_rows();
controller.render();
}
});
}
fn toggle_fullscreen(&self) {
if self.window.is_fullscreen() {
self.window.unfullscreen();
} else {
self.window.fullscreen();
}
}
fn load_archive(self: &Rc<Self>) {
self.spinner.start();
let path = self.source.local_path.clone();
let case_insensitive_sort = self.case_insensitive_sort;
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let _ = sender.send(
list_pages(&path, case_insensitive_sort).map_err(|error| format!("{error:#}")),
);
});
let controller = Rc::downgrade(self);
glib::timeout_add_local(Duration::from_millis(20), move || {
match receiver.try_recv() {
Ok(result) => {
if let Some(controller) = controller.upgrade() {
controller.finish_archive_load(result);
}
glib::ControlFlow::Break
}
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
}
});
}
fn finish_archive_load(self: &Rc<Self>, result: Result<Vec<ArchivePage>, String>) {
match result {
Ok(pages) if pages.is_empty() => {
self.spinner.stop();
self.status_label
.set_text("지원하는 이미지가 ZIP 파일에 없습니다.");
}
Ok(pages) => {
let page_count = pages.len();
let saved = self
.database
.load_progress(&self.source.source_uri)
.ok()
.flatten()
.unwrap_or(0)
.min(pages.len() - 1);
let mut state = self.state.borrow_mut();
state.pages = pages;
state.anchor = saved;
drop(state);
self.page_seek
.set_range(0.0, navigation_scale_upper(page_count));
self.page_seek.set_sensitive(page_count > 1);
self.build_continuous_rows();
self.render();
}
Err(error) => {
self.spinner.stop();
self.status_label.set_text(&error);
}
}
}
fn build_continuous_rows(self: &Rc<Self>) {
while let Some(child) = self.continuous_box.first_child() {
self.continuous_box.remove(&child);
}
let count = self.state.borrow().pages.len();
let mut rows = Vec::with_capacity(count);
let default_ratio = 2.0 / 3.0;
let width = self.continuous_content_width();
for _ in 0..count {
let picture = gtk::Picture::new();
picture.set_content_fit(gtk::ContentFit::Contain);
picture.set_hexpand(true);
picture.set_vexpand(false);
picture.set_can_shrink(true);
picture.set_size_request(1, 1);
let frame = gtk::AspectFrame::new(0.5, 0.5, default_ratio, false);
frame.set_hexpand(true);
frame.set_size_request(-1, row_height(width, default_ratio));
frame.set_child(Some(&picture));
self.continuous_box.append(&frame);
rows.push(ContinuousRow {
frame,
picture,
ratio: default_ratio,
});
}
let mut state = self.state.borrow_mut();
state.continuous_rows = rows;
state.continuous_bound.clear();
}
fn view_mode(&self) -> ViewMode {
match self.mode_dropdown.selected() {
0 => ViewMode::ActualSize,
2 => ViewMode::FitHeight,
3 => ViewMode::TwoPage,
4 => ViewMode::Continuous,
_ => ViewMode::FitWidth,
}
}
fn apply_view_mode(self: &Rc<Self>) {
let mode = self.view_mode();
self.shift_back_button
.set_visible(mode == ViewMode::TwoPage);
self.shift_forward_button
.set_visible(mode == ViewMode::TwoPage);
self.direction_dropdown
.set_visible(mode == ViewMode::TwoPage);
match mode {
ViewMode::TwoPage => self.stack.set_visible_child_name("spread"),
ViewMode::Continuous => self.stack.set_visible_child_name("continuous"),
_ => self.stack.set_visible_child_name("single"),
}
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<Self>, forward: bool) {
let step = if self.view_mode() == ViewMode::TwoPage {
2
} else {
1
};
let mut state = self.state.borrow_mut();
if state.pages.is_empty() {
return;
}
let next = if forward {
state.anchor.saturating_add(step).min(state.pages.len() - 1)
} else {
state.anchor.saturating_sub(step)
};
if next == state.anchor {
return;
}
state.anchor = next;
drop(state);
self.render();
if self.view_mode() == ViewMode::Continuous {
self.schedule_scroll_to_anchor();
}
}
fn shift_pairing(self: &Rc<Self>, offset: isize) {
let mut state = self.state.borrow_mut();
if state.pages.is_empty() {
return;
}
state.anchor = state
.anchor
.saturating_add_signed(offset)
.min(state.pages.len() - 1);
drop(state);
self.render();
}
fn seek_page(self: &Rc<Self>, index: usize) {
let mut state = self.state.borrow_mut();
if state.pages.is_empty() {
return;
}
let index = index.min(state.pages.len() - 1);
if index == state.anchor {
return;
}
state.anchor = index;
drop(state);
self.render();
if self.view_mode() == ViewMode::Continuous {
self.schedule_scroll_to_anchor();
}
}
fn render(self: &Rc<Self>) {
let visible = self.visible_indices();
if visible.is_empty() {
return;
}
match self.view_mode() {
ViewMode::TwoPage => self.render_spread(&visible),
ViewMode::Continuous => self.render_continuous(&visible),
_ => self.render_single(visible[0]),
}
self.update_navigation();
self.request_pages(self.prefetch_indices(&visible));
let state = self.state.borrow();
let _ =
self.database
.save_progress(&self.source.source_uri, state.anchor, state.pages.len());
}
fn visible_indices(&self) -> Vec<usize> {
let state = self.state.borrow();
if state.pages.is_empty() {
return Vec::new();
}
if self.view_mode() == ViewMode::TwoPage {
spread_indices(state.anchor, state.pages.len()).to_vec()
} else if self.view_mode() == ViewMode::Continuous {
drop(state);
self.continuous_visible_indices()
} else {
vec![state.anchor]
}
}
fn prefetch_indices(&self, visible: &[usize]) -> Vec<usize> {
let count = self.state.borrow().pages.len();
prefetch_window(visible, count, self.fast_image_loading)
}
fn request_pages(self: &Rc<Self>, indices: Vec<usize>) {
let mut state = self.state.borrow_mut();
let mut prioritized = VecDeque::new();
for index in indices {
if index >= state.pages.len()
|| state.cache.contains_key(&index)
|| state.failed.contains_key(&index)
{
continue;
}
if state.requested.contains(&index) {
if state.pending.contains(&index) {
prioritized.push_back(index);
}
} else {
state.requested.insert(index);
prioritized.push_back(index);
}
}
let prioritized_set: HashSet<_> = prioritized.iter().copied().collect();
state
.pending
.retain(|index| !prioritized_set.contains(index));
prioritized.append(&mut state.pending);
state.pending = prioritized;
drop(state);
self.pump_requests();
self.update_spinner();
}
fn pump_requests(self: &Rc<Self>) {
let concurrency = if self.fast_image_loading { 4 } else { 2 };
while let Some((index, page)) = {
let mut state = self.state.borrow_mut();
if state.in_flight >= concurrency {
None
} else {
state.pending.pop_front().map(|index| {
state.in_flight += 1;
(index, state.pages[index].clone())
})
}
} {
let path = self.source.local_path.clone();
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let result = decode_page(&path, &page).map_err(|error| format!("{error:#}"));
let _ = sender.send(result);
});
let controller = Rc::downgrade(self);
glib::timeout_add_local(Duration::from_millis(20), move || {
match receiver.try_recv() {
Ok(result) => {
if let Some(controller) = controller.upgrade() {
controller.finish_page_load(index, result);
}
glib::ControlFlow::Break
}
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
}
});
}
}
fn finish_page_load(self: &Rc<Self>, index: usize, result: Result<DecodedPage, String>) {
let mut state = self.state.borrow_mut();
state.requested.remove(&index);
state.in_flight = state.in_flight.saturating_sub(1);
let mut decoded_ratio = None;
match result {
Ok(page) => {
let ratio = page.width as f32 / page.height as f32;
decoded_ratio = Some(ratio);
let 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::<Vec<_>>()
.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<Self>) {
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<usize> {
let state = self.state.borrow();
if state.pages.is_empty() {
return Vec::new();
}
let adjustment = self.continuous_scrolled.vadjustment();
let top = adjustment.value();
let bottom = top + adjustment.page_size();
if adjustment.page_size() <= 0.0 {
return vec![state.anchor];
}
let bounds: Vec<_> = state
.continuous_rows
.iter()
.map(|row| {
row.frame
.compute_bounds(&self.continuous_box)
.map(|bounds| {
let row_top = f64::from(bounds.y());
(row_top, row_top + f64::from(bounds.height()))
})
})
.collect();
let mut visible = intersecting_rows(&bounds, top, bottom);
if visible.is_empty() {
visible.push(state.anchor);
}
visible
}
fn schedule_scroll_to_anchor(self: &Rc<Self>) {
if self.view_mode() != ViewMode::Continuous || self.state.borrow().pages.is_empty() {
return;
}
let controller = Rc::downgrade(self);
glib::idle_add_local_once(move || {
if let Some(controller) = controller.upgrade() {
controller.scroll_to_anchor();
}
});
}
fn scroll_to_anchor(&self) {
let 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: &gtk::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(&gtk::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<SiblingNavigator>,
favorite_button: gtk::Button,
favorite_toggle: Rc<dyn Fn() -> bool>,
download_button: gtk::Button,
file_download: Rc<dyn Fn(gtk::Window, gtk::Label)>,
seek_generation: Cell<u64>,
seek_opening: Cell<bool>,
seek_hide_timer: RefCell<Option<glib::SourceId>>,
last_pointer_position: Cell<Option<(f64, f64)>>,
key_controller: RefCell<Option<gtk::EventControllerKey>>,
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
navigation_message: gtk::Box,
navigation_message_label: gtk::Label,
navigation_message_generation: Rc<Cell<u64>>,
}
impl MediaViewerController {
fn connect_signals(self: &Rc<Self>) {
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<Self>) {
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
timer.remove();
}
if let Some((current, count)) = self
.sibling_navigator
.as_ref()
.and_then(SiblingNavigator::position)
{
self.media_seek
.set_range(0.0, navigation_scale_upper(count));
self.media_seek.set_sensitive(count > 1);
self.media_seek.set_value(current as f64);
}
self.set_seek_visible(true);
let controller = Rc::downgrade(self);
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
if let Some(controller) = controller.upgrade() {
controller.seek_hide_timer.borrow_mut().take();
controller.set_seek_visible(false);
}
});
self.seek_hide_timer.replace(Some(timer));
}
fn pointer_moved(self: &Rc<Self>, x: f64, y: f64) {
if self.last_pointer_position.get() == Some((x, y)) {
return;
}
self.last_pointer_position.set(Some((x, y)));
self.show_seek_temporarily();
}
fn hide_seek(self: &Rc<Self>) {
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
timer.remove();
}
self.set_seek_visible(false);
}
fn set_seek_visible(self: &Rc<Self>, visible: bool) {
if self.media_seek_row.is_visible() == visible {
return;
}
self.media_seek_row.set_visible(visible);
self.window.queue_allocate();
let controller = Rc::downgrade(self);
glib::idle_add_local_once(move || {
if let Some(controller) = controller.upgrade() {
controller.stack.queue_allocate();
controller.picture.queue_allocate();
controller.video.queue_allocate();
}
});
}
fn schedule_seek(self: &Rc<Self>, index: usize) {
let Some(navigator) = &self.sibling_navigator else {
return;
};
if navigator
.position()
.is_none_or(|(current, _)| index == current)
|| self.seek_opening.get()
{
return;
}
let generation = self.seek_generation.get().wrapping_add(1);
self.seek_generation.set(generation);
let controller = Rc::downgrade(self);
glib::timeout_add_local_once(Duration::from_millis(180), move || {
let Some(controller) = controller.upgrade() else {
return;
};
if controller.seek_generation.get() != generation || controller.seek_opening.get() {
return;
}
let Some(navigator) = &controller.sibling_navigator else {
return;
};
if navigator.open_index(index, controller.window.clone()) {
controller.seek_opening.set(true);
controller.prepare_for_navigation();
}
});
}
fn prepare_for_navigation(&self) {
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>) {
self.stack.set_visible_child_name("image");
self.status_label.set_text("이미지를 읽는 중...");
let path = self.source.local_path.clone();
let display_name = self.source.display_name.clone();
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let result =
decode_image_file(&path, &display_name).map_err(|error| format!("{error:#}"));
let _ = sender.send(result);
});
let controller = Rc::downgrade(self);
glib::timeout_add_local(Duration::from_millis(20), move || {
match receiver.try_recv() {
Ok(result) => {
if let Some(controller) = controller.upgrade() {
controller.finish_image_load(result);
}
glib::ControlFlow::Break
}
Err(TryRecvError::Empty) => glib::ControlFlow::Continue,
Err(TryRecvError::Disconnected) => glib::ControlFlow::Break,
}
});
}
fn finish_image_load(&self, result: Result<DecodedPage, String>) {
match result {
Ok(page) => {
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: &gtk::Scale) -> gtk::Box {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 0);
row.set_height_request(42);
row.set_hexpand(true);
row.append(scale);
row.set_visible(false);
row
}
fn navigation_scale_upper(count: usize) -> f64 {
count.saturating_sub(1).max(1) as f64
}
fn spread_indices(anchor: usize, count: usize) -> Vec<usize> {
if count == 0 {
return Vec::new();
}
let anchor = anchor.min(count - 1);
let mut pages = vec![anchor];
if anchor + 1 < count {
pages.push(anchor + 1);
}
pages
}
fn row_height(width: i32, ratio: f32) -> i32 {
((width as f32 / ratio).round() as i32).clamp(1, 30_000)
}
fn prefetch_window(visible: &[usize], count: usize, fast: bool) -> Vec<usize> {
if visible.is_empty() || count == 0 {
return Vec::new();
}
let (ahead, behind) = if fast { (12, 4) } else { (2, 1) };
let mut indices = visible.to_vec();
let last = *visible.last().unwrap();
for offset in 1..=ahead {
let index = last + offset;
if index >= count {
break;
}
indices.push(index);
}
let first = visible[0];
for offset in 1..=behind.min(first) {
indices.push(first - offset);
}
indices
}
fn intersecting_rows(bounds: &[Option<(f64, f64)>], top: f64, bottom: f64) -> Vec<usize> {
bounds
.iter()
.enumerate()
.filter_map(|(index, bounds)| {
bounds
.filter(|(row_top, row_bottom)| *row_bottom > top && *row_top < bottom)
.map(|_| index)
})
.collect()
}
fn page_seek_index(value: f64, count: usize) -> Option<usize> {
(count > 0).then(|| (value.round().max(0.0) as usize).min(count - 1))
}
fn sibling_index(current: usize, count: usize, forward: bool) -> Option<usize> {
if forward {
(current + 1 < count).then_some(current + 1)
} else {
current.checked_sub(1)
}
}
#[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),
"더 이상 다음 파일이 없습니다."
);
}
}