Release v0.2.3

This commit is contained in:
burnintuna 2026-07-23 20:09:11 +09:00
parent b31acd3f58
commit 86f3185c59
13 changed files with 1255 additions and 68 deletions

View file

@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::{Rc, Weak};
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
use gtk::gdk;
use gtk::glib;
@ -26,12 +26,12 @@ const MEDIA_VIEWER_CONTROLLER_KEY: &str = "comicviewer-media-viewer-controller";
fn clear_viewer_controller(window: &gtk::ApplicationWindow) {
unsafe {
if let Some(controller) = window.steal_data::<Rc<ViewerController>>(VIEWER_CONTROLLER_KEY) {
controller.stop_animations();
controller.detach_window_controllers();
}
if let Some(controller) =
window.steal_data::<Rc<MediaViewerController>>(MEDIA_VIEWER_CONTROLLER_KEY)
{
controller.stop_playback();
controller.prepare_for_navigation();
}
}
}
@ -47,7 +47,45 @@ fn ensure_viewer_close_handler(window: &gtk::ApplicationWindow) {
});
}
pub(crate) fn set_viewer_header(window: &gtk::ApplicationWindow, header: &gtk::HeaderBar) {
pub(crate) fn set_viewer_loading(window: &gtk::ApplicationWindow, loading: bool) {
if !window.has_css_class("viewer-loading-key-handler") {
window.add_css_class("viewer-loading-key-handler");
let keys = gtk::EventControllerKey::new();
keys.set_propagation_phase(gtk::PropagationPhase::Capture);
let weak_window = window.downgrade();
keys.connect_key_pressed(move |_, key, _, _| {
let Some(window) = weak_window.upgrade() else {
return glib::Propagation::Proceed;
};
if !window.has_css_class("viewer-loading") || !window.is_fullscreen() {
return glib::Propagation::Proceed;
}
if let Some(titlebar) = window.titlebar() {
titlebar.set_visible(true);
}
if key == gdk::Key::Escape
|| key
.to_unicode()
.is_some_and(|character| character.eq_ignore_ascii_case(&'f'))
{
window.unfullscreen();
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
window.add_controller(keys);
}
if loading {
window.add_css_class("viewer-loading");
} else {
window.remove_css_class("viewer-loading");
}
}
pub(crate) fn set_viewer_header(
window: &gtk::ApplicationWindow,
header: &gtk::HeaderBar,
) -> gtk::Box {
let host = window
.titlebar()
.and_downcast::<gtk::Box>()
@ -62,6 +100,8 @@ pub(crate) fn set_viewer_header(window: &gtk::ApplicationWindow, header: &gtk::H
}
header.set_hexpand(true);
host.append(header);
host.set_visible(!window.is_fullscreen());
host
}
pub struct ViewerWindow;
@ -244,6 +284,7 @@ impl ViewerWindow {
});
install_file_drop_target(&window, file_drop_control);
ensure_viewer_close_handler(&window);
set_viewer_loading(&window, false);
window.set_title(Some(&title));
let header = gtk::HeaderBar::new();
header.set_title_widget(Some(&gtk::Label::new(Some(&title))));
@ -255,7 +296,7 @@ impl ViewerWindow {
header.pack_end(&download_button);
header.pack_end(&favorite_button);
header.pack_end(&file_list_button);
set_viewer_header(&window, &header);
let titlebar_host = set_viewer_header(&window, &header);
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
@ -358,6 +399,7 @@ impl ViewerWindow {
let controller = Rc::new(ViewerController {
window,
titlebar_host,
source,
database,
stack,
@ -372,6 +414,7 @@ impl ViewerWindow {
page_label,
page_seek,
page_seek_row,
toolbar,
status_label,
previous_button,
next_button,
@ -395,6 +438,10 @@ impl ViewerWindow {
last_pointer_position: Cell::new(None),
key_controller: RefCell::new(None),
motion_controller: RefCell::new(None),
fullscreen_key_controller: RefCell::new(None),
fullscreen_ui_hide_timer: RefCell::new(None),
fullscreen_ui_visible: Cell::new(false),
fullscreen_notify_handler: RefCell::new(None),
navigation_message,
navigation_message_label,
navigation_message_generation: Rc::new(Cell::new(0)),
@ -417,6 +464,7 @@ impl ViewerWindow {
}),
});
controller.connect_signals();
controller.fullscreen_changed();
if controller.file_list_button.is_active() {
controller.refresh_file_list();
}
@ -506,6 +554,7 @@ struct ViewerState {
struct ViewerController {
window: gtk::ApplicationWindow,
titlebar_host: gtk::Box,
source: ArchiveSource,
database: Rc<Database>,
stack: gtk::Stack,
@ -520,6 +569,7 @@ struct ViewerController {
page_label: gtk::Label,
page_seek: gtk::Scale,
page_seek_row: gtk::Box,
toolbar: gtk::Box,
status_label: gtk::Label,
previous_button: gtk::Button,
next_button: gtk::Button,
@ -543,6 +593,10 @@ struct ViewerController {
last_pointer_position: Cell<Option<(f64, f64)>>,
key_controller: RefCell<Option<gtk::EventControllerKey>>,
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
fullscreen_key_controller: RefCell<Option<gtk::EventControllerKey>>,
fullscreen_ui_hide_timer: RefCell<Option<glib::SourceId>>,
fullscreen_ui_visible: Cell<bool>,
fullscreen_notify_handler: RefCell<Option<glib::SignalHandlerId>>,
navigation_message: gtk::Box,
navigation_message_label: gtk::Label,
navigation_message_generation: Rc<Cell<u64>>,
@ -612,7 +666,11 @@ impl ViewerController {
let controller = Rc::downgrade(self);
self.file_list_button.connect_toggled(move |button| {
if let Some(controller) = controller.upgrade() {
controller.file_list_panel.set_visible(button.is_active());
controller.file_list_panel.set_visible(
button.is_active()
&& (!controller.window.is_fullscreen()
|| controller.fullscreen_ui_visible.get()),
);
if button.is_active() {
controller.window.add_css_class("viewer-file-list-visible");
} else {
@ -631,6 +689,22 @@ impl ViewerController {
controller.open_file_list_row(row.index());
}
});
let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default()));
let file_list_keys = gtk::EventControllerKey::new();
let controller = Rc::downgrade(self);
file_list_keys.connect_key_pressed(move |_, key, _, modifiers| {
let Some(controller) = controller.upgrade() else {
return glib::Propagation::Proceed;
};
file_list_typeahead_select(
&controller.file_list,
&controller.file_list_files.borrow(),
&mut file_list_typeahead.borrow_mut(),
key,
modifiers,
)
});
self.file_list.add_controller(file_list_keys);
let file_list_click = gtk::GestureClick::builder().button(3).build();
let controller = Rc::downgrade(self);
file_list_click.connect_pressed(move |_, _, x, y| {
@ -717,13 +791,30 @@ impl ViewerController {
self.window.add_controller(motion_controller.clone());
self.motion_controller.replace(Some(motion_controller));
let fullscreen_keys = gtk::EventControllerKey::new();
fullscreen_keys.set_propagation_phase(gtk::PropagationPhase::Capture);
let controller = Rc::downgrade(self);
fullscreen_keys.connect_key_pressed(move |_, _, _, _| {
if let Some(controller) = controller.upgrade()
&& controller.window.is_fullscreen()
{
controller.show_fullscreen_ui_temporarily();
}
glib::Propagation::Proceed
});
self.window.add_controller(fullscreen_keys.clone());
self.fullscreen_key_controller
.replace(Some(fullscreen_keys));
let key_controller = gtk::EventControllerKey::new();
let controller = Rc::downgrade(self);
key_controller.connect_key_pressed(move |_, key, _, _| {
let Some(controller) = controller.upgrade() else {
return glib::Propagation::Proceed;
};
controller.hide_seek();
if !controller.window.is_fullscreen() {
controller.hide_seek();
}
match key {
gdk::Key::Home => {
controller.seek_edge(false);
@ -783,6 +874,15 @@ impl ViewerController {
});
self.window.add_controller(key_controller.clone());
self.key_controller.replace(Some(key_controller));
let controller = Rc::downgrade(self);
let handler = self
.window
.connect_notify_local(Some("fullscreened"), move |_, _| {
if let Some(controller) = controller.upgrade() {
controller.fullscreen_changed();
}
});
self.fullscreen_notify_handler.replace(Some(handler));
}
fn open_sibling(&self, forward: bool) {
@ -876,12 +976,22 @@ impl ViewerController {
fn detach_window_controllers(&self) {
self.stop_animations();
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
timer.remove();
}
self.cancel_fullscreen_ui_timer();
if let Some(handler) = self.fullscreen_notify_handler.borrow_mut().take() {
self.window.disconnect(handler);
}
if let Some(controller) = self.key_controller.borrow_mut().take() {
self.window.remove_controller(&controller);
}
if let Some(controller) = self.motion_controller.borrow_mut().take() {
self.window.remove_controller(&controller);
}
if let Some(controller) = self.fullscreen_key_controller.borrow_mut().take() {
self.window.remove_controller(&controller);
}
}
fn stop_animations(&self) {
@ -913,7 +1023,11 @@ impl ViewerController {
return;
}
self.last_pointer_position.set(Some((x, y)));
self.show_seek_temporarily();
if self.window.is_fullscreen() {
self.show_fullscreen_ui_temporarily();
} else {
self.show_seek_temporarily();
}
}
fn hide_seek(self: &Rc<Self>) {
@ -947,6 +1061,62 @@ impl ViewerController {
}
}
fn fullscreen_changed(self: &Rc<Self>) {
self.cancel_fullscreen_ui_timer();
self.hide_seek();
if self.window.is_fullscreen() {
self.set_fullscreen_ui_visible(false);
} else {
self.fullscreen_ui_visible.set(true);
self.titlebar_host.set_visible(true);
self.toolbar.set_visible(true);
self.status_label.set_visible(true);
self.file_list_panel
.set_visible(self.file_list_button.is_active());
}
}
fn show_fullscreen_ui_temporarily(self: &Rc<Self>) {
if !self.window.is_fullscreen() {
return;
}
self.cancel_fullscreen_ui_timer();
self.set_fullscreen_ui_visible(true);
let controller = Rc::downgrade(self);
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
if let Some(controller) = controller.upgrade() {
controller.fullscreen_ui_hide_timer.borrow_mut().take();
controller.set_fullscreen_ui_visible(false);
}
});
self.fullscreen_ui_hide_timer.replace(Some(timer));
}
fn cancel_fullscreen_ui_timer(&self) {
if let Some(timer) = self.fullscreen_ui_hide_timer.borrow_mut().take() {
timer.remove();
}
}
fn set_fullscreen_ui_visible(self: &Rc<Self>, visible: bool) {
self.fullscreen_ui_visible.set(visible);
self.titlebar_host.set_visible(visible);
self.toolbar.set_visible(visible);
self.page_seek_row.set_visible(visible);
self.status_label.set_visible(visible);
self.file_list_panel
.set_visible(visible && self.file_list_button.is_active());
self.window.queue_allocate();
let controller = Rc::downgrade(self);
glib::idle_add_local_once(move || {
if let Some(controller) = controller.upgrade() {
controller.stack.queue_allocate();
controller.resize_continuous_rows();
controller.render();
}
});
}
fn load_archive(self: &Rc<Self>) {
self.spinner.start();
let path = self.source.local_path.clone();
@ -1727,6 +1897,7 @@ impl MediaViewerWindow {
});
install_file_drop_target(&window, file_drop_control);
ensure_viewer_close_handler(&window);
set_viewer_loading(&window, false);
window.set_title(Some(&title));
let header = gtk::HeaderBar::new();
header.set_title_widget(Some(&gtk::Label::new(Some(&title))));
@ -1752,7 +1923,7 @@ impl MediaViewerWindow {
header.pack_end(&download_button);
header.pack_end(&favorite_button);
header.pack_end(&file_list_button);
set_viewer_header(&window, &header);
let titlebar_host = set_viewer_header(&window, &header);
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let stack = gtk::Stack::new();
@ -1816,6 +1987,7 @@ impl MediaViewerWindow {
let controller = Rc::new(MediaViewerController {
window,
titlebar_host,
source,
kind,
picture,
@ -1845,6 +2017,10 @@ impl MediaViewerWindow {
last_pointer_position: Cell::new(None),
key_controller: RefCell::new(None),
motion_controller: RefCell::new(None),
fullscreen_key_controller: RefCell::new(None),
fullscreen_ui_hide_timer: RefCell::new(None),
fullscreen_ui_visible: Cell::new(false),
fullscreen_notify_handler: RefCell::new(None),
navigation_message,
navigation_message_label,
navigation_message_generation: Rc::new(Cell::new(0)),
@ -1858,6 +2034,7 @@ impl MediaViewerWindow {
});
}
controller.connect_signals();
controller.fullscreen_changed();
if controller.file_list_button.is_active() {
controller.refresh_file_list();
}
@ -1883,6 +2060,7 @@ impl MediaViewerWindow {
struct MediaViewerController {
window: gtk::ApplicationWindow,
titlebar_host: gtk::Box,
source: ArchiveSource,
kind: EntryKind,
picture: gtk::Picture,
@ -1912,6 +2090,10 @@ struct MediaViewerController {
last_pointer_position: Cell<Option<(f64, f64)>>,
key_controller: RefCell<Option<gtk::EventControllerKey>>,
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
fullscreen_key_controller: RefCell<Option<gtk::EventControllerKey>>,
fullscreen_ui_hide_timer: RefCell<Option<glib::SourceId>>,
fullscreen_ui_visible: Cell<bool>,
fullscreen_notify_handler: RefCell<Option<glib::SignalHandlerId>>,
navigation_message: gtk::Box,
navigation_message_label: gtk::Label,
navigation_message_generation: Rc<Cell<u64>>,
@ -1930,6 +2112,21 @@ impl MediaViewerController {
self.window.add_controller(motion_controller.clone());
self.motion_controller.replace(Some(motion_controller));
let fullscreen_keys = gtk::EventControllerKey::new();
fullscreen_keys.set_propagation_phase(gtk::PropagationPhase::Capture);
let controller = Rc::downgrade(self);
fullscreen_keys.connect_key_pressed(move |_, _, _, _| {
if let Some(controller) = controller.upgrade()
&& controller.window.is_fullscreen()
{
controller.show_fullscreen_ui_temporarily();
}
glib::Propagation::Proceed
});
self.window.add_controller(fullscreen_keys.clone());
self.fullscreen_key_controller
.replace(Some(fullscreen_keys));
let keys = gtk::EventControllerKey::new();
let controller = Rc::downgrade(self);
keys.connect_key_pressed(move |_, key, _, modifiers| {
@ -1943,7 +2140,9 @@ impl MediaViewerController {
) {
return glib::Propagation::Proceed;
}
controller.hide_seek();
if !controller.window.is_fullscreen() {
controller.hide_seek();
}
match key {
gdk::Key::Left if controller.kind == EntryKind::Video => {
controller.seek_video(-VIDEO_SEEK_STEP_US);
@ -2014,6 +2213,15 @@ impl MediaViewerController {
self.window.add_controller(keys.clone());
self.key_controller.replace(Some(keys));
let controller = Rc::downgrade(self);
let handler = self
.window
.connect_notify_local(Some("fullscreened"), move |_, _| {
if let Some(controller) = controller.upgrade() {
controller.fullscreen_changed();
}
});
self.fullscreen_notify_handler.replace(Some(handler));
let controller = Rc::downgrade(self);
self.favorite_button.connect_clicked(move |_| {
if let Some(controller) = controller.upgrade() {
let active = (controller.favorite_toggle)();
@ -2032,7 +2240,11 @@ impl MediaViewerController {
let controller = Rc::downgrade(self);
self.file_list_button.connect_toggled(move |button| {
if let Some(controller) = controller.upgrade() {
controller.file_list_panel.set_visible(button.is_active());
controller.file_list_panel.set_visible(
button.is_active()
&& (!controller.window.is_fullscreen()
|| controller.fullscreen_ui_visible.get()),
);
if button.is_active() {
controller.window.add_css_class("viewer-file-list-visible");
} else {
@ -2051,6 +2263,22 @@ impl MediaViewerController {
controller.open_file_list_row(row.index());
}
});
let file_list_typeahead = Rc::new(RefCell::new(TypeaheadBuffer::default()));
let file_list_keys = gtk::EventControllerKey::new();
let controller = Rc::downgrade(self);
file_list_keys.connect_key_pressed(move |_, key, _, modifiers| {
let Some(controller) = controller.upgrade() else {
return glib::Propagation::Proceed;
};
file_list_typeahead_select(
&controller.file_list,
&controller.file_list_files.borrow(),
&mut file_list_typeahead.borrow_mut(),
key,
modifiers,
)
});
self.file_list.add_controller(file_list_keys);
let file_list_click = gtk::GestureClick::builder().button(3).build();
let controller = Rc::downgrade(self);
file_list_click.connect_pressed(move |_, _, x, y| {
@ -2318,7 +2546,11 @@ impl MediaViewerController {
return;
}
self.last_pointer_position.set(Some((x, y)));
self.show_seek_temporarily();
if self.window.is_fullscreen() {
self.show_fullscreen_ui_temporarily();
} else {
self.show_seek_temporarily();
}
}
fn hide_seek(self: &Rc<Self>) {
@ -2377,12 +2609,22 @@ impl MediaViewerController {
fn prepare_for_navigation(&self) {
self.stop_playback();
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
timer.remove();
}
self.cancel_fullscreen_ui_timer();
if let Some(handler) = self.fullscreen_notify_handler.borrow_mut().take() {
self.window.disconnect(handler);
}
if let Some(controller) = self.key_controller.borrow_mut().take() {
self.window.remove_controller(&controller);
}
if let Some(controller) = self.motion_controller.borrow_mut().take() {
self.window.remove_controller(&controller);
}
if let Some(controller) = self.fullscreen_key_controller.borrow_mut().take() {
self.window.remove_controller(&controller);
}
}
fn stop_playback(&self) {
@ -2400,6 +2642,61 @@ impl MediaViewerController {
}
}
fn fullscreen_changed(self: &Rc<Self>) {
self.cancel_fullscreen_ui_timer();
self.hide_seek();
if self.window.is_fullscreen() {
self.set_fullscreen_ui_visible(false);
} else {
self.fullscreen_ui_visible.set(true);
self.titlebar_host.set_visible(true);
self.status_label.set_visible(true);
self.file_list_panel
.set_visible(self.file_list_button.is_active());
}
}
fn show_fullscreen_ui_temporarily(self: &Rc<Self>) {
if !self.window.is_fullscreen() {
return;
}
self.cancel_fullscreen_ui_timer();
self.set_fullscreen_ui_visible(true);
let controller = Rc::downgrade(self);
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
if let Some(controller) = controller.upgrade() {
controller.fullscreen_ui_hide_timer.borrow_mut().take();
controller.set_fullscreen_ui_visible(false);
}
});
self.fullscreen_ui_hide_timer.replace(Some(timer));
}
fn cancel_fullscreen_ui_timer(&self) {
if let Some(timer) = self.fullscreen_ui_hide_timer.borrow_mut().take() {
timer.remove();
}
}
fn set_fullscreen_ui_visible(self: &Rc<Self>, visible: bool) {
self.fullscreen_ui_visible.set(visible);
self.titlebar_host.set_visible(visible);
self.media_seek_row.set_visible(visible);
self.status_label.set_visible(visible);
self.file_list_panel
.set_visible(visible && self.file_list_button.is_active());
self.window.queue_allocate();
let controller = Rc::downgrade(self);
glib::idle_add_local_once(move || {
if let Some(controller) = controller.upgrade() {
controller.stack.queue_allocate();
controller.picture.queue_allocate();
controller.video.queue_allocate();
controller.resize_image_picture();
}
});
}
fn load_image(self: &Rc<Self>) {
self.stack.set_visible_child_name("image");
self.status_label.set_text("이미지를 읽는 중...");
@ -2458,6 +2755,85 @@ struct ViewerFileList {
list: gtk::ListBox,
}
#[derive(Default)]
struct TypeaheadBuffer {
query: String,
last_input: Option<Instant>,
}
fn file_list_typeahead_select(
list: &gtk::ListBox,
files: &[SiblingFile],
state: &mut TypeaheadBuffer,
key: gdk::Key,
modifiers: gdk::ModifierType,
) -> glib::Propagation {
let blocked = gdk::ModifierType::CONTROL_MASK
| gdk::ModifierType::ALT_MASK
| gdk::ModifierType::SUPER_MASK;
if modifiers.intersects(blocked) {
return glib::Propagation::Proceed;
}
if key == gdk::Key::Escape {
state.query.clear();
state.last_input = None;
return glib::Propagation::Proceed;
}
if matches!(key, gdk::Key::space)
|| key.to_unicode().is_some_and(|character| {
character.eq_ignore_ascii_case(&'f') || matches!(character, '[' | ']')
})
{
return glib::Propagation::Proceed;
}
let now = Instant::now();
if state
.last_input
.is_none_or(|last| now.duration_since(last) > Duration::from_millis(1200))
{
state.query.clear();
}
if key == gdk::Key::BackSpace {
return if state.query.pop().is_some() {
state.last_input = Some(now);
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
};
}
let Some(character) = key.to_unicode().filter(|character| !character.is_control()) else {
return glib::Propagation::Proceed;
};
let input = character.to_lowercase().collect::<String>();
let repeated_single = state.query.chars().count() == 1 && state.query == input;
if !repeated_single {
state.query.push_str(&input);
}
state.last_input = Some(now);
let selected = list.selected_row().map(|row| row.index() as usize);
let start = if repeated_single {
selected.map_or(0, |index| index + 1)
} else {
0
};
let found = find_sibling_prefix(files, &state.query, start);
if let Some(row) = found.and_then(|index| list.row_at_index(index as i32)) {
list.select_row(Some(&row));
let _ = row.grab_focus();
}
glib::Propagation::Stop
}
fn find_sibling_prefix(files: &[SiblingFile], query: &str, start: usize) -> Option<usize> {
if files.is_empty() {
return None;
}
let query = query.to_lowercase();
(0..files.len())
.map(|offset| (start + offset) % files.len())
.find(|index| files[*index].name.to_lowercase().starts_with(&query))
}
fn viewer_file_list() -> ViewerFileList {
let list = gtk::ListBox::new();
list.set_selection_mode(gtk::SelectionMode::Single);
@ -2943,6 +3319,19 @@ mod tests {
assert_eq!(video_volume_target(0.5, 0.05), 0.55);
}
#[test]
fn viewer_file_typeahead_wraps_by_display_name() {
let files = ["Alpha.zip", "beta.jpg", "Bravo.mp4"].map(|name| SiblingFile {
uri: format!("file:///{name}"),
name: name.into(),
kind: EntryKind::Image,
});
assert_eq!(find_sibling_prefix(&files, "br", 0), Some(2));
assert_eq!(find_sibling_prefix(&files, "b", 2), Some(2));
assert_eq!(find_sibling_prefix(&files, "b", 3), Some(1));
}
#[test]
fn navigation_boundary_messages_match_direction() {
assert_eq!(