Reuse viewer windows and organize favorites

This commit is contained in:
burnintuna 2026-07-22 02:37:56 +09:00
parent ef2a0a7915
commit bb73c1abea
3 changed files with 587 additions and 163 deletions

View file

@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
pub struct AppConfig { pub struct AppConfig {
pub cache_quota_gb: u64, pub cache_quota_gb: u64,
pub fast_image_loading: bool, pub fast_image_loading: bool,
pub viewer_mode: ViewerMode,
pub locations: Vec<LocationProfile>, pub locations: Vec<LocationProfile>,
pub favorites: Vec<FavoriteEntry>, pub favorites: Vec<FavoriteEntry>,
} }
@ -19,6 +20,7 @@ impl Default for AppConfig {
Self { Self {
cache_quota_gb: 10, cache_quota_gb: 10,
fast_image_loading: true, fast_image_loading: true,
viewer_mode: ViewerMode::FitWidth,
locations: Vec::new(), locations: Vec::new(),
favorites: Vec::new(), favorites: Vec::new(),
} }
@ -55,6 +57,39 @@ pub enum LocationKind {
Sftp, Sftp,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ViewerMode {
ActualSize,
#[default]
FitWidth,
FitHeight,
TwoPage,
Continuous,
}
impl ViewerMode {
pub fn dropdown_index(self) -> u32 {
match self {
Self::ActualSize => 0,
Self::FitWidth => 1,
Self::FitHeight => 2,
Self::TwoPage => 3,
Self::Continuous => 4,
}
}
pub fn from_dropdown_index(index: u32) -> Self {
match index {
0 => Self::ActualSize,
2 => Self::FitHeight,
3 => Self::TwoPage,
4 => Self::Continuous,
_ => Self::FitWidth,
}
}
}
impl AppConfig { impl AppConfig {
pub fn load_or_create(path: &Path) -> Result<Self> { pub fn load_or_create(path: &Path) -> Result<Self> {
if path.exists() { if path.exists() {
@ -139,6 +174,7 @@ mod tests {
let config = AppConfig { let config = AppConfig {
cache_quota_gb: 0, cache_quota_gb: 0,
fast_image_loading: true, fast_image_loading: true,
viewer_mode: ViewerMode::FitWidth,
locations: vec![LocationProfile { locations: vec![LocationProfile {
name: "테스트".into(), name: "테스트".into(),
kind: LocationKind::Sftp, kind: LocationKind::Sftp,
@ -173,6 +209,21 @@ mod tests {
fn fast_image_loading_is_enabled_for_existing_configs() { fn fast_image_loading_is_enabled_for_existing_configs() {
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
assert!(decoded.fast_image_loading); assert!(decoded.fast_image_loading);
assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth);
assert!(decoded.favorites.is_empty()); assert!(decoded.favorites.is_empty());
} }
#[test]
fn viewer_modes_round_trip_through_dropdown_indices() {
for mode in [
ViewerMode::ActualSize,
ViewerMode::FitWidth,
ViewerMode::FitHeight,
ViewerMode::TwoPage,
ViewerMode::Continuous,
] {
assert_eq!(ViewerMode::from_dropdown_index(mode.dropdown_index()), mode);
}
assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth);
}
} }

View file

@ -24,11 +24,15 @@ pub struct ViewerWindow;
pub struct SiblingNavigator { pub struct SiblingNavigator {
current: usize, current: usize,
count: usize, count: usize,
open: Rc<dyn Fn(usize) -> bool>, open: Rc<dyn Fn(usize, gtk::ApplicationWindow) -> bool>,
} }
impl SiblingNavigator { impl SiblingNavigator {
pub fn new(current: usize, count: usize, open: Rc<dyn Fn(usize) -> bool>) -> Self { pub fn new(
current: usize,
count: usize,
open: Rc<dyn Fn(usize, gtk::ApplicationWindow) -> bool>,
) -> Self {
Self { Self {
current, current,
count, count,
@ -36,15 +40,15 @@ impl SiblingNavigator {
} }
} }
fn open_relative(&self, forward: bool) -> bool { fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool {
let Some(index) = sibling_index(self.current, self.count, forward) else { let Some(index) = sibling_index(self.current, self.count, forward) else {
return false; return false;
}; };
(self.open)(index) (self.open)(index, window)
} }
fn open_index(&self, index: usize) -> bool { fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool {
index < self.count && index != self.current && (self.open)(index) index < self.count && index != self.current && (self.open)(index, window)
} }
} }
@ -57,23 +61,43 @@ pub struct FileDownloadControl {
pub download: Rc<dyn Fn(gtk::Window, gtk::Label)>, 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 { impl ViewerWindow {
pub fn open( pub fn open(
application: &gtk::Application, application: &gtk::Application,
source: ArchiveSource, source: ArchiveSource,
database: Rc<Database>, database: Rc<Database>,
fast_image_loading: bool, fast_image_loading: bool,
sibling_navigator: Option<SiblingNavigator>, view_mode_control: ViewModeControl,
favorite_control: FavoriteControl, controls: FileViewerControls,
download_control: FileDownloadControl,
) { ) {
let FileViewerControls {
sibling_navigator,
favorite_control,
download_control,
reuse_window,
} = controls;
let title = source.display_name.clone(); let title = source.display_name.clone();
let window = gtk::ApplicationWindow::builder() let window = reuse_window.unwrap_or_else(|| {
.application(application) gtk::ApplicationWindow::builder()
.title(&title) .application(application)
.default_width(1100) .title(&title)
.default_height(850) .default_width(1100)
.build(); .default_height(850)
.build()
});
window.set_title(Some(&title));
let header = gtk::HeaderBar::new(); let header = gtk::HeaderBar::new();
header.set_title_widget(Some(&gtk::Label::new(Some(&title)))); header.set_title_widget(Some(&gtk::Label::new(Some(&title))));
let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
@ -109,7 +133,7 @@ impl ViewerWindow {
"2페이지", "2페이지",
"이어서 보기", "이어서 보기",
]); ]);
mode_dropdown.set_selected(1); mode_dropdown.set_selected(view_mode_control.selected);
toolbar.append(&previous_button); toolbar.append(&previous_button);
toolbar.append(&shift_back_button); toolbar.append(&shift_back_button);
toolbar.append(&shift_forward_button); toolbar.append(&shift_forward_button);
@ -159,14 +183,12 @@ impl ViewerWindow {
.margin_start(8) .margin_start(8)
.margin_end(8) .margin_end(8)
.build(); .build();
let page_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); let page_seek = navigation_scale();
page_seek.set_draw_value(false); let page_seek_row = navigation_scale_row(&page_seek);
page_seek.set_sensitive(false); page_seek.set_sensitive(false);
page_seek.set_margin_start(8);
page_seek.set_margin_end(8);
root.append(&toolbar); root.append(&toolbar);
root.append(&overlay); root.append(&overlay);
root.append(&page_seek); root.append(&page_seek_row);
root.append(&status_label); root.append(&status_label);
window.set_child(Some(&root)); window.set_child(Some(&root));
@ -183,6 +205,7 @@ impl ViewerWindow {
spinner, spinner,
page_label, page_label,
page_seek, page_seek,
page_seek_row,
status_label, status_label,
previous_button, previous_button,
next_button, next_button,
@ -196,6 +219,11 @@ impl ViewerWindow {
favorite_toggle: favorite_control.toggle, favorite_toggle: favorite_control.toggle,
download_button, download_button,
file_download: download_control.download, 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),
state: RefCell::new(ViewerState { state: RefCell::new(ViewerState {
pages: Vec::new(), pages: Vec::new(),
anchor: 0, anchor: 0,
@ -263,6 +291,7 @@ struct ViewerController {
spinner: gtk::Spinner, spinner: gtk::Spinner,
page_label: gtk::Label, page_label: gtk::Label,
page_seek: gtk::Scale, page_seek: gtk::Scale,
page_seek_row: gtk::Box,
status_label: gtk::Label, status_label: gtk::Label,
previous_button: gtk::Button, previous_button: gtk::Button,
next_button: gtk::Button, next_button: gtk::Button,
@ -276,6 +305,11 @@ struct ViewerController {
favorite_toggle: Rc<dyn Fn() -> bool>, favorite_toggle: Rc<dyn Fn() -> bool>,
download_button: gtk::Button, download_button: gtk::Button,
file_download: Rc<dyn Fn(gtk::Window, gtk::Label)>, 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>>,
state: RefCell<ViewerState>, state: RefCell<ViewerState>,
} }
@ -294,8 +328,10 @@ impl ViewerController {
self.shift_forward_button self.shift_forward_button
.connect_clicked(move |_| controller.shift_pairing(1)); .connect_clicked(move |_| controller.shift_pairing(1));
let controller = Rc::clone(self); let controller = Rc::clone(self);
self.mode_dropdown self.mode_dropdown.connect_selected_notify(move |dropdown| {
.connect_selected_notify(move |_| controller.apply_view_mode()); (controller.view_mode_change)(dropdown.selected());
controller.apply_view_mode();
});
let controller = Rc::clone(self); let controller = Rc::clone(self);
self.direction_dropdown self.direction_dropdown
.connect_selected_notify(move |_| controller.render()); .connect_selected_notify(move |_| controller.render());
@ -313,9 +349,8 @@ impl ViewerController {
}); });
let controller = Rc::clone(self); let controller = Rc::clone(self);
self.page_seek.connect_value_changed(move |scale| { self.page_seek.connect_value_changed(move |scale| {
if let Some(index) = let page_count = controller.state.borrow().pages.len();
page_seek_index(scale.value(), controller.state.borrow().pages.len()) if let Some(index) = page_seek_index(scale.value(), page_count) {
{
controller.seek_page(index); controller.seek_page(index);
} }
}); });
@ -345,49 +380,115 @@ impl ViewerController {
}); });
self.stack.add_controller(scroll_controller); 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 key_controller = gtk::EventControllerKey::new();
let controller = Rc::clone(self); let controller = Rc::clone(self);
key_controller.connect_key_pressed(move |_, key, _, _| match key { key_controller.connect_key_pressed(move |_, key, _, _| {
gdk::Key::Left | gdk::Key::Page_Up => { controller.hide_seek();
controller.navigate(false); match key {
glib::Propagation::Stop 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,
} }
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); self.window.add_controller(key_controller.clone());
self.key_controller.replace(Some(key_controller));
} }
fn open_sibling(&self, forward: bool) { fn open_sibling(&self, forward: bool) {
if let Some(navigate) = &self.sibling_navigator if let Some(navigate) = &self.sibling_navigator
&& navigate.open_relative(forward) && navigate.open_relative(forward, self.window.clone())
{ {
self.window.close(); self.detach_window_controllers();
} }
} }
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) { fn toggle_fullscreen(&self) {
if self.window.is_fullscreen() { if self.window.is_fullscreen() {
self.window.unfullscreen(); self.window.unfullscreen();
@ -438,7 +539,8 @@ impl ViewerController {
state.pages = pages; state.pages = pages;
state.anchor = saved; state.anchor = saved;
drop(state); drop(state);
self.page_seek.set_range(0.0, (page_count - 1) as f64); self.page_seek
.set_range(0.0, navigation_scale_upper(page_count));
self.page_seek.set_sensitive(page_count > 1); self.page_seek.set_sensitive(page_count > 1);
self.build_continuous_rows(); self.build_continuous_rows();
self.render(); self.render();
@ -986,17 +1088,24 @@ impl MediaViewerWindow {
application: &gtk::Application, application: &gtk::Application,
source: ArchiveSource, source: ArchiveSource,
kind: EntryKind, kind: EntryKind,
sibling_navigator: Option<SiblingNavigator>, controls: FileViewerControls,
favorite_control: FavoriteControl,
download_control: FileDownloadControl,
) { ) {
let FileViewerControls {
sibling_navigator,
favorite_control,
download_control,
reuse_window,
} = controls;
let title = source.display_name.clone(); let title = source.display_name.clone();
let window = gtk::ApplicationWindow::builder() let window = reuse_window.unwrap_or_else(|| {
.application(application) gtk::ApplicationWindow::builder()
.title(&title) .application(application)
.default_width(1100) .title(&title)
.default_height(850) .default_width(1100)
.build(); .default_height(850)
.build()
});
window.set_title(Some(&title));
let header = gtk::HeaderBar::new(); let header = gtk::HeaderBar::new();
header.set_title_widget(Some(&gtk::Label::new(Some(&title)))); header.set_title_widget(Some(&gtk::Label::new(Some(&title))));
let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active)); let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
@ -1028,19 +1137,17 @@ impl MediaViewerWindow {
.margin_start(8) .margin_start(8)
.margin_end(8) .margin_end(8)
.build(); .build();
let media_seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 0.0, 1.0); let media_seek = navigation_scale();
media_seek.set_draw_value(false); let media_seek_row = navigation_scale_row(&media_seek);
media_seek.set_margin_start(8);
media_seek.set_margin_end(8);
if let Some(navigator) = &sibling_navigator { if let Some(navigator) = &sibling_navigator {
media_seek.set_range(0.0, navigator.count.saturating_sub(1) as f64); media_seek.set_range(0.0, navigation_scale_upper(navigator.count));
media_seek.set_value(navigator.current as f64); media_seek.set_value(navigator.current as f64);
media_seek.set_sensitive(navigator.count > 1); media_seek.set_sensitive(navigator.count > 1);
} else { } else {
media_seek.set_sensitive(false); media_seek.set_sensitive(false);
} }
root.append(&stack); root.append(&stack);
root.append(&media_seek); root.append(&media_seek_row);
root.append(&status_label); root.append(&status_label);
window.set_child(Some(&root)); window.set_child(Some(&root));
@ -1052,6 +1159,7 @@ impl MediaViewerWindow {
stack, stack,
status_label, status_label,
media_seek, media_seek,
media_seek_row,
sibling_navigator, sibling_navigator,
favorite_button, favorite_button,
favorite_toggle: favorite_control.toggle, favorite_toggle: favorite_control.toggle,
@ -1059,6 +1167,10 @@ impl MediaViewerWindow {
file_download: download_control.download, file_download: download_control.download,
seek_generation: Cell::new(0), seek_generation: Cell::new(0),
seek_opening: Cell::new(false), 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),
}); });
if let Some(stream) = controller.video.media_stream() { if let Some(stream) = controller.video.media_stream() {
let status_label = controller.status_label.clone(); let status_label = controller.status_label.clone();
@ -1091,6 +1203,7 @@ struct MediaViewerController {
stack: gtk::Stack, stack: gtk::Stack,
status_label: gtk::Label, status_label: gtk::Label,
media_seek: gtk::Scale, media_seek: gtk::Scale,
media_seek_row: gtk::Box,
sibling_navigator: Option<SiblingNavigator>, sibling_navigator: Option<SiblingNavigator>,
favorite_button: gtk::Button, favorite_button: gtk::Button,
favorite_toggle: Rc<dyn Fn() -> bool>, favorite_toggle: Rc<dyn Fn() -> bool>,
@ -1098,32 +1211,47 @@ struct MediaViewerController {
file_download: Rc<dyn Fn(gtk::Window, gtk::Label)>, file_download: Rc<dyn Fn(gtk::Window, gtk::Label)>,
seek_generation: Cell<u64>, seek_generation: Cell<u64>,
seek_opening: Cell<bool>, 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>>,
} }
impl MediaViewerController { impl MediaViewerController {
fn connect_signals(self: &Rc<Self>) { 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 keys = gtk::EventControllerKey::new();
let controller = Rc::clone(self); let controller = Rc::clone(self);
keys.connect_key_pressed(move |_, key, _, _| match key.to_unicode() { keys.connect_key_pressed(move |_, key, _, _| {
Some('[') => { controller.hide_seek();
controller.open_sibling(false); match key.to_unicode() {
glib::Propagation::Stop 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,
} }
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); self.window.add_controller(keys.clone());
self.key_controller.replace(Some(keys));
let controller = Rc::clone(self); let controller = Rc::clone(self);
self.favorite_button.connect_clicked(move |_| { self.favorite_button.connect_clicked(move |_| {
let active = (controller.favorite_toggle)(); let active = (controller.favorite_toggle)();
@ -1150,12 +1278,58 @@ impl MediaViewerController {
fn open_sibling(&self, forward: bool) { fn open_sibling(&self, forward: bool) {
if let Some(navigate) = &self.sibling_navigator if let Some(navigate) = &self.sibling_navigator
&& navigate.open_relative(forward) && navigate.open_relative(forward, self.window.clone())
{ {
self.close_for_navigation(); self.prepare_for_navigation();
} }
} }
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.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) { fn schedule_seek(self: &Rc<Self>, index: usize) {
let Some(navigator) = &self.sibling_navigator else { let Some(navigator) = &self.sibling_navigator else {
return; return;
@ -1176,18 +1350,23 @@ impl MediaViewerController {
let Some(navigator) = &controller.sibling_navigator else { let Some(navigator) = &controller.sibling_navigator else {
return; return;
}; };
if navigator.open_index(index) { if navigator.open_index(index, controller.window.clone()) {
controller.seek_opening.set(true); controller.seek_opening.set(true);
controller.close_for_navigation(); controller.prepare_for_navigation();
} }
}); });
} }
fn close_for_navigation(&self) { fn prepare_for_navigation(&self) {
if let Some(stream) = self.video.media_stream() { if let Some(stream) = self.video.media_stream() {
stream.pause(); stream.pause();
} }
self.window.close(); 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) { fn toggle_fullscreen(&self) {
@ -1259,6 +1438,33 @@ fn page_picture() -> gtk::Picture {
picture 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_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> { fn spread_indices(anchor: usize, count: usize) -> Vec<usize> {
if count == 0 { if count == 0 {
return Vec::new(); return Vec::new();
@ -1370,6 +1576,14 @@ mod tests {
assert_eq!(page_seek_index(0.0, 0), None); 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] #[test]
fn sibling_navigation_stops_at_boundaries() { fn sibling_navigation_stops_at_boundaries() {
assert_eq!(sibling_index(1, 3, false), Some(0)); assert_eq!(sibling_index(1, 3, false), Some(0));

View file

@ -14,14 +14,15 @@ use gtk::prelude::*;
use crate::browser::{ use crate::browser::{
BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries,
}; };
use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile}; use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile, ViewerMode};
use crate::database::Database; use crate::database::Database;
use crate::source::{ use crate::source::{
display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root, display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root,
mount_operation, parent_within_root, root_file, validate_profile, mount_operation, parent_within_root, root_file, validate_profile,
}; };
use crate::viewer::{ use crate::viewer::{
FavoriteControl, FileDownloadControl, MediaViewerWindow, SiblingNavigator, ViewerWindow, FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator,
ViewModeControl, ViewerWindow,
}; };
use crate::zip_cache::ZipCache; use crate::zip_cache::ZipCache;
@ -70,12 +71,6 @@ impl ComicWindow {
locations.append(&edit_location_button); locations.append(&edit_location_button);
locations.append(&delete_location_button); locations.append(&delete_location_button);
let favorites = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let favorite_dropdown = gtk::DropDown::from_strings(&["즐겨찾기 선택"]);
favorite_dropdown.set_hexpand(true);
favorites.append(&gtk::Label::new(Some("즐겨찾기")));
favorites.append(&favorite_dropdown);
let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let up_button = gtk::Button::builder() let up_button = gtk::Button::builder()
.icon_name("go-up-symbolic") .icon_name("go-up-symbolic")
@ -119,7 +114,6 @@ impl ComicWindow {
let status_label = gtk::Label::builder().xalign(0.0).build(); let status_label = gtk::Label::builder().xalign(0.0).build();
root.append(&locations); root.append(&locations);
root.append(&favorites);
root.append(&navigation); root.append(&navigation);
root.append(&controls); root.append(&controls);
root.append(&scrolled); root.append(&scrolled);
@ -129,7 +123,6 @@ impl ComicWindow {
let controller = Rc::new(BrowserController { let controller = Rc::new(BrowserController {
window, window,
location_dropdown, location_dropdown,
favorite_dropdown,
edit_location_button, edit_location_button,
delete_location_button, delete_location_button,
path_entry, path_entry,
@ -141,6 +134,7 @@ impl ComicWindow {
state: RefCell::new(BrowserState { state: RefCell::new(BrowserState {
profile_index: 0, profile_index: 0,
favorite_profile: None, favorite_profile: None,
browsing_favorites: false,
root_uri: root_uri.clone(), root_uri: root_uri.clone(),
current_uri: root_uri, current_uri: root_uri,
entries: Vec::new(), entries: Vec::new(),
@ -158,7 +152,6 @@ impl ComicWindow {
}); });
controller.rebuild_profiles(); controller.rebuild_profiles();
controller.rebuild_favorites();
controller.connect_signals( controller.connect_signals(
open_button, open_button,
refresh_button, refresh_button,
@ -178,6 +171,7 @@ impl ComicWindow {
struct BrowserState { struct BrowserState {
profile_index: usize, profile_index: usize,
favorite_profile: Option<LocationProfile>, favorite_profile: Option<LocationProfile>,
browsing_favorites: bool,
root_uri: String, root_uri: String,
current_uri: String, current_uri: String,
entries: Vec<BrowserEntry>, entries: Vec<BrowserEntry>,
@ -192,7 +186,6 @@ struct BrowserState {
struct BrowserController { struct BrowserController {
window: gtk::ApplicationWindow, window: gtk::ApplicationWindow,
location_dropdown: gtk::DropDown, location_dropdown: gtk::DropDown,
favorite_dropdown: gtk::DropDown,
edit_location_button: gtk::Button, edit_location_button: gtk::Button,
delete_location_button: gtk::Button, delete_location_button: gtk::Button,
path_entry: gtk::Entry, path_entry: gtk::Entry,
@ -244,9 +237,6 @@ impl BrowserController {
self.location_dropdown self.location_dropdown
.connect_selected_notify(move |_| controller.select_profile()); .connect_selected_notify(move |_| controller.select_profile());
let controller = Rc::clone(self); let controller = Rc::clone(self);
self.favorite_dropdown
.connect_selected_notify(move |_| controller.select_favorite());
let controller = Rc::clone(self);
self.search_entry self.search_entry
.connect_search_changed(move |_| controller.render()); .connect_search_changed(move |_| controller.render());
let controller = Rc::clone(self); let controller = Rc::clone(self);
@ -261,8 +251,8 @@ impl BrowserController {
let context_click = gtk::GestureClick::builder().button(3).build(); let context_click = gtk::GestureClick::builder().button(3).build();
let controller = Rc::clone(self); let controller = Rc::clone(self);
context_click.connect_pressed(move |_, _, _, y| { context_click.connect_pressed(move |_, _, x, y| {
controller.show_context_menu(y as i32); controller.show_context_menu(x, y);
}); });
self.list_box.add_controller(context_click); self.list_box.add_controller(context_click);
@ -296,38 +286,26 @@ impl BrowserController {
fn rebuild_profiles(&self) { fn rebuild_profiles(&self) {
let profiles = self.profiles(); let profiles = self.profiles();
let names: Vec<_> = profiles let mut names: Vec<_> = profiles
.iter() .iter()
.map(|profile| profile.name.as_str()) .map(|profile| profile.name.clone())
.collect(); .collect();
names.push("즐겨찾기".into());
let names: Vec<_> = names.iter().map(String::as_str).collect();
self.location_dropdown self.location_dropdown
.set_model(Some(&gtk::StringList::new(&names))); .set_model(Some(&gtk::StringList::new(&names)));
} }
fn rebuild_favorites(&self) {
let config = self.config.borrow();
let mut names = Vec::with_capacity(config.favorites.len() + 1);
names.push("즐겨찾기 선택".to_owned());
names.extend(config.favorites.iter().map(|favorite| {
let marker = if favorite.is_directory {
"폴더"
} else {
"파일"
};
format!("[{marker}] {}", favorite.name)
}));
let names: Vec<_> = names.iter().map(String::as_str).collect();
self.favorite_dropdown
.set_model(Some(&gtk::StringList::new(&names)));
self.favorite_dropdown.set_selected(0);
}
fn select_profile(self: &Rc<Self>) { fn select_profile(self: &Rc<Self>) {
let profile_index = self.location_dropdown.selected() as usize; let profile_index = self.location_dropdown.selected() as usize;
let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len(); let profiles = self.profiles();
if profile_index == profiles.len() {
self.show_favorites();
return;
}
let editable = profile_index > 0 && profile_index < profiles.len();
self.edit_location_button.set_sensitive(editable); self.edit_location_button.set_sensitive(editable);
self.delete_location_button.set_sensitive(editable); self.delete_location_button.set_sensitive(editable);
let profiles = self.profiles();
let Some(profile) = profiles.get(profile_index) else { let Some(profile) = profiles.get(profile_index) else {
return; return;
}; };
@ -337,9 +315,11 @@ impl BrowserController {
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
state.profile_index = profile_index; state.profile_index = profile_index;
state.favorite_profile = None; state.favorite_profile = None;
state.browsing_favorites = false;
state.root_uri = root_uri.clone(); state.root_uri = root_uri.clone();
state.current_uri = root_uri; state.current_uri = root_uri;
drop(state); drop(state);
self.path_entry.set_editable(true);
self.search_entry.set_text(""); self.search_entry.set_text("");
self.refresh(); self.refresh();
} }
@ -347,15 +327,28 @@ impl BrowserController {
} }
} }
fn select_favorite(self: &Rc<Self>) { fn show_favorites(&self) {
let Some(index) = (self.favorite_dropdown.selected() as usize).checked_sub(1) else { self.edit_location_button.set_sensitive(false);
return; self.delete_location_button.set_sensitive(false);
}; self.path_entry.set_text("즐겨찾기");
let Some(favorite) = self.config.borrow().favorites.get(index).cloned() else { self.path_entry.set_editable(false);
self.favorite_dropdown.set_selected(0); self.search_entry.set_text("");
return; let entries = self
}; .config
self.favorite_dropdown.set_selected(0); .borrow()
.favorites
.iter()
.map(favorite_browser_entry)
.collect();
let mut state = self.state.borrow_mut();
state.browsing_favorites = true;
state.favorite_profile = None;
state.entries = entries;
drop(state);
self.render();
}
fn open_favorite(self: &Rc<Self>, favorite: FavoriteEntry) {
let Ok(root) = root_file(&favorite.profile) else { let Ok(root) = root_file(&favorite.profile) else {
self.status_label self.status_label
.set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다."); .set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다.");
@ -374,6 +367,7 @@ impl BrowserController {
self.delete_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false);
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
state.favorite_profile = Some(favorite.profile.clone()); state.favorite_profile = Some(favorite.profile.clone());
state.browsing_favorites = false;
state.root_uri = root.uri().into(); state.root_uri = root.uri().into();
state.current_uri = favorite.uri; state.current_uri = favorite.uri;
drop(state); drop(state);
@ -412,11 +406,15 @@ impl BrowserController {
etag: None, etag: None,
}; };
let siblings = Rc::new(vec![entry.clone()]); let siblings = Rc::new(vec![entry.clone()]);
controller.open_viewable(entry, siblings, 0, favorite.profile); controller.open_viewable(entry, siblings, 0, favorite.profile, None);
}); });
} }
fn open_entered_path(self: &Rc<Self>) { fn open_entered_path(self: &Rc<Self>) {
let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
return;
}
let profile = self.current_profile(); let profile = self.current_profile();
let entered = self.path_entry.text(); let entered = self.path_entry.text();
let file = file_from_user_input(profile.kind, entered.as_str()); let file = file_from_user_input(profile.kind, entered.as_str());
@ -431,6 +429,10 @@ impl BrowserController {
} }
fn go_up(self: &Rc<Self>) { fn go_up(self: &Rc<Self>) {
let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
return;
}
let state = self.state.borrow(); let state = self.state.borrow();
let current = gio::File::for_uri(&state.current_uri); let current = gio::File::for_uri(&state.current_uri);
let root = gio::File::for_uri(&state.root_uri); let root = gio::File::for_uri(&state.root_uri);
@ -442,6 +444,11 @@ impl BrowserController {
} }
fn refresh(self: &Rc<Self>) { fn refresh(self: &Rc<Self>) {
let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
self.show_favorites();
return;
}
let (profile, uri, generation) = { let (profile, uri, generation) = {
let mut state = self.state.borrow_mut(); let mut state = self.state.borrow_mut();
state.generation = state.generation.wrapping_add(1); state.generation = state.generation.wrapping_add(1);
@ -602,6 +609,20 @@ impl BrowserController {
else { else {
return; return;
}; };
let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
let favorite = self
.config
.borrow()
.favorites
.iter()
.find(|favorite| favorite.uri == entry.uri)
.cloned();
if let Some(favorite) = favorite {
self.open_favorite(favorite);
}
return;
}
match entry.kind { match entry.kind {
EntryKind::Directory => { EntryKind::Directory => {
self.state.borrow_mut().current_uri = entry.uri; self.state.borrow_mut().current_uri = entry.uri;
@ -623,7 +644,7 @@ impl BrowserController {
}; };
let siblings = Rc::new(siblings); let siblings = Rc::new(siblings);
if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) { if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) {
self.open_viewable(entry, siblings, current, self.current_profile()); self.open_viewable(entry, siblings, current, self.current_profile(), None);
} }
} }
EntryKind::OtherFile => {} EntryKind::OtherFile => {}
@ -636,9 +657,12 @@ impl BrowserController {
siblings: Rc<Vec<BrowserEntry>>, siblings: Rc<Vec<BrowserEntry>>,
current: usize, current: usize,
profile: LocationProfile, profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) { ) {
match entry.kind { match entry.kind {
EntryKind::ZipArchive => self.open_archive(entry, siblings, current, profile), EntryKind::ZipArchive => {
self.open_archive(entry, siblings, current, profile, reuse_window)
}
EntryKind::Image | EntryKind::Video => { EntryKind::Image | EntryKind::Video => {
let media_siblings = Rc::new( let media_siblings = Rc::new(
siblings siblings
@ -651,7 +675,7 @@ impl BrowserController {
.iter() .iter()
.position(|sibling| sibling.uri == entry.uri) .position(|sibling| sibling.uri == entry.uri)
{ {
self.open_media(entry, media_siblings, current, profile); self.open_media(entry, media_siblings, current, profile, reuse_window);
} }
} }
_ => {} _ => {}
@ -666,7 +690,7 @@ impl BrowserController {
) -> SiblingNavigator { ) -> SiblingNavigator {
let controller = Rc::downgrade(self); let controller = Rc::downgrade(self);
let count = siblings.len(); let count = siblings.len();
let open = Rc::new(move |next: usize| { let open = Rc::new(move |next: usize, reuse_window: gtk::ApplicationWindow| {
let Some(controller) = controller.upgrade() else { let Some(controller) = controller.upgrade() else {
return false; return false;
}; };
@ -681,6 +705,7 @@ impl BrowserController {
Rc::clone(&siblings), Rc::clone(&siblings),
next, next,
profile.clone(), profile.clone(),
Some(reuse_window),
); );
true true
}); });
@ -693,13 +718,19 @@ impl BrowserController {
siblings: Rc<Vec<BrowserEntry>>, siblings: Rc<Vec<BrowserEntry>>,
current: usize, current: usize,
profile: LocationProfile, profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) { ) {
self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
let progress_label = self.status_label.clone(); let progress_label = reuse_window.as_ref().map_or_else(
|| self.status_label.clone(),
|window| show_viewer_loading(window, &entry.name, "ZIP 파일을 준비하는 중..."),
);
let result_label = progress_label.clone();
let controller = Rc::clone(self); let controller = Rc::clone(self);
let navigator = self.sibling_navigator(siblings, current, profile.clone()); let navigator = self.sibling_navigator(siblings, current, profile.clone());
let favorite_control = self.favorite_control(&entry, &profile); let favorite_control = self.favorite_control(&entry, &profile);
let download_control = self.download_control(&entry); let download_control = self.download_control(&entry);
let view_mode_control = self.view_mode_control();
self.zip_cache.prepare( self.zip_cache.prepare(
entry, entry,
move |current, total| { move |current, total| {
@ -709,6 +740,7 @@ impl BrowserController {
move |result| match result { move |result| match result {
Ok(source) => { Ok(source) => {
controller.status_label.set_text("다운로드 완료"); controller.status_label.set_text("다운로드 완료");
result_label.set_text("ZIP 파일을 여는 중...");
let Some(application) = controller.window.application() else { let Some(application) = controller.window.application() else {
return; return;
}; };
@ -717,12 +749,20 @@ impl BrowserController {
source, source,
Rc::clone(&controller.database), Rc::clone(&controller.database),
controller.config.borrow().fast_image_loading, controller.config.borrow().fast_image_loading,
Some(navigator), view_mode_control,
favorite_control, FileViewerControls {
download_control, sibling_navigator: Some(navigator),
favorite_control,
download_control,
reuse_window,
},
); );
} }
Err(error) => controller.status_label.set_text(&format!("{error:#}")), Err(error) => {
let message = format!("{error:#}");
controller.status_label.set_text(&message);
result_label.set_text(&message);
}
}, },
); );
} }
@ -733,11 +773,16 @@ impl BrowserController {
siblings: Rc<Vec<BrowserEntry>>, siblings: Rc<Vec<BrowserEntry>>,
current: usize, current: usize,
profile: LocationProfile, profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) { ) {
let kind = entry.kind; let kind = entry.kind;
self.status_label self.status_label
.set_text("미디어 파일 다운로드 준비 중..."); .set_text("미디어 파일 다운로드 준비 중...");
let progress_label = self.status_label.clone(); let progress_label = reuse_window.as_ref().map_or_else(
|| self.status_label.clone(),
|window| show_viewer_loading(window, &entry.name, "미디어 파일을 준비하는 중..."),
);
let result_label = progress_label.clone();
let controller = Rc::clone(self); let controller = Rc::clone(self);
let navigator = self.sibling_navigator(siblings, current, profile.clone()); let navigator = self.sibling_navigator(siblings, current, profile.clone());
let favorite_control = self.favorite_control(&entry, &profile); let favorite_control = self.favorite_control(&entry, &profile);
@ -751,6 +796,7 @@ impl BrowserController {
move |result| match result { move |result| match result {
Ok(source) => { Ok(source) => {
controller.status_label.set_text("다운로드 완료"); controller.status_label.set_text("다운로드 완료");
result_label.set_text("미디어 파일을 여는 중...");
let Some(application) = controller.window.application() else { let Some(application) = controller.window.application() else {
return; return;
}; };
@ -758,18 +804,37 @@ impl BrowserController {
&application, &application,
source, source,
kind, kind,
Some(navigator), FileViewerControls {
favorite_control, sibling_navigator: Some(navigator),
download_control, favorite_control,
download_control,
reuse_window,
},
); );
} }
Err(error) => controller.status_label.set_text(&format!("{error:#}")), Err(error) => {
let message = format!("{error:#}");
controller.status_label.set_text(&message);
result_label.set_text(&message);
}
}, },
); );
} }
fn show_context_menu(self: &Rc<Self>, y: i32) { fn show_context_menu(self: &Rc<Self>, x: f64, y: f64) {
let Some(row) = self.list_box.row_at_y(y) else { let Some(widget) = self.list_box.pick(x, y, gtk::PickFlags::DEFAULT) else {
return;
};
let Some(row) = widget
.clone()
.downcast::<gtk::ListBoxRow>()
.ok()
.or_else(|| {
widget
.ancestor(gtk::ListBoxRow::static_type())
.and_then(|widget| widget.downcast::<gtk::ListBoxRow>().ok())
})
else {
return; return;
}; };
let Some(entry) = self let Some(entry) = self
@ -869,6 +934,32 @@ impl BrowserController {
} }
} }
fn view_mode_control(self: &Rc<Self>) -> ViewModeControl {
let selected = self.config.borrow().viewer_mode.dropdown_index();
let controller = Rc::downgrade(self);
ViewModeControl {
selected,
change: Rc::new(move |index| {
let Some(controller) = controller.upgrade() else {
return;
};
let viewer_mode = ViewerMode::from_dropdown_index(index);
if controller.config.borrow().viewer_mode == viewer_mode {
return;
}
let mut updated = controller.config.borrow().clone();
updated.viewer_mode = viewer_mode;
if let Err(error) = updated.save(&controller.config_path) {
controller
.status_label
.set_text(&format!("보기 모드를 저장할 수 없습니다: {error:#}"));
return;
}
*controller.config.borrow_mut() = updated;
}),
}
}
fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool { fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool {
let mut updated = self.config.borrow().clone(); let mut updated = self.config.borrow().clone();
let was_active = self.is_favorite(&entry.uri); let was_active = self.is_favorite(&entry.uri);
@ -879,7 +970,10 @@ impl BrowserController {
return was_active; return was_active;
} }
*self.config.borrow_mut() = updated; *self.config.borrow_mut() = updated;
self.rebuild_favorites(); let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
self.show_favorites();
}
self.status_label.set_text(if active { self.status_label.set_text(if active {
"즐겨찾기에 추가했습니다." "즐겨찾기에 추가했습니다."
} else { } else {
@ -1245,6 +1339,51 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool {
} }
} }
fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry {
BrowserEntry {
uri: favorite.uri.clone(),
name: favorite.name.clone(),
kind: if favorite.is_directory {
EntryKind::Directory
} else {
classify_file(Path::new(&favorite.name))
},
size: None,
modified: None,
created: None,
etag: None,
}
}
fn show_viewer_loading(
window: &gtk::ApplicationWindow,
file_name: &str,
status: &str,
) -> gtk::Label {
window.set_title(Some(file_name));
let header = gtk::HeaderBar::new();
header.set_title_widget(Some(&gtk::Label::new(Some(file_name))));
window.set_titlebar(Some(&header));
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
content.set_halign(gtk::Align::Center);
content.set_valign(gtk::Align::Center);
content.set_hexpand(true);
content.set_vexpand(true);
let spinner = gtk::Spinner::new();
spinner.set_spinning(true);
let name_label = gtk::Label::new(Some(file_name));
name_label.add_css_class("title-2");
name_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle);
name_label.set_max_width_chars(60);
let status_label = gtk::Label::new(Some(status));
content.append(&spinner);
content.append(&name_label);
content.append(&status_label);
window.set_child(Some(&content));
status_label
}
fn download_part_path(final_path: &std::path::Path) -> PathBuf { fn download_part_path(final_path: &std::path::Path) -> PathBuf {
final_path.with_file_name(format!( final_path.with_file_name(format!(
"{}.part", "{}.part",
@ -1315,6 +1454,26 @@ mod tests {
assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile));
} }
#[test]
fn favorites_are_rendered_as_browser_paths_and_files() {
let profile = home_profile();
let folder = favorite_browser_entry(&FavoriteEntry {
name: "Comics".into(),
uri: "file:///tmp/Comics".into(),
is_directory: true,
profile: profile.clone(),
});
let archive = favorite_browser_entry(&FavoriteEntry {
name: "book.zip".into(),
uri: "file:///tmp/book.zip".into(),
is_directory: false,
profile,
});
assert_eq!(folder.kind, EntryKind::Directory);
assert_eq!(archive.kind, EntryKind::ZipArchive);
}
#[test] #[test]
fn incomplete_download_uses_distinct_suffix() { fn incomplete_download_uses_distinct_suffix() {
assert_eq!( assert_eq!(