Initial Comicviewer implementation
This commit is contained in:
commit
cb01395612
20 changed files with 4940 additions and 0 deletions
822
src/window.rs
Normal file
822
src/window.rs
Normal file
|
|
@ -0,0 +1,822 @@
|
|||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Local};
|
||||
use gtk::gio;
|
||||
use gtk::gio::prelude::*;
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::browser::{BrowserEntry, EntryKind, SortDirection, SortField, sort_entries};
|
||||
use crate::config::{AppConfig, LocationKind, LocationProfile};
|
||||
use crate::database::Database;
|
||||
use crate::source::{
|
||||
display_remote_uri, enumerate_directory, file_from_user_input, is_within_root, mount_operation,
|
||||
parent_within_root, root_file, validate_profile,
|
||||
};
|
||||
use crate::viewer::ViewerWindow;
|
||||
use crate::zip_cache::ZipCache;
|
||||
|
||||
pub struct ComicWindow {
|
||||
controller: Rc<BrowserController>,
|
||||
}
|
||||
|
||||
impl ComicWindow {
|
||||
pub fn new(
|
||||
application: >k::Application,
|
||||
config: AppConfig,
|
||||
config_path: PathBuf,
|
||||
database: Database,
|
||||
zip_cache: Rc<ZipCache>,
|
||||
) -> Result<Self> {
|
||||
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"))));
|
||||
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("현재 폴더에서 검색")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]);
|
||||
let direction_button = gtk::ToggleButton::with_label("오름차순");
|
||||
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(>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,
|
||||
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,
|
||||
root_uri: root_uri.clone(),
|
||||
current_uri: root_uri,
|
||||
entries: Vec::new(),
|
||||
visible_entries: Vec::new(),
|
||||
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,
|
||||
});
|
||||
|
||||
controller.rebuild_profiles();
|
||||
controller.connect_signals(
|
||||
open_button,
|
||||
refresh_button,
|
||||
up_button,
|
||||
add_location_button,
|
||||
fast_loading_switch,
|
||||
);
|
||||
controller.refresh();
|
||||
Ok(Self { controller })
|
||||
}
|
||||
|
||||
pub fn present(&self) {
|
||||
self.controller.window.present();
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowserState {
|
||||
profile_index: usize,
|
||||
root_uri: String,
|
||||
current_uri: String,
|
||||
entries: Vec<BrowserEntry>,
|
||||
visible_entries: Vec<BrowserEntry>,
|
||||
sort_field: SortField,
|
||||
sort_direction: SortDirection,
|
||||
generation: u64,
|
||||
typeahead_query: String,
|
||||
typeahead_last_input: Option<Instant>,
|
||||
}
|
||||
|
||||
struct BrowserController {
|
||||
window: gtk::ApplicationWindow,
|
||||
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<BrowserState>,
|
||||
config: RefCell<AppConfig>,
|
||||
config_path: PathBuf,
|
||||
database: Rc<Database>,
|
||||
zip_cache: Rc<ZipCache>,
|
||||
}
|
||||
|
||||
impl BrowserController {
|
||||
fn connect_signals(
|
||||
self: &Rc<Self>,
|
||||
open_button: gtk::Button,
|
||||
refresh_button: gtk::Button,
|
||||
up_button: gtk::Button,
|
||||
add_location_button: gtk::Button,
|
||||
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.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 controller = Rc::clone(self);
|
||||
self.search_entry
|
||||
.connect_search_changed(move |_| controller.render());
|
||||
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);
|
||||
self.list_box
|
||||
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
|
||||
|
||||
let key_controller = gtk::EventControllerKey::new();
|
||||
let controller = Rc::clone(self);
|
||||
key_controller
|
||||
.connect_key_pressed(move |_, key, _, modifiers| controller.typeahead(key, modifiers));
|
||||
self.list_box.add_controller(key_controller);
|
||||
|
||||
let controller = Rc::clone(self);
|
||||
fast_loading_switch.connect_active_notify(move |switch| {
|
||||
controller.set_fast_image_loading(switch);
|
||||
});
|
||||
}
|
||||
|
||||
fn profiles(&self) -> Vec<LocationProfile> {
|
||||
let mut profiles = vec![home_profile()];
|
||||
profiles.extend(self.config.borrow().locations.clone());
|
||||
profiles
|
||||
}
|
||||
|
||||
fn rebuild_profiles(&self) {
|
||||
let profiles = self.profiles();
|
||||
let names: Vec<_> = profiles
|
||||
.iter()
|
||||
.map(|profile| profile.name.as_str())
|
||||
.collect();
|
||||
self.location_dropdown
|
||||
.set_model(Some(>k::StringList::new(&names)));
|
||||
}
|
||||
|
||||
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();
|
||||
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;
|
||||
};
|
||||
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.root_uri = root_uri.clone();
|
||||
state.current_uri = root_uri;
|
||||
drop(state);
|
||||
self.search_entry.set_text("");
|
||||
self.refresh();
|
||||
}
|
||||
Err(error) => self.status_label.set_text(&format!("{error:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_entered_path(self: &Rc<Self>) {
|
||||
let profile = self.profiles()[self.state.borrow().profile_index].clone();
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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;
|
||||
(
|
||||
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);
|
||||
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;
|
||||
};
|
||||
if controller.state.borrow().generation != generation {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(entries) => {
|
||||
controller.state.borrow_mut().entries = entries;
|
||||
controller.render();
|
||||
}
|
||||
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 mut state = self.state.borrow_mut();
|
||||
let mut visible_entries: Vec<_> = state
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.name.to_lowercase().contains(query.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
sort_entries(&mut visible_entries, state.sort_field, state.sort_direction);
|
||||
for entry in &visible_entries {
|
||||
self.list_box.append(&entry_row(entry));
|
||||
}
|
||||
let count = visible_entries.len();
|
||||
state.visible_entries = visible_entries;
|
||||
self.status_label.set_text(&format!("{count}개 항목"));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
let Some(character) = key
|
||||
.to_unicode()
|
||||
.filter(|character| character.is_alphanumeric())
|
||||
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::<String>();
|
||||
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].name.clone());
|
||||
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 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<Self>, index: i32) {
|
||||
let Some(entry) = self
|
||||
.state
|
||||
.borrow()
|
||||
.visible_entries
|
||||
.get(index as usize)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match entry.kind {
|
||||
EntryKind::Directory => {
|
||||
self.state.borrow_mut().current_uri = entry.uri;
|
||||
self.search_entry.set_text("");
|
||||
self.refresh();
|
||||
}
|
||||
EntryKind::ZipArchive => self.open_archive(entry),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_archive(self: &Rc<Self>, entry: BrowserEntry) {
|
||||
self.status_label.set_text("ZIP 파일 다운로드 준비 중...");
|
||||
let progress_label = self.status_label.clone();
|
||||
let controller = Rc::clone(self);
|
||||
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| match result {
|
||||
Ok(source) => {
|
||||
controller.status_label.set_text("다운로드 완료");
|
||||
let Some(application) = controller.window.application() else {
|
||||
return;
|
||||
};
|
||||
ViewerWindow::open(
|
||||
&application,
|
||||
source,
|
||||
Rc::clone(&controller.database),
|
||||
controller.config.borrow().fast_image_loading,
|
||||
);
|
||||
}
|
||||
Err(error) => controller.status_label.set_text(&format!("{error:#}")),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
let index = (self.location_dropdown.selected() as usize).checked_sub(1)?;
|
||||
(index < self.config.borrow().locations.len()).then_some(index)
|
||||
}
|
||||
|
||||
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());
|
||||
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::<gtk::Widget>()),
|
||||
(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<Self>, 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 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",
|
||||
};
|
||||
content.append(>k::Image::from_icon_name(icon));
|
||||
content.append(
|
||||
>k::Label::builder()
|
||||
.label(&entry.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<std::time::SystemTime>) -> String {
|
||||
time.map(|time| {
|
||||
let datetime: DateTime<Local> = 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<usize> {
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let query = query.to_lowercase();
|
||||
(0..entries.len())
|
||||
.map(|offset| (start + offset) % entries.len())
|
||||
.find(|index| entries[*index].name.to_lowercase().starts_with(&query))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(name: &str) -> BrowserEntry {
|
||||
BrowserEntry {
|
||||
uri: format!("file:///{name}"),
|
||||
name: name.into(),
|
||||
kind: EntryKind::Directory,
|
||||
size: None,
|
||||
modified: None,
|
||||
created: None,
|
||||
etag: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue