Add local and remote favorites
This commit is contained in:
parent
5f9b5d9796
commit
490c925f62
4 changed files with 357 additions and 28 deletions
302
src/window.rs
302
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 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));
|
||||
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());
|
||||
});
|
||||
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("다운로드...");
|
||||
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