use std::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_page, list_pages}; 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; impl ViewerWindow { pub fn open( application: >k::Application, source: ArchiveSource, database: Rc, fast_image_loading: bool, ) { let title = source .local_path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| "Comicviewer".into()); let window = gtk::ApplicationWindow::builder() .application(application) .title(&title) .default_width(1100) .default_height(850) .build(); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(&title)))); 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(1); 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(); root.append(&toolbar); root.append(&overlay); root.append(&status_label); window.set_child(Some(&root)); let controller = Rc::new(ViewerController { window, source, database, stack, single_picture, left_picture, right_picture, continuous_box, continuous_scrolled, spinner, page_label, status_label, previous_button, next_button, shift_back_button, shift_forward_button, mode_dropdown, direction_dropdown, fast_image_loading, 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, 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, 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 |_| controller.apply_view_mode()); let controller = Rc::clone(self); self.direction_dropdown .connect_selected_notify(move |_| controller.render()); 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 key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); key_controller.connect_key_pressed(move |_, key, _, _| 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() .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); } 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 (sender, receiver) = mpsc::channel(); thread::spawn(move || { let _ = sender.send(list_pages(&path).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 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.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 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 state = self.state.borrow(); let count = state.pages.len(); if count == 0 { self.page_label.set_text("- / -"); return; } let visible = self.visible_indices(); let end = visible.last().copied().unwrap_or(state.anchor) + 1; if visible.len() > 1 && self.view_mode() == ViewMode::TwoPage { self.page_label .set_text(&format!("{}-{end} / {count}", state.anchor + 1)); } else { self.page_label .set_text(&format!("{} / {count}", state.anchor + 1)); } self.previous_button.set_sensitive(state.anchor > 0); self.next_button.set_sensitive(end < count); self.shift_back_button.set_sensitive(state.anchor > 0); self.shift_forward_button .set_sensitive(state.anchor + 1 < count); } } 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 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() } #[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 ] ); } }