Sync viewer navigation with browser sorting
This commit is contained in:
parent
bb73c1abea
commit
f0d5cf3496
5 changed files with 375 additions and 87 deletions
|
|
@ -6,7 +6,7 @@ use anyhow::{Context, Result, bail};
|
||||||
use image::ImageReader;
|
use image::ImageReader;
|
||||||
use zip::ZipArchive;
|
use zip::ZipArchive;
|
||||||
|
|
||||||
use crate::browser::natural_cmp;
|
use crate::browser::natural_cmp_with_case;
|
||||||
|
|
||||||
const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
|
const MAX_ENTRY_SIZE: u64 = 256 * 1024 * 1024;
|
||||||
const MAX_IMAGE_DIMENSION: u32 = 30_000;
|
const MAX_IMAGE_DIMENSION: u32 = 30_000;
|
||||||
|
|
@ -24,7 +24,7 @@ pub struct DecodedPage {
|
||||||
pub rgba: Vec<u8>,
|
pub rgba: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_pages(path: &Path) -> Result<Vec<ArchivePage>> {
|
pub fn list_pages(path: &Path, case_insensitive: bool) -> Result<Vec<ArchivePage>> {
|
||||||
let file = File::open(path)
|
let file = File::open(path)
|
||||||
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
|
.with_context(|| format!("ZIP 파일을 열 수 없습니다: {}", path.display()))?;
|
||||||
let mut archive = ZipArchive::new(file)
|
let mut archive = ZipArchive::new(file)
|
||||||
|
|
@ -41,7 +41,7 @@ pub fn list_pages(path: &Path) -> Result<Vec<ArchivePage>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pages.sort_by(|left, right| natural_cmp(&left.name, &right.name));
|
pages.sort_by(|left, right| natural_cmp_with_case(&left.name, &right.name, case_insensitive));
|
||||||
Ok(pages)
|
Ok(pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +178,7 @@ mod tests {
|
||||||
archive.write_all(b"ignored").unwrap();
|
archive.write_all(b"ignored").unwrap();
|
||||||
archive.finish().unwrap();
|
archive.finish().unwrap();
|
||||||
|
|
||||||
let pages = list_pages(&path).unwrap();
|
let pages = list_pages(&path, true).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pages
|
pages
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,12 @@ pub enum SortDirection {
|
||||||
Descending,
|
Descending,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: SortDirection) {
|
pub fn sort_entries(
|
||||||
|
entries: &mut [BrowserEntry],
|
||||||
|
field: SortField,
|
||||||
|
direction: SortDirection,
|
||||||
|
case_insensitive: bool,
|
||||||
|
) {
|
||||||
entries.sort_by(|left, right| {
|
entries.sort_by(|left, right| {
|
||||||
let kind_order = match (
|
let kind_order = match (
|
||||||
left.kind == EntryKind::Directory,
|
left.kind == EntryKind::Directory,
|
||||||
|
|
@ -60,11 +65,24 @@ pub fn sort_entries(entries: &mut [BrowserEntry], field: SortField, direction: S
|
||||||
}
|
}
|
||||||
|
|
||||||
match field {
|
match field {
|
||||||
SortField::Name => apply_direction(natural_cmp(&left.name, &right.name), direction),
|
SortField::Name => apply_direction(
|
||||||
|
natural_cmp_with_case(&left.name, &right.name, case_insensitive),
|
||||||
|
direction,
|
||||||
|
),
|
||||||
SortField::Modified => compare_optional_time(left.modified, right.modified, direction)
|
SortField::Modified => compare_optional_time(left.modified, right.modified, direction)
|
||||||
.then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)),
|
.then_with(|| {
|
||||||
|
apply_direction(
|
||||||
|
natural_cmp_with_case(&left.name, &right.name, case_insensitive),
|
||||||
|
direction,
|
||||||
|
)
|
||||||
|
}),
|
||||||
SortField::Created => compare_optional_time(left.created, right.created, direction)
|
SortField::Created => compare_optional_time(left.created, right.created, direction)
|
||||||
.then_with(|| apply_direction(natural_cmp(&left.name, &right.name), direction)),
|
.then_with(|| {
|
||||||
|
apply_direction(
|
||||||
|
natural_cmp_with_case(&left.name, &right.name, case_insensitive),
|
||||||
|
direction,
|
||||||
|
)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -104,13 +122,13 @@ pub fn classify_file(path: &Path) -> EntryKind {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn natural_cmp(left: &str, right: &str) -> Ordering {
|
pub fn natural_cmp_with_case(left: &str, right: &str, case_insensitive: bool) -> Ordering {
|
||||||
let mut left_chars = left.chars().peekable();
|
let mut left_chars = left.chars().peekable();
|
||||||
let mut right_chars = right.chars().peekable();
|
let mut right_chars = right.chars().peekable();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match (left_chars.peek(), right_chars.peek()) {
|
match (left_chars.peek(), right_chars.peek()) {
|
||||||
(None, None) => return Ordering::Equal,
|
(None, None) => return left.cmp(right),
|
||||||
(None, Some(_)) => return Ordering::Less,
|
(None, Some(_)) => return Ordering::Less,
|
||||||
(Some(_), None) => return Ordering::Greater,
|
(Some(_), None) => return Ordering::Greater,
|
||||||
(Some(left_char), Some(right_char))
|
(Some(left_char), Some(right_char))
|
||||||
|
|
@ -143,10 +161,11 @@ pub fn natural_cmp(left: &str, right: &str) -> Ordering {
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => {
|
||||||
let left_char = left_chars.next().unwrap();
|
let left_char = left_chars.next().unwrap();
|
||||||
let right_char = right_chars.next().unwrap();
|
let right_char = right_chars.next().unwrap();
|
||||||
let order = left_char
|
let order = if case_insensitive {
|
||||||
.to_lowercase()
|
left_char.to_lowercase().cmp(right_char.to_lowercase())
|
||||||
.cmp(right_char.to_lowercase())
|
} else {
|
||||||
.then_with(|| left_char.cmp(&right_char));
|
left_char.cmp(&right_char)
|
||||||
|
};
|
||||||
if order != Ordering::Equal {
|
if order != Ordering::Equal {
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
@ -173,18 +192,30 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn natural_order_places_two_before_ten() {
|
fn natural_order_places_two_before_ten() {
|
||||||
let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"];
|
let mut names = ["page10.jpg", "page2.jpg", "page1.jpg"];
|
||||||
names.sort_by(|left, right| natural_cmp(left, right));
|
names.sort_by(|left, right| natural_cmp_with_case(left, right, true));
|
||||||
assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]);
|
assert_eq!(names, ["page1.jpg", "page2.jpg", "page10.jpg"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn natural_order_handles_numbers_larger_than_integers() {
|
fn natural_order_handles_numbers_larger_than_integers() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
natural_cmp("9.zip", "123456789012345678901234567890.zip"),
|
natural_cmp_with_case("9.zip", "123456789012345678901234567890.zip", true),
|
||||||
Ordering::Less
|
Ordering::Less
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn natural_order_can_ignore_or_respect_case() {
|
||||||
|
assert_eq!(
|
||||||
|
natural_cmp_with_case("a2.jpg", "A10.jpg", true),
|
||||||
|
Ordering::Less
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
natural_cmp_with_case("a2.jpg", "A10.jpg", false),
|
||||||
|
Ordering::Greater
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zip_extension_is_case_insensitive() {
|
fn zip_extension_is_case_insensitive() {
|
||||||
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);
|
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub cache_quota_gb: u64,
|
pub cache_quota_gb: u64,
|
||||||
pub fast_image_loading: bool,
|
pub fast_image_loading: bool,
|
||||||
|
pub case_insensitive_sort: bool,
|
||||||
pub viewer_mode: ViewerMode,
|
pub viewer_mode: ViewerMode,
|
||||||
pub locations: Vec<LocationProfile>,
|
pub locations: Vec<LocationProfile>,
|
||||||
pub favorites: Vec<FavoriteEntry>,
|
pub favorites: Vec<FavoriteEntry>,
|
||||||
|
|
@ -20,6 +21,7 @@ impl Default for AppConfig {
|
||||||
Self {
|
Self {
|
||||||
cache_quota_gb: 10,
|
cache_quota_gb: 10,
|
||||||
fast_image_loading: true,
|
fast_image_loading: true,
|
||||||
|
case_insensitive_sort: true,
|
||||||
viewer_mode: ViewerMode::FitWidth,
|
viewer_mode: ViewerMode::FitWidth,
|
||||||
locations: Vec::new(),
|
locations: Vec::new(),
|
||||||
favorites: Vec::new(),
|
favorites: Vec::new(),
|
||||||
|
|
@ -167,6 +169,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn default_cache_quota_is_ten_gigabytes() {
|
fn default_cache_quota_is_ten_gigabytes() {
|
||||||
assert_eq!(AppConfig::default().cache_quota_gb, 10);
|
assert_eq!(AppConfig::default().cache_quota_gb, 10);
|
||||||
|
assert!(AppConfig::default().case_insensitive_sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -174,6 +177,7 @@ mod tests {
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
cache_quota_gb: 0,
|
cache_quota_gb: 0,
|
||||||
fast_image_loading: true,
|
fast_image_loading: true,
|
||||||
|
case_insensitive_sort: true,
|
||||||
viewer_mode: ViewerMode::FitWidth,
|
viewer_mode: ViewerMode::FitWidth,
|
||||||
locations: vec![LocationProfile {
|
locations: vec![LocationProfile {
|
||||||
name: "테스트".into(),
|
name: "테스트".into(),
|
||||||
|
|
@ -209,6 +213,7 @@ mod tests {
|
||||||
fn fast_image_loading_is_enabled_for_existing_configs() {
|
fn fast_image_loading_is_enabled_for_existing_configs() {
|
||||||
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
|
let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap();
|
||||||
assert!(decoded.fast_image_loading);
|
assert!(decoded.fast_image_loading);
|
||||||
|
assert!(decoded.case_insensitive_sort);
|
||||||
assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth);
|
assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth);
|
||||||
assert!(decoded.favorites.is_empty());
|
assert!(decoded.favorites.is_empty());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
177
src/viewer.rs
177
src/viewer.rs
|
|
@ -22,33 +22,43 @@ const FAST_CACHE_LIMIT_PAGES: usize = 64;
|
||||||
pub struct ViewerWindow;
|
pub struct ViewerWindow;
|
||||||
|
|
||||||
pub struct SiblingNavigator {
|
pub struct SiblingNavigator {
|
||||||
current: usize,
|
position: Rc<dyn Fn() -> Option<(usize, usize)>>,
|
||||||
count: usize,
|
navigate: Rc<dyn Fn(SiblingTarget, gtk::ApplicationWindow) -> bool>,
|
||||||
open: Rc<dyn Fn(usize, gtk::ApplicationWindow) -> bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SiblingNavigator {
|
impl SiblingNavigator {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
current: usize,
|
position: Rc<dyn Fn() -> Option<(usize, usize)>>,
|
||||||
count: usize,
|
navigate: Rc<dyn Fn(SiblingTarget, gtk::ApplicationWindow) -> bool>,
|
||||||
open: Rc<dyn Fn(usize, gtk::ApplicationWindow) -> bool>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self { position, navigate }
|
||||||
current,
|
|
||||||
count,
|
|
||||||
open,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool {
|
fn open_relative(&self, forward: bool, window: gtk::ApplicationWindow) -> bool {
|
||||||
let Some(index) = sibling_index(self.current, self.count, forward) else {
|
(self.navigate)(SiblingTarget::Relative(forward), window)
|
||||||
return false;
|
|
||||||
};
|
|
||||||
(self.open)(index, window)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool {
|
fn open_index(&self, index: usize, window: gtk::ApplicationWindow) -> bool {
|
||||||
index < self.count && index != self.current && (self.open)(index, window)
|
(self.navigate)(SiblingTarget::Index(index), window)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position(&self) -> Option<(usize, usize)> {
|
||||||
|
(self.position)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum SiblingTarget {
|
||||||
|
Relative(bool),
|
||||||
|
Index(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SiblingTarget {
|
||||||
|
pub fn resolve(self, current: usize, count: usize) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Self::Relative(forward) => sibling_index(current, count, forward),
|
||||||
|
Self::Index(index) => (index < count && index != current).then_some(index),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +89,7 @@ impl ViewerWindow {
|
||||||
source: ArchiveSource,
|
source: ArchiveSource,
|
||||||
database: Rc<Database>,
|
database: Rc<Database>,
|
||||||
fast_image_loading: bool,
|
fast_image_loading: bool,
|
||||||
|
case_insensitive_sort: bool,
|
||||||
view_mode_control: ViewModeControl,
|
view_mode_control: ViewModeControl,
|
||||||
controls: FileViewerControls,
|
controls: FileViewerControls,
|
||||||
) {
|
) {
|
||||||
|
|
@ -190,7 +201,11 @@ impl ViewerWindow {
|
||||||
root.append(&overlay);
|
root.append(&overlay);
|
||||||
root.append(&page_seek_row);
|
root.append(&page_seek_row);
|
||||||
root.append(&status_label);
|
root.append(&status_label);
|
||||||
window.set_child(Some(&root));
|
let window_overlay = gtk::Overlay::new();
|
||||||
|
window_overlay.set_child(Some(&root));
|
||||||
|
let (navigation_message, navigation_message_label) = navigation_message();
|
||||||
|
window_overlay.add_overlay(&navigation_message);
|
||||||
|
window.set_child(Some(&window_overlay));
|
||||||
|
|
||||||
let controller = Rc::new(ViewerController {
|
let controller = Rc::new(ViewerController {
|
||||||
window,
|
window,
|
||||||
|
|
@ -214,6 +229,7 @@ impl ViewerWindow {
|
||||||
mode_dropdown,
|
mode_dropdown,
|
||||||
direction_dropdown,
|
direction_dropdown,
|
||||||
fast_image_loading,
|
fast_image_loading,
|
||||||
|
case_insensitive_sort,
|
||||||
sibling_navigator,
|
sibling_navigator,
|
||||||
favorite_button,
|
favorite_button,
|
||||||
favorite_toggle: favorite_control.toggle,
|
favorite_toggle: favorite_control.toggle,
|
||||||
|
|
@ -224,6 +240,9 @@ impl ViewerWindow {
|
||||||
last_pointer_position: Cell::new(None),
|
last_pointer_position: Cell::new(None),
|
||||||
key_controller: RefCell::new(None),
|
key_controller: RefCell::new(None),
|
||||||
motion_controller: RefCell::new(None),
|
motion_controller: RefCell::new(None),
|
||||||
|
navigation_message,
|
||||||
|
navigation_message_label,
|
||||||
|
navigation_message_generation: Rc::new(Cell::new(0)),
|
||||||
state: RefCell::new(ViewerState {
|
state: RefCell::new(ViewerState {
|
||||||
pages: Vec::new(),
|
pages: Vec::new(),
|
||||||
anchor: 0,
|
anchor: 0,
|
||||||
|
|
@ -300,6 +319,7 @@ struct ViewerController {
|
||||||
mode_dropdown: gtk::DropDown,
|
mode_dropdown: gtk::DropDown,
|
||||||
direction_dropdown: gtk::DropDown,
|
direction_dropdown: gtk::DropDown,
|
||||||
fast_image_loading: bool,
|
fast_image_loading: bool,
|
||||||
|
case_insensitive_sort: bool,
|
||||||
sibling_navigator: Option<SiblingNavigator>,
|
sibling_navigator: Option<SiblingNavigator>,
|
||||||
favorite_button: gtk::Button,
|
favorite_button: gtk::Button,
|
||||||
favorite_toggle: Rc<dyn Fn() -> bool>,
|
favorite_toggle: Rc<dyn Fn() -> bool>,
|
||||||
|
|
@ -310,6 +330,9 @@ struct ViewerController {
|
||||||
last_pointer_position: Cell<Option<(f64, f64)>>,
|
last_pointer_position: Cell<Option<(f64, f64)>>,
|
||||||
key_controller: RefCell<Option<gtk::EventControllerKey>>,
|
key_controller: RefCell<Option<gtk::EventControllerKey>>,
|
||||||
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
|
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
|
||||||
|
navigation_message: gtk::Box,
|
||||||
|
navigation_message_label: gtk::Label,
|
||||||
|
navigation_message_generation: Rc<Cell<u64>>,
|
||||||
state: RefCell<ViewerState>,
|
state: RefCell<ViewerState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,9 +454,26 @@ impl ViewerController {
|
||||||
&& navigate.open_relative(forward, self.window.clone())
|
&& navigate.open_relative(forward, self.window.clone())
|
||||||
{
|
{
|
||||||
self.detach_window_controllers();
|
self.detach_window_controllers();
|
||||||
|
} else {
|
||||||
|
self.show_navigation_error(forward);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn show_navigation_error(&self, forward: bool) {
|
||||||
|
self.navigation_message_label
|
||||||
|
.set_text(navigation_error_message(forward));
|
||||||
|
self.navigation_message.set_visible(true);
|
||||||
|
let message = self.navigation_message.clone();
|
||||||
|
let generation = self.navigation_message_generation.get().wrapping_add(1);
|
||||||
|
self.navigation_message_generation.set(generation);
|
||||||
|
let current_generation = Rc::clone(&self.navigation_message_generation);
|
||||||
|
glib::timeout_add_local_once(Duration::from_secs(2), move || {
|
||||||
|
if current_generation.get() == generation {
|
||||||
|
message.set_visible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn detach_window_controllers(&self) {
|
fn detach_window_controllers(&self) {
|
||||||
if let Some(controller) = self.key_controller.borrow_mut().take() {
|
if let Some(controller) = self.key_controller.borrow_mut().take() {
|
||||||
self.window.remove_controller(&controller);
|
self.window.remove_controller(&controller);
|
||||||
|
|
@ -500,9 +540,12 @@ impl ViewerController {
|
||||||
fn load_archive(self: &Rc<Self>) {
|
fn load_archive(self: &Rc<Self>) {
|
||||||
self.spinner.start();
|
self.spinner.start();
|
||||||
let path = self.source.local_path.clone();
|
let path = self.source.local_path.clone();
|
||||||
|
let case_insensitive_sort = self.case_insensitive_sort;
|
||||||
let (sender, receiver) = mpsc::channel();
|
let (sender, receiver) = mpsc::channel();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let _ = sender.send(list_pages(&path).map_err(|error| format!("{error:#}")));
|
let _ = sender.send(
|
||||||
|
list_pages(&path, case_insensitive_sort).map_err(|error| format!("{error:#}")),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
let controller = Rc::downgrade(self);
|
let controller = Rc::downgrade(self);
|
||||||
glib::timeout_add_local(Duration::from_millis(20), move || {
|
glib::timeout_add_local(Duration::from_millis(20), move || {
|
||||||
|
|
@ -1139,17 +1182,24 @@ impl MediaViewerWindow {
|
||||||
.build();
|
.build();
|
||||||
let media_seek = navigation_scale();
|
let media_seek = navigation_scale();
|
||||||
let media_seek_row = navigation_scale_row(&media_seek);
|
let media_seek_row = navigation_scale_row(&media_seek);
|
||||||
if let Some(navigator) = &sibling_navigator {
|
if let Some((current, count)) = sibling_navigator
|
||||||
media_seek.set_range(0.0, navigation_scale_upper(navigator.count));
|
.as_ref()
|
||||||
media_seek.set_value(navigator.current as f64);
|
.and_then(SiblingNavigator::position)
|
||||||
media_seek.set_sensitive(navigator.count > 1);
|
{
|
||||||
|
media_seek.set_range(0.0, navigation_scale_upper(count));
|
||||||
|
media_seek.set_value(current as f64);
|
||||||
|
media_seek.set_sensitive(count > 1);
|
||||||
} else {
|
} else {
|
||||||
media_seek.set_sensitive(false);
|
media_seek.set_sensitive(false);
|
||||||
}
|
}
|
||||||
root.append(&stack);
|
root.append(&stack);
|
||||||
root.append(&media_seek_row);
|
root.append(&media_seek_row);
|
||||||
root.append(&status_label);
|
root.append(&status_label);
|
||||||
window.set_child(Some(&root));
|
let window_overlay = gtk::Overlay::new();
|
||||||
|
window_overlay.set_child(Some(&root));
|
||||||
|
let (navigation_message, navigation_message_label) = navigation_message();
|
||||||
|
window_overlay.add_overlay(&navigation_message);
|
||||||
|
window.set_child(Some(&window_overlay));
|
||||||
|
|
||||||
let controller = Rc::new(MediaViewerController {
|
let controller = Rc::new(MediaViewerController {
|
||||||
window,
|
window,
|
||||||
|
|
@ -1171,6 +1221,9 @@ impl MediaViewerWindow {
|
||||||
last_pointer_position: Cell::new(None),
|
last_pointer_position: Cell::new(None),
|
||||||
key_controller: RefCell::new(None),
|
key_controller: RefCell::new(None),
|
||||||
motion_controller: RefCell::new(None),
|
motion_controller: RefCell::new(None),
|
||||||
|
navigation_message,
|
||||||
|
navigation_message_label,
|
||||||
|
navigation_message_generation: Rc::new(Cell::new(0)),
|
||||||
});
|
});
|
||||||
if let Some(stream) = controller.video.media_stream() {
|
if let Some(stream) = controller.video.media_stream() {
|
||||||
let status_label = controller.status_label.clone();
|
let status_label = controller.status_label.clone();
|
||||||
|
|
@ -1215,6 +1268,9 @@ struct MediaViewerController {
|
||||||
last_pointer_position: Cell<Option<(f64, f64)>>,
|
last_pointer_position: Cell<Option<(f64, f64)>>,
|
||||||
key_controller: RefCell<Option<gtk::EventControllerKey>>,
|
key_controller: RefCell<Option<gtk::EventControllerKey>>,
|
||||||
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
|
motion_controller: RefCell<Option<gtk::EventControllerMotion>>,
|
||||||
|
navigation_message: gtk::Box,
|
||||||
|
navigation_message_label: gtk::Label,
|
||||||
|
navigation_message_generation: Rc<Cell<u64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaViewerController {
|
impl MediaViewerController {
|
||||||
|
|
@ -1269,7 +1325,10 @@ impl MediaViewerController {
|
||||||
let Some(navigator) = &controller.sibling_navigator else {
|
let Some(navigator) = &controller.sibling_navigator else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(index) = page_seek_index(scale.value(), navigator.count) else {
|
let Some((_, count)) = navigator.position() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(index) = page_seek_index(scale.value(), count) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
controller.schedule_seek(index);
|
controller.schedule_seek(index);
|
||||||
|
|
@ -1281,13 +1340,40 @@ impl MediaViewerController {
|
||||||
&& navigate.open_relative(forward, self.window.clone())
|
&& navigate.open_relative(forward, self.window.clone())
|
||||||
{
|
{
|
||||||
self.prepare_for_navigation();
|
self.prepare_for_navigation();
|
||||||
|
} else {
|
||||||
|
self.show_navigation_error(forward);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn show_navigation_error(&self, forward: bool) {
|
||||||
|
self.navigation_message_label
|
||||||
|
.set_text(navigation_error_message(forward));
|
||||||
|
self.navigation_message.set_visible(true);
|
||||||
|
let message = self.navigation_message.clone();
|
||||||
|
let generation = self.navigation_message_generation.get().wrapping_add(1);
|
||||||
|
self.navigation_message_generation.set(generation);
|
||||||
|
let current_generation = Rc::clone(&self.navigation_message_generation);
|
||||||
|
glib::timeout_add_local_once(Duration::from_secs(2), move || {
|
||||||
|
if current_generation.get() == generation {
|
||||||
|
message.set_visible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn show_seek_temporarily(self: &Rc<Self>) {
|
fn show_seek_temporarily(self: &Rc<Self>) {
|
||||||
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
|
if let Some(timer) = self.seek_hide_timer.borrow_mut().take() {
|
||||||
timer.remove();
|
timer.remove();
|
||||||
}
|
}
|
||||||
|
if let Some((current, count)) = self
|
||||||
|
.sibling_navigator
|
||||||
|
.as_ref()
|
||||||
|
.and_then(SiblingNavigator::position)
|
||||||
|
{
|
||||||
|
self.media_seek
|
||||||
|
.set_range(0.0, navigation_scale_upper(count));
|
||||||
|
self.media_seek.set_sensitive(count > 1);
|
||||||
|
self.media_seek.set_value(current as f64);
|
||||||
|
}
|
||||||
self.set_seek_visible(true);
|
self.set_seek_visible(true);
|
||||||
let controller = Rc::downgrade(self);
|
let controller = Rc::downgrade(self);
|
||||||
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
|
let timer = glib::timeout_add_local_once(Duration::from_secs(5), move || {
|
||||||
|
|
@ -1334,7 +1420,11 @@ impl MediaViewerController {
|
||||||
let Some(navigator) = &self.sibling_navigator else {
|
let Some(navigator) = &self.sibling_navigator else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if index == navigator.current || self.seek_opening.get() {
|
if navigator
|
||||||
|
.position()
|
||||||
|
.is_none_or(|(current, _)| index == current)
|
||||||
|
|| self.seek_opening.get()
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let generation = self.seek_generation.get().wrapping_add(1);
|
let generation = self.seek_generation.get().wrapping_add(1);
|
||||||
|
|
@ -1452,6 +1542,31 @@ fn navigation_scale() -> gtk::Scale {
|
||||||
scale
|
scale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn navigation_message() -> (gtk::Box, gtk::Label) {
|
||||||
|
let message = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||||
|
message.add_css_class("osd");
|
||||||
|
message.set_halign(gtk::Align::Center);
|
||||||
|
message.set_valign(gtk::Align::Center);
|
||||||
|
message.set_can_target(false);
|
||||||
|
message.set_margin_top(12);
|
||||||
|
message.set_margin_bottom(12);
|
||||||
|
message.set_margin_start(18);
|
||||||
|
message.set_margin_end(18);
|
||||||
|
message.set_visible(false);
|
||||||
|
let label = gtk::Label::new(None);
|
||||||
|
label.add_css_class("title-2");
|
||||||
|
message.append(&label);
|
||||||
|
(message, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigation_error_message(forward: bool) -> &'static str {
|
||||||
|
if forward {
|
||||||
|
"더 이상 다음 파일이 없습니다."
|
||||||
|
} else {
|
||||||
|
"더 이상 이전 파일이 없습니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn navigation_scale_row(scale: >k::Scale) -> gtk::Box {
|
fn navigation_scale_row(scale: >k::Scale) -> gtk::Box {
|
||||||
let row = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
let row = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||||
row.set_height_request(42);
|
row.set_height_request(42);
|
||||||
|
|
@ -1591,4 +1706,16 @@ mod tests {
|
||||||
assert_eq!(sibling_index(0, 3, false), None);
|
assert_eq!(sibling_index(0, 3, false), None);
|
||||||
assert_eq!(sibling_index(2, 3, true), None);
|
assert_eq!(sibling_index(2, 3, true), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_boundary_messages_match_direction() {
|
||||||
|
assert_eq!(
|
||||||
|
navigation_error_message(false),
|
||||||
|
"더 이상 이전 파일이 없습니다."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
navigation_error_message(true),
|
||||||
|
"더 이상 다음 파일이 없습니다."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
195
src/window.rs
195
src/window.rs
|
|
@ -22,7 +22,7 @@ use crate::source::{
|
||||||
};
|
};
|
||||||
use crate::viewer::{
|
use crate::viewer::{
|
||||||
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator,
|
FavoriteControl, FileDownloadControl, FileViewerControls, MediaViewerWindow, SiblingNavigator,
|
||||||
ViewModeControl, ViewerWindow,
|
SiblingTarget, ViewModeControl, ViewerWindow,
|
||||||
};
|
};
|
||||||
use crate::zip_cache::ZipCache;
|
use crate::zip_cache::ZipCache;
|
||||||
|
|
||||||
|
|
@ -94,6 +94,8 @@ impl ComicWindow {
|
||||||
.build();
|
.build();
|
||||||
let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]);
|
let sort_dropdown = gtk::DropDown::from_strings(&["이름", "수정 시간", "생성 시간"]);
|
||||||
let direction_button = gtk::ToggleButton::with_label("오름차순");
|
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()
|
let fast_loading_switch = gtk::Switch::builder()
|
||||||
.active(config.fast_image_loading)
|
.active(config.fast_image_loading)
|
||||||
.tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지")
|
.tooltip_text("더 많은 페이지를 미리 디코딩하여 메모리에 유지")
|
||||||
|
|
@ -101,6 +103,7 @@ impl ComicWindow {
|
||||||
controls.append(&search_entry);
|
controls.append(&search_entry);
|
||||||
controls.append(&sort_dropdown);
|
controls.append(&sort_dropdown);
|
||||||
controls.append(&direction_button);
|
controls.append(&direction_button);
|
||||||
|
controls.append(&case_insensitive_check);
|
||||||
controls.append(>k::Label::new(Some("빠른 이미지 로딩")));
|
controls.append(>k::Label::new(Some("빠른 이미지 로딩")));
|
||||||
controls.append(&fast_loading_switch);
|
controls.append(&fast_loading_switch);
|
||||||
|
|
||||||
|
|
@ -157,6 +160,7 @@ impl ComicWindow {
|
||||||
refresh_button,
|
refresh_button,
|
||||||
up_button,
|
up_button,
|
||||||
add_location_button,
|
add_location_button,
|
||||||
|
case_insensitive_check,
|
||||||
fast_loading_switch,
|
fast_loading_switch,
|
||||||
);
|
);
|
||||||
controller.refresh();
|
controller.refresh();
|
||||||
|
|
@ -208,6 +212,7 @@ impl BrowserController {
|
||||||
refresh_button: gtk::Button,
|
refresh_button: gtk::Button,
|
||||||
up_button: gtk::Button,
|
up_button: gtk::Button,
|
||||||
add_location_button: gtk::Button,
|
add_location_button: gtk::Button,
|
||||||
|
case_insensitive_check: gtk::CheckButton,
|
||||||
fast_loading_switch: gtk::Switch,
|
fast_loading_switch: gtk::Switch,
|
||||||
) {
|
) {
|
||||||
let controller = Rc::clone(self);
|
let controller = Rc::clone(self);
|
||||||
|
|
@ -246,6 +251,10 @@ impl BrowserController {
|
||||||
self.direction_button
|
self.direction_button
|
||||||
.connect_toggled(move |_| controller.change_sort_direction());
|
.connect_toggled(move |_| controller.change_sort_direction());
|
||||||
let controller = Rc::clone(self);
|
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
|
self.list_box
|
||||||
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
|
.connect_row_activated(move |_, row| controller.activate_row(row.index()));
|
||||||
|
|
||||||
|
|
@ -406,7 +415,7 @@ impl BrowserController {
|
||||||
etag: None,
|
etag: None,
|
||||||
};
|
};
|
||||||
let siblings = Rc::new(vec![entry.clone()]);
|
let siblings = Rc::new(vec![entry.clone()]);
|
||||||
controller.open_viewable(entry, siblings, 0, favorite.profile, None);
|
controller.open_viewable(entry, siblings, favorite.profile, None);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -502,6 +511,7 @@ impl BrowserController {
|
||||||
self.list_box.remove(&child);
|
self.list_box.remove(&child);
|
||||||
}
|
}
|
||||||
let query = self.search_entry.text().to_lowercase();
|
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 mut state = self.state.borrow_mut();
|
||||||
let mut visible_entries: Vec<_> = state
|
let mut visible_entries: Vec<_> = state
|
||||||
.entries
|
.entries
|
||||||
|
|
@ -509,7 +519,12 @@ impl BrowserController {
|
||||||
.filter(|entry| entry.name.to_lowercase().contains(query.as_str()))
|
.filter(|entry| entry.name.to_lowercase().contains(query.as_str()))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
sort_entries(&mut visible_entries, state.sort_field, state.sort_direction);
|
sort_entries(
|
||||||
|
&mut visible_entries,
|
||||||
|
state.sort_field,
|
||||||
|
state.sort_direction,
|
||||||
|
case_insensitive,
|
||||||
|
);
|
||||||
for entry in &visible_entries {
|
for entry in &visible_entries {
|
||||||
self.list_box.append(&entry_row(entry));
|
self.list_box.append(&entry_row(entry));
|
||||||
}
|
}
|
||||||
|
|
@ -631,20 +646,20 @@ impl BrowserController {
|
||||||
}
|
}
|
||||||
EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => {
|
EntryKind::ZipArchive | EntryKind::Image | EntryKind::Video => {
|
||||||
let current_kind = entry.kind;
|
let current_kind = entry.kind;
|
||||||
|
let case_insensitive = self.config.borrow().case_insensitive_sort;
|
||||||
let siblings = {
|
let siblings = {
|
||||||
let state = self.state.borrow();
|
let state = self.state.borrow();
|
||||||
let mut entries = state
|
sorted_navigable_entries(
|
||||||
.entries
|
&state.entries,
|
||||||
.iter()
|
current_kind,
|
||||||
.filter(|entry| navigable_sibling(current_kind, entry.kind))
|
state.sort_field,
|
||||||
.cloned()
|
state.sort_direction,
|
||||||
.collect::<Vec<_>>();
|
case_insensitive,
|
||||||
sort_entries(&mut entries, state.sort_field, state.sort_direction);
|
)
|
||||||
entries
|
|
||||||
};
|
};
|
||||||
let siblings = Rc::new(siblings);
|
let siblings = Rc::new(siblings);
|
||||||
if let Some(current) = siblings.iter().position(|item| item.uri == entry.uri) {
|
if siblings.iter().any(|item| item.uri == entry.uri) {
|
||||||
self.open_viewable(entry, siblings, current, self.current_profile(), None);
|
self.open_viewable(entry, siblings, self.current_profile(), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EntryKind::OtherFile => {}
|
EntryKind::OtherFile => {}
|
||||||
|
|
@ -655,14 +670,11 @@ impl BrowserController {
|
||||||
self: &Rc<Self>,
|
self: &Rc<Self>,
|
||||||
entry: BrowserEntry,
|
entry: BrowserEntry,
|
||||||
siblings: Rc<Vec<BrowserEntry>>,
|
siblings: Rc<Vec<BrowserEntry>>,
|
||||||
current: usize,
|
|
||||||
profile: LocationProfile,
|
profile: LocationProfile,
|
||||||
reuse_window: Option<gtk::ApplicationWindow>,
|
reuse_window: Option<gtk::ApplicationWindow>,
|
||||||
) {
|
) {
|
||||||
match entry.kind {
|
match entry.kind {
|
||||||
EntryKind::ZipArchive => {
|
EntryKind::ZipArchive => self.open_archive(entry, siblings, profile, reuse_window),
|
||||||
self.open_archive(entry, siblings, current, profile, reuse_window)
|
|
||||||
}
|
|
||||||
EntryKind::Image | EntryKind::Video => {
|
EntryKind::Image | EntryKind::Video => {
|
||||||
let media_siblings = Rc::new(
|
let media_siblings = Rc::new(
|
||||||
siblings
|
siblings
|
||||||
|
|
@ -671,11 +683,11 @@ impl BrowserController {
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
if let Some(current) = media_siblings
|
if media_siblings
|
||||||
.iter()
|
.iter()
|
||||||
.position(|sibling| sibling.uri == entry.uri)
|
.any(|sibling| sibling.uri == entry.uri)
|
||||||
{
|
{
|
||||||
self.open_media(entry, media_siblings, current, profile, reuse_window);
|
self.open_media(entry, media_siblings, profile, reuse_window);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -684,39 +696,79 @@ impl BrowserController {
|
||||||
|
|
||||||
fn sibling_navigator(
|
fn sibling_navigator(
|
||||||
self: &Rc<Self>,
|
self: &Rc<Self>,
|
||||||
|
entry: &BrowserEntry,
|
||||||
siblings: Rc<Vec<BrowserEntry>>,
|
siblings: Rc<Vec<BrowserEntry>>,
|
||||||
current: usize,
|
|
||||||
profile: LocationProfile,
|
profile: LocationProfile,
|
||||||
) -> SiblingNavigator {
|
) -> SiblingNavigator {
|
||||||
|
let current_uri = entry.uri.clone();
|
||||||
|
let current_kind = entry.kind;
|
||||||
|
let position_controller = Rc::downgrade(self);
|
||||||
|
let position_uri = current_uri.clone();
|
||||||
|
let position_fallback = Rc::clone(&siblings);
|
||||||
|
let position = Rc::new(move || {
|
||||||
|
let controller = position_controller.upgrade()?;
|
||||||
|
let siblings =
|
||||||
|
controller.navigation_siblings(&position_uri, current_kind, &position_fallback);
|
||||||
|
siblings
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.uri == position_uri)
|
||||||
|
.map(|current| (current, siblings.len()))
|
||||||
|
});
|
||||||
|
|
||||||
let controller = Rc::downgrade(self);
|
let controller = Rc::downgrade(self);
|
||||||
let count = siblings.len();
|
let navigate = Rc::new(
|
||||||
let open = Rc::new(move |next: usize, reuse_window: gtk::ApplicationWindow| {
|
move |target: SiblingTarget, reuse_window: gtk::ApplicationWindow| {
|
||||||
let Some(controller) = controller.upgrade() else {
|
let Some(controller) = controller.upgrade() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if next >= siblings.len() {
|
let siblings =
|
||||||
controller
|
controller.navigation_siblings(¤t_uri, current_kind, &siblings);
|
||||||
.status_label
|
let Some(current) = siblings.iter().position(|entry| entry.uri == current_uri)
|
||||||
.set_text("더 이상 이동할 파일이 없습니다.");
|
else {
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
|
let Some(next) = target.resolve(current, siblings.len()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
controller.open_viewable(
|
controller.open_viewable(
|
||||||
siblings[next].clone(),
|
siblings[next].clone(),
|
||||||
Rc::clone(&siblings),
|
Rc::clone(&siblings),
|
||||||
next,
|
|
||||||
profile.clone(),
|
profile.clone(),
|
||||||
Some(reuse_window),
|
Some(reuse_window),
|
||||||
);
|
);
|
||||||
true
|
true
|
||||||
});
|
},
|
||||||
SiblingNavigator::new(current, count, open)
|
);
|
||||||
|
SiblingNavigator::new(position, navigate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigation_siblings(
|
||||||
|
&self,
|
||||||
|
current_uri: &str,
|
||||||
|
current_kind: EntryKind,
|
||||||
|
fallback: &Rc<Vec<BrowserEntry>>,
|
||||||
|
) -> Rc<Vec<BrowserEntry>> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
drop(state);
|
||||||
|
if entries.iter().any(|entry| entry.uri == current_uri) {
|
||||||
|
Rc::new(entries)
|
||||||
|
} else {
|
||||||
|
Rc::clone(fallback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_archive(
|
fn open_archive(
|
||||||
self: &Rc<Self>,
|
self: &Rc<Self>,
|
||||||
entry: BrowserEntry,
|
entry: BrowserEntry,
|
||||||
siblings: Rc<Vec<BrowserEntry>>,
|
siblings: Rc<Vec<BrowserEntry>>,
|
||||||
current: usize,
|
|
||||||
profile: LocationProfile,
|
profile: LocationProfile,
|
||||||
reuse_window: Option<gtk::ApplicationWindow>,
|
reuse_window: Option<gtk::ApplicationWindow>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -727,7 +779,7 @@ impl BrowserController {
|
||||||
);
|
);
|
||||||
let result_label = progress_label.clone();
|
let result_label = progress_label.clone();
|
||||||
let controller = Rc::clone(self);
|
let controller = Rc::clone(self);
|
||||||
let navigator = self.sibling_navigator(siblings, current, profile.clone());
|
let navigator = self.sibling_navigator(&entry, siblings, profile.clone());
|
||||||
let favorite_control = self.favorite_control(&entry, &profile);
|
let favorite_control = self.favorite_control(&entry, &profile);
|
||||||
let download_control = self.download_control(&entry);
|
let download_control = self.download_control(&entry);
|
||||||
let view_mode_control = self.view_mode_control();
|
let view_mode_control = self.view_mode_control();
|
||||||
|
|
@ -749,6 +801,7 @@ impl BrowserController {
|
||||||
source,
|
source,
|
||||||
Rc::clone(&controller.database),
|
Rc::clone(&controller.database),
|
||||||
controller.config.borrow().fast_image_loading,
|
controller.config.borrow().fast_image_loading,
|
||||||
|
controller.config.borrow().case_insensitive_sort,
|
||||||
view_mode_control,
|
view_mode_control,
|
||||||
FileViewerControls {
|
FileViewerControls {
|
||||||
sibling_navigator: Some(navigator),
|
sibling_navigator: Some(navigator),
|
||||||
|
|
@ -771,7 +824,6 @@ impl BrowserController {
|
||||||
self: &Rc<Self>,
|
self: &Rc<Self>,
|
||||||
entry: BrowserEntry,
|
entry: BrowserEntry,
|
||||||
siblings: Rc<Vec<BrowserEntry>>,
|
siblings: Rc<Vec<BrowserEntry>>,
|
||||||
current: usize,
|
|
||||||
profile: LocationProfile,
|
profile: LocationProfile,
|
||||||
reuse_window: Option<gtk::ApplicationWindow>,
|
reuse_window: Option<gtk::ApplicationWindow>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -784,7 +836,7 @@ impl BrowserController {
|
||||||
);
|
);
|
||||||
let result_label = progress_label.clone();
|
let result_label = progress_label.clone();
|
||||||
let controller = Rc::clone(self);
|
let controller = Rc::clone(self);
|
||||||
let navigator = self.sibling_navigator(siblings, current, profile.clone());
|
let navigator = self.sibling_navigator(&entry, siblings, profile.clone());
|
||||||
let favorite_control = self.favorite_control(&entry, &profile);
|
let favorite_control = self.favorite_control(&entry, &profile);
|
||||||
let download_control = self.download_control(&entry);
|
let download_control = self.download_control(&entry);
|
||||||
self.zip_cache.prepare_media(
|
self.zip_cache.prepare_media(
|
||||||
|
|
@ -1050,6 +1102,27 @@ impl BrowserController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
fn set_fast_image_loading(&self, switch: >k::Switch) {
|
||||||
let active = switch.is_active();
|
let active = switch.is_active();
|
||||||
let previous = self.config.borrow().fast_image_loading;
|
let previous = self.config.borrow().fast_image_loading;
|
||||||
|
|
@ -1339,6 +1412,22 @@ fn navigable_sibling(current: EntryKind, candidate: EntryKind) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sorted_navigable_entries(
|
||||||
|
entries: &[BrowserEntry],
|
||||||
|
current_kind: EntryKind,
|
||||||
|
sort_field: SortField,
|
||||||
|
sort_direction: SortDirection,
|
||||||
|
case_insensitive: bool,
|
||||||
|
) -> Vec<BrowserEntry> {
|
||||||
|
let mut siblings = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| navigable_sibling(current_kind, entry.kind))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
sort_entries(&mut siblings, sort_field, sort_direction, case_insensitive);
|
||||||
|
siblings
|
||||||
|
}
|
||||||
|
|
||||||
fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry {
|
fn favorite_browser_entry(favorite: &FavoriteEntry) -> BrowserEntry {
|
||||||
BrowserEntry {
|
BrowserEntry {
|
||||||
uri: favorite.uri.clone(),
|
uri: favorite.uri.clone(),
|
||||||
|
|
@ -1432,6 +1521,12 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn media_entry(name: &str) -> BrowserEntry {
|
||||||
|
let mut entry = entry(name);
|
||||||
|
entry.kind = EntryKind::Image;
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typeahead_matches_case_insensitively() {
|
fn typeahead_matches_case_insensitively() {
|
||||||
let entries = [entry("Alpha"), entry("beta"), entry("Bravo")];
|
let entries = [entry("Alpha"), entry("beta"), entry("Bravo")];
|
||||||
|
|
@ -1454,6 +1549,36 @@ mod tests {
|
||||||
assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile));
|
assert!(!navigable_sibling(EntryKind::Video, EntryKind::OtherFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
fn favorites_are_rendered_as_browser_paths_and_files() {
|
fn favorites_are_rendered_as_browser_paths_and_files() {
|
||||||
let profile = home_profile();
|
let profile = home_profile();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue