diff --git a/CHANGELOG.md b/CHANGELOG.md index d424f9f..3aa8d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ to better preserve the files' executable permissions. For Steam roots, this also supports shortcuts to non-Steam games, where the placeholder will map to the shortcut's dynamic app ID. * Paths may now use the `` placeholder. + * GUI: On the backup and restore screens, + if you activate the filter options, + then the backup/restore buttons will only process the currently listed games. + This allows you to quickly scan a specific subset of games. * You can now choose whether a custom game will override or extend a manifest entry with the same name. Previously, it would always override the manifest entry completely. diff --git a/lang/en-US.ftl b/lang/en-US.ftl index 4fc3288..e152af8 100644 --- a/lang/en-US.ftl +++ b/lang/en-US.ftl @@ -286,3 +286,5 @@ new-version-available = An application update is available: {$version}. Would yo custom-game-will-override = This custom game overrides a manifest entry custom-game-will-extend = This custom game extends a manifest entry + +operation-will-only-include-listed-games = This will only process the games that are currently listed diff --git a/src/gui/app.rs b/src/gui/app.rs index c60f837..a5a07b1 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -274,7 +274,7 @@ impl App { preview, repair, jump, - games, + mut games, } => { if !self.operation.idle() { return Task::none(); @@ -282,10 +282,19 @@ impl App { let mut cleared_log = false; if games.is_none() { - self.backup_screen.log.clear(); - self.backup_screen.duplicate_detector.clear(); - self.reset_scroll_position(ScrollSubject::Backup); - cleared_log = true; + if self.backup_screen.log.is_filtered() { + games = Some(self.backup_screen.log.visible_games( + false, + &self.config, + &self.manifest.extended, + &self.backup_screen.duplicate_detector, + )); + } else { + self.backup_screen.log.clear(); + self.backup_screen.duplicate_detector.clear(); + self.reset_scroll_position(ScrollSubject::Backup); + cleared_log = true; + } } if jump { @@ -693,7 +702,7 @@ impl App { fn handle_restore(&mut self, phase: RestorePhase) -> Task { match phase { RestorePhase::Confirm { games } => self.show_modal(Modal::ConfirmRestore { games }), - RestorePhase::Start { preview, games } => { + RestorePhase::Start { preview, mut games } => { if !self.operation.idle() { return Task::none(); } @@ -707,10 +716,19 @@ impl App { let mut cleared_log = false; if games.is_none() { - self.restore_screen.log.clear(); - self.restore_screen.duplicate_detector.clear(); - self.reset_scroll_position(ScrollSubject::Restore); - cleared_log = true; + if self.restore_screen.log.is_filtered() { + games = Some(self.restore_screen.log.visible_games( + false, + &self.config, + &self.manifest.extended, + &self.restore_screen.duplicate_detector, + )); + } else { + self.restore_screen.log.clear(); + self.restore_screen.duplicate_detector.clear(); + self.reset_scroll_position(ScrollSubject::Restore); + cleared_log = true; + } } self.operation = diff --git a/src/gui/button.rs b/src/gui/button.rs index a26d3a6..4053773 100644 --- a/src/gui/button.rs +++ b/src/gui/button.rs @@ -1,4 +1,4 @@ -use iced::{alignment, keyboard}; +use iced::{alignment, keyboard, Length}; use crate::{ gui::{ @@ -8,7 +8,7 @@ use crate::{ }, icon::Icon, style, - widget::{text, Button, Element, Text}, + widget::{text, Button, Container, Element, Row, Text, Tooltip}, }, lang::TRANSLATOR, prelude::{Finality, SyncDirection}, @@ -33,6 +33,48 @@ fn template_bare(content: Text, action: Option, style: Option, + style: Option, + icon: Option, + tooltip: Option, +) -> Element { + let button = match icon { + Some(icon) => template_complex( + Container::new( + Row::new() + .spacing(5) + .push(icon.text_narrow()) + .push(content.width(Length::Shrink)), + ) + .center_x(WIDTH), + action, + style, + ), + None => template(content, action, style), + }; + + match tooltip { + Some(tooltip) => Tooltip::new(button, text(tooltip), iced::widget::tooltip::Position::Top) + .class(style::Container::Tooltip) + .into(), + None => button, + } +} + +fn template_complex<'a>( + content: impl Into>, + action: Option, + style: Option, +) -> Element<'a> { + Button::new(content) + .on_press_maybe(action) + .class(style.unwrap_or(style::Button::Primary)) + .padding(5) + .into() +} + pub fn primary<'a>(content: String, action: Option) -> Element<'a> { Button::new(text(content).align_x(alignment::Horizontal::Center)) .on_press_maybe(action) @@ -329,8 +371,8 @@ pub fn download<'a>(operation: &Operation) -> Element<'a> { ) } -pub fn backup<'a>(ongoing: &Operation) -> Element<'a> { - template( +pub fn backup<'a>(ongoing: &Operation, filtered: bool) -> Element<'a> { + template_extended( text(match ongoing { Operation::Backup { finality: Finality::Final, @@ -363,11 +405,13 @@ pub fn backup<'a>(ongoing: &Operation) -> Element<'a> { } ) .then_some(style::Button::Negative), + filtered.then_some(Icon::Filter), + filtered.then(|| TRANSLATOR.operation_will_only_include_listed_games()), ) } -pub fn backup_preview<'a>(ongoing: &Operation) -> Element<'a> { - template( +pub fn backup_preview<'a>(ongoing: &Operation, filtered: bool) -> Element<'a> { + template_extended( text(match ongoing { Operation::Backup { finality: Finality::Preview, @@ -405,11 +449,13 @@ pub fn backup_preview<'a>(ongoing: &Operation) -> Element<'a> { } ) .then_some(style::Button::Negative), + filtered.then_some(Icon::Filter), + filtered.then(|| TRANSLATOR.operation_will_only_include_listed_games()), ) } -pub fn restore<'a>(ongoing: &Operation) -> Element<'a> { - template( +pub fn restore<'a>(ongoing: &Operation, filtered: bool) -> Element<'a> { + template_extended( text(match ongoing { Operation::Restore { finality: Finality::Final, @@ -442,11 +488,13 @@ pub fn restore<'a>(ongoing: &Operation) -> Element<'a> { } ) .then_some(style::Button::Negative), + filtered.then_some(Icon::Filter), + filtered.then(|| TRANSLATOR.operation_will_only_include_listed_games()), ) } -pub fn restore_preview<'a>(ongoing: &Operation) -> Element<'a> { - template( +pub fn restore_preview<'a>(ongoing: &Operation, filtered: bool) -> Element<'a> { + template_extended( text(match ongoing { Operation::Restore { finality: Finality::Preview, @@ -482,6 +530,8 @@ pub fn restore_preview<'a>(ongoing: &Operation) -> Element<'a> { } ) .then_some(style::Button::Negative), + filtered.then_some(Icon::Filter), + filtered.then(|| TRANSLATOR.operation_will_only_include_listed_games()), ) } diff --git a/src/gui/game_list.rs b/src/gui/game_list.rs index 607cd2b..bf67117 100644 --- a/src/gui/game_list.rs +++ b/src/gui/game_list.rs @@ -423,31 +423,16 @@ impl GameList { let content = self .entries .iter() - .filter(|x| { - config.should_show_game( - &x.scan_info.game_name, + .filter(|entry| { + self.filter_game( + entry, restoring, - x.scan_info.overall_change().is_changed(), - x.scan_info.found_anything(), + config, + manifest, + duplicate_detector, + duplicatees.as_ref(), ) }) - .filter(|x| { - !self.search.show - || self.search.qualifies( - &x.scan_info, - manifest, - config.is_game_enabled_for_operation(&x.scan_info.game_name, restoring), - config.is_game_customized(&x.scan_info.game_name), - duplicate_detector.is_game_duplicated(&x.scan_info.game_name), - config.scan.show_deselected_games, - ) - }) - .filter(|x| { - duplicatees - .as_ref() - .map(|xs| xs.contains(&x.scan_info.game_name)) - .unwrap_or(true) - }) .fold( Column::new() .width(Length::Fill) @@ -478,6 +463,76 @@ impl GameList { .all(|x| config.is_game_enabled_for_operation(&x.scan_info.game_name, restoring)) } + fn filter_game( + &self, + entry: &GameListEntry, + restoring: bool, + config: &Config, + manifest: &Manifest, + duplicate_detector: &DuplicateDetector, + duplicatees: Option<&HashSet>, + ) -> bool { + let show = config.should_show_game( + &entry.scan_info.game_name, + restoring, + entry.scan_info.overall_change().is_changed(), + entry.scan_info.found_anything(), + ); + + let qualifies = self.search.qualifies( + &entry.scan_info, + manifest, + config.is_game_enabled_for_operation(&entry.scan_info.game_name, restoring), + config.is_game_customized(&entry.scan_info.game_name), + duplicate_detector.is_game_duplicated(&entry.scan_info.game_name), + config.scan.show_deselected_games, + ); + + let duplicate = duplicatees + .as_ref() + .map(|xs| xs.contains(&entry.scan_info.game_name)) + .unwrap_or(true); + + show && qualifies && duplicate + } + + pub fn visible_games( + &self, + restoring: bool, + config: &Config, + manifest: &Manifest, + duplicate_detector: &DuplicateDetector, + ) -> Vec { + let duplicatees = self.filter_duplicates_of.as_ref().and_then(|game| { + let mut duplicatees = duplicate_detector.duplicate_games(game); + if duplicatees.is_empty() { + None + } else { + duplicatees.insert(game.clone()); + Some(duplicatees) + } + }); + + self.entries + .iter() + .filter(|entry| { + self.filter_game( + entry, + restoring, + config, + manifest, + duplicate_detector, + duplicatees.as_ref(), + ) + }) + .map(|x| x.scan_info.game_name.clone()) + .collect() + } + + pub fn is_filtered(&self) -> bool { + self.search.show || self.filter_duplicates_of.is_some() + } + pub fn compute_operation_status(&self, config: &Config, restoring: bool) -> OperationStatus { let mut status = OperationStatus::default(); for entry in self.entries.iter() { diff --git a/src/gui/screen.rs b/src/gui/screen.rs index f07e190..c5f6efc 100644 --- a/src/gui/screen.rs +++ b/src/gui/screen.rs @@ -90,8 +90,8 @@ impl Backup { .padding([0, 20]) .spacing(20) .align_y(Alignment::Center) - .push(button::backup_preview(operation)) - .push(button::backup(operation)) + .push(button::backup_preview(operation, self.log.is_filtered())) + .push(button::backup(operation, self.log.is_filtered())) .push(button::toggle_all_scanned_games( self.log.all_entries_selected(config, false), )) @@ -165,8 +165,8 @@ impl Restore { .padding([0, 20]) .spacing(20) .align_y(Alignment::Center) - .push(button::restore_preview(operation)) - .push(button::restore(operation)) + .push(button::restore_preview(operation, self.log.is_filtered())) + .push(button::restore(operation, self.log.is_filtered())) .push(button::toggle_all_scanned_games( self.log.all_entries_selected(config, true), )) diff --git a/src/gui/search.rs b/src/gui/search.rs index 7761757..25d7dc3 100644 --- a/src/gui/search.rs +++ b/src/gui/search.rs @@ -119,6 +119,10 @@ impl FilterComponent { duplicated: Duplication, show_deselected_games: bool, ) -> bool { + if !self.show { + return true; + } + let fuzzy = self.game_name.is_empty() || fuzzy_matcher::skim::SkimMatcherV2::default() .fuzzy_match(&scan.game_name, &self.game_name) diff --git a/src/lang.rs b/src/lang.rs index aae6072..0bdac6d 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -1434,6 +1434,10 @@ impl Translator { pub fn custom_game_will_extend(&self) -> String { translate("custom-game-will-extend") } + + pub fn operation_will_only_include_listed_games(&self) -> String { + translate("operation-will-only-include-listed-games") + } } #[cfg(test)]