Add local and remote favorites
This commit is contained in:
parent
5f9b5d9796
commit
490c925f62
4 changed files with 357 additions and 28 deletions
|
|
@ -11,6 +11,7 @@ pub struct AppConfig {
|
|||
pub cache_quota_gb: u64,
|
||||
pub fast_image_loading: bool,
|
||||
pub locations: Vec<LocationProfile>,
|
||||
pub favorites: Vec<FavoriteEntry>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
|
|
@ -19,6 +20,7 @@ impl Default for AppConfig {
|
|||
cache_quota_gb: 10,
|
||||
fast_image_loading: true,
|
||||
locations: Vec::new(),
|
||||
favorites: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +38,14 @@ pub struct LocationProfile {
|
|||
pub domain: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FavoriteEntry {
|
||||
pub name: String,
|
||||
pub uri: String,
|
||||
pub is_directory: bool,
|
||||
pub profile: LocationProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LocationKind {
|
||||
|
|
@ -137,16 +147,32 @@ mod tests {
|
|||
password: "plain-password".into(),
|
||||
domain: String::new(),
|
||||
}],
|
||||
favorites: vec![FavoriteEntry {
|
||||
name: "원격 만화".into(),
|
||||
uri: "sftp://example.invalid/comics/book.zip".into(),
|
||||
is_directory: false,
|
||||
profile: LocationProfile {
|
||||
name: "즐겨찾기 서버".into(),
|
||||
kind: LocationKind::Sftp,
|
||||
address: "sftp://example.invalid/comics".into(),
|
||||
username: "favorite-reader".into(),
|
||||
password: "favorite-password".into(),
|
||||
domain: String::new(),
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let encoded = toml::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = toml::from_str(&encoded).unwrap();
|
||||
assert_eq!(decoded.locations[0].password, "plain-password");
|
||||
assert_eq!(decoded.favorites[0].profile.username, "favorite-reader");
|
||||
assert_eq!(decoded.favorites[0].profile.password, "favorite-password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_image_loading_is_enabled_for_existing_configs() {
|
||||
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
|
||||
assert!(decoded.fast_image_loading);
|
||||
assert!(decoded.favorites.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,26 @@ pub fn mount_operation(
|
|||
operation
|
||||
}
|
||||
|
||||
pub async fn ensure_mounted(
|
||||
file: &gio::File,
|
||||
profile: &LocationProfile,
|
||||
operation: >k::MountOperation,
|
||||
) -> Result<()> {
|
||||
if profile.kind == LocationKind::Local || file.is_native() {
|
||||
return Ok(());
|
||||
}
|
||||
match file
|
||||
.mount_enclosing_volume_future(gio::MountMountFlags::NONE, Some(operation))
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.matches(gio::IOErrorEnum::AlreadyMounted) => Ok(()),
|
||||
Err(error) => {
|
||||
Err(error).with_context(|| format!("원격 위치를 마운트할 수 없습니다: {}", file.uri()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn enumerate_with_mount_retry(
|
||||
directory: &gio::File,
|
||||
operation: >k::MountOperation,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ pub struct ViewerWindow;
|
|||
|
||||
pub type SiblingNavigator = Rc<dyn Fn(bool) -> bool>;
|
||||
|
||||
pub struct FavoriteControl {
|
||||
pub active: bool,
|
||||
pub toggle: Rc<dyn Fn() -> bool>,
|
||||
}
|
||||
|
||||
impl ViewerWindow {
|
||||
pub fn open(
|
||||
application: >k::Application,
|
||||
|
|
@ -30,6 +35,7 @@ impl ViewerWindow {
|
|||
database: Rc<Database>,
|
||||
fast_image_loading: bool,
|
||||
sibling_navigator: Option<SiblingNavigator>,
|
||||
favorite_control: FavoriteControl,
|
||||
) {
|
||||
let title = source.display_name.clone();
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
|
|
@ -40,6 +46,8 @@ impl ViewerWindow {
|
|||
.build();
|
||||
let header = gtk::HeaderBar::new();
|
||||
header.set_title_widget(Some(>k::Label::new(Some(&title))));
|
||||
let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
|
||||
header.pack_end(&favorite_button);
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
|
|
@ -152,6 +160,8 @@ impl ViewerWindow {
|
|||
direction_dropdown,
|
||||
fast_image_loading,
|
||||
sibling_navigator,
|
||||
favorite_button,
|
||||
favorite_toggle: favorite_control.toggle,
|
||||
state: RefCell::new(ViewerState {
|
||||
pages: Vec::new(),
|
||||
anchor: 0,
|
||||
|
|
@ -228,6 +238,8 @@ struct ViewerController {
|
|||
direction_dropdown: gtk::DropDown,
|
||||
fast_image_loading: bool,
|
||||
sibling_navigator: Option<SiblingNavigator>,
|
||||
favorite_button: gtk::Button,
|
||||
favorite_toggle: Rc<dyn Fn() -> bool>,
|
||||
state: RefCell<ViewerState>,
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +264,11 @@ impl ViewerController {
|
|||
self.direction_dropdown
|
||||
.connect_selected_notify(move |_| controller.render());
|
||||
let controller = Rc::clone(self);
|
||||
self.favorite_button.connect_clicked(move |_| {
|
||||
let active = (controller.favorite_toggle)();
|
||||
controller.favorite_button.set_label(favorite_label(active));
|
||||
});
|
||||
let controller = Rc::clone(self);
|
||||
self.page_seek.connect_value_changed(move |scale| {
|
||||
if let Some(index) =
|
||||
page_seek_index(scale.value(), controller.state.borrow().pages.len())
|
||||
|
|
@ -927,6 +944,7 @@ impl MediaViewerWindow {
|
|||
source: ArchiveSource,
|
||||
kind: EntryKind,
|
||||
sibling_navigator: Option<SiblingNavigator>,
|
||||
favorite_control: FavoriteControl,
|
||||
) {
|
||||
let title = source.display_name.clone();
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
|
|
@ -937,6 +955,8 @@ impl MediaViewerWindow {
|
|||
.build();
|
||||
let header = gtk::HeaderBar::new();
|
||||
header.set_title_widget(Some(>k::Label::new(Some(&title))));
|
||||
let favorite_button = gtk::Button::with_label(favorite_label(favorite_control.active));
|
||||
header.pack_end(&favorite_button);
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
|
|
@ -974,6 +994,8 @@ impl MediaViewerWindow {
|
|||
stack,
|
||||
status_label,
|
||||
sibling_navigator,
|
||||
favorite_button,
|
||||
favorite_toggle: favorite_control.toggle,
|
||||
});
|
||||
if let Some(stream) = controller.video.media_stream() {
|
||||
let status_label = controller.status_label.clone();
|
||||
|
|
@ -1006,6 +1028,8 @@ struct MediaViewerController {
|
|||
stack: gtk::Stack,
|
||||
status_label: gtk::Label,
|
||||
sibling_navigator: Option<SiblingNavigator>,
|
||||
favorite_button: gtk::Button,
|
||||
favorite_toggle: Rc<dyn Fn() -> bool>,
|
||||
}
|
||||
|
||||
impl MediaViewerController {
|
||||
|
|
@ -1032,6 +1056,11 @@ impl MediaViewerController {
|
|||
_ => glib::Propagation::Proceed,
|
||||
});
|
||||
self.window.add_controller(keys);
|
||||
let controller = Rc::clone(self);
|
||||
self.favorite_button.connect_clicked(move |_| {
|
||||
let active = (controller.favorite_toggle)();
|
||||
controller.favorite_button.set_label(favorite_label(active));
|
||||
});
|
||||
}
|
||||
|
||||
fn open_sibling(&self, forward: bool) {
|
||||
|
|
@ -1098,6 +1127,14 @@ impl MediaViewerController {
|
|||
}
|
||||
}
|
||||
|
||||
fn favorite_label(active: bool) -> &'static str {
|
||||
if active {
|
||||
"즐겨찾기 제거"
|
||||
} else {
|
||||
"즐겨찾기 추가"
|
||||
}
|
||||
}
|
||||
|
||||
fn page_picture() -> gtk::Picture {
|
||||
let picture = gtk::Picture::new();
|
||||
picture.set_content_fit(gtk::ContentFit::Contain);
|
||||
|
|
|
|||
288
src/window.rs
288
src/window.rs
|
|
@ -1,6 +1,6 @@
|
|||
use std::cell::RefCell;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -11,14 +11,16 @@ use gtk::gio::prelude::*;
|
|||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::browser::{BrowserEntry, EntryKind, SortDirection, SortField, sort_entries};
|
||||
use crate::config::{AppConfig, LocationKind, LocationProfile};
|
||||
use crate::browser::{
|
||||
BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries,
|
||||
};
|
||||
use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile};
|
||||
use crate::database::Database;
|
||||
use crate::source::{
|
||||
display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation,
|
||||
parent_within_root, root_file, validate_profile,
|
||||
display_remote_uri, ensure_mounted, enumerate_directory, file_from_user_input, is_within_root,
|
||||
mount_operation, parent_within_root, root_file, validate_profile,
|
||||
};
|
||||
use crate::viewer::{MediaViewerWindow, SiblingNavigator, ViewerWindow};
|
||||
use crate::viewer::{FavoriteControl, MediaViewerWindow, SiblingNavigator, ViewerWindow};
|
||||
use crate::zip_cache::ZipCache;
|
||||
|
||||
pub struct ComicWindow {
|
||||
|
|
@ -66,6 +68,12 @@ impl ComicWindow {
|
|||
locations.append(&edit_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(>k::Label::new(Some("즐겨찾기")));
|
||||
favorites.append(&favorite_dropdown);
|
||||
|
||||
let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
let up_button = gtk::Button::builder()
|
||||
.icon_name("go-up-symbolic")
|
||||
|
|
@ -109,6 +117,7 @@ impl ComicWindow {
|
|||
let status_label = gtk::Label::builder().xalign(0.0).build();
|
||||
|
||||
root.append(&locations);
|
||||
root.append(&favorites);
|
||||
root.append(&navigation);
|
||||
root.append(&controls);
|
||||
root.append(&scrolled);
|
||||
|
|
@ -118,6 +127,7 @@ impl ComicWindow {
|
|||
let controller = Rc::new(BrowserController {
|
||||
window,
|
||||
location_dropdown,
|
||||
favorite_dropdown,
|
||||
edit_location_button,
|
||||
delete_location_button,
|
||||
path_entry,
|
||||
|
|
@ -128,6 +138,7 @@ impl ComicWindow {
|
|||
status_label,
|
||||
state: RefCell::new(BrowserState {
|
||||
profile_index: 0,
|
||||
favorite_profile: None,
|
||||
root_uri: root_uri.clone(),
|
||||
current_uri: root_uri,
|
||||
entries: Vec::new(),
|
||||
|
|
@ -145,6 +156,7 @@ impl ComicWindow {
|
|||
});
|
||||
|
||||
controller.rebuild_profiles();
|
||||
controller.rebuild_favorites();
|
||||
controller.connect_signals(
|
||||
open_button,
|
||||
refresh_button,
|
||||
|
|
@ -163,6 +175,7 @@ impl ComicWindow {
|
|||
|
||||
struct BrowserState {
|
||||
profile_index: usize,
|
||||
favorite_profile: Option<LocationProfile>,
|
||||
root_uri: String,
|
||||
current_uri: String,
|
||||
entries: Vec<BrowserEntry>,
|
||||
|
|
@ -177,6 +190,7 @@ struct BrowserState {
|
|||
struct BrowserController {
|
||||
window: gtk::ApplicationWindow,
|
||||
location_dropdown: gtk::DropDown,
|
||||
favorite_dropdown: gtk::DropDown,
|
||||
edit_location_button: gtk::Button,
|
||||
delete_location_button: gtk::Button,
|
||||
path_entry: gtk::Entry,
|
||||
|
|
@ -228,6 +242,9 @@ impl BrowserController {
|
|||
self.location_dropdown
|
||||
.connect_selected_notify(move |_| controller.select_profile());
|
||||
let controller = Rc::clone(self);
|
||||
self.favorite_dropdown
|
||||
.connect_selected_notify(move |_| controller.select_favorite());
|
||||
let controller = Rc::clone(self);
|
||||
self.search_entry
|
||||
.connect_search_changed(move |_| controller.render());
|
||||
let controller = Rc::clone(self);
|
||||
|
|
@ -265,6 +282,16 @@ impl BrowserController {
|
|||
profiles
|
||||
}
|
||||
|
||||
fn current_profile(&self) -> LocationProfile {
|
||||
let state = self.state.borrow();
|
||||
state.favorite_profile.clone().unwrap_or_else(|| {
|
||||
self.profiles()
|
||||
.get(state.profile_index)
|
||||
.cloned()
|
||||
.unwrap_or_else(home_profile)
|
||||
})
|
||||
}
|
||||
|
||||
fn rebuild_profiles(&self) {
|
||||
let profiles = self.profiles();
|
||||
let names: Vec<_> = profiles
|
||||
|
|
@ -275,6 +302,24 @@ impl BrowserController {
|
|||
.set_model(Some(>k::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(>k::StringList::new(&names)));
|
||||
self.favorite_dropdown.set_selected(0);
|
||||
}
|
||||
|
||||
fn select_profile(self: &Rc<Self>) {
|
||||
let profile_index = self.location_dropdown.selected() as usize;
|
||||
let editable = profile_index > 0 && profile_index <= self.config.borrow().locations.len();
|
||||
|
|
@ -289,6 +334,7 @@ impl BrowserController {
|
|||
let root_uri: String = root.uri().into();
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.profile_index = profile_index;
|
||||
state.favorite_profile = None;
|
||||
state.root_uri = root_uri.clone();
|
||||
state.current_uri = root_uri;
|
||||
drop(state);
|
||||
|
|
@ -299,8 +345,77 @@ impl BrowserController {
|
|||
}
|
||||
}
|
||||
|
||||
fn select_favorite(self: &Rc<Self>) {
|
||||
let Some(index) = (self.favorite_dropdown.selected() as usize).checked_sub(1) else {
|
||||
return;
|
||||
};
|
||||
let Some(favorite) = self.config.borrow().favorites.get(index).cloned() else {
|
||||
self.favorite_dropdown.set_selected(0);
|
||||
return;
|
||||
};
|
||||
self.favorite_dropdown.set_selected(0);
|
||||
let Ok(root) = root_file(&favorite.profile) else {
|
||||
self.status_label
|
||||
.set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다.");
|
||||
return;
|
||||
};
|
||||
let favorite_file = gio::File::for_uri(&favorite.uri);
|
||||
if !is_within_root(&favorite_file, &root) {
|
||||
self.status_label
|
||||
.set_text("즐겨찾기가 저장된 위치의 루트 밖을 가리킵니다.");
|
||||
return;
|
||||
}
|
||||
if favorite.is_directory {
|
||||
self.location_dropdown
|
||||
.set_selected(gtk::INVALID_LIST_POSITION);
|
||||
self.edit_location_button.set_sensitive(false);
|
||||
self.delete_location_button.set_sensitive(false);
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.favorite_profile = Some(favorite.profile.clone());
|
||||
state.root_uri = root.uri().into();
|
||||
state.current_uri = favorite.uri;
|
||||
drop(state);
|
||||
self.search_entry.set_text("");
|
||||
self.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
self.status_label.set_text("즐겨찾기에 연결하는 중...");
|
||||
let operation = mount_operation(&favorite.profile, &self.window);
|
||||
let controller = Rc::downgrade(self);
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
let file = gio::File::for_uri(&favorite.uri);
|
||||
let result = ensure_mounted(&file, &favorite.profile, &operation).await;
|
||||
let Some(controller) = controller.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = result {
|
||||
controller.status_label.set_text(&format!("{error:#}"));
|
||||
return;
|
||||
}
|
||||
let kind = classify_file(Path::new(&favorite.name));
|
||||
if !kind.is_viewable() {
|
||||
controller
|
||||
.status_label
|
||||
.set_text("이 즐겨찾기 파일 형식은 열 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
let entry = BrowserEntry {
|
||||
uri: favorite.uri,
|
||||
name: favorite.name,
|
||||
kind,
|
||||
size: None,
|
||||
modified: None,
|
||||
created: None,
|
||||
etag: None,
|
||||
};
|
||||
let siblings = Rc::new(vec![entry.clone()]);
|
||||
controller.open_viewable(entry, siblings, 0, favorite.profile);
|
||||
});
|
||||
}
|
||||
|
||||
fn open_entered_path(self: &Rc<Self>) {
|
||||
let profile = self.profiles()[self.state.borrow().profile_index].clone();
|
||||
let profile = self.current_profile();
|
||||
let entered = self.path_entry.text();
|
||||
let file = file_from_user_input(profile.kind, entered.as_str());
|
||||
let root = gio::File::for_uri(&self.state.borrow().root_uri);
|
||||
|
|
@ -331,7 +446,10 @@ impl BrowserController {
|
|||
state.typeahead_query.clear();
|
||||
state.typeahead_last_input = None;
|
||||
(
|
||||
self.profiles()[state.profile_index].clone(),
|
||||
state
|
||||
.favorite_profile
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.profiles()[state.profile_index].clone()),
|
||||
state.current_uri.clone(),
|
||||
state.generation,
|
||||
)
|
||||
|
|
@ -502,7 +620,7 @@ impl BrowserController {
|
|||
};
|
||||
let siblings = Rc::new(siblings);
|
||||
if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) {
|
||||
self.open_viewable(entry, siblings, current);
|
||||
self.open_viewable(entry, siblings, current, self.current_profile());
|
||||
}
|
||||
}
|
||||
EntryKind::OtherFile => {}
|
||||
|
|
@ -514,10 +632,13 @@ impl BrowserController {
|
|||
entry: BrowserEntry,
|
||||
siblings: Rc<Vec<BrowserEntry>>,
|
||||
current: usize,
|
||||
profile: LocationProfile,
|
||||
) {
|
||||
match entry.kind {
|
||||
EntryKind::ZipArchive => self.open_archive(entry, siblings, current),
|
||||
EntryKind::Image | EntryKind::Video => self.open_media(entry, siblings, current),
|
||||
EntryKind::ZipArchive => self.open_archive(entry, siblings, current, profile),
|
||||
EntryKind::Image | EntryKind::Video => {
|
||||
self.open_media(entry, siblings, current, profile)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -526,6 +647,7 @@ impl BrowserController {
|
|||
self: &Rc<Self>,
|
||||
siblings: Rc<Vec<BrowserEntry>>,
|
||||
current: usize,
|
||||
profile: LocationProfile,
|
||||
) -> SiblingNavigator {
|
||||
let controller = Rc::downgrade(self);
|
||||
Rc::new(move |forward| {
|
||||
|
|
@ -538,7 +660,12 @@ impl BrowserController {
|
|||
.set_text("더 이상 이동할 파일이 없습니다.");
|
||||
return false;
|
||||
};
|
||||
controller.open_viewable(siblings[next].clone(), Rc::clone(&siblings), next);
|
||||
controller.open_viewable(
|
||||
siblings[next].clone(),
|
||||
Rc::clone(&siblings),
|
||||
next,
|
||||
profile.clone(),
|
||||
);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
|
@ -548,11 +675,13 @@ impl BrowserController {
|
|||
entry: BrowserEntry,
|
||||
siblings: Rc<Vec<BrowserEntry>>,
|
||||
current: usize,
|
||||
profile: LocationProfile,
|
||||
) {
|
||||
self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
|
||||
let progress_label = self.status_label.clone();
|
||||
let controller = Rc::clone(self);
|
||||
let navigator = self.sibling_navigator(siblings, current);
|
||||
let navigator = self.sibling_navigator(siblings, current, profile.clone());
|
||||
let favorite_control = self.favorite_control(&entry, &profile);
|
||||
self.zip_cache.prepare(
|
||||
entry,
|
||||
move |current, total| {
|
||||
|
|
@ -571,6 +700,7 @@ impl BrowserController {
|
|||
Rc::clone(&controller.database),
|
||||
controller.config.borrow().fast_image_loading,
|
||||
Some(navigator),
|
||||
favorite_control,
|
||||
);
|
||||
}
|
||||
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||
|
|
@ -583,13 +713,15 @@ impl BrowserController {
|
|||
entry: BrowserEntry,
|
||||
siblings: Rc<Vec<BrowserEntry>>,
|
||||
current: usize,
|
||||
profile: LocationProfile,
|
||||
) {
|
||||
let kind = entry.kind;
|
||||
self.status_label
|
||||
.set_text("미디어 파일 다운로드 준비 중...");
|
||||
let progress_label = self.status_label.clone();
|
||||
let controller = Rc::clone(self);
|
||||
let navigator = self.sibling_navigator(siblings, current);
|
||||
let navigator = self.sibling_navigator(siblings, current, profile.clone());
|
||||
let favorite_control = self.favorite_control(&entry, &profile);
|
||||
self.zip_cache.prepare_media(
|
||||
entry,
|
||||
move |current, total| {
|
||||
|
|
@ -602,7 +734,13 @@ impl BrowserController {
|
|||
let Some(application) = controller.window.application() else {
|
||||
return;
|
||||
};
|
||||
MediaViewerWindow::open(&application, source, kind, Some(navigator));
|
||||
MediaViewerWindow::open(
|
||||
&application,
|
||||
source,
|
||||
kind,
|
||||
Some(navigator),
|
||||
favorite_control,
|
||||
);
|
||||
}
|
||||
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||
},
|
||||
|
|
@ -622,27 +760,97 @@ impl BrowserController {
|
|||
else {
|
||||
return;
|
||||
};
|
||||
if entry.kind == EntryKind::Directory || gio::File::for_uri(&entry.uri).is_native() {
|
||||
let can_favorite = entry.kind == EntryKind::Directory || entry.kind.is_viewable();
|
||||
let can_download =
|
||||
entry.kind != EntryKind::Directory && !gio::File::for_uri(&entry.uri).is_native();
|
||||
if !can_favorite && !can_download {
|
||||
return;
|
||||
}
|
||||
self.list_box.select_row(Some(&row));
|
||||
let popover = gtk::Popover::new();
|
||||
popover.set_parent(&row);
|
||||
let actions = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
actions.set_margin_top(6);
|
||||
actions.set_margin_bottom(6);
|
||||
actions.set_margin_start(6);
|
||||
actions.set_margin_end(6);
|
||||
if can_favorite {
|
||||
let is_favorite = self.is_favorite(&entry.uri);
|
||||
let favorite_button = gtk::Button::with_label(if is_favorite {
|
||||
"즐겨찾기 제거"
|
||||
} else {
|
||||
"즐겨찾기 추가"
|
||||
});
|
||||
actions.append(&favorite_button);
|
||||
let controller = Rc::clone(self);
|
||||
let popover_to_close = popover.clone();
|
||||
let favorite_entry = entry.clone();
|
||||
favorite_button.connect_clicked(move |_| {
|
||||
popover_to_close.popdown();
|
||||
let profile = controller.current_profile();
|
||||
controller.toggle_favorite(&favorite_entry, &profile);
|
||||
});
|
||||
}
|
||||
if can_download {
|
||||
let download_button = gtk::Button::with_label("다운로드...");
|
||||
download_button.set_margin_top(6);
|
||||
download_button.set_margin_bottom(6);
|
||||
download_button.set_margin_start(6);
|
||||
download_button.set_margin_end(6);
|
||||
popover.set_child(Some(&download_button));
|
||||
actions.append(&download_button);
|
||||
let controller = Rc::clone(self);
|
||||
let popover_to_close = popover.clone();
|
||||
download_button.connect_clicked(move |_| {
|
||||
popover_to_close.popdown();
|
||||
controller.choose_download_destination(entry.clone());
|
||||
});
|
||||
}
|
||||
popover.set_child(Some(&actions));
|
||||
popover.popup();
|
||||
}
|
||||
|
||||
fn is_favorite(&self, uri: &str) -> bool {
|
||||
self.config
|
||||
.borrow()
|
||||
.favorites
|
||||
.iter()
|
||||
.any(|favorite| favorite.uri == uri)
|
||||
}
|
||||
|
||||
fn favorite_control(
|
||||
self: &Rc<Self>,
|
||||
entry: &BrowserEntry,
|
||||
profile: &LocationProfile,
|
||||
) -> FavoriteControl {
|
||||
let entry = entry.clone();
|
||||
let profile = profile.clone();
|
||||
let controller = Rc::downgrade(self);
|
||||
FavoriteControl {
|
||||
active: self.is_favorite(&entry.uri),
|
||||
toggle: Rc::new(move || {
|
||||
let Some(controller) = controller.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
controller.toggle_favorite(&entry, &profile)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_favorite(&self, entry: &BrowserEntry, profile: &LocationProfile) -> bool {
|
||||
let mut updated = self.config.borrow().clone();
|
||||
let was_active = self.is_favorite(&entry.uri);
|
||||
let active = toggle_favorite_entry(&mut updated, entry, profile);
|
||||
if let Err(error) = updated.save(&self.config_path) {
|
||||
self.status_label
|
||||
.set_text(&format!("즐겨찾기를 저장할 수 없습니다: {error:#}"));
|
||||
return was_active;
|
||||
}
|
||||
*self.config.borrow_mut() = updated;
|
||||
self.rebuild_favorites();
|
||||
self.status_label.set_text(if active {
|
||||
"즐겨찾기에 추가했습니다."
|
||||
} else {
|
||||
"즐겨찾기에서 제거했습니다."
|
||||
});
|
||||
active
|
||||
}
|
||||
|
||||
fn choose_download_destination(self: &Rc<Self>, entry: BrowserEntry) {
|
||||
let dialog = gtk::FileDialog::builder()
|
||||
.title("다운로드 위치 선택")
|
||||
|
|
@ -1008,6 +1216,29 @@ fn download_part_path(final_path: &std::path::Path) -> PathBuf {
|
|||
))
|
||||
}
|
||||
|
||||
fn toggle_favorite_entry(
|
||||
config: &mut AppConfig,
|
||||
entry: &BrowserEntry,
|
||||
profile: &LocationProfile,
|
||||
) -> bool {
|
||||
if let Some(index) = config
|
||||
.favorites
|
||||
.iter()
|
||||
.position(|favorite| favorite.uri == entry.uri)
|
||||
{
|
||||
config.favorites.remove(index);
|
||||
false
|
||||
} else {
|
||||
config.favorites.push(FavoriteEntry {
|
||||
name: entry.name.clone(),
|
||||
uri: entry.uri.clone(),
|
||||
is_directory: entry.kind == EntryKind::Directory,
|
||||
profile: profile.clone(),
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1053,4 +1284,19 @@ mod tests {
|
|||
PathBuf::from("/tmp/만화.zip.part")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorite_toggle_adds_and_removes_the_same_uri() {
|
||||
let mut config = AppConfig::default();
|
||||
let entry = entry("Comics");
|
||||
let profile = home_profile();
|
||||
|
||||
assert!(toggle_favorite_entry(&mut config, &entry, &profile));
|
||||
assert_eq!(config.favorites.len(), 1);
|
||||
assert!(config.favorites[0].is_directory);
|
||||
assert_eq!(config.favorites[0].profile.address, profile.address);
|
||||
|
||||
assert!(!toggle_favorite_entry(&mut config, &entry, &profile));
|
||||
assert!(config.favorites.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue