Release v0.2.1

This commit is contained in:
burnintuna 2026-07-22 14:42:23 +09:00
parent ffa8bc93ba
commit 048df6355e
10 changed files with 1467 additions and 254 deletions

View file

@ -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(&gtk::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(&current_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(&gtk::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]