use std::cell::{Cell, RefCell}; use std::fs; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Local}; use gtk::gio; use gtk::gio::prelude::*; use gtk::glib; use gtk::prelude::*; use crate::browser::{ BrowserEntry, EntryKind, SortDirection, SortField, classify_file, sort_entries, }; use crate::config::{AppConfig, FavoriteEntry, LocationKind, LocationProfile, ViewerMode}; use crate::database::Database; use crate::source::{ display_remote_uri, ensure_mounted, enumerate_directory, enumerate_tree_entries, file_from_user_input, is_within_root, mount_operation, parent_within_root, root_file, validate_profile, }; use crate::viewer::{ FavoriteControl, FileDownloadControl, FileDropControl, FileViewerControls, MediaViewerWindow, SiblingFile, SiblingFileActions, SiblingNavigator, SiblingTarget, ViewModeControl, ViewerWindow, install_file_drop_target, set_viewer_header, set_viewer_loading, }; use crate::zip_cache::ZipCache; const MOUSE_BACK_BUTTON: u32 = 8; pub struct ComicWindow { controller: Rc, } impl ComicWindow { pub fn new( application: >k::Application, config: AppConfig, config_path: PathBuf, database: Database, zip_cache: Rc, ) -> Result { let initial_profile = home_profile(); let root_uri: String = root_file(&initial_profile)?.uri().into(); let window = gtk::ApplicationWindow::builder() .application(application) .title("Comicviewer") .default_width(1100) .default_height(720) .build(); let header = gtk::HeaderBar::new(); 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); root.set_margin_top(12); root.set_margin_bottom(12); root.set_margin_start(12); root.set_margin_end(12); let locations = gtk::Box::new(gtk::Orientation::Horizontal, 6); let location_dropdown = gtk::DropDown::from_strings(&["내 홈"]); location_dropdown.set_hexpand(true); let add_location_button = gtk::Button::with_label("위치 추가"); let edit_location_button = gtk::Button::with_label("수정"); edit_location_button.set_sensitive(false); let delete_location_button = gtk::Button::with_label("삭제"); delete_location_button.set_sensitive(false); locations.append(>k::Label::new(Some("위치"))); locations.append(&location_dropdown); locations.append(&add_location_button); locations.append(&edit_location_button); locations.append(&delete_location_button); let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 6); let up_button = gtk::Button::builder() .icon_name("go-up-symbolic") .tooltip_text("상위 폴더") .build(); let path_entry = gtk::Entry::builder().hexpand(true).build(); let open_button = gtk::Button::with_label("이동"); let refresh_button = gtk::Button::builder() .icon_name("view-refresh-symbolic") .tooltip_text("새로고침") .build(); navigation.append(&up_button); navigation.append(&path_entry); navigation.append(&open_button); navigation.append(&refresh_button); let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6); let search_entry = gtk::SearchEntry::builder() .placeholder_text("현재 위치 아래 폴더와 ZIP 검색") .hexpand(true) .build(); let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]); let direction_button = gtk::ToggleButton::with_label("오름차순"); let case_insensitive_check = gtk::CheckButton::with_label("대소문자 구분 안 함"); case_insensitive_check.set_active(config.case_insensitive_sort); let fast_loading_switch = gtk::Switch::builder() .active(config.fast_image_loading) .tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지") .build(); controls.append(&search_entry); controls.append(&sort_dropdown); controls.append(&direction_button); controls.append(&case_insensitive_check); controls.append(>k::Label::new(Some("빠른 이미지 로딩"))); controls.append(&fast_loading_switch); let list_box = gtk::ListBox::new(); list_box.set_selection_mode(gtk::SelectionMode::Single); list_box.set_activate_on_single_click(false); let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) .child(&list_box) .build(); let status_label = gtk::Label::builder().xalign(0.0).build(); root.append(&locations); root.append(&navigation); root.append(&controls); root.append(&scrolled); root.append(&status_label); window.set_child(Some(&root)); let controller = Rc::new(BrowserController { window, about_button, clear_cache_button, location_dropdown, edit_location_button, delete_location_button, path_entry, search_entry, sort_dropdown, direction_button, list_box, status_label, 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(), visible_entries: Vec::new(), recursive_entries: Vec::new(), recursive_root_uri: None, recursive_ready: false, recursive_scanning: false, sort_field: SortField::Name, sort_direction: SortDirection::Ascending, generation: 0, typeahead_query: String::new(), typeahead_last_input: None, }), config: RefCell::new(config), config_path, database: Rc::new(database), zip_cache, listing_task: RefCell::new(None), search_task: RefCell::new(None), search_generation: Cell::new(0), }); controller.rebuild_profiles(); controller.connect_signals( open_button, refresh_button, up_button, add_location_button, case_insensitive_check, fast_loading_switch, ); install_file_drop_target(&controller.window, controller.file_drop_control()); controller.refresh(); Ok(Self { controller }) } pub fn present(&self) { self.controller.window.present(); } pub fn open_local_items(&self, files: &[gio::File]) { self.present(); self.controller .open_dropped_files(files.to_vec(), self.controller.window.clone().upcast()); } } struct BrowserState { profile_index: usize, favorite_profile: Option, browsing_favorites: bool, root_uri: String, current_uri: String, entries: Vec, visible_entries: Vec, recursive_entries: Vec, recursive_root_uri: Option, recursive_ready: bool, recursive_scanning: bool, sort_field: SortField, sort_direction: SortDirection, generation: u64, typeahead_query: String, typeahead_last_input: Option, } 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, path_entry: gtk::Entry, search_entry: gtk::SearchEntry, sort_dropdown: gtk::DropDown, direction_button: gtk::ToggleButton, list_box: gtk::ListBox, status_label: gtk::Label, state: RefCell, config: RefCell, config_path: PathBuf, database: Rc, zip_cache: Rc, listing_task: RefCell>>, search_task: RefCell>>, search_generation: Cell, } impl BrowserController { fn connect_signals( self: &Rc, open_button: gtk::Button, refresh_button: gtk::Button, up_button: gtk::Button, add_location_button: gtk::Button, case_insensitive_check: gtk::CheckButton, fast_loading_switch: gtk::Switch, ) { let controller = Rc::clone(self); open_button.connect_clicked(move |_| controller.open_entered_path()); let controller = Rc::clone(self); self.path_entry .connect_activate(move |_| controller.open_entered_path()); let controller = Rc::clone(self); refresh_button.connect_clicked(move |_| controller.refresh()); let controller = Rc::clone(self); up_button.connect_clicked(move |_| controller.go_up()); let controller = Rc::clone(self); add_location_button.connect_clicked(move |_| controller.show_location_dialog(None)); let controller = Rc::clone(self); self.about_button .connect_clicked(move |_| controller.show_about_dialog()); let controller = Rc::clone(self); self.edit_location_button.connect_clicked(move |_| { if let Some(index) = controller.selected_config_index() { controller.show_location_dialog(Some(index)); } }); let controller = Rc::clone(self); self.delete_location_button.connect_clicked(move |_| { if let Some(index) = controller.selected_config_index() { controller.show_delete_location_dialog(index); } }); let controller = Rc::clone(self); self.location_dropdown .connect_selected_notify(move |_| controller.select_profile()); let location_factory = gtk::SignalListItemFactory::new(); let controller = Rc::downgrade(self); location_factory.connect_setup(move |_, item| { let Some(item) = item.downcast_ref::() else { return; }; let label = gtk::Label::builder().xalign(0.0).build(); let click = gtk::GestureClick::builder().button(1).build(); item.set_child(Some(&label)); let item = item.downgrade(); let controller = controller.clone(); click.connect_released(move |_, _, _, _| { let Some(item) = item.upgrade() else { return; }; let Some(controller) = controller.upgrade() else { return; }; if is_favorites_selection(item.position(), controller.profiles().len()) { controller.show_favorites(); } }); label.add_controller(click); }); location_factory.connect_bind(|_, item| { let Some(item) = item.downcast_ref::() else { return; }; let Some(label) = item.child().and_downcast::() else { return; }; let Some(value) = item.item().and_downcast::() else { return; }; label.set_text(value.string().as_str()); }); self.location_dropdown .set_list_factory(Some(&location_factory)); let controller = Rc::clone(self); self.search_entry .connect_search_changed(move |_| controller.search_changed()); let controller = Rc::clone(self); self.sort_dropdown .connect_selected_notify(move |_| controller.change_sort_field()); let controller = Rc::clone(self); self.direction_button .connect_toggled(move |_| controller.change_sort_direction()); let controller = Rc::clone(self); case_insensitive_check.connect_toggled(move |check| { controller.set_case_insensitive_sort(check); }); let controller = Rc::clone(self); self.list_box .connect_row_activated(move |_, row| controller.activate_row(row.index())); let context_click = gtk::GestureClick::builder().button(3).build(); let controller = Rc::clone(self); context_click.connect_pressed(move |_, _, x, y| { controller.show_context_menu(x, y); }); self.list_box.add_controller(context_click); let key_controller = gtk::EventControllerKey::new(); let controller = Rc::clone(self); key_controller.connect_key_pressed(move |_, key, _, modifiers| { if key == gtk::gdk::Key::BackSpace && !controller.typeahead_is_active() { controller.go_up(); glib::Propagation::Stop } else { controller.typeahead(key, modifiers) } }); self.list_box.add_controller(key_controller); let mouse_back = gtk::GestureClick::builder() .button(MOUSE_BACK_BUTTON) .propagation_phase(gtk::PropagationPhase::Capture) .build(); let controller = Rc::clone(self); mouse_back.connect_pressed(move |gesture, _, _, _| { gesture.set_state(gtk::EventSequenceState::Claimed); controller.go_up(); }); self.window.add_controller(mouse_back); let controller = Rc::clone(self); 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 { let mut profiles = vec![home_profile()]; profiles.extend(self.config.borrow().locations.clone()); 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 mut names: Vec<_> = profiles .iter() .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(>k::StringList::new(&names))); } fn select_profile(self: &Rc) { let profile_index = self.location_dropdown.selected() as usize; let profiles = self.profiles(); if is_favorites_selection(self.location_dropdown.selected(), 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 Some(profile) = profiles.get(profile_index) else { return; }; match root_file(profile) { Ok(root) => { let root_uri: String = root.uri().into(); 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(); } Err(error) => self.status_label.set_text(&format!("{error:#}")), } } fn show_favorites(&self) { self.cancel_listing(); self.cancel_recursive_search(); 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); let entries = self .config .borrow() .favorites .iter() .map(favorite_browser_entry) .collect(); let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.typeahead_query.clear(); state.typeahead_last_input = None; state.browsing_favorites = true; state.favorite_profile = None; state.entries = entries; drop(state); self.search_entry.set_text(""); self.render(); } fn open_favorite(self: &Rc, favorite: FavoriteEntry) { 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.browsing_favorites = false; state.root_uri = root.uri().into(); state.current_uri = favorite.uri; drop(state); self.search_entry.set_text(""); self.refresh(); return; } self.cancel_listing(); self.status_label.set_text("즐겨찾기에 연결하는 중..."); let operation = mount_operation(&favorite.profile, &self.window); let controller = Rc::downgrade(self); 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; }; 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 = 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(), list_name: None, 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) { 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()); let root = gio::File::for_uri(&self.state.borrow().root_uri); if !is_within_root(&file, &root) { self.status_label .set_text("저장된 위치의 루트 아래 경로만 열 수 있습니다."); return; } self.state.borrow_mut().current_uri = file.uri().into(); self.refresh(); } fn go_up(self: &Rc) { 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); drop(state); if let Some(parent) = parent_within_root(¤t, &root) { self.state.borrow_mut().current_uri = parent.uri().into(); self.refresh(); } } fn refresh(self: &Rc) { let browsing_favorites = self.state.borrow().browsing_favorites; if browsing_favorites { self.show_favorites(); return; } self.cancel_listing(); self.cancel_recursive_search(); let (profile, uri, generation) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.typeahead_query.clear(); state.typeahead_last_input = None; ( state .favorite_profile .clone() .unwrap_or_else(|| self.profiles()[state.profile_index].clone()), state.current_uri.clone(), state.generation, ) }; self.path_entry.set_text(&display_uri(&uri)); self.status_label .set_text("폴더를 불러오는 중... (0개의 파일/폴더를 읽음)"); let operation = mount_operation(&profile, &self.window); let progress_controller = Rc::downgrade(self); let controller = Rc::downgrade(self); 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; }; if controller.state.borrow().generation == generation { controller.status_label.set_text(&format!( "폴더를 불러오는 중... ({count}개의 파일/폴더를 읽음)" )); } }) .await; let Some(controller) = controller.upgrade() else { return; }; let state = controller.state.borrow(); let stale = state.generation != generation || state.browsing_favorites; drop(state); if stale { return; } match result { Ok(entries) => { controller.state.borrow_mut().entries = entries; controller.render(); if !controller.search_entry.text().is_empty() { controller.start_recursive_search(); } } 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 cancel_recursive_search(&self) { if let Some(task) = self.search_task.borrow_mut().take() { task.abort(); } self.search_generation .set(self.search_generation.get().wrapping_add(1)); let mut state = self.state.borrow_mut(); state.recursive_entries.clear(); state.recursive_root_uri = None; state.recursive_ready = false; state.recursive_scanning = false; } fn search_changed(self: &Rc) { if self.search_entry.text().is_empty() { self.cancel_recursive_search(); self.render(); return; } self.render(); if !self.state.borrow().browsing_favorites { self.start_recursive_search(); } } fn start_recursive_search(self: &Rc) { let (profile, root_uri) = { let mut state = self.state.borrow_mut(); if state.browsing_favorites || (state.recursive_root_uri.as_deref() == Some(&state.current_uri) && (state.recursive_ready || state.recursive_scanning)) { return; } let root_uri = state.current_uri.clone(); state.recursive_root_uri = Some(root_uri.clone()); state.recursive_entries.clear(); state.recursive_ready = false; state.recursive_scanning = true; ( state .favorite_profile .clone() .unwrap_or_else(|| self.profiles()[state.profile_index].clone()), root_uri, ) }; if let Some(task) = self.search_task.borrow_mut().take() { task.abort(); } let generation = self.search_generation.get().wrapping_add(1); self.search_generation.set(generation); let root = gio::File::for_uri(&root_uri); let location_id = root_file(&profile) .map(|root| root.uri().to_string()) .unwrap_or_else(|_| profile.address.clone()); if let Ok(cached_entries) = self.database.load_cached_tree(&location_id, &root_uri) && !cached_entries.is_empty() { let mut state = self.state.borrow_mut(); state.recursive_entries = cached_entries; state.recursive_ready = true; drop(state); self.render(); } let operation = mount_operation(&profile, &self.window); let progress_controller = Rc::downgrade(self); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { let result = enumerate_tree_entries(&profile, &root, Some(&operation), move |progress| { let Some(controller) = progress_controller.upgrade() else { return; }; if controller.search_generation.get() == generation && !controller.search_entry.text().is_empty() { controller.status_label.set_text(&format!( "하위 폴더 검색 색인 생성 중... (폴더 {}개, 항목 {}개)", progress.directories, progress.scanned )); } }) .await; let Some(controller) = controller.upgrade() else { return; }; if controller.search_generation.get() != generation || controller.state.borrow().current_uri != root_uri { return; } match result { Ok((entries, progress)) => { let cache_error = (progress.skipped == 0) .then(|| { controller.database.replace_cached_tree( &location_id, &root_uri, &entries, ) }) .transpose() .err(); let mut state = controller.state.borrow_mut(); state.recursive_entries = entries; state.recursive_ready = true; state.recursive_scanning = false; drop(state); if !controller.search_entry.text().is_empty() { controller.render(); if progress.skipped > 0 { controller.status_label.set_text(&format!( "검색 결과 생성 완료 · 접근할 수 없는 폴더 {}개 건너뜀", progress.skipped )); } else if let Some(error) = cache_error { controller .status_label .set_text(&format!("검색 색인을 저장할 수 없습니다: {error:#}")); } } } Err(error) => { controller.state.borrow_mut().recursive_scanning = false; if !controller.search_entry.text().is_empty() { controller .status_label .set_text(&format!("하위 폴더 검색을 완료할 수 없습니다: {error:#}")); } } } }); self.search_task.replace(Some(task)); } fn render(&self) { while let Some(child) = self.list_box.first_child() { self.list_box.remove(&child); } let query = self.search_entry.text().to_lowercase(); let case_insensitive = self.config.borrow().case_insensitive_sort; let mut state = self.state.borrow_mut(); let recursive_ready = !query.is_empty() && state.recursive_ready && state.recursive_root_uri.as_deref() == Some(&state.current_uri); let source = if recursive_ready { &state.recursive_entries } else { &state.entries }; let mut visible_entries: Vec<_> = source .iter() .filter(|entry| query.is_empty() || recursive_search_match(entry, query.as_str())) .cloned() .collect(); sort_entries( &mut visible_entries, state.sort_field, state.sort_direction, case_insensitive, ); for entry in &visible_entries { self.list_box.append(&entry_row(entry)); } let count = visible_entries.len(); state.visible_entries = visible_entries; let status = if query.is_empty() { format!("{count}개 항목") } else if state.recursive_scanning { format!("현재 폴더 결과 {count}개 · 하위 폴더 색인 생성 중...") } else { format!("하위 폴더 검색 결과 {count}개") }; self.status_label.set_text(&status); } fn typeahead( &self, key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType, ) -> glib::Propagation { let blocked = gtk::gdk::ModifierType::CONTROL_MASK | gtk::gdk::ModifierType::ALT_MASK | gtk::gdk::ModifierType::SUPER_MASK; if modifiers.intersects(blocked) { return glib::Propagation::Proceed; } if key == gtk::gdk::Key::Escape { let mut state = self.state.borrow_mut(); state.typeahead_query.clear(); state.typeahead_last_input = None; return glib::Propagation::Stop; } if key == gtk::gdk::Key::BackSpace { let mut state = self.state.borrow_mut(); if state.typeahead_query.pop().is_none() { return glib::Propagation::Proceed; } state.typeahead_last_input = Some(Instant::now()); return glib::Propagation::Stop; } let Some(character) = key.to_unicode().filter(|character| !character.is_control()) else { return glib::Propagation::Proceed; }; let selected = self.list_box.selected_row().map(|row| row.index() as usize); let now = Instant::now(); let mut state = self.state.borrow_mut(); if state .typeahead_last_input .is_none_or(|last| now.duration_since(last) > Duration::from_millis(1200)) { state.typeahead_query.clear(); } let input = character.to_lowercase().collect::(); let repeated_single = state.typeahead_query.chars().count() == 1 && state.typeahead_query == input; if !repeated_single { state.typeahead_query.push_str(&input); } state.typeahead_last_input = Some(now); let query = state.typeahead_query.clone(); let start = if repeated_single { selected.map_or(0, |index| index + 1) } else { 0 }; let found = find_prefix_index(&state.visible_entries, &query, start); let found_name = found.map(|index| state.visible_entries[index].display_name().to_owned()); drop(state); if let Some(index) = found && let Some(row) = self.list_box.row_at_index(index as i32) { self.list_box.select_row(Some(&row)); let _ = row.grab_focus(); self.status_label.set_text(&format!( "'{query}'로 이동: {}", found_name.unwrap_or_default() )); } else { self.status_label .set_text(&format!("'{query}'로 시작하는 항목이 없습니다.")); } glib::Propagation::Stop } fn typeahead_is_active(&self) -> bool { let state = self.state.borrow(); !state.typeahead_query.is_empty() && state.typeahead_last_input.is_some_and(|last| { Instant::now().duration_since(last) <= Duration::from_millis(1200) }) } fn change_sort_field(&self) { self.state.borrow_mut().sort_field = match self.sort_dropdown.selected() { 1 => SortField::Modified, 2 => SortField::Created, _ => SortField::Name, }; self.render(); } fn change_sort_direction(&self) { let direction = if self.direction_button.is_active() { self.direction_button.set_label("내림차순"); SortDirection::Descending } else { self.direction_button.set_label("오름차순"); SortDirection::Ascending }; self.state.borrow_mut().sort_direction = direction; self.render(); } fn activate_row(self: &Rc, index: i32) { let Some(entry) = self .state .borrow() .visible_entries .get(index as usize) .cloned() 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; self.search_entry.set_text(""); self.refresh(); } EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => { if !self.search_entry.text().is_empty() && entry.list_name.is_some() { let siblings = self .state .borrow() .visible_entries .iter() .filter(|candidate| candidate.kind.is_viewable()) .cloned() .collect::>(); self.open_viewable( entry.clone(), Rc::new(siblings), self.current_profile(), None, ); return; } let current_kind = entry.kind; let case_insensitive = self.config.borrow().case_insensitive_sort; let siblings = { let state = self.state.borrow(); sorted_navigable_entries( &state.entries, current_kind, state.sort_field, state.sort_direction, case_insensitive, ) }; let siblings = Rc::new(siblings); if siblings.iter().any(|item| item.uri == entry.uri) { self.open_viewable(entry, siblings, self.current_profile(), None); } } EntryKind::OtherFile => {} } } fn open_viewable( self: &Rc, entry: BrowserEntry, siblings: Rc>, profile: LocationProfile, reuse_window: Option, ) { match entry.kind { EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window), EntryKind::Image | EntryKind::Video => { self.open_media(entry, siblings, profile, reuse_window) } _ => {} } } fn sibling_navigator( self: &Rc, entry: &BrowserEntry, siblings: Rc>, profile: LocationProfile, ) -> SiblingNavigator { let recursive = siblings.iter().any(|entry| entry.list_name.is_some()); let current_uri = entry.uri.clone(); let current_kind = entry.kind; 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( &navigate_current_uri, current_kind, &navigate_siblings, recursive, ); let Some(current) = siblings .iter() .position(|entry| entry.uri == navigate_current_uri) else { return false; }; let Some(next) = target.resolve(current, siblings.len()) else { return false; }; controller.open_viewable( siblings[next].clone(), Rc::clone(&siblings), navigate_profile.clone(), Some(reuse_window), ); true }, ); 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, recursive) .iter() .map(|entry| SiblingFile { uri: entry.uri.clone(), name: entry.display_name().to_owned(), 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, recursive, ); 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(navigate, files, actions, recursive) } fn navigation_siblings( &self, current_uri: &str, current_kind: EntryKind, fallback: &Rc>, fixed: bool, ) -> Rc> { if fixed { return Rc::clone(fallback); } let case_insensitive = self.config.borrow().case_insensitive_sort; let state = self.state.borrow(); let entries = sorted_navigable_entries( &state.entries, current_kind, state.sort_field, state.sort_direction, case_insensitive, ); let browsing_favorites = state.browsing_favorites; drop(state); if !browsing_favorites && entries.iter().any(|entry| entry.uri == current_uri) { Rc::new(entries) } else { Rc::clone(fallback) } } fn open_archive( self: &Rc, entry: BrowserEntry, siblings: Rc>, profile: LocationProfile, reuse_window: Option, ) { self.status_label.set_text("ZIP 파일 다운로드 준비 중..."); 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(&entry, siblings, 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| { let percent = if total > 0 { current * 100 / total } else { 0 }; progress_label.set_text(&format!("ZIP 다운로드 중... {percent}%")); }, move |result| { if reuse_window .as_ref() .is_some_and(|window| !window.is_visible()) { return; } 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, file_drop_control: controller.file_drop_control(), reuse_window, view_mode_control: None, }, ); } Err(error) => { let message = format!("{error:#}"); controller.status_label.set_text(&message); result_label.set_text(&message); } } }, ); } fn open_media( self: &Rc, entry: BrowserEntry, siblings: Rc>, profile: LocationProfile, reuse_window: Option, ) { let kind = entry.kind; self.status_label .set_text("미디어 파일 다운로드 준비 중..."); 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(&entry, siblings, profile.clone()); let favorite_control = self.favorite_control(&entry, &profile); let download_control = self.download_control(&entry); let view_mode_control = (kind == EntryKind::Image).then(|| self.view_mode_control()); self.zip_cache.prepare_media( entry, move |current, total| { let percent = if total > 0 { current * 100 / total } else { 0 }; progress_label.set_text(&format!("미디어 다운로드 중... {percent}%")); }, move |result| { if reuse_window .as_ref() .is_some_and(|window| !window.is_visible()) { return; } 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, file_drop_control: controller.file_drop_control(), reuse_window, view_mode_control, }, ); } Err(error) => { let message = format!("{error:#}"); controller.status_label.set_text(&message); result_label.set_text(&message); } } }, ); } fn show_context_menu(self: &Rc, x: f64, y: f64) { let Some(widget) = self.list_box.pick(x, y, gtk::PickFlags::DEFAULT) else { return; }; let Some(row) = widget .clone() .downcast::() .ok() .or_else(|| { widget .ancestor(gtk::ListBoxRow::static_type()) .and_then(|widget| widget.downcast::().ok()) }) else { return; }; let Some(entry) = self .state .borrow() .visible_entries .get(row.index() as usize) .cloned() else { return; }; let can_favorite = entry.kind == EntryKind::Directory || entry.kind.is_viewable(); let can_download = entry.kind != EntryKind::Directory; 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 entry.kind == EntryKind::Directory { let open_folder_button = gtk::Button::with_label("폴더 열기"); actions.append(&open_folder_button); let controller = Rc::clone(self); let popover_to_close = popover.clone(); let folder_entry = entry.clone(); open_folder_button.connect_clicked(move |_| { popover_to_close.popdown(); controller.open_folder_recursive(folder_entry.clone()); }); } 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.profile_for_entry(&favorite_entry); 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(), controller.window.clone().upcast(), controller.status_label.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 profile_for_entry(&self, entry: &BrowserEntry) -> LocationProfile { if self.state.borrow().browsing_favorites && let Some(profile) = self .config .borrow() .favorites .iter() .find(|favorite| favorite.uri == entry.uri) .map(|favorite| favorite.profile.clone()) { return profile; } self.current_profile() } fn open_folder_recursive(self: &Rc, entry: BrowserEntry) { self.cancel_recursive_search(); let profile = self.profile_for_entry(&entry); let Ok(profile_root) = root_file(&profile) else { self.status_label .set_text("폴더의 저장된 위치 정보를 확인할 수 없습니다."); return; }; let folder = gio::File::for_uri(&entry.uri); if !is_within_root(&folder, &profile_root) { self.status_label .set_text("저장된 위치의 루트 아래 폴더만 열 수 있습니다."); return; } self.cancel_listing(); let (generation, sort_field, sort_direction) = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); (state.generation, state.sort_field, state.sort_direction) }; let case_insensitive = self.config.borrow().case_insensitive_sort; self.status_label .set_text("하위 폴더에서 열 수 있는 파일을 찾는 중..."); let operation = mount_operation(&profile, &self.window); let progress_controller = Rc::downgrade(self); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { let result = enumerate_tree_entries(&profile, &folder, Some(&operation), move |progress| { let Some(controller) = progress_controller.upgrade() else { return; }; if controller.state.borrow().generation == generation { controller.status_label.set_text(&format!( "하위 폴더 검색 중... (폴더 {}개, 항목 {}개, 파일 {}개)", progress.directories, progress.scanned, progress.viewable )); } }) .await; let Some(controller) = controller.upgrade() else { return; }; if controller.state.borrow().generation != generation { return; } match result { Ok((mut entries, progress)) => { entries.retain(|entry| entry.kind.is_viewable()); sort_entries(&mut entries, sort_field, sort_direction, case_insensitive); let Some(initial) = entries.first().cloned() else { controller .status_label .set_text("하위 폴더에 열 수 있는 파일이 없습니다."); return; }; let skipped = if progress.skipped == 0 { String::new() } else { format!(" · 접근할 수 없는 폴더 {}개 건너뜀", progress.skipped) }; controller.status_label.set_text(&format!( "하위 폴더에서 {}개 파일을 찾았습니다{skipped}.", entries.len() )); controller.open_viewable(initial, Rc::new(entries), profile, None); } Err(error) => controller .status_label .set_text(&format!("하위 폴더를 열 수 없습니다: {error:#}")), } }); self.listing_task.replace(Some(task)); } fn favorite_control( self: &Rc, 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 download_control(self: &Rc, entry: &BrowserEntry) -> FileDownloadControl { let entry = entry.clone(); let controller = Rc::downgrade(self); FileDownloadControl { download: Rc::new(move |parent, status_label| { let Some(controller) = controller.upgrade() else { return; }; controller.choose_download_destination(entry.clone(), parent, status_label); }), } } fn file_drop_control(self: &Rc) -> FileDropControl { let controller = Rc::downgrade(self); FileDropControl { handle: Rc::new(move |files, parent| { if let Some(controller) = controller.upgrade() { controller.open_dropped_files(files, parent); } }), } } fn open_dropped_files(self: &Rc, files: Vec, parent: gtk::Window) { let [file] = files.as_slice() else { show_drop_error(&parent, "한 번에 하나의 파일 또는 폴더만 열 수 있습니다."); return; }; if !file.is_native() { show_drop_error(&parent, "로컬 파일 또는 폴더만 드래그해서 열 수 있습니다."); return; } let file = file.clone(); self.cancel_listing(); self.cancel_recursive_search(); let generation = { let mut state = self.state.borrow_mut(); state.generation = state.generation.wrapping_add(1); state.generation }; self.status_label.set_text("열 항목을 확인하는 중..."); let controller = Rc::downgrade(self); let task = glib::MainContext::default().spawn_local(async move { let result: Result<_> = async { let info = file .query_info_future( "standard::type,standard::display-name", gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT, ) .await .context("열 항목을 확인할 수 없습니다")?; let selected = match info.file_type() { gio::FileType::Directory => None, gio::FileType::Regular => { if !classify_file(Path::new(info.display_name().as_str())).is_viewable() { bail!("지원하지 않는 파일 형식입니다: {}", info.display_name()); } Some(file.clone()) } _ => bail!("일반 파일 또는 폴더만 열 수 있습니다"), }; let directory = selected .as_ref() .and_then(gio::File::parent) .unwrap_or_else(|| file.clone()); let profile = local_profile_for_directory(&directory)?; let operation = mount_operation(&profile, &parent); let entries = enumerate_directory(&profile, directory.uri().as_str(), &operation, |_| {}) .await?; Ok((directory, profile, entries, selected)) } .await; let Some(controller) = controller.upgrade() else { return; }; if controller.state.borrow().generation != generation || !parent.is_visible() { return; } let (directory, profile, entries, selected) = match result { Ok(result) => result, Err(error) => { let message = format!("항목을 열 수 없습니다: {error:#}"); controller.status_label.set_text(&message); show_drop_error(&parent, &message); return; } }; controller.show_dropped_directory(&directory, profile.clone(), entries.clone()); let Some(selected) = selected else { return; }; let Some(entry) = entries .iter() .find(|entry| gio::File::for_uri(&entry.uri).equal(&selected)) .cloned() else { show_drop_error(&parent, "파일을 부모 폴더에서 찾을 수 없습니다."); return; }; let state = controller.state.borrow(); let Some((siblings, _selected_index)) = sorted_navigable_selection( &entries, &entry.uri, state.sort_field, state.sort_direction, controller.config.borrow().case_insensitive_sort, ) else { drop(state); show_drop_error(&parent, "파일의 목록 위치를 확인할 수 없습니다."); return; }; drop(state); controller.open_viewable(entry, Rc::new(siblings), profile, None); }); self.listing_task.replace(Some(task)); } fn show_dropped_directory( &self, directory: &gio::File, profile: LocationProfile, entries: Vec, ) { self.location_dropdown .set_selected(gtk::INVALID_LIST_POSITION); self.edit_location_button.set_sensitive(false); self.delete_location_button.set_sensitive(false); self.path_entry.set_editable(true); let uri: String = directory.uri().into(); let mut state = self.state.borrow_mut(); state.favorite_profile = Some(profile); state.browsing_favorites = false; state.root_uri = uri.clone(); state.current_uri = uri.clone(); state.entries = entries; state.typeahead_query.clear(); state.typeahead_last_input = None; drop(state); self.path_entry.set_text(&display_uri(&uri)); self.search_entry.set_text(""); self.render(); self.window.present(); } fn view_mode_control(self: &Rc) -> 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); 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; let browsing_favorites = self.state.borrow().browsing_favorites; if browsing_favorites { self.show_favorites(); } self.status_label.set_text(if active { "즐겨찾기에 추가했습니다." } else { "즐겨찾기에서 제거했습니다." }); active } fn choose_download_destination( self: &Rc, entry: BrowserEntry, parent: gtk::Window, status_label: gtk::Label, ) { let dialog = gtk::FileDialog::builder() .title("다운로드 위치 선택") .initial_name(&entry.name) .build(); let controller = Rc::downgrade(self); glib::MainContext::default().spawn_local(async move { let result = dialog.save_future(Some(&parent)).await; let Some(controller) = controller.upgrade() else { return; }; match result { Ok(destination) if destination.is_native() => { controller.download_file(entry, destination, status_label); } Ok(_) => status_label.set_text("로컬 파일 시스템 위치를 선택해 주세요."), Err(error) if error.matches(gio::IOErrorEnum::Cancelled) => {} Err(error) => { status_label.set_text(&format!("저장 위치를 선택할 수 없습니다: {error}")); } } }); } fn download_file(&self, entry: BrowserEntry, destination: gio::File, status_label: gtk::Label) { let Some(final_path) = destination.path() else { status_label.set_text("로컬 파일 시스템 위치를 선택해 주세요."); return; }; let part_path = download_part_path(&final_path); let source = gio::File::for_uri(&entry.uri); let part_file = gio::File::for_path(&part_path); let progress_label = status_label.clone(); let result_label = status_label; let expected_size = entry.size; source.copy_async( &part_file, gio::FileCopyFlags::OVERWRITE, glib::Priority::DEFAULT, gio::Cancellable::NONE, Some(Box::new(move |current, total| { let percent = if total > 0 { current * 100 / total } else { 0 }; progress_label.set_text(&format!("{} 다운로드 중... {percent}%", entry.name)); })), move |result| { let completed = result.map_err(anyhow::Error::from).and_then(|()| { let actual_size = fs::metadata(&part_path)?.len(); if expected_size.is_some_and(|expected| expected != actual_size) { anyhow::bail!("다운로드 크기가 원본과 다릅니다."); } fs::rename(&part_path, &final_path)?; Ok(()) }); if let Err(error) = completed { let _ = fs::remove_file(&part_path); result_label.set_text(&format!("다운로드 실패: {error:#}")); } else { result_label.set_text(&format!("다운로드 완료: {}", final_path.display())); } }, ); } fn set_case_insensitive_sort(&self, check: >k::CheckButton) { let active = check.is_active(); let previous = self.config.borrow().case_insensitive_sort; if active == previous { return; } let mut updated = self.config.borrow().clone(); updated.case_insensitive_sort = active; match updated.save(&self.config_path) { Ok(()) => { *self.config.borrow_mut() = updated; self.render(); } Err(error) => { self.status_label .set_text(&format!("정렬 설정을 저장할 수 없습니다: {error:#}")); check.set_active(previous); } } } fn set_fast_image_loading(&self, switch: >k::Switch) { let active = switch.is_active(); let previous = self.config.borrow().fast_image_loading; if active == previous { return; } let mut updated = self.config.borrow().clone(); updated.fast_image_loading = active; match updated.save(&self.config_path) { Ok(()) => { *self.config.borrow_mut() = updated; self.status_label.set_text(if active { "빠른 이미지 로딩을 켰습니다." } else { "빠른 이미지 로딩을 껐습니다." }); } Err(error) => { self.status_label .set_text(&format!("설정을 저장할 수 없습니다: {error:#}")); switch.set_active(previous); } } } fn selected_config_index(&self) -> Option { let index = (self.location_dropdown.selected() as usize).checked_sub(1)?; (index < self.config.borrow().locations.len()).then_some(index) } fn show_about_dialog(&self) { let dialog = gtk::Window::builder() .title("Comicviewer 정보") .transient_for(&self.window) .modal(true) .resizable(false) .default_width(520) .build(); let content = gtk::Box::new(gtk::Orientation::Vertical, 14); content.set_margin_top(24); content.set_margin_bottom(16); content.set_margin_start(24); content.set_margin_end(24); let name = gtk::Label::new(Some(&format!( "Comicviewer ({})", env!("CARGO_PKG_VERSION") ))); name.add_css_class("title-2"); let contact = gtk::Label::new(Some("개발자 연락처 : burningtuna@gmail.com")); let libraries = gtk::Label::new(Some( "사용한 라이브러리\n\ GTK4 / GIO · GStreamer · image · zip · rusqlite / SQLite\n\ serde · toml · anyhow · chrono · filetime · percent-encoding\n\ sha2 · tempfile", )); libraries.set_xalign(0.0); libraries.set_wrap(true); let close_button = gtk::Button::with_label("닫기"); close_button.set_halign(gtk::Align::End); let dialog_to_close = dialog.clone(); close_button.connect_clicked(move |_| dialog_to_close.close()); content.append(&name); content.append(&contact); content.append(&libraries); content.append(&close_button); dialog.set_child(Some(&content)); dialog.present(); } fn show_clear_cache_dialog(self: &Rc) { 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, edit_index: Option) { let existing = edit_index.and_then(|index| self.config.borrow().locations.get(index).cloned()); let dialog = gtk::Window::builder() .title(if existing.is_some() { "위치 수정" } else { "위치 추가" }) .transient_for(&self.window) .modal(true) .default_width(520) .build(); let content = gtk::Box::new(gtk::Orientation::Vertical, 12); let grid = gtk::Grid::builder() .row_spacing(8) .column_spacing(10) .margin_top(12) .margin_bottom(12) .margin_start(12) .margin_end(12) .build(); let kind = gtk::DropDown::from_strings(&["로컬", "SMB", "FTP", "SFTP"]); let name = gtk::Entry::new(); let address = gtk::Entry::builder() .placeholder_text("smb://server/share 또는 /home/user/comics") .build(); let username = gtk::Entry::new(); let password = gtk::Entry::builder().visibility(false).build(); let domain = gtk::Entry::new(); if let Some(profile) = &existing { kind.set_selected(location_kind_index(profile.kind)); name.set_text(&profile.name); address.set_text(&profile.address); username.set_text(&profile.username); password.set_text(&profile.password); domain.set_text(&profile.domain); } for (row, label, widget) in [ (0, "종류", kind.clone().upcast::()), (1, "이름", name.clone().upcast()), (2, "주소", address.clone().upcast()), (3, "사용자", username.clone().upcast()), (4, "비밀번호", password.clone().upcast()), (5, "SMB 도메인", domain.clone().upcast()), ] { grid.attach(>k::Label::new(Some(label)), 0, row, 1, 1); grid.attach(&widget, 1, row, 1, 1); } let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); actions.set_halign(gtk::Align::End); actions.set_margin_bottom(12); actions.set_margin_end(12); let cancel_button = gtk::Button::with_label("취소"); let save_button = gtk::Button::with_label("저장"); actions.append(&cancel_button); actions.append(&save_button); content.append(&grid); 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(); save_button.connect_clicked(move |_| { let profile = LocationProfile { name: name.text().into(), kind: match kind.selected() { 1 => LocationKind::Smb, 2 => LocationKind::Ftp, 3 => LocationKind::Sftp, _ => LocationKind::Local, }, address: address.text().into(), username: username.text().into(), password: password.text().into(), domain: domain.text().into(), }; if let Err(error) = validate_profile(&profile) { controller.status_label.set_text(&format!("{error:#}")); return; } let mut updated = controller.config.borrow().clone(); let selected_profile = if let Some(index) = edit_index { let Some(existing) = updated.locations.get_mut(index) else { controller .status_label .set_text("수정할 위치를 찾을 수 없습니다."); return; }; *existing = profile; index + 1 } else { updated.locations.push(profile); updated.locations.len() }; if let Err(error) = updated.save(&controller.config_path) { controller.status_label.set_text(&format!("{error:#}")); return; } *controller.config.borrow_mut() = updated; controller.rebuild_profiles(); controller .location_dropdown .set_selected(selected_profile as u32); dialog_to_close.close(); }); dialog.present(); } fn show_delete_location_dialog(self: &Rc, index: usize) { let Some(profile) = self.config.borrow().locations.get(index).cloned() else { return; }; let dialog = gtk::Window::builder() .title("위치 삭제") .transient_for(&self.window) .modal(true) .default_width(420) .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(&format!( "저장된 위치 '{}'을(를) 삭제하시겠습니까?\n서버의 파일은 삭제되지 않습니다.", profile.name ))); 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 delete_button = gtk::Button::with_label("삭제"); delete_button.add_css_class("destructive-action"); actions.append(&cancel_button); actions.append(&delete_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(); delete_button.connect_clicked(move |_| { let mut updated = controller.config.borrow().clone(); if index >= updated.locations.len() { controller .status_label .set_text("삭제할 위치를 찾을 수 없습니다."); dialog_to_close.close(); return; } updated.locations.remove(index); if let Err(error) = updated.save(&controller.config_path) { controller.status_label.set_text(&format!("{error:#}")); return; } *controller.config.borrow_mut() = updated; controller.rebuild_profiles(); controller.location_dropdown.set_selected(0); controller .status_label .set_text("저장된 위치를 삭제했습니다."); dialog_to_close.close(); }); dialog.present(); } } fn location_kind_index(kind: LocationKind) -> u32 { match kind { LocationKind::Local => 0, LocationKind::Smb => 1, LocationKind::Ftp => 2, LocationKind::Sftp => 3, } } fn home_profile() -> LocationProfile { LocationProfile { name: "내 홈".into(), kind: LocationKind::Local, address: std::env::var("HOME").unwrap_or_else(|_| "/".into()), username: String::new(), password: String::new(), domain: String::new(), } } fn local_profile_for_directory(directory: &gio::File) -> Result { let path = directory .path() .context("로컬 폴더 경로를 확인할 수 없습니다")?; let address = path .to_str() .context("UTF-8로 표현할 수 없는 로컬 경로는 열 수 없습니다")?; let name = path .file_name() .and_then(|name| name.to_str()) .filter(|name| !name.is_empty()) .unwrap_or(address) .to_owned(); Ok(LocationProfile { name, kind: LocationKind::Local, address: address.to_owned(), username: String::new(), password: String::new(), domain: String::new(), }) } fn show_drop_error(parent: >k::Window, message: &str) { gtk::AlertDialog::builder() .modal(true) .message(message) .build() .show(Some(parent)); } fn display_uri(uri: &str) -> String { let file = gio::File::for_uri(uri); file.path().map_or_else( || display_remote_uri(file.uri().as_str()), |path| path.to_string_lossy().into_owned(), ) } fn entry_row(entry: &BrowserEntry) -> gtk::ListBoxRow { let row = gtk::ListBoxRow::new(); let content = gtk::Box::new(gtk::Orientation::Horizontal, 12); content.set_margin_top(8); content.set_margin_bottom(8); content.set_margin_start(10); content.set_margin_end(10); let icon = match entry.kind { EntryKind::Directory => "folder-symbolic", EntryKind::ZipArchive => "package-x-generic-symbolic", EntryKind::Image => "image-x-generic-symbolic", EntryKind::Video => "video-x-generic-symbolic", EntryKind::OtherFile => "text-x-generic-symbolic", }; content.append(>k::Image::from_icon_name(icon)); content.append( >k::Label::builder() .label(entry.display_name()) .hexpand(true) .xalign(0.0) .ellipsize(gtk::pango::EllipsizeMode::Middle) .build(), ); for time in [entry.modified, entry.created] { let label = gtk::Label::new(Some(&format_time(time))); label.set_width_chars(17); label.set_xalign(1.0); content.append(&label); } row.set_child(Some(&content)); row } fn format_time(time: Option) -> String { time.map(|time| { let datetime: DateTime = time.into(); datetime.format("%Y-%m-%d %H:%M").to_string() }) .unwrap_or_else(|| "-".into()) } fn find_prefix_index(entries: &[BrowserEntry], query: &str, start: usize) -> Option { if entries.is_empty() { return None; } let query = query.to_lowercase(); (0..entries.len()) .map(|offset| (start + offset) % entries.len()) .find(|index| { entries[*index] .display_name() .to_lowercase() .starts_with(&query) }) } fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool { current.is_viewable() && candidate.is_viewable() } fn is_favorites_selection(selected: u32, profile_count: usize) -> bool { selected != gtk::INVALID_LIST_POSITION && selected as usize == profile_count } fn sorted_navigable_entries( entries: &[BrowserEntry], current_kind: EntryKind, sort_field: SortField, sort_direction: SortDirection, case_insensitive: bool, ) -> Vec { let mut siblings = entries .iter() .filter(|entry| navigable_sibling(current_kind, entry.kind)) .cloned() .collect::>(); sort_entries(&mut siblings, sort_field, sort_direction, case_insensitive); siblings } fn sorted_navigable_selection( entries: &[BrowserEntry], selected_uri: &str, sort_field: SortField, sort_direction: SortDirection, case_insensitive: bool, ) -> Option<(Vec, usize)> { let entries = sorted_navigable_entries( entries, EntryKind::Image, sort_field, sort_direction, case_insensitive, ); let selected = entries.iter().position(|entry| entry.uri == selected_uri)?; Some((entries, selected)) } fn recursive_search_match(entry: &BrowserEntry, lowercase_query: &str) -> bool { matches!(entry.kind, EntryKind::Directory | EntryKind::ZipArchive) && entry .display_name() .to_lowercase() .contains(lowercase_query) } fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry { BrowserEntry { uri: favorite.uri.clone(), name: favorite.name.clone(), list_name: None, 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: >k::ApplicationWindow, file_name: &str, status: &str, ) -> gtk::Label { set_viewer_loading(window, true); window.set_title(Some(file_name)); let header = gtk::HeaderBar::new(); header.set_title_widget(Some(>k::Label::new(Some(file_name)))); set_viewer_header(window, &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", final_path .file_name() .map_or_else(|| "download".into(), |name| name.to_string_lossy()) )) } 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::*; fn entry(name: &str) -> BrowserEntry { BrowserEntry { uri: format!("file:///{name}"), name: name.into(), list_name: None, kind: EntryKind::Directory, size: None, modified: None, created: None, etag: None, } } fn media_entry(name: &str) -> BrowserEntry { let mut entry = entry(name); entry.kind = EntryKind::Image; entry } #[test] fn typeahead_matches_case_insensitively() { let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; assert_eq!(find_prefix_index(&entries, "br", 0), Some(2)); assert_eq!(find_prefix_index(&entries, "AL", 0), Some(0)); } #[test] fn repeated_typeahead_wraps_to_next_match() { let entries = [entry("Alpha"), entry("beta"), entry("Bravo")]; assert_eq!(find_prefix_index(&entries, "b", 2), Some(2)); assert_eq!(find_prefix_index(&entries, "b", 3), Some(1)); } #[test] 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::ZipArchive, EntryKind::Video)); assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile)); assert!(!navigable_sibling(EntryKind::Directory, EntryKind::Image)); } #[test] fn media_navigation_recalculates_position_for_case_sorting() { let entries = [media_entry("a2.jpg"), media_entry("A10.jpg")]; let insensitive = sorted_navigable_entries( &entries, EntryKind::Image, SortField::Name, SortDirection::Ascending, true, ); let sensitive = sorted_navigable_entries( &entries, EntryKind::Image, SortField::Name, SortDirection::Ascending, false, ); assert_eq!(insensitive[0].name, "a2.jpg"); assert_eq!(sensitive[0].name, "A10.jpg"); assert_eq!( insensitive.iter().position(|entry| entry.name == "a2.jpg"), Some(0) ); assert_eq!( sensitive.iter().position(|entry| entry.name == "a2.jpg"), Some(1) ); } #[test] fn dropped_file_selection_uses_sorted_parent_index() { let entries = [ media_entry("page10.jpg"), entry("folder"), media_entry("page2.jpg"), ]; let selected_uri = entries[0].uri.clone(); let (siblings, selected) = sorted_navigable_selection( &entries, &selected_uri, SortField::Name, SortDirection::Ascending, true, ) .unwrap(); assert_eq!(siblings.len(), 2); assert_eq!(siblings[0].name, "page2.jpg"); assert_eq!(siblings[selected].name, "page10.jpg"); } #[test] fn recursive_search_only_matches_folders_and_zip_names() { let mut folder = entry("Comics"); folder.list_name = Some("Archive/Comics".into()); let mut zip = media_entry("book.zip"); zip.kind = EntryKind::ZipArchive; let image = media_entry("book.jpg"); assert!(recursive_search_match(&folder, "comics")); assert!(recursive_search_match(&zip, "book")); assert!(!recursive_search_match(&image, "book")); } #[test] fn mouse_back_button_matches_standard_x11_button() { assert_eq!(MOUSE_BACK_BUTTON, 8); } #[test] fn dropped_directory_creates_local_root_profile() { let directory = tempfile::tempdir().unwrap(); let file = gio::File::for_path(directory.path()); let profile = local_profile_for_directory(&file).unwrap(); assert_eq!(profile.kind, LocationKind::Local); assert_eq!(Path::new(&profile.address), directory.path()); assert!(profile.username.is_empty()); assert!(profile.password.is_empty()); } #[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); assert!(is_favorites_selection(3, 3)); assert!(!is_favorites_selection(2, 3)); assert!(!is_favorites_selection(gtk::INVALID_LIST_POSITION, 3)); } #[test] fn incomplete_download_uses_distinct_suffix() { assert_eq!( download_part_path(std::path::Path::new("/tmp/만화.zip")), 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()); } }