From 3daccb98e8f4affae795c044b98050f7f79b332a Mon Sep 17 00:00:00 2001 From: Yusuf Bham Date: Sat, 23 Sep 2023 17:20:32 -0400 Subject: [PATCH] feat: update path selection ui and logic Use the executable instead of the folder for selection, which bypasses issues with old FreeDesktop file portal pickers not having support for folders. Closes #186, #177. --- Scarab.sln.DotSettings | 3 +- ...ckViewModel.cs => MockModPageViewModel.cs} | 8 +- Scarab/Resources.fr.resx | 2 +- Scarab/Resources.hu-HU.resx | 2 +- Scarab/Resources.pt-BR.resx | 2 +- Scarab/Resources.resx | 2 +- Scarab/Resources.zh.resx | 2 +- Scarab/Settings.cs | 21 ++- Scarab/Util/PathUtil.cs | 145 ++++++++---------- Scarab/Util/ValidPath.cs | 23 ++- Scarab/ViewModels/PathViewModel.cs | 26 ++++ Scarab/ViewModels/SettingsViewModel.cs | 27 +++- Scarab/Views/AboutView.axaml | 2 +- Scarab/Views/ModDetailsView.axaml | 2 +- Scarab/Views/ModListItem.axaml | 2 +- Scarab/Views/ModListView.axaml | 2 +- Scarab/Views/ModPageView.axaml | 2 +- Scarab/Views/PathWindow.axaml | 95 ++++++++++++ Scarab/Views/PathWindow.axaml.cs | 108 +++++++++++++ Scarab/Views/SettingsView.axaml | 2 +- 20 files changed, 367 insertions(+), 111 deletions(-) rename Scarab/Mock/{MockViewModel.cs => MockModPageViewModel.cs} (90%) create mode 100644 Scarab/ViewModels/PathViewModel.cs create mode 100644 Scarab/Views/PathWindow.axaml create mode 100644 Scarab/Views/PathWindow.axaml.cs diff --git a/Scarab.sln.DotSettings b/Scarab.sln.DotSettings index 723df8bc..d6da59ed 100644 --- a/Scarab.sln.DotSettings +++ b/Scarab.sln.DotSettings @@ -8,4 +8,5 @@ True True True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/Scarab/Mock/MockViewModel.cs b/Scarab/Mock/MockModPageViewModel.cs similarity index 90% rename from Scarab/Mock/MockViewModel.cs rename to Scarab/Mock/MockModPageViewModel.cs index 90bf7287..910d3c79 100644 --- a/Scarab/Mock/MockViewModel.cs +++ b/Scarab/Mock/MockModPageViewModel.cs @@ -15,7 +15,13 @@ public DesignModPageViewModel(ISettings settings, public IModDatabase Database { get; } } -public static class MockViewModel +public static class MockPathViewModel +{ + public static PathViewModel DesignInstance => + new PathViewModel(new SuffixNotFoundError("/home/home/Downloads", PathUtil.SUFFIXES)); +}; + +public static class MockModPageViewModel { public static DesignModPageViewModel DesignInstance { diff --git a/Scarab/Resources.fr.resx b/Scarab/Resources.fr.resx index 7e7e4514..e3f13a9c 100644 --- a/Scarab/Resources.fr.resx +++ b/Scarab/Resources.fr.resx @@ -268,7 +268,7 @@ Dossier Managed ou Assembly-CSharp non trouvé ! - Sélectionner le dossier de Hollow Knight. + Sélectionner le {0} de Hollow Knight. Chemin diff --git a/Scarab/Resources.hu-HU.resx b/Scarab/Resources.hu-HU.resx index b8ea3f70..2eb0cd91 100644 --- a/Scarab/Resources.hu-HU.resx +++ b/Scarab/Resources.hu-HU.resx @@ -268,7 +268,7 @@ Hiányzó Managed mappa, vagy Assembly-CSharp! - Add meg a Hollow Knight mappát! + Add meg a Hollow Knight {0}! Útvonal diff --git a/Scarab/Resources.pt-BR.resx b/Scarab/Resources.pt-BR.resx index 1297fdd9..f28c2314 100644 --- a/Scarab/Resources.pt-BR.resx +++ b/Scarab/Resources.pt-BR.resx @@ -268,7 +268,7 @@ A pasta Managed ou o Assembly-CSharp está ausente! - Selecione a sua pasta do Hollow Knight. + Selecione a sua {0} do Hollow Knight. Caminho diff --git a/Scarab/Resources.resx b/Scarab/Resources.resx index 490d7925..2ddea4de 100644 --- a/Scarab/Resources.resx +++ b/Scarab/Resources.resx @@ -268,7 +268,7 @@ Missing Managed folder or Assembly-CSharp! - Select your Hollow Knight folder. + Select your Hollow Knight {0}. Path diff --git a/Scarab/Resources.zh.resx b/Scarab/Resources.zh.resx index 5d83d068..655aa610 100644 --- a/Scarab/Resources.zh.resx +++ b/Scarab/Resources.zh.resx @@ -268,7 +268,7 @@ 缺少Managed或Assembly-CSharp.dll - 选择您的空洞骑士文件夹 + 选择你的空洞骑士 {0} 路径 diff --git a/Scarab/Settings.cs b/Scarab/Settings.cs index b09e35ba..941bdd6a 100644 --- a/Scarab/Settings.cs +++ b/Scarab/Settings.cs @@ -78,21 +78,25 @@ public static string GetOrCreateDirPath() internal static bool TryAutoDetect([MaybeNullWhen(false)] out ValidPath path) { - path = STATIC_PATHS.Select(PathUtil.ValidateWithSuffix).FirstOrDefault(x => x is not null); + var p = STATIC_PATHS.Select(PathUtil.ValidateWithSuffix).FirstOrDefault(x => x is not null); // If that's valid, use it. - if (path is not null) + if (p is ValidPath v) + { + path = v; return true; + } // Otherwise, we go through the user profile suffixes. string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); path = USER_SUFFIX_PATHS - .Select(suffix => Path.Combine(home, suffix)) - .Select(PathUtil.ValidateWithSuffix) - .FirstOrDefault(x => x is not null); - - return path is not null || TryDetectFromRegistry(out path); + .Select(suffix => Path.Combine(home, suffix)) + .Select(PathUtil.ValidateWithSuffix) + .Select(x => x as ValidPath) + .FirstOrDefault(x => x is not null); + + return p is not null || TryDetectFromRegistry(out path); } private static bool TryDetectFromRegistry([MaybeNullWhen(false)] out ValidPath path) @@ -114,7 +118,7 @@ private static bool TryDetectGogRegistry([MaybeNullWhen(false)] out ValidPath pa return false; // Double check, just in case. - if (PathUtil.ValidateWithSuffix(gog_path) is not { } validPath) + if (PathUtil.ValidateWithSuffix(gog_path) is not ValidPath validPath) return false; path = validPath; @@ -164,6 +168,7 @@ or DirectoryNotFoundException path = library_paths.Select(library_path => Path.Combine(library_path, "steamapps", "common", "Hollow Knight")) .Select(PathUtil.ValidateWithSuffix) + .Select(x => x as ValidPath) .FirstOrDefault(x => x is not null); return path is not null; diff --git a/Scarab/Util/PathUtil.cs b/Scarab/Util/PathUtil.cs index ffe37031..0dfc5a10 100644 --- a/Scarab/Util/PathUtil.cs +++ b/Scarab/Util/PathUtil.cs @@ -1,115 +1,83 @@ using System.Runtime.InteropServices; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform.Storage; -using MessageBox.Avalonia; -using MessageBox.Avalonia.DTO; +using Scarab.Views; namespace Scarab.Util; public static class PathUtil { - // There isn't any [return: MaybeNullWhen(param is null)] so this overload will have to do - // Not really a huge point but it's nice to have the nullable static analysis - public static async Task SelectPathFallible() => await SelectPath(true); - - public static async Task SelectPath(bool fail = false) + public static async Task SelectPath(Window? parent = null) { Log.Information("Selecting path..."); - Window parent = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow - ?? throw new InvalidOperationException(); + parent ??= (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow + ?? throw new InvalidOperationException(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - return await SelectMacApp(parent, fail); + PathResult res = await TrySelection(parent); while (true) { - IStorageFolder? result = (await parent.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions - { - AllowMultiple = false, - Title = Resources.PU_SelectPath - })).FirstOrDefault(); - - - if (result is null) - { - await MessageBoxManager.GetMessageBoxStandardWindow( - Resources.PU_InvalidPathTitle, - Resources.PU_NoSelect - ) - .Show(); - - Log.Information("No path was selected!"); - } - else if (ValidateWithSuffix(result.Path.LocalPath) is not var (managed, suffix)) + if (res is not ValidPath (var managed, var suffix)) { - await MessageBoxManager.GetMessageBoxStandardWindow( - new MessageBoxStandardParams - { - ContentTitle = Resources.PU_InvalidPathTitle, - ContentHeader = Resources.PU_InvalidPathHeader, - ContentMessage = Resources.PU_InvalidPath, - MinHeight = 140 - } - ) - .Show(); + Log.Information("Invalid path selection! {Result}", res); - Log.Information("User selected invalid path {Path}", result.Path.LocalPath); + var w = new PathWindow { ViewModel = new PathViewModel(res) }; + + // The dialog asks the user to select again, so we check + // if we got a non-null path back from it + if (await w.ShowDialog(parent) is { } p) + return p; } else { return Path.Combine(managed, suffix); } - - if (fail) - return null!; } } - // ReSharper disable once SuggestBaseTypeForParameter - private static async Task SelectMacApp(Window parent, bool fail) + public static async Task TrySelection(Window? parent = null) { - while (true) + string LocalizeToOS() { - IStorageFile? result = (await parent.StorageProvider.OpenFilePickerAsync( - new FilePickerOpenOptions - { - AllowMultiple = false, - FileTypeFilter = new[] { new FilePickerFileType("app") { Patterns = new[] { "*.app" } } } - } - )).FirstOrDefault(); - - if (result is null) - { - await MessageBoxManager.GetMessageBoxStandardWindow( - Resources.PU_InvalidPathTitle, - Resources.PU_NoSelectMac - ) - .Show(); - - Log.Information("No path was selected!"); - } - // Don't need to log these, as ValidateWithSuffix does so for us - else if (ValidateWithSuffix(result.Path.LocalPath) is not var (managed, suffix)) - { - await MessageBoxManager.GetMessageBoxStandardWindow(new MessageBoxStandardParams { - ContentTitle = Resources.PU_InvalidPathTitle, - ContentHeader = Resources.PU_InvalidAppHeader, - ContentMessage = Resources.PU_InvalidApp, - MinHeight = 200 - }).Show(); - } - else + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return string.Format(Resources.PU_SelectPath, "app"); + + // ReSharper disable once ConvertIfStatementToReturnStatement + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return string.Format(Resources.PU_SelectPath, "exe"); + + // Default to the linux one, + return string.Format(Resources.PU_SelectPath, "hollow_knight.x86_64"); + } + + parent ??= Application.Current?.ApplicationLifetime is + IClassicDesktopStyleApplicationLifetime { MainWindow: { } main } + ? main + : throw new InvalidOperationException("No window found!"); + + IStorageFile? result = (await parent.StorageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions { - return Path.Combine(managed, suffix); + Title = LocalizeToOS(), + AllowMultiple = false, + FileTypeFilter = new[] { new FilePickerFileType("Hollow Knight file") { + Patterns = new[] { "*.app", "*.exe", "*.x86_64" } + } } } + )).FirstOrDefault(); - if (fail) - return null!; - } - } + if (result is not { Path.LocalPath: var localPath }) + return new PathNotSelectedError(); - private static readonly string[] SUFFIXES = + var path = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? localPath + : Path.GetDirectoryName(localPath)!; + + return ValidateWithSuffix(path); + } + + internal static readonly string[] SUFFIXES = { // GoG "Hollow Knight_Data/Managed", @@ -119,10 +87,13 @@ await MessageBoxManager.GetMessageBoxStandardWindow(new MessageBoxStandardParams "Contents/Resources/Data/Managed" }; - public static ValidPath? ValidateWithSuffix(string root) + public static PathResult ValidateWithSuffix(string? root) { + if (root is null) + return new PathNotSelectedError(); + if (!Directory.Exists(root)) - return null; + return new RootNotFoundError(); string? suffix = SUFFIXES.FirstOrDefault(s => { @@ -136,7 +107,8 @@ await MessageBoxManager.GetMessageBoxStandardWindow(new MessageBoxStandardParams if (suffix is null) { Log.Information("Selected path root {Root} had no valid suffix with Managed folder!", root); - return null; + + return new SuffixNotFoundError(root, SUFFIXES.Select(s => Path.Combine(root, s)).ToArray()); } if (File.Exists(Path.Combine(root, suffix, "Assembly-CSharp.dll"))) @@ -151,7 +123,10 @@ await MessageBoxManager.GetMessageBoxStandardWindow(new MessageBoxStandardParams suffix ); - return null; + return new AssemblyNotFoundError( + Path.Combine(root, suffix), + new[] { Path.Combine(root, suffix, "Assembly-CSharp.dll") } + ); } diff --git a/Scarab/Util/ValidPath.cs b/Scarab/Util/ValidPath.cs index c41625b4..2b0188a6 100644 --- a/Scarab/Util/ValidPath.cs +++ b/Scarab/Util/ValidPath.cs @@ -1,3 +1,24 @@ namespace Scarab.Util; -public record ValidPath(string Root, string Suffix); \ No newline at end of file +public abstract record PathResult +{ + public string? Path => this switch + { + // Not a failure + ValidPath v => System.IO.Path.Combine(v.Root, v.Suffix), + + RootNotFoundError => null, + SuffixNotFoundError s => s.Root, + AssemblyNotFoundError a => a.Root, + PathNotSelectedError => null, + + _ => throw new ArgumentOutOfRangeException() + }; +} + +public record ValidPath(string Root, string Suffix) : PathResult; + +public record RootNotFoundError : PathResult; +public record SuffixNotFoundError(string Root, string[] AttemptedSuffixes) : PathResult; +public record AssemblyNotFoundError(string Root, string[] MissingFiles) : PathResult; +public record PathNotSelectedError : PathResult; \ No newline at end of file diff --git a/Scarab/ViewModels/PathViewModel.cs b/Scarab/ViewModels/PathViewModel.cs new file mode 100644 index 00000000..7bb5625d --- /dev/null +++ b/Scarab/ViewModels/PathViewModel.cs @@ -0,0 +1,26 @@ +namespace Scarab.ViewModels; + +[UsedImplicitly] +public partial class PathViewModel : ViewModelBase +{ + public string? Selection => Result.Path; + + [Notify] + private PathResult _result; + + public ReactiveCommand ChangePath { get; } + + public PathViewModel(PathResult res) + { + ChangePath = ReactiveCommand.CreateFromTask(ChangePathAsync); + Log.Debug("Result = {Result}", res); + Result = _result = res; + } + + private async Task ChangePathAsync() + { + Result = await PathUtil.TrySelection(); + + Log.Information("Set selection to new path: {Path}", Selection); + } +} \ No newline at end of file diff --git a/Scarab/ViewModels/SettingsViewModel.cs b/Scarab/ViewModels/SettingsViewModel.cs index fbd69379..7b50a7fe 100644 --- a/Scarab/ViewModels/SettingsViewModel.cs +++ b/Scarab/ViewModels/SettingsViewModel.cs @@ -1,6 +1,8 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Styling; using MessageBox.Avalonia; +using MessageBox.Avalonia.Enums; +using Scarab.Views; namespace Scarab.ViewModels; @@ -78,11 +80,28 @@ public SettingsViewModel(ISettings settings, IModSource mods) private async Task ChangePathAsync() { - string? path = await PathUtil.SelectPathFallible(); - - if (path is null) + var main = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow + ?? throw new InvalidOperationException(); + + var res = await PathUtil.TrySelection(main); + + string? path; + + if (res is not ValidPath (var root, var suffix)) + { + var w = new PathWindow { ViewModel = new PathViewModel(res) }; + + path = await w.ShowDialog(main); + } + else + { + path = Path.Combine(root, suffix); + } + + // User closed out of the dialog + if (string.IsNullOrEmpty(path)) return; - + Settings.ManagedFolder = path; Settings.Save(); diff --git a/Scarab/Views/AboutView.axaml b/Scarab/Views/AboutView.axaml index cfae9c2a..1ee20d7a 100644 --- a/Scarab/Views/AboutView.axaml +++ b/Scarab/Views/AboutView.axaml @@ -9,7 +9,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:DataType="viewModels:AboutViewModel" x:Class="Scarab.Views.AboutView" - d:DataContext="{x:Static mock:MockViewModel.AboutInstance}"> + d:DataContext="{x:Static mock:MockModPageViewModel.AboutInstance}"> diff --git a/Scarab/Views/ModDetailsView.axaml b/Scarab/Views/ModDetailsView.axaml index 023bddbc..2250ff00 100644 --- a/Scarab/Views/ModDetailsView.axaml +++ b/Scarab/Views/ModDetailsView.axaml @@ -15,7 +15,7 @@ mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="550" - d:DataContext="{x:Static mock:MockViewModel.DesignInstance}" + d:DataContext="{x:Static mock:MockModPageViewModel.DesignInstance}" x:DataType="viewModels:ModPageViewModel"> diff --git a/Scarab/Views/ModListView.axaml b/Scarab/Views/ModListView.axaml index bd7f1f6b..b8be45fa 100644 --- a/Scarab/Views/ModListView.axaml +++ b/Scarab/Views/ModListView.axaml @@ -9,7 +9,7 @@ xmlns:mock="clr-namespace:Scarab.Mock;assembly=Scarab" mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="550" x:DataType="viewModels:ModPageViewModel" - d:DataContext="{x:Static mock:MockViewModel.DesignInstance}" + d:DataContext="{x:Static mock:MockModPageViewModel.DesignInstance}" Name="UserControl"> diff --git a/Scarab/Views/PathWindow.axaml b/Scarab/Views/PathWindow.axaml new file mode 100644 index 00000000..24445e36 --- /dev/null +++ b/Scarab/Views/PathWindow.axaml @@ -0,0 +1,95 @@ + + + + Unable to verify path! + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Scarab/Views/PathWindow.axaml.cs b/Scarab/Views/PathWindow.axaml.cs new file mode 100644 index 00000000..f8f20225 --- /dev/null +++ b/Scarab/Views/PathWindow.axaml.cs @@ -0,0 +1,108 @@ +using Projektanker.Icons.Avalonia; + +namespace Scarab.Views; + +public partial class PathWindow : ReactiveWindow +{ + public PathWindow() + { + InitializeComponent(); + + this.WhenActivatedVM(Act); + } + + private void Act(PathViewModel vm, CompositeDisposable d) + { + // TODO: i18n + void OnNext(string? s) + { + switch (vm.Result) + { + case RootNotFoundError: + { + VerificationExpander.IsVisible = false; + + VerificationBlock.IsVisible = true; + VerificationBlock.Text = "Root not found!"; + break; + } + + case AssemblyNotFoundError e: + { + VerificationBlock.IsVisible = false; + + VerificationExpander.IsVisible = true; + VerificationExpander.Header = "Assembly not found!"; + + var files = e.MissingFiles.Select(x => (x, success: false)) + .Prepend((e.Root, success: true)); + + ShowFiles(files); + + break; + } + + case PathNotSelectedError: + { + VerificationExpander.IsVisible = false; + + VerificationBlock.IsVisible = true; + VerificationBlock.Text = "No path selected!"; + break; + } + + case SuffixNotFoundError se: + { + VerificationBlock.IsVisible = false; + + VerificationExpander.IsVisible = true; + VerificationExpander.Header = "Couldn't find Managed folder!"; + + ShowFiles(se.AttemptedSuffixes.Select(x => (x, success: false))); + + break; + } + + case ValidPath: + { + Close(dialogResult: vm.Selection); + + break; + } + } + } + + vm.WhenAnyValue(x => x.Selection) + .Subscribe(OnNext) + .DisposeWith(d); + } + + private void ShowFiles(IEnumerable<(string path, bool success)> files) + { + VerificationPanel.Children.Clear(); + + foreach (var (ind, file) in files.Select((x, ind) => (ind, x))) + { + var g = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("Auto,*") }; + + var suffixText = new TextBlock { Text = file.path }; + + var icon = new Icon { + Value = file.success + ? "fa-solid fa-check" + : "fa-solid fa-xmark", + }; + + suffixText.SetValue(Grid.RowProperty, ind); + suffixText.SetValue(Grid.ColumnProperty, 1); + + icon.SetValue(Grid.RowProperty, ind); + icon.SetValue(Grid.ColumnProperty, 0); + + g.Children.Add(suffixText); + g.Children.Add(icon); + + VerificationPanel.Children.Add(g); + } + } +} \ No newline at end of file diff --git a/Scarab/Views/SettingsView.axaml b/Scarab/Views/SettingsView.axaml index 34295308..331e70ac 100644 --- a/Scarab/Views/SettingsView.axaml +++ b/Scarab/Views/SettingsView.axaml @@ -10,7 +10,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Scarab.Views.SettingsView" x:DataType="viewModels:SettingsViewModel" - d:DataContext="{x:Static mock:MockViewModel.SettingsInstance}"> + d:DataContext="{x:Static mock:MockModPageViewModel.SettingsInstance}">