Initial Comicviewer implementation
This commit is contained in:
commit
cb01395612
20 changed files with 4940 additions and 0 deletions
963
src/viewer.rs
Normal file
963
src/viewer.rs
Normal file
|
|
@ -0,0 +1,963 @@
|
|||
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<Database>,
|
||||
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<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,
|
||||
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<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 |_| 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>) {
|
||||
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<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 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<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 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 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<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()
|
||||
}
|
||||
|
||||
#[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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue