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

@ -14,14 +14,15 @@ use gtk::prelude::*;
use crate::browser::{
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::source::{
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::{
FavoriteControl, FileDownloadControl, MediaViewerWindow, SiblingNavigator, ViewerWindow,
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator,
ViewModeControl, ViewerWindow,
};
use crate::zip_cache::ZipCache;
@ -70,12 +71,6 @@ 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(&gtk::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")
@ -119,7 +114,6 @@ 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);
@ -129,7 +123,6 @@ impl ComicWindow {
let controller = Rc::new(BrowserController {
window,
location_dropdown,
favorite_dropdown,
edit_location_button,
delete_location_button,
path_entry,
@ -141,6 +134,7 @@ impl ComicWindow {
state: RefCell::new(BrowserState {
profile_index: 0,
favorite_profile: None,
browsing_favorites: false,
root_uri: root_uri.clone(),
current_uri: root_uri,
entries: Vec::new(),
@ -158,7 +152,6 @@ impl ComicWindow {
});
controller.rebuild_profiles();
controller.rebuild_favorites();
controller.connect_signals(
open_button,
refresh_button,
@ -178,6 +171,7 @@ impl ComicWindow {
struct BrowserState {
profile_index: usize,
favorite_profile: Option<LocationProfile>,
browsing_favorites: bool,
root_uri: String,
current_uri: String,
entries: Vec<BrowserEntry>,
@ -192,7 +186,6 @@ 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,
@ -244,9 +237,6 @@ 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);
@ -261,8 +251,8 @@ impl BrowserController {
let context_click = gtk::GestureClick::builder().button(3).build();
let controller = Rc::clone(self);
context_click.connect_pressed(move |_, _, _, y| {
controller.show_context_menu(y as i32);
context_click.connect_pressed(move |_, _, x, y| {
controller.show_context_menu(x, y);
});
self.list_box.add_controller(context_click);
@ -296,38 +286,26 @@ impl BrowserController {
fn rebuild_profiles(&self) {
let profiles = self.profiles();
let names: Vec<_> = profiles
let mut names: Vec<_> = profiles
.iter()
.map(|profile| profile.name.as_str())
.map(|profile| profile.name.clone())
.collect();
names.push("즐겨찾기".into());
let names: Vec<_> = names.iter().map(String::as_str).collect();
self.location_dropdown
.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>) {
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.delete_location_button.set_sensitive(editable);
let profiles = self.profiles();
let Some(profile) = profiles.get(profile_index) else {
return;
};
@ -337,9 +315,11 @@ impl BrowserController {
let mut state = self.state.borrow_mut();
state.profile_index = profile_index;
state.favorite_profile = None;
state.browsing_favorites = false;
state.root_uri = root_uri.clone();
state.current_uri = root_uri;
drop(state);
self.path_entry.set_editable(true);
self.search_entry.set_text("");
self.refresh();
}
@ -347,15 +327,28 @@ 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);
fn show_favorites(&self) {
self.edit_location_button.set_sensitive(false);
self.delete_location_button.set_sensitive(false);
self.path_entry.set_text("즐겨찾기");
self.path_entry.set_editable(false);
self.search_entry.set_text("");
let entries = self
.config
.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 {
self.status_label
.set_text("즐겨찾기의 저장된 위치가 올바르지 않습니다.");
@ -374,6 +367,7 @@ impl BrowserController {
self.delete_location_button.set_sensitive(false);
let mut state = self.state.borrow_mut();
state.favorite_profile = Some(favorite.profile.clone());
state.browsing_favorites = false;
state.root_uri = root.uri().into();
state.current_uri = favorite.uri;
drop(state);
@ -412,11 +406,15 @@ impl BrowserController {
etag: None,
};
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>) {
let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
return;
}
let profile = self.current_profile();
let entered = self.path_entry.text();
let file = file_from_user_input(profile.kind, entered.as_str());
@ -431,6 +429,10 @@ impl BrowserController {
}
fn go_up(self: &Rc<Self>) {
let browsing_favorites = self.state.borrow().browsing_favorites;
if browsing_favorites {
return;
}
let state = self.state.borrow();
let current = gio::File::for_uri(&state.current_uri);
let root = gio::File::for_uri(&state.root_uri);
@ -442,6 +444,11 @@ impl BrowserController {
}
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 mut state = self.state.borrow_mut();
state.generation = state.generation.wrapping_add(1);
@ -602,6 +609,20 @@ impl BrowserController {
else {
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 {
EntryKind::Directory => {
self.state.borrow_mut().current_uri = entry.uri;
@ -623,7 +644,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.current_profile());
self.open_viewable(entry, siblings, current, self.current_profile(), None);
}
}
EntryKind::OtherFile => {}
@ -636,9 +657,12 @@ impl BrowserController {
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) {
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 => {
let media_siblings = Rc::new(
siblings
@ -651,7 +675,7 @@ impl BrowserController {
.iter()
.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 {
let controller = Rc::downgrade(self);
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 {
return false;
};
@ -681,6 +705,7 @@ impl BrowserController {
Rc::clone(&siblings),
next,
profile.clone(),
Some(reuse_window),
);
true
});
@ -693,13 +718,19 @@ impl BrowserController {
siblings: Rc<Vec<BrowserEntry>>,
current: usize,
profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) {
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 navigator = self.sibling_navigator(siblings, current, profile.clone());
let favorite_control = self.favorite_control(&entry, &profile);
let download_control = self.download_control(&entry);
let view_mode_control = self.view_mode_control();
self.zip_cache.prepare(
entry,
move |current, total| {
@ -709,6 +740,7 @@ impl BrowserController {
move |result| match result {
Ok(source) => {
controller.status_label.set_text("다운로드 완료");
result_label.set_text("ZIP 파일을 여는 중...");
let Some(application) = controller.window.application() else {
return;
};
@ -717,12 +749,20 @@ impl BrowserController {
source,
Rc::clone(&controller.database),
controller.config.borrow().fast_image_loading,
Some(navigator),
favorite_control,
download_control,
view_mode_control,
FileViewerControls {
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>>,
current: usize,
profile: LocationProfile,
reuse_window: Option<gtk::ApplicationWindow>,
) {
let kind = entry.kind;
self.status_label
.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 navigator = self.sibling_navigator(siblings, current, profile.clone());
let favorite_control = self.favorite_control(&entry, &profile);
@ -751,6 +796,7 @@ impl BrowserController {
move |result| match result {
Ok(source) => {
controller.status_label.set_text("다운로드 완료");
result_label.set_text("미디어 파일을 여는 중...");
let Some(application) = controller.window.application() else {
return;
};
@ -758,18 +804,37 @@ impl BrowserController {
&application,
source,
kind,
Some(navigator),
favorite_control,
download_control,
FileViewerControls {
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);
}
},
);
}
fn show_context_menu(self: &Rc<Self>, y: i32) {
let Some(row) = self.list_box.row_at_y(y) else {
fn show_context_menu(self: &Rc<Self>, x: f64, y: f64) {
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;
};
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 {
let mut updated = self.config.borrow().clone();
let was_active = self.is_favorite(&entry.uri);
@ -879,7 +970,10 @@ impl BrowserController {
return was_active;
}
*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 {
"즐겨찾기에 추가했습니다."
} 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 {
final_path.with_file_name(format!(
"{}.part",
@ -1315,6 +1454,26 @@ mod tests {
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]
fn incomplete_download_uses_distinct_suffix() {
assert_eq!(