From 05e4187999b4e532ddecfc4078f40aa2d17088ce Mon Sep 17 00:00:00 2001 From: "villoh.contact" Date: Sat, 17 Oct 2026 21:17:32 +0000 Subject: [PATCH 1/2] refactor(arca-gui): make the active pane the only copy of its state Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- arca-gui/src/controller/disk.rs | 52 ++++---- arca-gui/src/controller/disk_tests.rs | 22 +-- arca-gui/src/controller/mod.rs | 109 +++++++-------- arca-gui/src/controller/panes.rs | 170 +++++++++++------------- arca-gui/src/controller/preview.rs | 35 ++--- arca-gui/src/controller/rar_tests.rs | 2 +- arca-gui/src/controller/sevenz_tests.rs | 4 +- arca-gui/src/controller/state.rs | 9 +- arca-gui/src/ui/browser.rs | 20 +-- arca-gui/src/ui/dialogs.rs | 4 +- arca-gui/src/ui/header.rs | 12 +- arca-gui/src/ui/mod.rs | 62 ++++++--- arca-gui/src/ui/pathbar.rs | 2 +- arca-gui/src/ui/preview.rs | 17 ++- arca-gui/src/ui/sidebar.rs | 4 +- 15 files changed, 275 insertions(+), 249 deletions(-) diff --git a/arca-gui/src/controller/disk.rs b/arca-gui/src/controller/disk.rs index 635100ea..3e16323d 100644 --- a/arca-gui/src/controller/disk.rs +++ b/arca-gui/src/controller/disk.rs @@ -774,7 +774,7 @@ impl AppController { pub(crate) fn create_disk_folder(&mut self, name: &str) -> Option { let s = self.s(); - let dir = format!("{}{name}/", self.state.current_dir); + let dir = format!("{}{name}/", self.state.active_pane().directory); let path = disk_path(&dir); if path.exists() { self.state.notice = fill(s.name_taken, &[("name", name)]); @@ -810,7 +810,7 @@ impl AppController { .unwrap_or_default(), many => fill(s.items_carried, &[("n", &many.len().to_string())]), }; - let here = disk_path(&self.state.current_dir); + let here = disk_path(&self.state.active_pane().directory); let sources = picked.clone(); let task = Task::new( if trash { @@ -870,7 +870,7 @@ impl AppController { .unwrap_or_default(), many => fill(s.items_carried, &[("n", &many.len().to_string())]), }; - let here = disk_path(&self.state.current_dir); + let here = disk_path(&self.state.active_pane().directory); let sources = picked.clone(); let task = Task::new( s.duplicating_files, @@ -1066,7 +1066,7 @@ impl AppController { pub(crate) fn folder_total(&self) -> usize { if self.on_disk() { - children_of(&self.state.entries, &self.state.current_dir).len() + children_of(&self.state.entries, &self.state.active_pane().directory).len() } else { self.state.entries.len() } @@ -1078,12 +1078,12 @@ impl AppController { } pub(crate) fn can_go_up(&self) -> bool { - !self.state.current_dir.is_empty() || self.can_close_archive() + !self.state.active_pane().directory.is_empty() || self.can_close_archive() } pub(crate) fn go_up(&mut self) { - if !self.state.current_dir.is_empty() { - self.go_to(parent_of(&self.state.current_dir)); + if !self.state.active_pane().directory.is_empty() { + self.go_to(parent_of(&self.state.active_pane().directory)); } else if self.can_close_archive() { self.close_archive(); } @@ -1116,8 +1116,8 @@ impl AppController { self.state.archive = None; self.state.origin = None; self.state.entries.clear(); - self.state.checked.clear(); - self.state.cursor = None; + self.state.active_pane_mut().checked.clear(); + self.state.active_pane_mut().cursor = None; self.state.folders = tree::folders_of(&[]); self.state.history.clear(); self.state.here = 0; @@ -1136,7 +1136,7 @@ impl AppController { }; // The panes still point into the archive that was open; rebuilding // them from that path would try to read it off the disk. - self.state.current_dir.clear(); + self.state.active_pane_mut().directory.clear(); self.reset_browser_panes(); self.navigate_panes(dir); self.record_pane_history(); @@ -1149,7 +1149,8 @@ impl AppController { } let leaf = self .state - .current_dir + .active_pane() + .directory .trim_end_matches('/') .rsplit('/') .next() @@ -1196,7 +1197,8 @@ impl AppController { disk.loaded.insert(ancestor); } } - self.state.checked.resize(self.state.entries.len(), false); + let entries = self.state.entries.len(); + self.state.active_pane_mut().checked.resize(entries, false); } /// Reads the folders being shown again, for a disk that changed under @@ -1205,16 +1207,17 @@ impl AppController { if !self.on_disk() { return; } - self.snapshot_active_pane(); - let dir = self.state.current_dir.clone(); + let dir = self.state.active_pane().directory.clone(); let remembered: Vec<(Vec, Option)> = (0..self.state.browser.panes.len()) .map(|pane| { let rows = self.pane_rows(pane); let state = &self.state.browser.panes[pane]; let selected = state - .selected + .checked .iter() - .filter_map(|i| self.state.entries.get(*i)) + .enumerate() + .filter(|(_, on)| **on) + .filter_map(|(i, _)| self.state.entries.get(i)) .map(|entry| entry.name.clone()) .collect(); let cursor = state @@ -1225,7 +1228,7 @@ impl AppController { }) .collect(); self.state.entries.clear(); - self.state.checked.clear(); + self.state.active_pane_mut().checked.clear(); self.state.disk = Some(DiskState::default()); self.load_disk_ancestors(&dir); let by_name: HashMap = self @@ -1235,20 +1238,17 @@ impl AppController { .enumerate() .map(|(i, entry)| (entry.name.clone(), i)) .collect(); + let entries = self.state.entries.len(); for (pane, (selected, cursor)) in remembered.into_iter().enumerate() { let rows = self.pane_rows(pane); - let state = &mut self.state.browser.panes[pane]; - state.selected = selected + let picked: HashSet = selected .iter() .filter_map(|name| by_name.get(name).copied()) .collect(); + let state = &mut self.state.browser.panes[pane]; + state.checked = (0..entries).map(|i| picked.contains(&i)).collect(); state.cursor = cursor.and_then(|path| rows.iter().position(|row| row.path == path)); } - let active = &self.state.browser.panes[self.state.browser.active]; - self.state.cursor = active.cursor; - self.state.checked = (0..self.state.entries.len()) - .map(|i| active.selected.contains(&i)) - .collect(); self.navigate_panes(dir); } @@ -1256,7 +1256,7 @@ impl AppController { if !self.on_disk() { return; } - let path = disk_path(&self.state.current_dir); + let path = disk_path(&self.state.active_pane().directory); let text = (!path.as_os_str().is_empty()).then(|| path.to_string_lossy().to_string()); if self.state.settings.last_folder != text { self.state.settings.last_folder = text; @@ -1278,7 +1278,7 @@ impl AppController { } if detect(&path).is_some() { let origin = Origin { - directory: self.state.current_dir.clone(), + directory: self.state.active_pane().directory.clone(), path: entry.name.clone(), }; self.open(path); diff --git a/arca-gui/src/controller/disk_tests.rs b/arca-gui/src/controller/disk_tests.rs index 1dbbc614..6cc3f380 100644 --- a/arca-gui/src/controller/disk_tests.rs +++ b/arca-gui/src/controller/disk_tests.rs @@ -78,7 +78,7 @@ fn browsing_lists_folders_as_they_are_entered() { app.browse_disk(&room.0); assert!(app.on_disk()); assert!(!app.state.busy); - assert_eq!(app.state.current_dir, disk_dir(&room.0)); + assert_eq!(app.state.active_pane().directory, disk_dir(&room.0)); assert_eq!(names(&app), vec!["nested", "top.txt"]); let nested = disk_dir(&room.path("nested")); @@ -98,7 +98,7 @@ fn browsing_lists_folders_as_they_are_entered() { assert_eq!(names(&app), vec!["deeper", "note.txt"]); assert!(app.can_go_up()); app.go_up(); - assert_eq!(app.state.current_dir, disk_dir(&room.0)); + assert_eq!(app.state.active_pane().directory, disk_dir(&room.0)); assert_eq!(names(&app), vec!["nested", "top.txt"]); assert_eq!( app.state @@ -153,7 +153,7 @@ fn an_archive_on_disk_opens_in_place_and_closes_back_onto_its_row() { app.go_up(); assert!(app.on_disk()); assert!(app.state.archive.is_none()); - assert_eq!(app.state.current_dir, disk_dir(&room.0)); + assert_eq!(app.state.active_pane().directory, disk_dir(&room.0)); let cursor = app.state.browser.panes[app.state.browser.active].cursor; let rows = app.visible_rows(); let under_cursor = cursor @@ -183,7 +183,7 @@ fn a_folder_opened_from_the_disk_is_remembered_for_the_next_start() { let mut next = AppController::new(settings); next.start_browsing(); assert!(next.on_disk()); - assert_eq!(next.state.current_dir, nested); + assert_eq!(next.state.active_pane().directory, nested); let gone = Settings { last_folder: Some(room.path("missing").to_string_lossy().to_string()), @@ -192,7 +192,10 @@ fn a_folder_opened_from_the_disk_is_remembered_for_the_next_start() { let mut fallback = AppController::new(gone); fallback.start_browsing(); assert!(fallback.on_disk()); - assert_ne!(fallback.state.current_dir, disk_dir(&room.path("missing"))); + assert_ne!( + fallback.state.active_pane().directory, + disk_dir(&room.path("missing")) + ); } #[test] @@ -306,15 +309,18 @@ fn refreshing_the_disk_keeps_the_selection_by_name() { .expect("top.txt listed"); app.set_pane_cursor(app.state.browser.active, Some(top)); let entry = rows[top].entry.expect("a file row has an entry"); - app.state.checked[entry] = true; - app.snapshot_active_pane(); + app.state.active_pane_mut().checked[entry] = true; assert_eq!(app.selected_disk_paths(), vec![room.path("top.txt")]); std::fs::write(room.path("aaa.txt"), b"sorts first").unwrap(); app.refresh_disk(); assert_eq!(app.selected_disk_paths(), vec![room.path("top.txt")]); let rows = app.visible_rows(); - let cursor = app.state.cursor.expect("cursor survives a refresh"); + let cursor = app + .state + .active_pane() + .cursor + .expect("cursor survives a refresh"); assert_eq!(rows[cursor].label, "top.txt"); assert!(names(&app).contains(&"aaa.txt".to_string())); } diff --git a/arca-gui/src/controller/mod.rs b/arca-gui/src/controller/mod.rs index ab7ff232..9aad6fad 100644 --- a/arca-gui/src/controller/mod.rs +++ b/arca-gui/src/controller/mod.rs @@ -278,8 +278,8 @@ impl AppController { } pub(crate) fn sort_by(&mut self, column: SortColumn) { - let order = if self.state.order.0 == column { - (column, !self.state.order.1) + let order = if self.state.active_pane().order.0 == column { + (column, !self.state.active_pane().order.1) } else { (column, true) }; @@ -300,7 +300,7 @@ impl AppController { dest = dest.join(archive_stem(&archive)); } let wanted = if only_checked { - self.state.checked.clone() + self.state.active_pane().checked.clone() } else { vec![true; self.state.entries.len()] }; @@ -550,7 +550,6 @@ impl AppController { for row in self.visible_rows() { self.set_checked_projection(&row, true); } - self.snapshot_active_pane(); } pub(crate) fn invert_visible(&mut self) { let rows = self.visible_rows(); @@ -559,7 +558,6 @@ impl AppController { for (r, v) in rows.iter().zip(values) { self.set_checked_projection(r, v); } - self.snapshot_active_pane(); } pub(crate) fn request_delete(&mut self) { if !self.can_change() { @@ -614,9 +612,6 @@ impl AppController { settings, archive: None, entries: Vec::new(), - checked: Vec::new(), - filter: String::new(), - order: (SortColumn::Name, true), channel: None, notice: String::new(), error: false, @@ -640,7 +635,6 @@ impl AppController { window_title: "Arca".to_string(), disk: None, origin: None, - current_dir: String::new(), show_settings: false, tasks: Vec::new(), next_task: 0, @@ -655,7 +649,6 @@ impl AppController { reread_queued: None, history: vec![String::new()], here: 0, - cursor: None, confirm_delete: None, clip_dir: None, cut_names: HashSet::new(), @@ -688,7 +681,7 @@ impl AppController { pub(crate) fn summary(&self) -> String { let s = self.s(); if self.on_disk() { - let rows = children_of(&self.state.entries, &self.state.current_dir); + let rows = children_of(&self.state.entries, &self.state.active_pane().directory); let raw: u64 = rows .iter() .filter(|row| !row.is_dir) @@ -731,8 +724,13 @@ impl AppController { } } pub(crate) fn selected_roots(&self) -> Vec { - self.selection_roots(&self.state.current_dir, |index| { - self.state.checked.get(index).copied().unwrap_or(false) + self.selection_roots(&self.state.active_pane().directory, |index| { + self.state + .active_pane() + .checked + .get(index) + .copied() + .unwrap_or(false) }) } @@ -743,7 +741,7 @@ impl AppController { let Some(pane) = self.state.browser.panes.get(pane) else { return Vec::new(); }; - self.selection_roots(&pane.directory, |index| pane.selected.contains(&index)) + self.selection_roots(&pane.directory, |index| pane.is_checked(index)) } fn selection_roots(&self, directory: &str, checked: impl Fn(usize) -> bool) -> Vec { @@ -805,11 +803,14 @@ impl AppController { // for good the moment somebody changed their mind. pub(crate) fn clear_picked(&mut self) { self.cancel_preview(); - self.state.checked.iter_mut().for_each(|c| *c = false); - self.state.cursor = None; + self.state + .active_pane_mut() + .checked + .iter_mut() + .for_each(|c| *c = false); + self.state.active_pane_mut().cursor = None; // A row number means something else in the folder now on screen. self.state.last_click = None; - self.snapshot_active_pane(); } pub(crate) fn go_forward(&mut self) { if self.can_go_forward() { @@ -831,7 +832,7 @@ impl AppController { self.state .entries .iter() - .zip(&self.state.checked) + .zip(&self.state.active_pane().checked) .filter(|(_, &on)| on) .map(|(e, _)| e.name.clone()) .collect() @@ -898,8 +899,8 @@ impl AppController { } self.state.folders = tree::folders_of(&self.state.entries); self.clear_picked(); - self.state.cursor = None; - self.state.current_dir.clear(); + self.state.active_pane_mut().cursor = None; + self.state.active_pane_mut().directory.clear(); self.state.history = vec![String::new()]; self.state.here = 0; self.reset_browser_panes(); @@ -1102,9 +1103,8 @@ impl AppController { self.go_to(parent_of(&normalized_dir(path))); self.clear_picked(); for i in entries_under(&self.state.entries, path) { - self.state.checked[i] = true; + self.state.active_pane_mut().checked[i] = true; } - self.snapshot_active_pane(); } pub(crate) fn set_checked(&mut self, row: &Row, value: bool) { @@ -1119,16 +1119,14 @@ impl AppController { } self.cancel_preview(); self.set_checked_projection(row, value); - self.snapshot_active_pane(); } pub(crate) fn select_only(&mut self, row: &Row) { self.cancel_preview(); - self.state.checked.fill(false); + self.state.active_pane_mut().checked.fill(false); if !row.up && self.is_visible(row) { self.set_checked_projection(row, true); } - self.snapshot_active_pane(); } fn is_visible(&self, row: &Row) -> bool { @@ -1143,13 +1141,13 @@ impl AppController { } match row.entry { Some(i) => { - if let Some(on) = self.state.checked.get_mut(i) { + if let Some(on) = self.state.active_pane_mut().checked.get_mut(i) { *on = value; } } None => { for i in entries_under(&self.state.entries, &row.path) { - self.state.checked[i] = value; + self.state.active_pane_mut().checked[i] = value; } } } @@ -1161,11 +1159,11 @@ impl AppController { // window as everything else. #[cfg(windows)] pub(crate) fn dragged_files(&self) -> Vec<(Entry, String)> { - let base = &self.state.current_dir; + let base = &self.state.active_pane().directory; self.state .entries .iter() - .zip(&self.state.checked) + .zip(&self.state.active_pane().checked) .filter(|(e, &on)| on && !e.is_dir) .map(|(e, _)| { let full = e.name.replace('\\', "/"); @@ -1352,7 +1350,7 @@ impl AppController { self.run_job(Job::Add { archive, inputs, - dir: self.state.current_dir.clone(), + dir: self.state.active_pane().directory.clone(), codec: self.state.codec, level: self.state.level, password: self.state.archive_password.clone(), @@ -1423,7 +1421,7 @@ impl AppController { // before it and takes the clipboard over, so it runs after it. let clipboard = temp.join("Arca").join("clipboard"); - let wanted = self.state.checked.clone(); + let wanted = self.state.active_pane().checked.clone(); let total = wanted.iter().filter(|b| **b).count(); let pw = self.state.archive_password.clone(); self.state.close_when_done = false; @@ -1599,7 +1597,7 @@ impl AppController { self.state.archive_password = None; self.state.waiting_on_password = Some(Pending::OpenArchive); } - self.state.checked = vec![false; v.len()]; + self.state.active_pane_mut().checked = vec![false; v.len()]; self.state.folders = tree::folders_of(&v); self.state.entries = v; if let Some(f) = detect(&path) { @@ -1623,11 +1621,11 @@ impl AppController { let restore_dir = self.state.reread_dir.take(); self.state.history = vec![String::new()]; self.state.here = 0; - self.state.current_dir = restore_dir + self.state.active_pane_mut().directory = restore_dir .map(|dir| nearest_existing_dir(&self.state.entries, &dir)) .unwrap_or_default(); - if !self.state.current_dir.is_empty() { - self.state.history = vec![self.state.current_dir.clone()]; + if !self.state.active_pane().directory.is_empty() { + self.state.history = vec![self.state.active_pane().directory.clone()]; } self.reset_browser_panes(); self.state.busy = false; @@ -1708,18 +1706,18 @@ impl AppController { } pub(crate) fn row_root(&self) -> &str { - if self.flat() || self.state.filter.trim().is_empty() { + if self.flat() || self.state.active_pane().filter.trim().is_empty() { "" } else { - &self.state.current_dir + &self.state.active_pane().directory } } pub(crate) fn visible_rows(&self) -> Vec { self.rows_for( - &self.state.current_dir, - &self.state.filter, - self.state.order, + &self.state.active_pane().directory, + &self.state.active_pane().filter, + self.state.active_pane().order, self.flat(), !self.state.browser.columns, ) @@ -1946,7 +1944,7 @@ impl AppController { } if self.on_disk() { self.state.origin = Some(Origin { - directory: self.state.current_dir.clone(), + directory: self.state.active_pane().directory.clone(), path: String::new(), }); } @@ -1956,13 +1954,13 @@ impl AppController { self.state.password_wrong = false; self.state.reread_dir = None; self.state.entries.clear(); - self.state.checked.clear(); + self.state.active_pane_mut().checked.clear(); self.state.folders = tree::Folder::default(); - self.state.current_dir.clear(); + self.state.active_pane_mut().directory.clear(); self.state.history = vec![String::new()]; self.state.here = 0; - self.state.cursor = None; - self.state.filter.clear(); + self.state.active_pane_mut().cursor = None; + self.state.active_pane_mut().filter.clear(); self.state.viewing = None; self.state.confirm_delete = None; self.state.confirm_drop = None; @@ -2003,7 +2001,7 @@ impl AppController { return; } if let Some(path) = self.state.archive.clone() { - let dir = self.state.current_dir.clone(); + let dir = self.state.active_pane().directory.clone(); self.open_with_password(path, self.state.archive_password.clone()); self.state.reread_dir = Some(dir); } @@ -2047,7 +2045,7 @@ impl AppController { self.state.notice = self.s().only_zip_can_change.into(); return; } - if reread_target(&job, &self.state.current_dir).is_some() { + if reread_target(&job, &self.state.active_pane().directory).is_some() { self.cancel_preview(); } if let Job::Test { @@ -2128,7 +2126,7 @@ impl AppController { let reread_after = if self.state.close_when_done { None } else { - reread_target(&job, &self.state.current_dir) + reread_target(&job, &self.state.active_pane().directory) }; // The ones that build the archive again leave the old one beside it. // What is kept here is the word for the change, so that offering to @@ -2211,7 +2209,14 @@ impl AppController { self.enqueue(task); } pub(crate) fn is_checked(&self, row: &Row) -> bool { - self.row_is_checked(row, |i| self.state.checked.get(i).copied().unwrap_or(false)) + self.row_is_checked(row, |i| { + self.state + .active_pane() + .checked + .get(i) + .copied() + .unwrap_or(false) + }) } fn row_is_checked(&self, row: &Row, selected: impl Fn(usize) -> bool) -> bool { @@ -2668,7 +2673,7 @@ mod password_tests { encrypted, zipcrypto: false, }]; - controller.state.checked = vec![false]; + controller.state.active_pane_mut().checked = vec![false]; controller } @@ -2778,8 +2783,8 @@ mod filter_tests { entry("Game/Data/level.dat"), entry("Other/redistributable.bin"), ]; - controller.state.current_dir = dir.to_string(); - controller.state.filter = filter.to_string(); + controller.state.active_pane_mut().directory = dir.to_string(); + controller.state.active_pane_mut().filter = filter.to_string(); controller .visible_rows() .into_iter() diff --git a/arca-gui/src/controller/panes.rs b/arca-gui/src/controller/panes.rs index ac287b2a..16dc36a2 100644 --- a/arca-gui/src/controller/panes.rs +++ b/arca-gui/src/controller/panes.rs @@ -4,7 +4,7 @@ use super::*; pub(crate) struct DirectoryPane { pub(crate) directory: String, pub(crate) cursor: Option, - pub(crate) selected: HashSet, + pub(crate) checked: Vec, pub(crate) scroll_offset: f32, pub(crate) filter: String, pub(crate) order: (SortColumn, bool), @@ -15,12 +15,16 @@ impl DirectoryPane { Self { directory, cursor: None, - selected: HashSet::new(), + checked: Vec::new(), scroll_offset: 0.0, filter: String::new(), order: (SortColumn::Name, true), } } + + pub(crate) fn is_checked(&self, index: usize) -> bool { + self.checked.get(index).copied().unwrap_or(false) + } } pub(crate) struct BrowserState { @@ -43,33 +47,22 @@ impl Default for BrowserState { } } -impl AppController { - // Legacy shell fields are authoritative only for the active pane. Inactive - // selections never flow into archive commands until explicitly activated. - pub(crate) fn snapshot_active_pane(&mut self) { - let pane = &mut self.state.browser.panes[self.state.browser.active]; - pane.directory = self.state.current_dir.clone(); - pane.cursor = self.state.cursor; - pane.filter = self.state.filter.clone(); - pane.order = self.state.order; - pane.selected = self - .state - .checked - .iter() - .enumerate() - .filter_map(|(i, on)| (*on && i < self.state.entries.len()).then_some(i)) - .collect(); +impl AppState { + pub(crate) fn active_pane(&self) -> &DirectoryPane { + &self.browser.panes[self.browser.active] } - fn restore_active_pane(&mut self) { - let pane = &self.state.browser.panes[self.state.browser.active]; - self.state.current_dir = pane.directory.clone(); - self.state.cursor = pane.cursor; - self.state.filter = pane.filter.clone(); - self.state.order = pane.order; - self.state.checked = (0..self.state.entries.len()) - .map(|i| pane.selected.contains(&i)) - .collect(); + pub(crate) fn active_pane_mut(&mut self) -> &mut DirectoryPane { + &mut self.browser.panes[self.browser.active] + } +} + +impl AppController { + // Selections of inactive panes never flow into archive commands until + // the pane is activated, which sizes its selection to the entries. + fn pane_switched(&mut self) { + let entries = self.state.entries.len(); + self.state.active_pane_mut().checked.resize(entries, false); self.state.last_click = None; self.state.renaming = None; self.cancel_preview(); @@ -106,10 +99,9 @@ impl AppController { if pane >= self.state.browser.panes.len() { return false; } - self.snapshot_active_pane(); if self.state.browser.active != pane { self.state.browser.active = pane; - self.restore_active_pane(); + self.pane_switched(); self.retain_visible_selection(); if remember { self.record_pane_history(); @@ -127,14 +119,11 @@ impl AppController { } pub(crate) fn pane_is_checked(&self, pane: usize, row: &Row) -> bool { - if pane == self.state.browser.active { - return self.is_checked(row); - } self.state .browser .panes .get(pane) - .is_some_and(|pane| self.row_is_checked(row, |i| pane.selected.contains(&i))) + .is_some_and(|pane| self.row_is_checked(row, |i| pane.is_checked(i))) } pub(crate) fn enter_folder_from_pane(&mut self, pane: usize, path: &str) -> bool { @@ -158,7 +147,6 @@ impl AppController { self.navigate_in_place(path); return; } - self.snapshot_active_pane(); self.load_disk_ancestors(&normalized_dir(&path)); let path = nearest_existing_dir(&self.state.entries, &path); let mut directories = vec![String::new()]; @@ -182,25 +170,23 @@ impl AppController { .panes .extend(directories.into_iter().skip(common).map(DirectoryPane::new)); self.state.browser.active = self.state.browser.panes.len() - 1; - self.restore_active_pane(); + self.pane_switched(); self.retain_visible_selection(); } /// In free panes, going somewhere moves only the active pane there. fn navigate_in_place(&mut self, path: String) { - self.snapshot_active_pane(); self.load_disk_ancestors(&normalized_dir(&path)); let path = nearest_existing_dir(&self.state.entries, &path); let active = self.state.browser.active; self.state.browser.panes[active] = DirectoryPane::new(path); - self.restore_active_pane(); + self.pane_switched(); self.retain_visible_selection(); } /// Opens `path` in a new pane right after the active one. pub(crate) fn open_in_pane(&mut self, path: &str) { let path = normalized_dir(path); - self.snapshot_active_pane(); self.load_disk_ancestors(&path); let path = nearest_existing_dir(&self.state.entries, &path); let at = self.state.browser.active + 1; @@ -209,7 +195,7 @@ impl AppController { .panes .insert(at, DirectoryPane::new(path)); self.state.browser.active = at; - self.restore_active_pane(); + self.pane_switched(); self.retain_visible_selection(); self.record_pane_history(); } @@ -219,7 +205,6 @@ impl AppController { if count <= 1 || pane >= count { return; } - self.snapshot_active_pane(); self.state.browser.panes.remove(pane); let active = self.state.browser.active; self.state.browser.active = if active > pane { @@ -227,7 +212,7 @@ impl AppController { } else { active.min(count - 2) }; - self.restore_active_pane(); + self.pane_switched(); self.retain_visible_selection(); self.record_pane_history(); } @@ -243,24 +228,26 @@ impl AppController { } pub(crate) fn record_pane_history(&mut self) { - if self.state.history.get(self.state.here) != Some(&self.state.current_dir) { + if self.state.history.get(self.state.here) != Some(&self.state.active_pane().directory) { self.state.history.truncate(self.state.here + 1); - self.state.history.push(self.state.current_dir.clone()); + self.state + .history + .push(self.state.active_pane().directory.clone()); self.state.here = self.state.history.len() - 1; } } pub(crate) fn reset_browser_panes(&mut self) { - let directory = self.state.current_dir.clone(); + let directory = self.state.active_pane().directory.clone(); let columns = self.state.browser.columns; let free = self.state.browser.free; self.state.browser = BrowserState::default(); self.state.browser.columns = columns; self.state.browser.free = free; - self.state.current_dir.clear(); - self.state.filter.clear(); - self.state.checked = vec![false; self.state.entries.len()]; - self.state.cursor = None; + self.state.active_pane_mut().directory.clear(); + self.state.active_pane_mut().filter.clear(); + self.state.active_pane_mut().checked = vec![false; self.state.entries.len()]; + self.state.active_pane_mut().cursor = None; self.navigate_panes(directory); } @@ -269,11 +256,10 @@ impl AppController { return; } let cursor = self.cursor_path(); - self.state.filter = filter; + self.state.active_pane_mut().filter = filter; self.restore_cursor_path(cursor); self.retain_visible_selection(); self.cancel_preview(); - self.snapshot_active_pane(); } pub(crate) fn set_pane_order(&mut self, pane: usize, order: (SortColumn, bool)) { @@ -281,9 +267,8 @@ impl AppController { return; } let cursor = self.cursor_path(); - self.state.order = order; + self.state.active_pane_mut().order = order; self.restore_cursor_path(cursor); - self.snapshot_active_pane(); } pub(crate) fn set_pane_cursor(&mut self, pane: usize, cursor: Option) { @@ -291,11 +276,10 @@ impl AppController { return; } let cursor = cursor.filter(|i| *i < self.visible_rows().len()); - if self.state.cursor != cursor { + if self.state.active_pane().cursor != cursor { self.cancel_preview(); } - self.state.cursor = cursor; - self.snapshot_active_pane(); + self.state.active_pane_mut().cursor = cursor; } pub(crate) fn set_pane_scroll(&mut self, pane: usize, offset: f32) { @@ -309,7 +293,6 @@ impl AppController { } pub(crate) fn transition_browser_view(&mut self, columns: bool, flat: bool) { - self.snapshot_active_pane(); let cursors: Vec<_> = self .state .browser @@ -327,23 +310,20 @@ impl AppController { let cursor = path.and_then(|path| self.pane_rows(pane).iter().position(|row| row.path == path)); self.state.browser.panes[pane].cursor = cursor; - if pane == self.state.browser.active { - self.state.cursor = cursor; - } } self.retain_visible_selection(); self.cancel_preview(); - self.snapshot_active_pane(); } fn cursor_path(&self) -> Option { self.state + .active_pane() .cursor .and_then(|i| self.visible_rows().get(i).map(|r| r.path.clone())) } fn restore_cursor_path(&mut self, path: Option) { - self.state.cursor = + self.state.active_pane_mut().cursor = path.and_then(|path| self.visible_rows().iter().position(|r| r.path == path)); } @@ -359,10 +339,9 @@ impl AppController { } } } - for (i, on) in self.state.checked.iter_mut().enumerate() { + for (i, on) in self.state.active_pane_mut().checked.iter_mut().enumerate() { *on &= valid.contains(&i); } - self.snapshot_active_pane(); } } @@ -402,7 +381,7 @@ mod tests { .into_iter() .map(entry) .collect(); - c.state.checked = vec![false; c.state.entries.len()]; + c.state.active_pane_mut().checked = vec![false; c.state.entries.len()]; c.transition_browser_view(true, false); c } @@ -430,7 +409,7 @@ mod tests { .into_iter() .find(|r| r.path == "a/") .unwrap(); - c.state.checked[1] = true; + c.state.active_pane_mut().checked[1] = true; assert!(!c.pane_is_checked(0, &folder)); c.go_to("a/".into()); assert!(!c.pane_is_checked(0, &folder)); @@ -451,28 +430,28 @@ mod tests { } #[test] - fn select_only_replaces_the_selection_and_updates_the_pane() { + fn select_only_replaces_the_selection_in_the_pane() { let mut c = controller(); let rows = c.visible_rows(); let file = rows.iter().find(|r| r.path == "root.txt").unwrap().clone(); let folder = rows.iter().find(|r| r.path == "a/").unwrap().clone(); - c.state.checked[0] = true; + c.state.active_pane_mut().checked[0] = true; c.select_only(&folder); - assert_eq!(c.state.checked, [false, true, true, true, true]); assert_eq!( - c.state.browser.panes[0].selected, - HashSet::from([1, 2, 3, 4]) + c.state.browser.panes[0].checked, + [false, true, true, true, true] ); c.select_only(&file); - assert_eq!(c.state.checked, [true, false, false, false, false]); - assert_eq!(c.state.browser.panes[0].selected, HashSet::from([0])); + assert_eq!( + c.state.browser.panes[0].checked, + [true, false, false, false, false] + ); let hidden = Row { path: "elsewhere.txt".into(), ..file }; c.select_only(&hidden); - assert!(c.state.checked.iter().all(|on| !on)); - assert!(c.state.browser.panes[0].selected.is_empty()); + assert!(c.state.browser.panes[0].checked.iter().all(|on| !on)); } #[test] @@ -491,11 +470,12 @@ mod tests { } #[test] - fn pane_activation_snapshots_legacy_fields_and_targets_only_active_selection() { + fn pane_activation_restores_pane_state_and_targets_only_active_selection() { let mut c = controller(); - c.state.checked[0] = true; - c.state.cursor = c.visible_rows().iter().position(|r| r.path == "root.txt"); - let root_cursor = c.state.cursor; + c.state.active_pane_mut().checked[0] = true; + c.state.active_pane_mut().cursor = + c.visible_rows().iter().position(|r| r.path == "root.txt"); + let root_cursor = c.state.active_pane().cursor; c.set_pane_scroll(0, 42.0); c.go_to("a/b/".into()); assert!(c.selected_names().is_empty()); @@ -512,15 +492,17 @@ mod tests { assert_eq!(c.selected_roots(), ["a/b/middle.txt"]); assert!(c.activate_pane(0)); assert_eq!(c.selected_names(), ["root.txt"]); - assert_eq!(c.state.cursor, root_cursor); + assert_eq!(c.state.active_pane().cursor, root_cursor); assert_eq!(c.state.browser.panes[0].scroll_offset, 42.0); - assert!(c.state.filter.is_empty()); + assert!(c.state.active_pane().filter.is_empty()); assert!(c.activate_pane(2)); assert_eq!(c.selected_names(), ["a/b/middle.txt"]); - assert_eq!(c.state.filter, "middle"); - assert!(c.state.order.0 == SortColumn::Size && !c.state.order.1); + assert_eq!(c.state.active_pane().filter, "middle"); + assert!( + c.state.active_pane().order.0 == SortColumn::Size && !c.state.active_pane().order.1 + ); assert_eq!(c.state.browser.panes[2].scroll_offset, 96.0); - assert_eq!(c.state.cursor, Some(0)); + assert_eq!(c.state.active_pane().cursor, Some(0)); } #[test] @@ -533,7 +515,7 @@ mod tests { let active = c.selected_roots(); assert_eq!(c.pane_selected_roots(0), roots); assert_eq!(c.selected_roots(), active); - assert_eq!(c.state.current_dir, "a/b/"); + assert_eq!(c.state.active_pane().directory, "a/b/"); assert!(c.pane_selected_roots(usize::MAX).is_empty()); assert!(c.activate_pane(0)); assert_eq!(c.selected_roots(), roots); @@ -562,26 +544,26 @@ mod tests { c.go_to("a/".into()); c.go_to("a/b/".into()); c.go_back(); - assert_eq!(c.state.current_dir, "a/"); + assert_eq!(c.state.active_pane().directory, "a/"); c.go_forward(); - assert_eq!(c.state.current_dir, "a/b/"); + assert_eq!(c.state.active_pane().directory, "a/b/"); c.go_to("a/b/c/".into()); c.select_all_visible(); c.set_pane_cursor(3, Some(0)); assert_eq!(c.selected_roots(), ["a/b/c/deep.txt"]); c.transition_browser_view(false, false); - assert_eq!(c.state.cursor, Some(1)); + assert_eq!(c.state.active_pane().cursor, Some(1)); assert_eq!(c.selected_names(), ["a/b/c/deep.txt"]); let up = c.visible_rows()[0].clone(); c.set_checked(&up, true); assert_eq!(c.selected_names(), ["a/b/c/deep.txt"]); c.transition_browser_view(false, true); - assert_eq!(c.state.current_dir, "a/b/c/"); + assert_eq!(c.state.active_pane().directory, "a/b/c/"); assert_eq!(c.visible_rows().len(), 5); c.select_all_visible(); c.transition_browser_view(true, false); assert_eq!(c.selected_names(), ["a/b/c/deep.txt"]); - assert_eq!(c.state.cursor, Some(0)); + assert_eq!(c.state.active_pane().cursor, Some(0)); } #[test] @@ -599,7 +581,7 @@ mod tests { c.set_checked(&root, true); assert!(c.selected_names().is_empty()); c.set_pane_cursor(2, Some(999)); - assert_eq!(c.state.cursor, None); + assert_eq!(c.state.active_pane().cursor, None); c.set_pane_scroll(2, f32::NAN); assert_eq!(c.state.browser.panes[2].scroll_offset, 0.0); } @@ -618,8 +600,8 @@ mod tests { .browser .panes .iter() - .all(|p| p.selected.is_empty() && p.cursor.is_none())); - assert_eq!(c.state.checked, [false]); + .all(|p| !p.checked.contains(&true) && p.cursor.is_none())); + assert_eq!(c.state.active_pane().checked, [false]); } #[test] @@ -627,7 +609,7 @@ mod tests { let mut c = controller(); c.go_to("a/other/".into()); c.pick_folder("a/b/"); - assert_eq!(c.state.current_dir, "a/"); + assert_eq!(c.state.active_pane().directory, "a/"); assert_eq!(c.selected_roots(), ["a/b"]); } @@ -640,7 +622,7 @@ mod tests { c.activate_pane(1); c.transition_browser_view(false, false); c.activate_pane(2); - assert_eq!(c.state.cursor, Some(2)); + assert_eq!(c.state.active_pane().cursor, Some(2)); assert_eq!(c.visible_rows()[2].path, "a/b/middle.txt"); assert_eq!(c.opened_descendant(usize::MAX), None); } diff --git a/arca-gui/src/controller/preview.rs b/arca-gui/src/controller/preview.rs index 703a283b..f1017a00 100644 --- a/arca-gui/src/controller/preview.rs +++ b/arca-gui/src/controller/preview.rs @@ -163,10 +163,10 @@ impl AppController { let mut changed = false; if self.state.preview.context.as_ref().is_some_and(|c| { c.archive != self.state.archive - || c.directory != self.state.current_dir - || c.cursor != self.state.cursor - || c.checked != self.state.checked - || c.filter != self.state.filter + || c.directory != self.state.active_pane().directory + || c.cursor != self.state.active_pane().cursor + || c.checked != self.state.active_pane().checked + || c.filter != self.state.active_pane().filter }) { self.cancel_preview(); changed = true; @@ -214,10 +214,10 @@ impl AppController { fn preview_context(&self) -> PreviewContext { PreviewContext { archive: self.state.archive.clone(), - directory: self.state.current_dir.clone(), - cursor: self.state.cursor, - checked: self.state.checked.clone(), - filter: self.state.filter.clone(), + directory: self.state.active_pane().directory.clone(), + cursor: self.state.active_pane().cursor, + checked: self.state.active_pane().checked.clone(), + filter: self.state.active_pane().filter.clone(), } } @@ -563,7 +563,7 @@ mod tests { let mut c = AppController::new(Settings::default()); c.state.archive = Some(self.0.clone()); c.state.entries = list_entries(&self.0, None, &|_, _, _| true).unwrap().0; - c.state.checked = vec![false; c.state.entries.len()]; + c.state.active_pane_mut().checked = vec![false; c.state.entries.len()]; c } } @@ -639,12 +639,15 @@ mod tests { c.transition_browser_view(columns, flat); let cursor = c.visible_rows().iter().position(|r| r.entry == Some(0)); c.set_pane_cursor(0, cursor); - c.state.checked[0] = true; + c.state.active_pane_mut().checked[0] = true; c.view_entry(1); - let row = &c.visible_rows()[c.state.cursor.unwrap()]; + let row = &c.visible_rows()[c.state.active_pane().cursor.unwrap()]; assert_eq!(row.entry, Some(1)); assert_eq!(c.state.preview.index, row.entry); - assert_eq!(c.state.browser.panes[0].cursor, c.state.cursor); + assert_eq!( + c.state.browser.panes[0].cursor, + c.state.active_pane().cursor + ); assert_eq!(c.selected_names(), ["first.txt"]); settle(&mut c); assert_eq!(c.state.viewing.as_ref().unwrap().name, "second.txt"); @@ -681,9 +684,9 @@ mod tests { let generation = c.state.preview.generation; match mutation { 0 => c.go_to(String::new()), - 1 => c.state.checked[0] = true, - 2 => c.state.cursor = Some(1), - 3 => c.state.filter = "second".into(), + 1 => c.state.active_pane_mut().checked[0] = true, + 2 => c.state.active_pane_mut().cursor = Some(1), + 3 => c.state.active_pane_mut().filter = "second".into(), 4 => c.cancel_preview(), 5 => c.reset_browser_panes(), _ => c.state.archive = Some(PathBuf::from("other.zip")), @@ -945,7 +948,7 @@ mod tests { assert!(Instant::now() < deadline, "refresh did not finish"); std::thread::sleep(Duration::from_millis(1)); } - assert_eq!(c.state.current_dir, "nested/deep/"); + assert_eq!(c.state.active_pane().directory, "nested/deep/"); assert_eq!(c.state.browser.panes.len(), 3); assert!(c.selected_names().is_empty()); assert!(c.state.viewing.is_none()); diff --git a/arca-gui/src/controller/rar_tests.rs b/arca-gui/src/controller/rar_tests.rs index 0b65610b..809d7937 100644 --- a/arca-gui/src/controller/rar_tests.rs +++ b/arca-gui/src/controller/rar_tests.rs @@ -364,7 +364,7 @@ fn rar_selected_extraction_retries_without_writing_and_resumes_only_the_selectio .iter() .position(|e| e.name == "folder/second.txt") .unwrap(); - controller.state.checked[index] = true; + controller.state.active_pane_mut().checked[index] = true; controller.start_extract_to(true, dest.clone()); assert!(matches!( controller.state.waiting_on_password, diff --git a/arca-gui/src/controller/sevenz_tests.rs b/arca-gui/src/controller/sevenz_tests.rs index c5697d2d..3e988675 100644 --- a/arca-gui/src/controller/sevenz_tests.rs +++ b/arca-gui/src/controller/sevenz_tests.rs @@ -137,7 +137,7 @@ fn opening_another_archive_discards_entries_pending_actions_and_old_responses() app.open(archive.clone()); assert!(old_stop.load(std::sync::atomic::Ordering::Relaxed)); assert!(app.state.entries.is_empty()); - assert!(app.state.checked.is_empty()); + assert!(app.state.active_pane().checked.is_empty()); assert!(app.state.waiting_on_password.is_none()); assert!(app.state.archive_password.is_none()); assert!(app.pending_reread().is_none()); @@ -280,7 +280,7 @@ fn selected_extraction_resumes_with_the_selection_after_password_entry() { .iter() .position(|e| e.name == "source/b.txt") .unwrap(); - app.state.checked[index] = true; + app.state.active_pane_mut().checked[index] = true; let dest = room.path("selected"); app.start_extract_to(true, dest.clone()); assert!(matches!( diff --git a/arca-gui/src/controller/state.rs b/arca-gui/src/controller/state.rs index 175a123c..98601690 100644 --- a/arca-gui/src/controller/state.rs +++ b/arca-gui/src/controller/state.rs @@ -1,7 +1,7 @@ //! Runtime state owned by the application controller. use super::{Cut, Message, Pending, Release, View, Viewed}; -use crate::model::{Format, SortColumn}; +use crate::model::Format; use crate::settings::Settings; use crate::tree; use arca_core::{Codec, Entry, Level}; @@ -20,9 +20,6 @@ pub(crate) struct AppState { pub(crate) settings: Settings, pub(crate) archive: Option, pub(crate) entries: Vec, - pub(crate) checked: Vec, - pub(crate) filter: String, - pub(crate) order: (SortColumn, bool), pub(crate) channel: Option>, pub(crate) notice: String, pub(crate) error: bool, @@ -51,7 +48,6 @@ pub(crate) struct AppState { pub(crate) window_title: String, pub(crate) disk: Option, pub(crate) origin: Option, - pub(crate) current_dir: String, pub(crate) show_settings: bool, // Every operation asked for, running or waiting or finished and not yet // put away. The listing is not one of them: it is what the window is. @@ -84,9 +80,6 @@ pub(crate) struct AppState { // whatever was ahead, the way a browser does. pub(crate) history: Vec, pub(crate) here: usize, - // The row the keyboard is on. Everything the arrows, Enter and Space do - // hangs off this. None means nothing is focused yet. - pub(crate) cursor: Option, // The entry being renamed and what has been typed into it so far. Held by // path rather than by row number so that sorting or filtering underneath a // half typed name cannot move the box onto somebody else's row. diff --git a/arca-gui/src/ui/browser.rs b/arca-gui/src/ui/browser.rs index eea7be4b..f9e6d5db 100644 --- a/arca-gui/src/ui/browser.rs +++ b/arca-gui/src/ui/browser.rs @@ -344,11 +344,11 @@ impl Shell { let state = &self.controller.state; let text = if state.archive.is_some() && state.entries.is_empty() - && state.filter.trim().is_empty() + && state.active_pane().filter.trim().is_empty() { s.empty_archive } else { - empty_state_aria_label(state.error, &state.filter, s) + empty_state_aria_label(state.error, &state.active_pane().filter, s) }; ui.add_space(40.0); ui.vertical_centered(|ui| { @@ -397,7 +397,7 @@ impl Shell { }) .collect(); let total: f32 = widths.iter().sum(); - let order = self.controller.state.order; + let order = self.controller.state.active_pane().order; let current_dir = self.controller.row_root().to_string(); egui::ScrollArea::horizontal() .id_salt("details-across") @@ -549,7 +549,7 @@ impl Shell { .id_salt(area_salt) .auto_shrink(false); if std::mem::take(&mut self.scroll_to_cursor) { - if let Some(cursor) = self.controller.state.cursor { + if let Some(cursor) = self.controller.state.active_pane().cursor { let id = ui.make_persistent_id(area_salt); let offset = egui::scroll_area::State::load(ui.ctx(), id) .map_or(0.0, |state| state.offset.y); @@ -573,7 +573,7 @@ impl Shell { ); let look = RowLook { checked: self.controller.is_checked(row), - cursor: self.controller.state.cursor == Some(index), + cursor: self.controller.state.active_pane().cursor == Some(index), open: false, cut: self.controller.state.cut_names.contains(&row.path) || carried(ui.ctx(), &row.path), @@ -709,7 +709,7 @@ impl Shell { let active = browser.active == pane; let data = &browser.panes[pane]; let cursor = if active { - self.controller.state.cursor + self.controller.state.active_pane().cursor } else { data.cursor }; @@ -1112,7 +1112,7 @@ impl Shell { let idle = self.background_idle(); let writable = self.controller.can_change(); let on_disk = self.controller.on_disk(); - let current_dir = self.controller.state.current_dir.clone(); + let current_dir = self.controller.state.active_pane().directory.clone(); let actions: &[RowAction] = if row.is_some() { &RowAction::ALL } else { @@ -1291,9 +1291,9 @@ impl Shell { self.controller.dispatch(AppAction::ClearSelection); } let base = if keep { - self.controller.state.checked.clone() + self.controller.state.active_pane().checked.clone() } else { - vec![false; self.controller.state.checked.len()] + vec![false; self.controller.state.active_pane().checked.len()] }; self.marquee = Some(Marquee { from, @@ -1325,7 +1325,7 @@ impl Shell { .panes .get(pane) .map(|pane| pane.directory.clone()), - None => Some(self.controller.state.current_dir.clone()), + None => Some(self.controller.state.active_pane().directory.clone()), }; if let Some(target) = target { self.blank_drop = Some((target, clip)); diff --git a/arca-gui/src/ui/dialogs.rs b/arca-gui/src/ui/dialogs.rs index 052ecac3..14d0ec87 100644 --- a/arca-gui/src/ui/dialogs.rs +++ b/arca-gui/src/ui/dialogs.rs @@ -824,7 +824,7 @@ impl Shell { .first() .and_then(|path| path.parent()) .map(|dir| dir.display().to_string()) - .unwrap_or_else(|| format!("/{}", self.controller.state.current_dir)); + .unwrap_or_else(|| format!("/{}", self.controller.state.active_pane().directory)); let mut place = self .controller .state @@ -1160,7 +1160,7 @@ impl Shell { }; heading(ui, title); let description = if kind == ModalKind::NewFolder { - format!("{hint}: /{}", self.controller.state.current_dir) + format!("{hint}: /{}", self.controller.state.active_pane().directory) } else if kind == ModalKind::Label { let device = self.device_label.as_ref().map(|v| v.device.clone()); format!("{hint} · {}", device.unwrap_or_default()) diff --git a/arca-gui/src/ui/header.rs b/arca-gui/src/ui/header.rs index cd07acb8..c5d42772 100644 --- a/arca-gui/src/ui/header.rs +++ b/arca-gui/src/ui/header.rs @@ -378,7 +378,7 @@ impl Shell { .unwrap_or_default(); ui.label(RichText::new(here).strong().color(hex(p.link))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let filtering = !self.controller.state.filter.is_empty(); + let filtering = !self.controller.state.active_pane().filter.is_empty(); let panes = self.panes_view(); let active = self.controller.state.browser.active; let on = if panes { @@ -399,7 +399,7 @@ impl Shell { }); }); let wanted = !self.panes_view() - && (self.filter_open || !self.controller.state.filter.is_empty()); + && (self.filter_open || !self.controller.state.active_pane().filter.is_empty()); let open = ui .ctx() .animate_bool(egui::Id::new("arca-filter-open"), wanted); @@ -427,7 +427,7 @@ impl Shell { /// The funnel button: opens the filter row with the field focused, or /// closes it and lets everything show again. pub(super) fn toggle_filter(&mut self) { - if self.filter_open || !self.controller.state.filter.is_empty() { + if self.filter_open || !self.controller.state.active_pane().filter.is_empty() { self.close_filter(); } else { self.filter_open = true; @@ -438,7 +438,7 @@ impl Shell { fn close_filter(&mut self) { self.filter_open = false; self.filter_value.clear(); - if !self.controller.state.filter.is_empty() { + if !self.controller.state.active_pane().filter.is_empty() { self.controller .dispatch(AppAction::SetFilter(String::new())); } @@ -449,8 +449,8 @@ impl Shell { let s = self.controller.s(); let id = egui::Id::new(FILTER_ID); let focused = ui.memory(|m| m.has_focus(id)); - if !focused && self.controller.state.filter != self.filter_value { - self.filter_value = self.controller.state.filter.clone(); + if !focused && self.controller.state.active_pane().filter != self.filter_value { + self.filter_value = self.controller.state.active_pane().filter.clone(); } let p = palette_of(ui.ctx()); ui.add_space(4.0); diff --git a/arca-gui/src/ui/mod.rs b/arca-gui/src/ui/mod.rs index 3b48f07f..b851e6ff 100644 --- a/arca-gui/src/ui/mod.rs +++ b/arca-gui/src/ui/mod.rs @@ -270,7 +270,7 @@ impl Shell { let settings_now = controller.state.settings.title_bar; egui_extras::install_image_loaders(ctx); Self { - filter_value: controller.state.filter.clone(), + filter_value: controller.state.active_pane().filter.clone(), controller, ctx: ctx.clone(), dialog: None, @@ -740,6 +740,7 @@ impl Shell { fn selected_count(&self) -> usize { self.controller .state + .active_pane() .checked .iter() .filter(|checked| **checked) @@ -789,7 +790,13 @@ impl Shell { if matches!(kind, DialogKind::Extract { .. }) && (self.controller.state.archive.is_none() || (matches!(kind, DialogKind::Extract { only_checked: true }) - && !self.controller.state.checked.iter().any(|checked| *checked))) + && !self + .controller + .state + .active_pane() + .checked + .iter() + .any(|checked| *checked))) { return; } @@ -993,7 +1000,7 @@ impl Shell { self.close_name(); return; } - let full = format!("{}{name}/", self.controller.state.current_dir); + let full = format!("{}{name}/", self.controller.state.active_pane().directory); self.close_name(); self.controller.dispatch(AppAction::Run(Job::NewFolder { archive, @@ -1049,9 +1056,9 @@ impl Shell { return; } if let Some(destination) = - action.destination(row.as_ref(), &self.controller.state.current_dir) + action.destination(row.as_ref(), &self.controller.state.active_pane().directory) { - if destination != self.controller.state.current_dir { + if destination != self.controller.state.active_pane().directory { self.controller.go_to(destination); } } @@ -1124,7 +1131,7 @@ impl Shell { RowAction::Properties => { let path = match row { Some(row) if !row.up => disk_path(&row.path), - _ => disk_path(&self.controller.state.current_dir), + _ => disk_path(&self.controller.state.active_pane().directory), }; let pinned = self.controller.is_pinned(&path); self.properties = Some(inspect(&path, pinned)); @@ -1155,7 +1162,7 @@ impl Shell { } Some(_) => {} None if self.controller.on_disk() => { - let here = disk_path(&self.controller.state.current_dir); + let here = disk_path(&self.controller.state.active_pane().directory); self.controller.dispatch(AppAction::TogglePinned(here)); } None => {} @@ -1248,7 +1255,7 @@ impl Shell { let Some((roots, cut)) = self.disk_clip.clone() else { return; }; - let target = self.controller.state.current_dir.clone(); + let target = self.controller.state.active_pane().directory.clone(); if cut { self.controller.move_on_disk(&roots, &target); self.disk_clip = None; @@ -1313,7 +1320,7 @@ impl Shell { return; }; if modifiers.shift { - let from = self.controller.state.cursor.unwrap_or(index); + let from = self.controller.state.active_pane().cursor.unwrap_or(index); let (lo, hi) = if from <= index { (from, index) } else { @@ -1432,7 +1439,12 @@ impl Shell { }; let letter = letter.to_lowercase().to_string(); let rows = self.controller.visible_rows(); - let start = self.controller.state.cursor.map_or(0, |cursor| cursor + 1); + let start = self + .controller + .state + .active_pane() + .cursor + .map_or(0, |cursor| cursor + 1); let found = (0..rows.len()) .map(|step| (start + step) % rows.len().max(1)) .find(|index| { @@ -1603,7 +1615,13 @@ impl Shell { Shortcut::Undo if self.controller.state.undo.is_some() => self.controller.undo_last(), Shortcut::Rename if archive.is_some() => { let rows = self.controller.visible_rows(); - if let Some(row) = self.controller.state.cursor.and_then(|i| rows.get(i)) { + if let Some(row) = self + .controller + .state + .active_pane() + .cursor + .and_then(|i| rows.get(i)) + { let path = row.path.clone(); self.begin_rename(&path, false); } @@ -1618,6 +1636,7 @@ impl Shell { if let Some(entry) = self .controller .state + .active_pane() .cursor .and_then(|index| rows.get(index)) .and_then(|row| row.entry) @@ -1652,7 +1671,13 @@ impl Shell { return; } if key == "space" { - if let Some(row) = self.controller.state.cursor.and_then(|i| rows.get(i)) { + if let Some(row) = self + .controller + .state + .active_pane() + .cursor + .and_then(|i| rows.get(i)) + { let value = !self.controller.is_checked(row); self.controller.dispatch(AppAction::SetChecked { row: row.clone(), @@ -1662,7 +1687,13 @@ impl Shell { return; } if matches!(key, "enter" | "right" | "arrowright") && !modifiers.alt { - if let Some(row) = self.controller.state.cursor.and_then(|i| rows.get(i)) { + if let Some(row) = self + .controller + .state + .active_pane() + .cursor + .and_then(|i| rows.get(i)) + { if row.is_dir || key == "enter" { let row = row.clone(); self.activate(&row); @@ -1679,7 +1710,7 @@ impl Shell { return; } let last = rows.len() - 1; - let current = self.controller.state.cursor; + let current = self.controller.state.active_pane().cursor; let Some(index) = cursor_step(key, current, last) else { return; }; @@ -1718,7 +1749,8 @@ impl Shell { for part in self .controller .state - .current_dir + .active_pane() + .directory .split('/') .filter(|part| !part.is_empty()) { diff --git a/arca-gui/src/ui/pathbar.rs b/arca-gui/src/ui/pathbar.rs index 8e5a500f..3300d12e 100644 --- a/arca-gui/src/ui/pathbar.rs +++ b/arca-gui/src/ui/pathbar.rs @@ -84,7 +84,7 @@ pub(crate) fn matching(names: &[String], stem: &str, hidden: bool) -> Vec String { - let dir = &self.controller.state.current_dir; + let dir = &self.controller.state.active_pane().directory; if self.controller.on_disk() { let mut text = disk_path(dir).display().to_string(); if !text.is_empty() && !text.ends_with(['/', '\\']) { diff --git a/arca-gui/src/ui/preview.rs b/arca-gui/src/ui/preview.rs index 1979b499..a42cffeb 100644 --- a/arca-gui/src/ui/preview.rs +++ b/arca-gui/src/ui/preview.rs @@ -5,12 +5,17 @@ use eframe::egui::{self, RichText}; impl Shell { pub(super) fn preview_for_cursor(&mut self) { - let index = self.controller.state.cursor.and_then(|cursor| { - self.controller - .visible_rows() - .get(cursor) - .and_then(|row| (!row.is_dir && !row.up).then_some(row.entry).flatten()) - }); + let index = self + .controller + .state + .active_pane() + .cursor + .and_then(|cursor| { + self.controller + .visible_rows() + .get(cursor) + .and_then(|row| (!row.is_dir && !row.up).then_some(row.entry).flatten()) + }); if index != self.controller.state.preview.index { if let Some(index) = index { self.controller.request_preview(index); diff --git a/arca-gui/src/ui/sidebar.rs b/arca-gui/src/ui/sidebar.rs index 9dd4cca4..de288b7a 100644 --- a/arca-gui/src/ui/sidebar.rs +++ b/arca-gui/src/ui/sidebar.rs @@ -44,7 +44,7 @@ impl Shell { let here = self .controller .on_disk() - .then(|| disk_path(&self.controller.state.current_dir)); + .then(|| disk_path(&self.controller.state.active_pane().directory)); let mut browse = None; let mut unpin = None; let mut open = None; @@ -279,7 +279,7 @@ impl Shell { .and_then(|archive| archive.file_name()) .map(|name| name.to_string_lossy().to_string()) .unwrap_or_default(); - let current = self.controller.state.current_dir.clone(); + let current = self.controller.state.active_pane().directory.clone(); let mut event = None; if nav_item(ui, icon::ARCHIVE, &root, current.is_empty(), false).clicked() { event = Some(TreeEvent::Navigate(String::new())); From bc73d44ec504062578338f06fec71ead51d7a2ac Mon Sep 17 00:00:00 2001 From: "villoh.contact" Date: Sun, 18 Oct 2026 10:09:14 +0000 Subject: [PATCH 2/2] refactor(arca-gui): route settings, codec, level, selection and notices through the controller Views mutated AppState and Settings directly and called Settings::save, which dropped I/O errors. Settings::save now returns io::Result and AppController::update_settings saves once and shows a failure as an error notice. Both create dialogs read the compressor from AppController::compressor and share one codec picker. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- arca-gui/src/controller/actions.rs | 10 +- arca-gui/src/controller/disk.rs | 6 +- arca-gui/src/controller/mod.rs | 115 +++++++++++++++-- arca-gui/src/controller/panes.rs | 11 ++ arca-gui/src/i18n.rs | 3 + arca-gui/src/settings.rs | 10 +- arca-gui/src/ui/browser.rs | 13 +- arca-gui/src/ui/dialogs.rs | 195 +++++++++++++++-------------- arca-gui/src/ui/header.rs | 31 +++-- arca-gui/src/ui/mod.rs | 67 +++++----- arca-gui/src/ui/preview.rs | 4 +- arca-gui/src/ui/sidebar.rs | 4 +- 12 files changed, 290 insertions(+), 179 deletions(-) diff --git a/arca-gui/src/controller/actions.rs b/arca-gui/src/controller/actions.rs index 7d6d26f3..e4d7ee2d 100644 --- a/arca-gui/src/controller/actions.rs +++ b/arca-gui/src/controller/actions.rs @@ -5,9 +5,17 @@ use crate::archive_ops::*; use crate::i18n::Lang; use crate::model::*; use crate::tree::{parent_of, Kind, Row}; -use arca_core::Entry; +use arca_core::{Codec, Entry}; use std::path::{Path, PathBuf}; +/// What the create and settings dialogs offer for the compressor of the +/// chosen format. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Compressor { + Fixed(&'static str), + Codec { codec: Codec, editable: bool }, +} + pub(crate) enum Message { Listing( PathBuf, diff --git a/arca-gui/src/controller/disk.rs b/arca-gui/src/controller/disk.rs index 3e16323d..8f359a5f 100644 --- a/arca-gui/src/controller/disk.rs +++ b/arca-gui/src/controller/disk.rs @@ -1260,7 +1260,7 @@ impl AppController { let text = (!path.as_os_str().is_empty()).then(|| path.to_string_lossy().to_string()); if self.state.settings.last_folder != text { self.state.settings.last_folder = text; - self.state.settings.save(); + self.save_settings(); } } @@ -1550,7 +1550,7 @@ impl AppController { } else { self.state.settings.pinned.push(text); } - self.state.settings.save(); + self.save_settings(); } pub(crate) fn set_show_hidden(&mut self, show: bool) { @@ -1558,7 +1558,7 @@ impl AppController { return; } self.state.settings.show_hidden = show; - self.state.settings.save(); + self.save_settings(); self.refresh_disk(); } } diff --git a/arca-gui/src/controller/mod.rs b/arca-gui/src/controller/mod.rs index 9aad6fad..658ac3ea 100644 --- a/arca-gui/src/controller/mod.rs +++ b/arca-gui/src/controller/mod.rs @@ -220,15 +220,15 @@ impl AppController { AppAction::ToggleColumn(column) => { let on = self.state.settings.columns.on(column); self.state.settings.columns.set(column, !on); - self.state.settings.save(); + self.save_settings(); } AppAction::SetLanguage(lang) => { self.state.settings.lang = lang; - self.state.settings.save(); + self.save_settings(); } AppAction::SetTheme(theme) => { self.state.settings.theme = theme; - self.state.settings.save(); + self.save_settings(); } AppAction::AnswerConflict(answer, name) => self.answer_conflict(answer, name), AppAction::CancelPassword => self.cancel_password(), @@ -462,11 +462,11 @@ impl AppController { /// The format is left alone while an archive is being browsed, because /// then `state.format` is the format of what is open. pub(crate) fn set_archive_defaults(&mut self, format: Format, codec: Codec, level: Level) { - let settings = &mut self.state.settings; - settings.new_format = format; - settings.new_codec = codec; - settings.new_level = level; - settings.save(); + self.update_settings(|settings| { + settings.new_format = format; + settings.new_codec = codec; + settings.new_level = level; + }); if self.state.archive.is_none() || matches!(self.state.view, View::Add) { self.set_create_format(format); } @@ -474,6 +474,68 @@ impl AppController { self.state.level = level; } + pub(crate) const CODECS: [Codec; 3] = [Codec::Store, Codec::Deflate, Codec::Zstd]; + pub(crate) const LEVEL_PRESETS: [Level; 4] = + [Level::STORE, Level::FAST, Level::NORMAL, Level::BEST]; + + pub(crate) fn compressor(&self) -> Compressor { + Self::compressor_for(self.state.format, self.state.codec) + } + + pub(crate) fn compressor_for(format: Format, codec: Codec) -> Compressor { + match format { + Format::SevenZ | Format::TarXz | Format::Xz => Compressor::Fixed("LZMA2"), + Format::Rar => Compressor::Fixed("RAR"), + Format::Zip => Compressor::Codec { + codec, + editable: true, + }, + _ => Compressor::Codec { + codec, + editable: false, + }, + } + } + + pub(crate) fn set_codec(&mut self, codec: Codec) { + self.state.codec = codec; + } + + pub(crate) fn set_level(&mut self, level: Level) { + self.state.level = level; + } + + pub(crate) fn set_notice(&mut self, notice: impl Into, error: bool) { + self.state.notice = notice.into(); + self.state.error = error; + } + + pub(crate) fn update_settings(&mut self, change: impl FnOnce(&mut Settings) -> R) -> R { + let changed = change(&mut self.state.settings); + self.save_settings(); + changed + } + + /// Widths change on every frame of a drag and are written once it ends. + pub(crate) fn set_column_width(&mut self, slot: usize, width: f32, persist: bool) { + if let Some(stored) = self.state.settings.widths.get_mut(slot) { + *stored = width; + } + if persist { + self.save_settings(); + } + } + + fn save_settings(&mut self) { + if let Err(error) = self.state.settings.save() { + let notice = fill( + self.s().settings_not_saved, + &[("error", &error.to_string())], + ); + self.set_notice(notice, true); + } + } + pub(crate) fn set_create_format(&mut self, format: Format) { let suggested = |format| { (!self.state.pending_inputs.is_empty()).then(|| { @@ -889,7 +951,7 @@ impl AppController { // remembered. pub(crate) fn reread_names(&mut self, page: arca_zip::pages::Page) { self.state.settings.page = page; - self.state.settings.save(); + self.save_settings(); for e in &mut self.state.entries { if e.utf8 { continue; @@ -1195,7 +1257,7 @@ impl AppController { self.state.settings.recent.retain(|p| *p != text); self.state.settings.recent.insert(0, text); self.state.settings.recent.truncate(10); - self.state.settings.save(); + self.save_settings(); } pub(crate) fn spawn(&mut self, total: usize, work: F) where @@ -2838,3 +2900,36 @@ mod filter_tests { ); } } + +#[cfg(test)] +mod compressor_tests { + use super::*; + + #[test] + fn only_zip_lets_the_codec_be_picked() { + let mut controller = AppController::new(Settings::default()); + for (format, expected) in [ + (Format::SevenZ, Compressor::Fixed("LZMA2")), + (Format::TarXz, Compressor::Fixed("LZMA2")), + (Format::Xz, Compressor::Fixed("LZMA2")), + (Format::Rar, Compressor::Fixed("RAR")), + ( + Format::Zip, + Compressor::Codec { + codec: Codec::Deflate, + editable: true, + }, + ), + ( + Format::TarGz, + Compressor::Codec { + codec: Codec::Deflate, + editable: false, + }, + ), + ] { + controller.state.format = format; + assert_eq!(controller.compressor(), expected, "{format:?}"); + } + } +} diff --git a/arca-gui/src/controller/panes.rs b/arca-gui/src/controller/panes.rs index 16dc36a2..db6353e7 100644 --- a/arca-gui/src/controller/panes.rs +++ b/arca-gui/src/controller/panes.rs @@ -395,6 +395,17 @@ mod tests { .collect() } + #[test] + fn select_only_replaces_the_active_selection() { + let mut c = controller(); + c.go_to("a/b/".into()); + let rows = c.visible_rows(); + let pick = |name: &str| rows.iter().find(|r| r.path == name).unwrap().clone(); + c.set_checked(&pick("a/b/middle.txt"), true); + c.select_only(&pick("a/b/c/")); + assert_eq!(c.selected_roots(), ["a/b/c"]); + } + #[test] fn inactive_folder_checks_match_active_checks_for_partial_and_complete_selection() { for backslashes in [false, true] { diff --git a/arca-gui/src/i18n.rs b/arca-gui/src/i18n.rs index 97f73038..6b55e2d2 100644 --- a/arca-gui/src/i18n.rs +++ b/arca-gui/src/i18n.rs @@ -414,6 +414,7 @@ pub struct Strings { pub duplicated_n: &'static str, pub could_not_remove: &'static str, pub could_not_trash: &'static str, + pub settings_not_saved: &'static str, pub pointer_focus: &'static str, pub pointer_focus_hint: &'static str, pub filter_pane: &'static str, @@ -841,6 +842,7 @@ const EN: Strings = Strings { duplicated_n: "{n} duplicated", could_not_remove: "Could not remove {name}", could_not_trash: "Could not move {name} to Trash", + settings_not_saved: "Could not save settings: {error}", pointer_focus: "Focus follows the pointer", pointer_focus_hint: "The pane under the pointer is the active one, as on a tiling desktop. Keys keep the focus where it is until the pointer moves again.", filter_pane: "Filter {count} items\u{2026}", @@ -1265,6 +1267,7 @@ const ES: Strings = Strings { duplicated_n: "{n} duplicados", could_not_remove: "No se pudo eliminar {name}", could_not_trash: "No se pudo mover {name} a la papelera", + settings_not_saved: "No se pudo guardar la configuración: {error}", pointer_focus: "El foco sigue al puntero", pointer_focus_hint: "El panel bajo el puntero es el activo, como en un escritorio en mosaico. Las teclas mantienen el foco donde está hasta que el puntero vuelve a moverse.", filter_pane: "Filtrar {count} elementos\u{2026}", diff --git a/arca-gui/src/settings.rs b/arca-gui/src/settings.rs index 5e2f0741..8d9ca803 100644 --- a/arca-gui/src/settings.rs +++ b/arca-gui/src/settings.rs @@ -398,12 +398,14 @@ impl Settings { s } - pub(crate) fn save(&self) { - let Some(p) = config_file() else { return }; + pub(crate) fn save(&self) -> std::io::Result<()> { + let Some(p) = config_file() else { + return Ok(()); + }; if let Some(dir) = p.parent() { - let _ = fs::create_dir_all(dir); + fs::create_dir_all(dir)?; } - let _ = fs::write(p, self.text()); + fs::write(p, self.text()) } /// The settings file as text. diff --git a/arca-gui/src/ui/browser.rs b/arca-gui/src/ui/browser.rs index f9e6d5db..392b8c58 100644 --- a/arca-gui/src/ui/browser.rs +++ b/arca-gui/src/ui/browser.rs @@ -523,14 +523,11 @@ impl Shell { } else { CELL_LEAST }; - let slot = column_slot(column); - let settings = &mut self.controller.state.settings; - if let Some(stored) = settings.widths.get_mut(slot) { - *stored = width.clamp(least, 900.0); - } - if done { - settings.save(); - } + self.controller.set_column_width( + column_slot(column), + width.clamp(least, 900.0), + done, + ); } if rows.is_empty() { self.empty_state(ui); diff --git a/arca-gui/src/ui/dialogs.rs b/arca-gui/src/ui/dialogs.rs index 14d0ec87..00c337cf 100644 --- a/arca-gui/src/ui/dialogs.rs +++ b/arca-gui/src/ui/dialogs.rs @@ -708,8 +708,6 @@ impl Shell { let format = self.controller.state.settings.new_format; let codec = self.controller.state.settings.new_codec; let level = self.controller.state.settings.new_level; - let is_zip = format == Format::Zip; - let is_lzma2 = matches!(format, Format::SevenZ | Format::TarXz | Format::Xz); let (mut new_format, mut new_codec, mut new_level) = (format, codec, level); egui::Grid::new(ui.id().with("pickers")) .num_columns(2) @@ -725,31 +723,27 @@ impl Shell { }); ui.end_row(); ui.label(s.compressor); - if is_lzma2 { - ui.label("LZMA2"); - } else if format == Format::Rar { - ui.label("RAR"); - } else { - ui.add_enabled_ui(is_zip, |ui| { - egui::ComboBox::from_id_salt("codec") - .selected_text(self.controller.codec_name(codec)) - .show_ui(ui, |ui| { - for candidate in [Codec::Store, Codec::Deflate, Codec::Zstd] { - ui.selectable_value( - &mut new_codec, - candidate, - self.controller.codec_name(candidate), - ); - } - }); - }); + match AppController::compressor_for(format, codec) { + Compressor::Fixed(name) => { + ui.label(name); + } + Compressor::Codec { codec, editable } => { + let chosen = ui + .add_enabled_ui(editable, |ui| { + codec_combo(ui, &self.controller, "codec", codec, None) + }) + .inner; + if let Some(codec) = chosen { + new_codec = codec; + } + } } ui.end_row(); ui.label(s.level); egui::ComboBox::from_id_salt("level") .selected_text(self.controller.level_name(level)) .show_ui(ui, |ui| { - for candidate in [Level::STORE, Level::FAST, Level::NORMAL, Level::BEST] { + for candidate in AppController::LEVEL_PRESETS { ui.selectable_value( &mut new_level, candidate, @@ -869,36 +863,25 @@ impl Shell { self.controller.set_create_format(chosen); } let format = self.controller.state.format; - let is_zip = format == Format::Zip; - let is_lzma2 = matches!(format, Format::SevenZ | Format::TarXz | Format::Xz); - let codec = self.controller.state.codec; let mut pick = None; card(ui, s.col_method, None, true, |ui| { - if is_lzma2 { - ui.label(RichText::new("LZMA2").color(hex(p.muted))); - } else if format == Format::Rar { - ui.label(RichText::new("RAR").color(hex(p.muted))); - } else if is_zip { - let mut chosen = codec; - egui::ComboBox::from_id_salt("compress-method") - .selected_text(self.controller.codec_name(codec)) - .width(200.0) - .show_ui(ui, |ui| { - for candidate in [Codec::Store, Codec::Deflate, Codec::Zstd] { - ui.selectable_value( - &mut chosen, - candidate, - self.controller.codec_name(candidate), - ); - } - }); - pick = (chosen != codec).then_some(chosen); - } else { - ui.label(RichText::new(self.controller.codec_name(codec)).color(hex(p.muted))); + match self.controller.compressor() { + Compressor::Fixed(name) => { + ui.label(RichText::new(name).color(hex(p.muted))); + } + Compressor::Codec { + codec, + editable: true, + } => { + pick = codec_combo(ui, &self.controller, "compress-method", codec, Some(200.0)); + } + Compressor::Codec { codec, .. } => { + ui.label(RichText::new(self.controller.codec_name(codec)).color(hex(p.muted))); + } } }); if let Some(codec) = pick { - self.controller.state.codec = codec; + self.controller.set_codec(codec); } let level = self.controller.state.level; let mut at = u32::from(level.get()); @@ -918,7 +901,7 @@ impl Shell { ui.label(RichText::new(s.min_word).size(12.0).color(hex(p.muted))); }); if moved { - self.controller.state.level = Level::new(at as u8); + self.controller.set_level(Level::new(at as u8)); } if matches!(format, Format::Zip | Format::SevenZ) { @@ -1503,8 +1486,8 @@ impl Shell { let mut folder_peek = self.controller.state.settings.folder_peek; card(ui, s.folder_peek, Some(s.folder_peek_hint), true, |ui| { if switch(ui, &mut folder_peek, s.folder_peek).changed() { - self.controller.state.settings.folder_peek = folder_peek; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.folder_peek = folder_peek); } }); let mut pointer_focus = self.controller.state.settings.pointer_focus; @@ -1515,8 +1498,8 @@ impl Shell { true, |ui| { if switch(ui, &mut pointer_focus, s.pointer_focus).changed() { - self.controller.state.settings.pointer_focus = pointer_focus; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.pointer_focus = pointer_focus); } }, ); @@ -1531,9 +1514,10 @@ impl Shell { }), ] { card(ui, label, Some(hint), true, |ui| { - let settings = &mut self.controller.state.settings; - if switch(ui, pick(settings), label).changed() { - settings.save(); + let mut on = *pick(&mut self.controller.state.settings); + if switch(ui, &mut on, label).changed() { + self.controller + .update_settings(|settings| *pick(settings) = on); } }); } @@ -1742,31 +1726,27 @@ impl Shell { &[(s.title_bar_system, !custom), (s.title_bar_custom, custom)], ); }); - let settings = &mut self.controller.state.settings; - let mut dirty = false; - if let Some(font) = chosen { - settings.font = font; - dirty = true; - } - if let Some(step) = size_step { - settings.text_size = (size + f32::from(step)).clamp(TEXT_LEAST, TEXT_MOST); - dirty = true; - } - if motion_changed { - settings.motion = motion; - dirty = true; - } - if let Some(i) = bar_pick { - settings.title_bar = if i == 0 { - TitleBar::System - } else { - TitleBar::Custom - }; - dirty = true; - } - if dirty { - settings.save(); + if chosen.is_none() && size_step.is_none() && !motion_changed && bar_pick.is_none() { + return; } + self.controller.update_settings(|settings| { + if let Some(font) = chosen { + settings.font = font; + } + if let Some(step) = size_step { + settings.text_size = (size + f32::from(step)).clamp(TEXT_LEAST, TEXT_MOST); + } + if motion_changed { + settings.motion = motion; + } + if let Some(i) = bar_pick { + settings.title_bar = if i == 0 { + TitleBar::System + } else { + TitleBar::Custom + }; + } + }); } fn theme_library(&self, ui: &mut egui::Ui, shown: &[Theme], dark_now: bool) -> LibraryClicks { @@ -1980,20 +1960,19 @@ impl Shell { self.theme_error = None; self.themes = themes::all(); self.look.clear(); - let settings = &mut self.controller.state.settings; - forget_theme(settings, name); - settings.save(); + self.controller + .update_settings(|settings| forget_theme(settings, name)); } fn apply_theme_choice(&mut self, t: &Theme, dark_now: bool) { - let settings = &mut self.controller.state.settings; - if t.dark { - settings.dark_theme = t.name.clone(); - } else { - settings.light_theme = t.name.clone(); - } - settings.save(); - let follow = settings.theme == ThemePreference::System && dark_now == t.dark; + let follow = self.controller.update_settings(|settings| { + if t.dark { + settings.dark_theme = t.name.clone(); + } else { + settings.light_theme = t.name.clone(); + } + settings.theme == ThemePreference::System && dark_now == t.dark + }); if !follow { self.set_theme(if t.dark { ThemePreference::Dark @@ -2053,8 +2032,8 @@ impl Shell { }, ); if ask_changed { - self.controller.state.settings.ask_on_drop = ask; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.ask_on_drop = ask); } let mut on = self.controller.state.settings.updates; let mut toggled = false; @@ -2084,8 +2063,7 @@ impl Shell { }; if let Some(channel) = channel { let controller = &mut self.controller; - controller.state.settings.channel = channel; - controller.state.settings.save(); + controller.update_settings(|settings| settings.channel = channel); controller.state.update = None; controller.state.update_check = UpdateCheck::Idle; if controller.state.settings.updates { @@ -2119,10 +2097,11 @@ impl Shell { SettingsControl::ThemeLight => self.set_theme(ThemePreference::Light), SettingsControl::ThemeDark => self.set_theme(ThemePreference::Dark), SettingsControl::Updates => { - let settings = &mut self.controller.state.settings; - settings.updates = !settings.updates; - settings.save(); - if settings.updates { + let updates = self.controller.update_settings(|settings| { + settings.updates = !settings.updates; + settings.updates + }); + if updates { self.controller.ask_about_updates(); } } @@ -2379,6 +2358,28 @@ pub(crate) fn plain_markdown(text: &str) -> String { out.replace("**", "").replace('`', "") } +/// The ZIP codec picker both the create and the settings dialogs show. +/// Returns the codec picked this frame, if it differs from `codec`. +fn codec_combo( + ui: &mut egui::Ui, + controller: &AppController, + id: &str, + codec: Codec, + width: Option, +) -> Option { + let mut chosen = codec; + let mut combo = egui::ComboBox::from_id_salt(id).selected_text(controller.codec_name(codec)); + if let Some(width) = width { + combo = combo.width(width); + } + combo.show_ui(ui, |ui| { + for candidate in AppController::CODECS { + ui.selectable_value(&mut chosen, candidate, controller.codec_name(candidate)); + } + }); + (chosen != codec).then_some(chosen) +} + #[cfg(test)] mod theme_tests { use super::{already_gone, forget_theme}; diff --git a/arca-gui/src/ui/header.rs b/arca-gui/src/ui/header.rs index c5d42772..caaa9200 100644 --- a/arca-gui/src/ui/header.rs +++ b/arca-gui/src/ui/header.rs @@ -222,8 +222,8 @@ impl Shell { } let preview = self.controller.state.settings.preview_visible; if pick_entry(ui, icon::EYE, s.preview_panel, preview).clicked() { - self.controller.state.settings.preview_visible = !preview; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.preview_visible = !preview); } ui.separator(); group_label(ui, s.panes_view); @@ -233,13 +233,13 @@ impl Shell { } let folder_peek = self.controller.state.settings.folder_peek; if pick_entry(ui, icon::FOLDER_OPEN, s.folder_peek, folder_peek).clicked() { - self.controller.state.settings.folder_peek = !folder_peek; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.folder_peek = !folder_peek); } let pointer_focus = self.controller.state.settings.pointer_focus; if pick_entry(ui, icon::CURSOR, s.pointer_focus, pointer_focus).clicked() { - self.controller.state.settings.pointer_focus = !pointer_focus; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.pointer_focus = !pointer_focus); } ui.separator(); group_label(ui, s.density_title); @@ -253,8 +253,8 @@ impl Shell { (icon::ARROWS_OUT_LINE_VERTICAL, s.airy_word, Density::Airy), ] { if pick_entry(ui, glyph, label, density == want).clicked() && density != want { - self.controller.state.settings.density = want; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.density = want); } } ui.separator(); @@ -291,10 +291,10 @@ impl Shell { } fn remember_width(&mut self, width: f32, slot: impl Fn(&mut Settings) -> &mut f32) { - let settings = &mut self.controller.state.settings; - if (*slot(settings) - width).abs() > 0.5 && !self.ctx.input(|i| i.pointer.any_down()) { - *slot(settings) = width; - settings.save(); + let stored = *slot(&mut self.controller.state.settings); + if (stored - width).abs() > 0.5 && !self.ctx.input(|i| i.pointer.any_down()) { + self.controller + .update_settings(|settings| *slot(settings) = width); } } @@ -308,8 +308,7 @@ impl Shell { } ui.horizontal(|ui| { if icon_button(ui, icon::SIDEBAR_SIMPLE, s.folder_tree, true).clicked() { - toggle_sidebar(&mut self.controller.state.settings); - self.controller.state.settings.save(); + self.controller.update_settings(toggle_sidebar); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if own_bar { @@ -714,8 +713,8 @@ impl Shell { .add_enabled(!recent.is_empty(), egui::Button::new(s.clear_history)) .clicked() { - self.controller.state.settings.recent.clear(); - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.recent.clear()); } }); ui.separator(); diff --git a/arca-gui/src/ui/mod.rs b/arca-gui/src/ui/mod.rs index b851e6ff..53aed022 100644 --- a/arca-gui/src/ui/mod.rs +++ b/arca-gui/src/ui/mod.rs @@ -972,8 +972,7 @@ impl Shell { return; } if name.is_empty() || name.contains('/') || name.contains('\\') { - self.controller.state.notice = s.bad_name.to_string(); - self.controller.state.error = true; + self.controller.set_notice(s.bad_name, true); return; } if let Some(dir) = self.controller.create_disk_folder(&name) { @@ -984,8 +983,7 @@ impl Shell { return; }; if name.is_empty() || name.contains('/') || name.contains('\\') { - self.controller.state.notice = s.bad_name.to_string(); - self.controller.state.error = true; + self.controller.set_notice(s.bad_name, true); self.close_name(); return; } @@ -995,8 +993,8 @@ impl Shell { .iter() .any(|row| row.label.eq_ignore_ascii_case(&name)) { - self.controller.state.notice = fill(s.name_taken, &[("name", &name)]); - self.controller.state.error = true; + self.controller + .set_notice(fill(s.name_taken, &[("name", &name)]), true); self.close_name(); return; } @@ -1232,18 +1230,19 @@ impl Shell { return; } if !clipboard::AVAILABLE { - self.controller.state.notice = self.controller.s().clipboard_windows_only.into(); - self.controller.state.error = true; + self.controller + .set_notice(self.controller.s().clipboard_windows_only, true); return; } let s = self.controller.s(); - self.controller.state.notice = if cut { - s.preparing_move - } else { - s.preparing_copy - } - .into(); - self.controller.state.error = false; + self.controller.set_notice( + if cut { + s.preparing_move + } else { + s.preparing_copy + }, + false, + ); self.controller.dispatch(AppAction::Copy { cut }); } @@ -1268,17 +1267,17 @@ impl Shell { return; } if !clipboard::AVAILABLE { - self.controller.state.notice = self.controller.s().clipboard_windows_only.into(); - self.controller.state.error = true; + self.controller + .set_notice(self.controller.s().clipboard_windows_only, true); return; } self.controller.dispatch(AppAction::Paste); } fn set_next_pane(&mut self, on: bool) { - self.controller.state.settings.next_pane = on; + self.controller + .update_settings(|settings| settings.next_pane = on); self.controller.set_free_panes(!on); - self.controller.state.settings.save(); } fn switch_view(&mut self, view: crate::settings::BrowserView, flat: bool) { @@ -1287,22 +1286,18 @@ impl Shell { self.controller.transition_browser_view(columns, flat); self.controller .set_free_panes(!self.controller.state.settings.next_pane); - self.controller.state.settings.browser_view = - if columns { view } else { BrowserView::Details }; - if flat { - self.controller - .state - .settings - .columns - .set(SortColumn::Path, true); - } - self.controller.state.settings.save(); + self.controller.update_settings(|settings| { + settings.browser_view = if columns { view } else { BrowserView::Details }; + if flat { + settings.columns.set(SortColumn::Path, true); + } + }); self.scroll_to_cursor = true; } fn show_preview(&mut self, entry: usize) { - self.controller.state.settings.preview_visible = true; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.preview_visible = true); self.controller.view_entry(entry); } @@ -1348,15 +1343,15 @@ impl Shell { /// Nudge the text size by `step` points, or back to the default for 0. pub(super) fn step_text_size(&mut self, step: f32) { use crate::settings::{TEXT_DEFAULT, TEXT_LEAST, TEXT_MOST}; - let settings = &mut self.controller.state.settings; + let size = self.controller.state.settings.text_size; let next = if step == 0.0 { TEXT_DEFAULT } else { - (settings.text_size + step).clamp(TEXT_LEAST, TEXT_MOST) + (size + step).clamp(TEXT_LEAST, TEXT_MOST) }; - if next != settings.text_size { - settings.text_size = next; - settings.save(); + if next != size { + self.controller + .update_settings(|settings| settings.text_size = next); } } diff --git a/arca-gui/src/ui/preview.rs b/arca-gui/src/ui/preview.rs index a42cffeb..c580e502 100644 --- a/arca-gui/src/ui/preview.rs +++ b/arca-gui/src/ui/preview.rs @@ -133,8 +133,8 @@ impl Shell { }); }); if close { - self.controller.state.settings.preview_visible = false; - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.preview_visible = false); self.close_preview(); return; } diff --git a/arca-gui/src/ui/sidebar.rs b/arca-gui/src/ui/sidebar.rs index de288b7a..ab9fe651 100644 --- a/arca-gui/src/ui/sidebar.rs +++ b/arca-gui/src/ui/sidebar.rs @@ -263,8 +263,8 @@ impl Shell { self.properties = Some(super::inspect(&path, pinned)); } if let Some(path) = forget { - self.controller.state.settings.recent.retain(|p| *p != path); - self.controller.state.settings.save(); + self.controller + .update_settings(|settings| settings.recent.retain(|p| *p != path)); } }