Release v0.2.1
This commit is contained in:
parent
ffa8bc93ba
commit
048df6355e
10 changed files with 1467 additions and 254 deletions
333
src/window.rs
333
src/window.rs
|
|
@ -21,8 +21,9 @@ use crate::source::{
|
|||
mount_operation, parent_within_root, root_file, validate_profile,
|
||||
};
|
||||
use crate::viewer::{
|
||||
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator,
|
||||
SiblingTarget, ViewModeControl, ViewerWindow,
|
||||
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingFile,
|
||||
SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow,
|
||||
set_viewer_header,
|
||||
};
|
||||
use crate::zip_cache::ZipCache;
|
||||
|
||||
|
|
@ -51,6 +52,8 @@ impl ComicWindow {
|
|||
header.set_title_widget(Some(>k::Label::new(Some("Comicviewer"))));
|
||||
let about_button = gtk::Button::with_label("정보");
|
||||
header.pack_end(&about_button);
|
||||
let clear_cache_button = gtk::Button::with_label("캐시 비우기");
|
||||
header.pack_end(&clear_cache_button);
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
let root = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
|
|
@ -128,6 +131,7 @@ impl ComicWindow {
|
|||
let controller = Rc::new(BrowserController {
|
||||
window,
|
||||
about_button,
|
||||
clear_cache_button,
|
||||
location_dropdown,
|
||||
edit_location_button,
|
||||
delete_location_button,
|
||||
|
|
@ -155,6 +159,7 @@ impl ComicWindow {
|
|||
config_path,
|
||||
database: Rc::new(database),
|
||||
zip_cache,
|
||||
listing_task: RefCell::new(None),
|
||||
});
|
||||
|
||||
controller.rebuild_profiles();
|
||||
|
|
@ -193,6 +198,7 @@ struct BrowserState {
|
|||
struct BrowserController {
|
||||
window: gtk::ApplicationWindow,
|
||||
about_button: gtk::Button,
|
||||
clear_cache_button: gtk::Button,
|
||||
location_dropdown: gtk::DropDown,
|
||||
edit_location_button: gtk::Button,
|
||||
delete_location_button: gtk::Button,
|
||||
|
|
@ -207,6 +213,7 @@ struct BrowserController {
|
|||
config_path: PathBuf,
|
||||
database: Rc<Database>,
|
||||
zip_cache: Rc<ZipCache>,
|
||||
listing_task: RefCell<Option<glib::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl BrowserController {
|
||||
|
|
@ -326,6 +333,9 @@ impl BrowserController {
|
|||
fast_loading_switch.connect_active_notify(move |switch| {
|
||||
controller.set_fast_image_loading(switch);
|
||||
});
|
||||
let controller = Rc::clone(self);
|
||||
self.clear_cache_button
|
||||
.connect_clicked(move |_| controller.show_clear_cache_dialog());
|
||||
}
|
||||
|
||||
fn profiles(&self) -> Vec<LocationProfile> {
|
||||
|
|
@ -388,6 +398,7 @@ impl BrowserController {
|
|||
}
|
||||
|
||||
fn show_favorites(&self) {
|
||||
self.cancel_listing();
|
||||
self.edit_location_button.set_sensitive(false);
|
||||
self.delete_location_button.set_sensitive(false);
|
||||
self.path_entry.set_text("즐겨찾기");
|
||||
|
|
@ -439,12 +450,29 @@ impl BrowserController {
|
|||
return;
|
||||
}
|
||||
|
||||
self.cancel_listing();
|
||||
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 task = 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 folder_entries = if result.is_ok() {
|
||||
if let Some(parent) = file.parent() {
|
||||
enumerate_directory(
|
||||
&favorite.profile,
|
||||
parent.uri().as_str(),
|
||||
&operation,
|
||||
|_| {},
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(controller) = controller.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -459,18 +487,36 @@ impl BrowserController {
|
|||
.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()]);
|
||||
let entry = folder_entries
|
||||
.as_ref()
|
||||
.and_then(|entries| entries.iter().find(|entry| entry.uri == favorite.uri))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| BrowserEntry {
|
||||
uri: favorite.uri.clone(),
|
||||
name: favorite.name.clone(),
|
||||
kind,
|
||||
size: None,
|
||||
modified: None,
|
||||
created: None,
|
||||
etag: None,
|
||||
});
|
||||
let siblings = folder_entries.map_or_else(
|
||||
|| vec![entry.clone()],
|
||||
|entries| {
|
||||
let state = controller.state.borrow();
|
||||
sorted_navigable_entries(
|
||||
&entries,
|
||||
kind,
|
||||
state.sort_field,
|
||||
state.sort_direction,
|
||||
controller.config.borrow().case_insensitive_sort,
|
||||
)
|
||||
},
|
||||
);
|
||||
let siblings = Rc::new(siblings);
|
||||
controller.open_viewable(entry, siblings, favorite.profile, None);
|
||||
});
|
||||
self.listing_task.replace(Some(task));
|
||||
}
|
||||
|
||||
fn open_entered_path(self: &Rc<Self>) {
|
||||
|
|
@ -512,6 +558,7 @@ impl BrowserController {
|
|||
self.show_favorites();
|
||||
return;
|
||||
}
|
||||
self.cancel_listing();
|
||||
let (profile, uri, generation) = {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.generation = state.generation.wrapping_add(1);
|
||||
|
|
@ -532,7 +579,7 @@ impl BrowserController {
|
|||
let operation = mount_operation(&profile, &self.window);
|
||||
let progress_controller = Rc::downgrade(self);
|
||||
let controller = Rc::downgrade(self);
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
let task = glib::MainContext::default().spawn_local(async move {
|
||||
let result = enumerate_directory(&profile, &uri, &operation, move |count| {
|
||||
let Some(controller) = progress_controller.upgrade() else {
|
||||
return;
|
||||
|
|
@ -561,6 +608,13 @@ impl BrowserController {
|
|||
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||
}
|
||||
});
|
||||
self.listing_task.replace(Some(task));
|
||||
}
|
||||
|
||||
fn cancel_listing(&self) {
|
||||
if let Some(task) = self.listing_task.borrow_mut().take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) {
|
||||
|
|
@ -733,19 +787,7 @@ impl BrowserController {
|
|||
match entry.kind {
|
||||
EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window),
|
||||
EntryKind::Image | EntryKind::Video => {
|
||||
let media_siblings = Rc::new(
|
||||
siblings
|
||||
.iter()
|
||||
.filter(|sibling| sibling.kind.is_media())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
if media_siblings
|
||||
.iter()
|
||||
.any(|sibling| sibling.uri == entry.uri)
|
||||
{
|
||||
self.open_media(entry, media_siblings, profile, reuse_window);
|
||||
}
|
||||
self.open_media(entry, siblings, profile, reuse_window)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -773,14 +815,22 @@ impl BrowserController {
|
|||
});
|
||||
|
||||
let controller = Rc::downgrade(self);
|
||||
let navigate_siblings = Rc::clone(&siblings);
|
||||
let navigate_current_uri = current_uri.clone();
|
||||
let navigate_profile = profile.clone();
|
||||
let navigate = Rc::new(
|
||||
move |target: SiblingTarget, reuse_window: gtk::ApplicationWindow| {
|
||||
let Some(controller) = controller.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
let siblings =
|
||||
controller.navigation_siblings(¤t_uri, current_kind, &siblings);
|
||||
let Some(current) = siblings.iter().position(|entry| entry.uri == current_uri)
|
||||
let siblings = controller.navigation_siblings(
|
||||
&navigate_current_uri,
|
||||
current_kind,
|
||||
&navigate_siblings,
|
||||
);
|
||||
let Some(current) = siblings
|
||||
.iter()
|
||||
.position(|entry| entry.uri == navigate_current_uri)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
|
@ -790,13 +840,47 @@ impl BrowserController {
|
|||
controller.open_viewable(
|
||||
siblings[next].clone(),
|
||||
Rc::clone(&siblings),
|
||||
profile.clone(),
|
||||
navigate_profile.clone(),
|
||||
Some(reuse_window),
|
||||
);
|
||||
true
|
||||
},
|
||||
);
|
||||
SiblingNavigator::new(position, navigate)
|
||||
let files_controller = Rc::downgrade(self);
|
||||
let files_current_uri = current_uri.clone();
|
||||
let files_fallback = Rc::clone(&siblings);
|
||||
let files = Rc::new(move || {
|
||||
let Some(controller) = files_controller.upgrade() else {
|
||||
return Vec::new();
|
||||
};
|
||||
controller
|
||||
.navigation_siblings(&files_current_uri, current_kind, &files_fallback)
|
||||
.iter()
|
||||
.map(|entry| SiblingFile {
|
||||
uri: entry.uri.clone(),
|
||||
name: entry.name.clone(),
|
||||
kind: entry.kind,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let actions_controller = Rc::downgrade(self);
|
||||
let actions_current_uri = current_uri;
|
||||
let actions_fallback = Rc::clone(&siblings);
|
||||
let actions_profile = profile;
|
||||
let actions = Rc::new(move |uri: &str| {
|
||||
let controller = actions_controller.upgrade()?;
|
||||
let entries = controller.navigation_siblings(
|
||||
&actions_current_uri,
|
||||
current_kind,
|
||||
&actions_fallback,
|
||||
);
|
||||
let entry = entries.iter().find(|entry| entry.uri == uri)?;
|
||||
Some(SiblingFileActions {
|
||||
favorite: controller.favorite_control(entry, &actions_profile),
|
||||
download: controller.download_control(entry),
|
||||
})
|
||||
});
|
||||
SiblingNavigator::new(position, navigate, files, actions)
|
||||
}
|
||||
|
||||
fn navigation_siblings(
|
||||
|
|
@ -814,8 +898,9 @@ impl BrowserController {
|
|||
state.sort_direction,
|
||||
case_insensitive,
|
||||
);
|
||||
let browsing_favorites = state.browsing_favorites;
|
||||
drop(state);
|
||||
if entries.iter().any(|entry| entry.uri == current_uri) {
|
||||
if !browsing_favorites && entries.iter().any(|entry| entry.uri == current_uri) {
|
||||
Rc::new(entries)
|
||||
} else {
|
||||
Rc::clone(fallback)
|
||||
|
|
@ -846,33 +931,41 @@ impl BrowserController {
|
|||
let percent = if total > 0 { current * 100 / total } else { 0 };
|
||||
progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%"));
|
||||
},
|
||||
move |result| match result {
|
||||
Ok(source) => {
|
||||
controller.status_label.set_text("다운로드 완료");
|
||||
result_label.set_text("ZIP 파일을 여는 중...");
|
||||
let Some(application) = controller.window.application() else {
|
||||
return;
|
||||
};
|
||||
ViewerWindow::open(
|
||||
&application,
|
||||
source,
|
||||
Rc::clone(&controller.database),
|
||||
controller.config.borrow().fast_image_loading,
|
||||
controller.config.borrow().case_insensitive_sort,
|
||||
view_mode_control,
|
||||
FileViewerControls {
|
||||
sibling_navigator: Some(navigator),
|
||||
favorite_control,
|
||||
download_control,
|
||||
reuse_window,
|
||||
view_mode_control: None,
|
||||
},
|
||||
);
|
||||
move |result| {
|
||||
if reuse_window
|
||||
.as_ref()
|
||||
.is_some_and(|window| !window.is_visible())
|
||||
{
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("{error:#}");
|
||||
controller.status_label.set_text(&message);
|
||||
result_label.set_text(&message);
|
||||
match result {
|
||||
Ok(source) => {
|
||||
controller.status_label.set_text("다운로드 완료");
|
||||
result_label.set_text("ZIP 파일을 여는 중...");
|
||||
let Some(application) = controller.window.application() else {
|
||||
return;
|
||||
};
|
||||
ViewerWindow::open(
|
||||
&application,
|
||||
source,
|
||||
Rc::clone(&controller.database),
|
||||
controller.config.borrow().fast_image_loading,
|
||||
controller.config.borrow().case_insensitive_sort,
|
||||
view_mode_control,
|
||||
FileViewerControls {
|
||||
sibling_navigator: Some(navigator),
|
||||
favorite_control,
|
||||
download_control,
|
||||
reuse_window,
|
||||
view_mode_control: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("{error:#}");
|
||||
controller.status_label.set_text(&message);
|
||||
result_label.set_text(&message);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -904,30 +997,38 @@ impl BrowserController {
|
|||
let percent = if total > 0 { current * 100 / total } else { 0 };
|
||||
progress_label.set_text(&format!("미디어 다운로드 중... {percent}%"));
|
||||
},
|
||||
move |result| match result {
|
||||
Ok(source) => {
|
||||
controller.status_label.set_text("다운로드 완료");
|
||||
result_label.set_text("미디어 파일을 여는 중...");
|
||||
let Some(application) = controller.window.application() else {
|
||||
return;
|
||||
};
|
||||
MediaViewerWindow::open(
|
||||
&application,
|
||||
source,
|
||||
kind,
|
||||
FileViewerControls {
|
||||
sibling_navigator: Some(navigator),
|
||||
favorite_control,
|
||||
download_control,
|
||||
reuse_window,
|
||||
view_mode_control,
|
||||
},
|
||||
);
|
||||
move |result| {
|
||||
if reuse_window
|
||||
.as_ref()
|
||||
.is_some_and(|window| !window.is_visible())
|
||||
{
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("{error:#}");
|
||||
controller.status_label.set_text(&message);
|
||||
result_label.set_text(&message);
|
||||
match result {
|
||||
Ok(source) => {
|
||||
controller.status_label.set_text("다운로드 완료");
|
||||
result_label.set_text("미디어 파일을 여는 중...");
|
||||
let Some(application) = controller.window.application() else {
|
||||
return;
|
||||
};
|
||||
MediaViewerWindow::open(
|
||||
&application,
|
||||
source,
|
||||
kind,
|
||||
FileViewerControls {
|
||||
sibling_navigator: Some(navigator),
|
||||
favorite_control,
|
||||
download_control,
|
||||
reuse_window,
|
||||
view_mode_control,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("{error:#}");
|
||||
controller.status_label.set_text(&message);
|
||||
result_label.set_text(&message);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -1252,6 +1353,60 @@ impl BrowserController {
|
|||
dialog.present();
|
||||
}
|
||||
|
||||
fn show_clear_cache_dialog(self: &Rc<Self>) {
|
||||
let dialog = gtk::Window::builder()
|
||||
.title("캐시 비우기")
|
||||
.transient_for(&self.window)
|
||||
.modal(true)
|
||||
.resizable(false)
|
||||
.default_width(440)
|
||||
.build();
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(18);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(18);
|
||||
content.set_margin_end(18);
|
||||
let message = gtk::Label::new(Some(
|
||||
"다운로드한 ZIP 캐시를 삭제하시겠습니까?\n현재 열려 있는 ZIP과 읽기 기록은 유지됩니다.",
|
||||
));
|
||||
message.set_wrap(true);
|
||||
message.set_xalign(0.0);
|
||||
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
actions.set_halign(gtk::Align::End);
|
||||
let cancel_button = gtk::Button::with_label("취소");
|
||||
let clear_button = gtk::Button::with_label("캐시 삭제");
|
||||
clear_button.add_css_class("destructive-action");
|
||||
actions.append(&cancel_button);
|
||||
actions.append(&clear_button);
|
||||
content.append(&message);
|
||||
content.append(&actions);
|
||||
dialog.set_child(Some(&content));
|
||||
|
||||
let dialog_to_close = dialog.clone();
|
||||
cancel_button.connect_clicked(move |_| dialog_to_close.close());
|
||||
let controller = Rc::clone(self);
|
||||
let dialog_to_close = dialog.clone();
|
||||
clear_button.connect_clicked(move |_| match controller.zip_cache.clear_persistent() {
|
||||
Ok(report) => {
|
||||
let removed_mib = report.removed_bytes as f64 / (1024.0 * 1024.0);
|
||||
let retained = if report.retained_open_files == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", 열려 있어 유지한 파일 {}개", report.retained_open_files)
|
||||
};
|
||||
controller.status_label.set_text(&format!(
|
||||
"ZIP 캐시 {}개({removed_mib:.1} MiB)를 삭제했습니다{retained}.",
|
||||
report.removed_files
|
||||
));
|
||||
dialog_to_close.close();
|
||||
}
|
||||
Err(error) => controller
|
||||
.status_label
|
||||
.set_text(&format!("캐시를 삭제할 수 없습니다: {error:#}")),
|
||||
});
|
||||
dialog.present();
|
||||
}
|
||||
|
||||
fn show_location_dialog(self: &Rc<Self>, edit_index: Option<usize>) {
|
||||
let existing =
|
||||
edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned());
|
||||
|
|
@ -1504,11 +1659,7 @@ fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Opt
|
|||
}
|
||||
|
||||
fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool {
|
||||
match current {
|
||||
EntryKind::ZipArchive => candidate.is_viewable(),
|
||||
EntryKind::Image | EntryKind::Video => candidate.is_media(),
|
||||
_ => false,
|
||||
}
|
||||
current.is_viewable() && candidate.is_viewable()
|
||||
}
|
||||
|
||||
fn is_favorites_selection(selected: u32, profile_count: usize) -> bool {
|
||||
|
|
@ -1555,7 +1706,7 @@ fn show_viewer_loading(
|
|||
window.set_title(Some(file_name));
|
||||
let header = gtk::HeaderBar::new();
|
||||
header.set_title_widget(Some(>k::Label::new(Some(file_name))));
|
||||
window.set_titlebar(Some(&header));
|
||||
set_viewer_header(window, &header);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||
content.set_halign(gtk::Align::Center);
|
||||
|
|
@ -1645,11 +1796,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn media_navigation_excludes_zip_archives() {
|
||||
fn viewer_navigation_includes_all_viewable_files() {
|
||||
assert!(navigable_sibling(EntryKind::Image, EntryKind::Video));
|
||||
assert!(navigable_sibling(EntryKind::Video, EntryKind::Image));
|
||||
assert!(!navigable_sibling(EntryKind::Image, EntryKind::ZipArchive));
|
||||
assert!(navigable_sibling(EntryKind::Image, EntryKind::ZipArchive));
|
||||
assert!(navigable_sibling(EntryKind::ZipArchive, EntryKind::Video));
|
||||
assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile));
|
||||
assert!(!navigable_sibling(EntryKind::Directory, EntryKind::Image));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue