diff --git a/Image-Sort.sln b/Image-Sort.sln index c691b894..34b4f781 100644 --- a/Image-Sort.sln +++ b/Image-Sort.sln @@ -31,6 +31,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0F31C99F EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageSort.WPF.UiTests", "tests\ImageSort.WPF.UiTests\ImageSort.WPF.UiTests.csproj", "{D72448F9-569A-4BFA-A0C7-79F20BE17F4F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageSort.Avalonia", "src\ImageSort.Avalonia\ImageSort.Avalonia.csproj", "{1086BFB7-F980-4B08-956F-555B06C70992}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -222,6 +224,30 @@ Global {D72448F9-569A-4BFA-A0C7-79F20BE17F4F}.Release|x64.Build.0 = Release|Any CPU {D72448F9-569A-4BFA-A0C7-79F20BE17F4F}.Release|x86.ActiveCfg = Release|Any CPU {D72448F9-569A-4BFA-A0C7-79F20BE17F4F}.Release|x86.Build.0 = Release|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|ARM64.Build.0 = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|x64.ActiveCfg = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|x64.Build.0 = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|x86.ActiveCfg = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|x86.Build.0 = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|Any CPU.ActiveCfg = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|Any CPU.Build.0 = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|ARM64.ActiveCfg = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|ARM64.Build.0 = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|x64.ActiveCfg = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|x64.Build.0 = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|x86.ActiveCfg = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|x86.Build.0 = Debug|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Release|Any CPU.Build.0 = Release|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Release|ARM64.ActiveCfg = Release|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Release|ARM64.Build.0 = Release|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Release|x64.ActiveCfg = Release|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Release|x64.Build.0 = Release|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Release|x86.ActiveCfg = Release|Any CPU + {1086BFB7-F980-4B08-956F-555B06C70992}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -235,6 +261,7 @@ Global {DF9B003E-4B31-494D-8186-15CE8DD69489} = {D1378C69-4AE6-4045-9CE3-1310C1DAFB2D} {1E511652-E2A5-44EE-B0F6-E8E53FD6AA8F} = {D1378C69-4AE6-4045-9CE3-1310C1DAFB2D} {D72448F9-569A-4BFA-A0C7-79F20BE17F4F} = {0F31C99F-8F09-4B2C-9411-26532A69AE62} + {1086BFB7-F980-4B08-956F-555B06C70992} = {D1378C69-4AE6-4045-9CE3-1310C1DAFB2D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0577168B-4A3F-4316-8E17-0E4423B092CC} diff --git a/src/ImageSort.Avalonia/App.axaml b/src/ImageSort.Avalonia/App.axaml new file mode 100644 index 00000000..5b11a10a --- /dev/null +++ b/src/ImageSort.Avalonia/App.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ImageSort.Avalonia/App.axaml.cs b/src/ImageSort.Avalonia/App.axaml.cs new file mode 100644 index 00000000..bcd131af --- /dev/null +++ b/src/ImageSort.Avalonia/App.axaml.cs @@ -0,0 +1,163 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core; +using Avalonia.Data.Core.Plugins; +using System.Linq; +using Avalonia.Markup.Xaml; +using ImageSort.Avalonia.ViewModels; +using ImageSort.Avalonia.Views; +using System; +using System.Globalization; +using System.Reactive.Concurrency; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using ImageSort.DependencyManagement; +using ImageSort.FileSystem; +using ImageSort.SettingsManagement; +using ReactiveUI; +using Splat; +using ImageSort.Avalonia.SettingsManagement; // For the new SettingsHelper and Restore extension +using ImageSort.ViewModels; // Added for core ViewModels +using System.IO; // Required for FileSystemWatcher +using ImageSort.Avalonia.FileSystem; // Added for TemporaryRecycleBin + +#if !DO_NOT_INCLUDE_UPDATER +#endif + +namespace ImageSort.Avalonia; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { +#if DEBUG && !DEBUG_LOCALIZATION + Thread.CurrentThread.CurrentCulture = new CultureInfo("en"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("en"); +#endif + + Locator.CurrentMutable.RegisterViewsForViewModels(Assembly.GetExecutingAssembly()); + Locator.CurrentMutable.RegisterManditoryDependencies(); + Locator.CurrentMutable.RegisterSettings(settings => + { + settings.Add(new GeneralSettingsGroupViewModel()); + settings.Add(new PinnedFolderSettingsViewModel()); + settings.Add(new KeyBindingsSettingsGroupViewModel()); + settings.Add(new MetadataPanelSettings()); + }); + Locator.CurrentMutable.RegisterLazySingleton(() => new SettingsViewModel()); + Locator.CurrentMutable.Register(() => new TemporaryRecycleBin()); // Register TemporaryRecycleBin + + Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory; + + TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + DisableAvaloniaDataAnnotationValidation(); + var fileSystem = Locator.Current.GetService(); + if (fileSystem == null) + { + throw new InvalidOperationException("IFileSystem service not registered."); + } + + var recycleBin = Locator.Current.GetService(); + if (recycleBin == null) + { + throw new InvalidOperationException("IRecycleBin service not registered."); + } + + var backgroundScheduler = RxApp.TaskpoolScheduler; + var mainThreadScheduler = RxApp.MainThreadScheduler; + + var foldersViewModel = new FoldersViewModel(fileSystem, backgroundScheduler); + // Correctly instantiate ImagesViewModel with its actual constructor signature + var imagesViewModel = new ImagesViewModel(fileSystem, null); // Pass fileSystem and null for the optional folderWatcherFactory + // Pass dependencies directly to ActionsViewModel constructor + var actionsViewModel = new ActionsViewModel(imagesViewModel, foldersViewModel, fileSystem); + + var mainWindowViewModel = new MainWindowViewModel( + foldersViewModel, + imagesViewModel, + actionsViewModel, + fileSystem, + recycleBin, + backgroundScheduler); + + desktop.MainWindow = new MainWindow + { + DataContext = mainWindowViewModel, // Set DataContext + ViewModel = mainWindowViewModel // Explicitly set the ViewModel property + }; + + OnAppStartup(); + } + + base.OnFrameworkInitializationCompleted(); + } + + private void DisableAvaloniaDataAnnotationValidation() + { + var dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + + foreach (var plugin in dataValidationPluginsToRemove) + { + BindingPlugins.DataValidators.Remove(plugin); + } + } + + private void CurrentDomain_UnhandledException(object? sender, UnhandledExceptionEventArgs e) + { + System.Diagnostics.Trace.WriteLine(e.ExceptionObject); + } + + private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + System.Diagnostics.Trace.WriteLine(e.Exception); + e.SetObserved(); + } + +#pragma warning disable CS1998 + private async void OnAppStartup() +#pragma warning restore CS1998 + { + var settings = Locator.Current.GetService(); + var mainWindowVm = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow?.DataContext as MainWindowViewModel; + + if (settings != null) + { + settings.Restore(); // This should set CurrentFolder (if saved) and PinnedFolder paths + } + + if (mainWindowVm != null && settings != null) + { + var pinnedFolderSettings = settings.GetGroup(); + if (pinnedFolderSettings != null && pinnedFolderSettings.PinnedFolders != null) + { + mainWindowVm.Folders.AddPinnedFoldersFromPaths(pinnedFolderSettings.PinnedFolders); + } + + // After settings are restored and pinned folders loaded, + // if CurrentFolder has been set (e.g., by settings restoration), + // force it to re-notify. This might help ensure DisplayedFolderItems updates correctly. + if (mainWindowVm.Folders.CurrentFolder != null) + { + var tempCurrentFolder = mainWindowVm.Folders.CurrentFolder; + // Temporarily setting to null and back to the original instance + // to ensure property change notifications are raised. + mainWindowVm.Folders.CurrentFolder = null; + mainWindowVm.Folders.CurrentFolder = tempCurrentFolder; + } + } + +#if !DO_NOT_INCLUDE_UPDATER +#endif + } +} \ No newline at end of file diff --git a/src/ImageSort.Avalonia/Assets/avalonia-logo.ico b/src/ImageSort.Avalonia/Assets/avalonia-logo.ico new file mode 100644 index 00000000..f7da8bb5 Binary files /dev/null and b/src/ImageSort.Avalonia/Assets/avalonia-logo.ico differ diff --git a/src/ImageSort.Avalonia/Converters/PathToBitmapConverter.cs b/src/ImageSort.Avalonia/Converters/PathToBitmapConverter.cs new file mode 100644 index 00000000..37277433 --- /dev/null +++ b/src/ImageSort.Avalonia/Converters/PathToBitmapConverter.cs @@ -0,0 +1,33 @@ +using Avalonia.Data.Converters; +using Avalonia.Media.Imaging; +using System; +using System.Globalization; +using System.IO; + +namespace ImageSort.Avalonia.Converters +{ + public class PathToBitmapConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string path && !string.IsNullOrEmpty(path) && File.Exists(path)) + { + try + { + return new Bitmap(path); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error loading image {path}: {ex.Message}"); + return null; + } + } + return null; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/ImageSort.Avalonia/Converters/PathToFilenameConverter.cs b/src/ImageSort.Avalonia/Converters/PathToFilenameConverter.cs new file mode 100644 index 00000000..384521a1 --- /dev/null +++ b/src/ImageSort.Avalonia/Converters/PathToFilenameConverter.cs @@ -0,0 +1,32 @@ +using Avalonia.Data.Converters; +using System; +using System.Globalization; +using System.IO; + +namespace ImageSort.Avalonia.Converters +{ + public class PathToFilenameConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string path && !string.IsNullOrEmpty(path)) + { + try + { + return Path.GetFileName(path); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting filename from {path}: {ex.Message}"); + return path; // Fallback to full path on error + } + } + return string.Empty; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/ImageSort.Avalonia/FileSystem/TemporaryRecycleBin.cs b/src/ImageSort.Avalonia/FileSystem/TemporaryRecycleBin.cs new file mode 100644 index 00000000..98227011 --- /dev/null +++ b/src/ImageSort.Avalonia/FileSystem/TemporaryRecycleBin.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using ImageSort.FileSystem; +using Splat; + +namespace ImageSort.Avalonia.FileSystem +{ + public class TemporaryRecycleBin : IRecycleBin + { + private readonly IFileSystem _fileSystem; + private const string RecycleBinFolderName = "_ImageSort_RecycleBin_"; + + public TemporaryRecycleBin(IFileSystem fileSystem = null) + { + _fileSystem = fileSystem ?? Locator.Current.GetService(); + } + + public IDisposable Send(string path, bool confirmationNeeded = false) + { + if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); + if (!_fileSystem.FileExists(path)) throw new FileNotFoundException("File not found.", path); + + var fileInfo = new FileInfo(path); + var parentDirectory = fileInfo.DirectoryName; + if (parentDirectory == null) throw new InvalidOperationException("Cannot determine parent directory."); + + var recycleBinDirectory = Path.Combine(parentDirectory, RecycleBinFolderName); + _fileSystem.CreateFolder(recycleBinDirectory); + + var fileName = fileInfo.Name; + var newPathInRecycleBin = Path.Combine(recycleBinDirectory, fileName); + + // Handle potential name conflicts in the recycle bin + int count = 1; + string uniqueFileName = fileName; + while (_fileSystem.FileExists(newPathInRecycleBin)) + { + uniqueFileName = $"{Path.GetFileNameWithoutExtension(fileName)}_{count++}{Path.GetExtension(fileName)}"; + newPathInRecycleBin = Path.Combine(recycleBinDirectory, uniqueFileName); + } + + _fileSystem.Move(path, newPathInRecycleBin); + + return new Restorer(_fileSystem, newPathInRecycleBin, path); + } + + private class Restorer : IDisposable + { + private readonly IFileSystem _fs; + private readonly string _recycledPath; + private readonly string _originalPath; + private bool _disposed = false; + + public Restorer(IFileSystem fs, string recycledPath, string originalPath) + { + _fs = fs; + _recycledPath = recycledPath; + _originalPath = originalPath; + } + + public void Dispose() + { + if (_disposed) return; + + try + { + if (_fs.FileExists(_recycledPath)) + { + // Ensure original directory exists + var originalDir = Path.GetDirectoryName(_originalPath); + if(originalDir != null && !_fs.DirectoryExists(originalDir)) + { + _fs.CreateFolder(originalDir); + } + + // Check if original file name is now taken, if so, find a new name + string pathToRestore = _originalPath; + int count = 1; + while(_fs.FileExists(pathToRestore)) + { + pathToRestore = $"{Path.Combine(Path.GetDirectoryName(_originalPath), Path.GetFileNameWithoutExtension(_originalPath))}_{count++}{Path.GetExtension(_originalPath)}"; + } + + _fs.Move(_recycledPath, pathToRestore); + } + } + catch (Exception ex) + { + // Log or handle restoration failure + // For now, we'll rethrow as FileRestorationNotPossibleException for consistency, + // though the original interface implies this is for when the recycle bin itself fails. + throw new FileRestorationNotPossibleException($"Failed to restore file from temporary recycle bin: {ex.Message}", ex); + } + finally + { + _disposed = true; + } + } + } + } +} diff --git a/src/ImageSort.Avalonia/FodyWeavers.xml b/src/ImageSort.Avalonia/FodyWeavers.xml new file mode 100644 index 00000000..63fc1484 --- /dev/null +++ b/src/ImageSort.Avalonia/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/ImageSort.Avalonia/FodyWeavers.xsd b/src/ImageSort.Avalonia/FodyWeavers.xsd new file mode 100644 index 00000000..f3ac4762 --- /dev/null +++ b/src/ImageSort.Avalonia/FodyWeavers.xsd @@ -0,0 +1,26 @@ + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/src/ImageSort.Avalonia/ImageSort.Avalonia.csproj b/src/ImageSort.Avalonia/ImageSort.Avalonia.csproj new file mode 100644 index 00000000..4dcdd5ee --- /dev/null +++ b/src/ImageSort.Avalonia/ImageSort.Avalonia.csproj @@ -0,0 +1,44 @@ + + + WinExe + net8.0 + enable + true + app.manifest + true + + + + + + + + + + + + + + + + + None + All + + + + + + + + + + + + + + + + + + diff --git a/src/ImageSort.Avalonia/Input/ActionKeyBinding.cs b/src/ImageSort.Avalonia/Input/ActionKeyBinding.cs new file mode 100644 index 00000000..d2446d17 --- /dev/null +++ b/src/ImageSort.Avalonia/Input/ActionKeyBinding.cs @@ -0,0 +1,19 @@ +using System.Windows.Input; + +namespace ImageSort.Avalonia.Input; + +public class ActionKeyBinding +{ + public string ActionName { get; } + public Hotkey Hotkey { get; set; } // Made settable for reassignment + public ICommand Command { get; } + public object? CommandParameter { get; } + + public ActionKeyBinding(string actionName, Hotkey hotkey, ICommand command, object? commandParameter = null) + { + ActionName = actionName; + Hotkey = hotkey; + Command = command; + CommandParameter = commandParameter; + } +} diff --git a/src/ImageSort.Avalonia/Input/AppAction.cs b/src/ImageSort.Avalonia/Input/AppAction.cs new file mode 100644 index 00000000..ddc00281 --- /dev/null +++ b/src/ImageSort.Avalonia/Input/AppAction.cs @@ -0,0 +1,51 @@ +namespace ImageSort.Avalonia.Input; + +public enum AppAction +{ + // Image Navigation + NextImage, + PreviousImage, + + // Action History + Undo, + Redo, + + // Image Operations + MoveImageToCurrentSelectedFolder, // Action for the folder currently selected in the main folder view/grid (e.g. Up Arrow) + DeleteImage, + RenameImage, + + // Folder Tree Navigation/Selection + SelectNextFolderInTree, // e.g., S key (moves selection down in the folder tree) + SelectPreviousFolderInTree, // e.g., W key (moves selection up in the folder tree) + ExpandSelectedTreeFolder, // e.g., D key (expands the selected folder in the tree) + CollapseSelectedTreeFolderOrGoToParent, // e.g., A key (collapses selected folder or navigates to parent of current working folder) + SetSelectedTreeFolderAsCurrent, // e.g., Enter key (makes the folder highlighted in the tree the current working folder) + + // Pinned Folder Image Move Operations + MoveImageToPinnedFolder1, + MoveImageToPinnedFolder2, + MoveImageToPinnedFolder3, + MoveImageToPinnedFolder4, + MoveImageToPinnedFolder5, + MoveImageToPinnedFolder6, + MoveImageToPinnedFolder7, + MoveImageToPinnedFolder8, + MoveImageToPinnedFolder9, + MoveImageToPinnedFolder0, // For the 10th pinned folder + + // UI Control/Focus + FocusSearchBox, + ToggleMetadataPanel, + // Future: FocusFolders, FocusImages, FocusMetadata if direct focus commands are needed + + // Application Level + OpenFolderDialog, // Triggers the 'Open Folder...' functionality (e.g. O key) + PinCurrentFolder, // Pins the current working folder (e.g. P key) + PinSelectedTreeFolder, // Pins the folder highlighted in the tree (e.g. F key) + UnpinSelectedTreeFolder, // Unpins the folder highlighted in the tree (e.g. U key) + CreateFolderInSelectedTreeFolder, // Creates a new folder inside the one highlighted in the tree (e.g. C key) + + // Settings (placeholder for now) + // OpenSettings, +} diff --git a/src/ImageSort.Avalonia/Input/Hotkey.cs b/src/ImageSort.Avalonia/Input/Hotkey.cs new file mode 100644 index 00000000..c733eb69 --- /dev/null +++ b/src/ImageSort.Avalonia/Input/Hotkey.cs @@ -0,0 +1,18 @@ +using Avalonia.Input; +using System.Text; + +namespace ImageSort.Avalonia.Input; + +public record Hotkey(Key Key, KeyModifiers Modifiers, AppAction Action) +{ + public override string ToString() + { + var str = new StringBuilder(); + if (Modifiers.HasFlag(KeyModifiers.Control)) str.Append("Ctrl + "); + if (Modifiers.HasFlag(KeyModifiers.Shift)) str.Append("Shift + "); + if (Modifiers.HasFlag(KeyModifiers.Alt)) str.Append("Alt + "); + if (Modifiers.HasFlag(KeyModifiers.Meta)) str.Append("Meta + "); // For Windows key or Command key + str.Append(Key.ToString()); + return str.ToString(); + } +} diff --git a/src/ImageSort.Avalonia/Input/HotkeyManagerService.cs b/src/ImageSort.Avalonia/Input/HotkeyManagerService.cs new file mode 100644 index 00000000..46153f57 --- /dev/null +++ b/src/ImageSort.Avalonia/Input/HotkeyManagerService.cs @@ -0,0 +1,74 @@ +using Avalonia.Input; +using System.Collections.Generic; +using System.Linq; + +namespace ImageSort.Avalonia.Input; + +public class HotkeyManagerService +{ + private readonly List _hotkeys; + + public HotkeyManagerService() + { + _hotkeys = GetDefaultHotkeys(); + } + + public AppAction? GetActionFor(Key key, KeyModifiers modifiers) + { + var hotkey = _hotkeys.FirstOrDefault(h => h.Key == key && h.Modifiers == modifiers); + return hotkey?.Action; + } + + // TODO: Implement loading and saving of custom hotkeys + // public void LoadCustomHotkeys(string filePath) { ... } + // public void SaveCustomHotkeys(string filePath) { ... } + + private List GetDefaultHotkeys() + { + return new List + { + // Image Navigation + new Hotkey(Key.Right, KeyModifiers.None, AppAction.NextImage), + new Hotkey(Key.Left, KeyModifiers.None, AppAction.PreviousImage), + + // Action History + new Hotkey(Key.Q, KeyModifiers.Control, AppAction.Undo), + new Hotkey(Key.E, KeyModifiers.Control, AppAction.Redo), + + // Image Operations + new Hotkey(Key.Up, KeyModifiers.None, AppAction.MoveImageToCurrentSelectedFolder), + new Hotkey(Key.Down, KeyModifiers.None, AppAction.DeleteImage), + new Hotkey(Key.F2, KeyModifiers.None, AppAction.RenameImage), + + // Folder Tree Navigation/Selection + new Hotkey(Key.S, KeyModifiers.None, AppAction.SelectNextFolderInTree), + new Hotkey(Key.W, KeyModifiers.None, AppAction.SelectPreviousFolderInTree), + new Hotkey(Key.D, KeyModifiers.None, AppAction.ExpandSelectedTreeFolder), + new Hotkey(Key.A, KeyModifiers.None, AppAction.CollapseSelectedTreeFolderOrGoToParent), + new Hotkey(Key.Enter, KeyModifiers.None, AppAction.SetSelectedTreeFolderAsCurrent), + + // Pinned Folder Image Move Operations + new Hotkey(Key.D1, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder1), + new Hotkey(Key.D2, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder2), + new Hotkey(Key.D3, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder3), + new Hotkey(Key.D4, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder4), + new Hotkey(Key.D5, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder5), + new Hotkey(Key.D6, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder6), + new Hotkey(Key.D7, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder7), + new Hotkey(Key.D8, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder8), + new Hotkey(Key.D9, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder9), + new Hotkey(Key.D0, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder0), + + // UI Control/Focus + new Hotkey(Key.F, KeyModifiers.Control, AppAction.FocusSearchBox), + new Hotkey(Key.M, KeyModifiers.Control, AppAction.ToggleMetadataPanel), + + // Application Level + new Hotkey(Key.O, KeyModifiers.Control, AppAction.OpenFolderDialog), + new Hotkey(Key.P, KeyModifiers.None, AppAction.PinCurrentFolder), + new Hotkey(Key.F, KeyModifiers.None, AppAction.PinSelectedTreeFolder), // Note: 'F' without modifier for pinning selected tree folder + new Hotkey(Key.U, KeyModifiers.None, AppAction.UnpinSelectedTreeFolder), + new Hotkey(Key.C, KeyModifiers.Control, AppAction.CreateFolderInSelectedTreeFolder), + }; + } +} diff --git a/src/ImageSort.Avalonia/Program.cs b/src/ImageSort.Avalonia/Program.cs new file mode 100644 index 00000000..6c90c00c --- /dev/null +++ b/src/ImageSort.Avalonia/Program.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.ReactiveUI; +using System; + +namespace ImageSort.Avalonia; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace() + .UseReactiveUI(); +} diff --git a/src/ImageSort.Avalonia/SettingsManagement/GeneralSettingsGroupViewModel.cs b/src/ImageSort.Avalonia/SettingsManagement/GeneralSettingsGroupViewModel.cs new file mode 100644 index 00000000..7ff9fec0 --- /dev/null +++ b/src/ImageSort.Avalonia/SettingsManagement/GeneralSettingsGroupViewModel.cs @@ -0,0 +1,47 @@ +using ImageSort.SettingsManagement; +using ImageSort.Localization; +using ReactiveUI; + +namespace ImageSort.Avalonia.SettingsManagement +{ + public class GeneralSettingsGroupViewModel : SettingsGroupViewModelBase + { + public override string Name => "General"; + public override string Header => Text.GeneralSettingsHeader; + + private bool _darkMode = false; + public bool DarkMode + { + get => _darkMode; + set => this.RaiseAndSetIfChanged(ref _darkMode, value); + } + + private bool _checkForUpdatesOnStartup = true; + public bool CheckForUpdatesOnStartup + { + get => _checkForUpdatesOnStartup; + set => this.RaiseAndSetIfChanged(ref _checkForUpdatesOnStartup, value); + } + + private bool _installPrereleaseBuilds = false; + public bool InstallPrereleaseBuilds + { + get => _installPrereleaseBuilds; + set => this.RaiseAndSetIfChanged(ref _installPrereleaseBuilds, value); + } + + private bool _animateGifs = true; + public bool AnimateGifs + { + get => _animateGifs; + set => this.RaiseAndSetIfChanged(ref _animateGifs, value); + } + + private bool _animateGifThumbnails = true; + public bool AnimateGifThumbnails + { + get => _animateGifThumbnails; + set => this.RaiseAndSetIfChanged(ref _animateGifThumbnails, value); + } + } +} diff --git a/src/ImageSort.Avalonia/SettingsManagement/KeyBindingsSettingsGroupViewModel.cs b/src/ImageSort.Avalonia/SettingsManagement/KeyBindingsSettingsGroupViewModel.cs new file mode 100644 index 00000000..48671fac --- /dev/null +++ b/src/ImageSort.Avalonia/SettingsManagement/KeyBindingsSettingsGroupViewModel.cs @@ -0,0 +1,52 @@ +using ImageSort.SettingsManagement; +using ImageSort.Localization; +using ReactiveUI; +using Avalonia.Input; + +namespace ImageSort.Avalonia.SettingsManagement +{ + public record Hotkey(Key Key, KeyModifiers Modifiers); + + public class KeyBindingsSettingsGroupViewModel : SettingsGroupViewModelBase + { + public override string Name => "KeyBindings"; + public override string Header => Text.KeyBindingsSettingsHeader; + + private Hotkey _move = new Hotkey(Key.Up, KeyModifiers.None); + public Hotkey Move + { + get => _move; + set => this.RaiseAndSetIfChanged(ref _move, value); + } + + private Hotkey _delete = new Hotkey(Key.Down, KeyModifiers.None); + public Hotkey Delete + { + get => _delete; + set => this.RaiseAndSetIfChanged(ref _delete, value); + } + + private Hotkey _rename = new Hotkey(Key.R, KeyModifiers.None); + public Hotkey Rename + { + get => _rename; + set => this.RaiseAndSetIfChanged(ref _rename, value); + } + + // Add other hotkeys as needed, mirroring the WPF version but using Avalonia types. + // For example: + private Hotkey _goLeft = new Hotkey(Key.Left, KeyModifiers.None); + public Hotkey GoLeft + { + get => _goLeft; + set => this.RaiseAndSetIfChanged(ref _goLeft, value); + } + + private Hotkey _goRight = new Hotkey(Key.Right, KeyModifiers.None); + public Hotkey GoRight + { + get => _goRight; + set => this.RaiseAndSetIfChanged(ref _goRight, value); + } + } +} diff --git a/src/ImageSort.Avalonia/SettingsManagement/MetadataPanelSettings.cs b/src/ImageSort.Avalonia/SettingsManagement/MetadataPanelSettings.cs new file mode 100644 index 00000000..86ba6eae --- /dev/null +++ b/src/ImageSort.Avalonia/SettingsManagement/MetadataPanelSettings.cs @@ -0,0 +1,27 @@ +using ImageSort.SettingsManagement; +using ImageSort.Localization; +using ReactiveUI; + +namespace ImageSort.Avalonia.SettingsManagement +{ + public class MetadataPanelSettings : SettingsGroupViewModelBase + { + public override string Name => "MetadataPanel"; + public override string Header => Text.MetadataPanelHeader; + public override bool IsVisible => false; + + private bool _isExpanded = false; + public bool IsExpanded + { + get => _isExpanded; + set => this.RaiseAndSetIfChanged(ref _isExpanded, value); + } + + private int _metadataPanelWidth = 300; + public int MetadataPanelWidth + { + get => _metadataPanelWidth; + set => this.RaiseAndSetIfChanged(ref _metadataPanelWidth, value); + } + } +} diff --git a/src/ImageSort.Avalonia/SettingsManagement/PinnedFolderSettingsViewModel.cs b/src/ImageSort.Avalonia/SettingsManagement/PinnedFolderSettingsViewModel.cs new file mode 100644 index 00000000..ec36fa75 --- /dev/null +++ b/src/ImageSort.Avalonia/SettingsManagement/PinnedFolderSettingsViewModel.cs @@ -0,0 +1,21 @@ +using ImageSort.SettingsManagement; +using ImageSort.Localization; +using ReactiveUI; +using System.Collections.Generic; + +namespace ImageSort.Avalonia.SettingsManagement +{ + public class PinnedFolderSettingsViewModel : SettingsGroupViewModelBase + { + public override string Name => "PinnedFolders"; + public override string Header => Text.PinnedFoldersSettingsHeader; + public override bool IsVisible => false; + + private IEnumerable _pinnedFolders = new List(); + public IEnumerable PinnedFolders + { + get => _pinnedFolders; + set => this.RaiseAndSetIfChanged(ref _pinnedFolders, value); + } + } +} diff --git a/src/ImageSort.Avalonia/SettingsManagement/SettingsHelper.cs b/src/ImageSort.Avalonia/SettingsManagement/SettingsHelper.cs new file mode 100644 index 00000000..0425726b --- /dev/null +++ b/src/ImageSort.Avalonia/SettingsManagement/SettingsHelper.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ImageSort.SettingsManagement; // Core project +using ImageSort.Avalonia.SettingsManagement.ShortCutManagement; // To be created +using Avalonia.Input; // For Avalonia Key and KeyModifiers + +namespace ImageSort.Avalonia.SettingsManagement; + +internal static class SettingsHelper +{ + static SettingsHelper() + { + if (Environment.GetEnvironmentVariable("UI_TEST") is string uiTest) + ConfigFileLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ui_test_config.json"); + } + + public static string ConfigFileLocation { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Image Sort", +#if DEBUG + "debug_config.json" +#else + "config.json" +#endif + ); + + public static async Task SaveAsync(this SettingsViewModel settings) + { + var dir = Path.GetDirectoryName(ConfigFileLocation); + + if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir); + + await using var file = File.Create(ConfigFileLocation); + + var serializerOptions = new JsonSerializerOptions + { + WriteIndented = true, + Converters = { new HotkeyJsonConverter() } // Added for Hotkey serialization + }; + + await JsonSerializer.SerializeAsync(file, settings.AsDictionary(), serializerOptions).ConfigureAwait(false); + } + + public static void Restore(this SettingsViewModel settings) + { + if (!File.Exists(ConfigFileLocation)) return; + + using var configFile = File.OpenRead(ConfigFileLocation); + + // Deserialize with the custom converter for Hotkeys + var serializerOptions = new JsonSerializerOptions + { + Converters = { new HotkeyJsonConverter() } + }; + + var configContents = JsonSerializer + .DeserializeAsync>>(configFile, serializerOptions).Result; + + // The JsonElement to Value conversion needs to be smarter or rely on the converter. + // For now, if HotkeyJsonConverter handles deserialization to Hotkey objects directly during initial deserialize, + // this manual JsonElement parsing for Hotkeys might not be needed or needs adjustment. + // Let's assume HotkeyJsonConverter handles it for now, simplifying this section. + // If not, the JsonElementToValue logic will need to be robustly ported. + + if (configContents != null) settings.RestoreFromDictionary(configContents); + } +} diff --git a/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/Hotkey.cs b/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/Hotkey.cs new file mode 100644 index 00000000..e4fdcf07 --- /dev/null +++ b/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/Hotkey.cs @@ -0,0 +1,26 @@ +using Avalonia.Input; +using System; +using System.Text; + +namespace ImageSort.Avalonia.SettingsManagement.ShortCutManagement; + +public record Hotkey(Key Key, KeyModifiers Modifiers) +{ + public override string ToString() + { + var str = new StringBuilder(); + + if (Modifiers.HasFlag(KeyModifiers.Control)) + str.Append("Ctrl + "); + if (Modifiers.HasFlag(KeyModifiers.Shift)) + str.Append("Shift + "); + if (Modifiers.HasFlag(KeyModifiers.Alt)) + str.Append("Alt + "); + if (Modifiers.HasFlag(KeyModifiers.Meta)) // Meta is often Windows/Command key in Avalonia + str.Append("Meta + "); + + str.Append(Key); + + return str.ToString(); + } +} diff --git a/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/HotkeyJsonConverter.cs b/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/HotkeyJsonConverter.cs new file mode 100644 index 00000000..e2a240d0 --- /dev/null +++ b/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/HotkeyJsonConverter.cs @@ -0,0 +1,79 @@ +using Avalonia.Input; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ImageSort.Avalonia.SettingsManagement.ShortCutManagement; + +public class HotkeyJsonConverter : JsonConverter +{ + public override Hotkey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + Key key = default; + KeyModifiers modifiers = default; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new Hotkey(key, modifiers); + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + string? propertyName = reader.GetString(); + reader.Read(); // Move to the property value + + switch (propertyName) + { + case "Key": + case "key": // for case-insensitivity from old files + if (reader.TryGetInt32(out int keyValue)) + { + key = (Key)keyValue; + } + else if (Enum.TryParse(reader.GetString(), true, out Key parsedKey)) + { + key = parsedKey; + } + else + { + throw new JsonException($"Could not parse Key value: {reader.GetString()}"); + } + break; + case "Modifiers": + case "modifiers": // for case-insensitivity + if (reader.TryGetInt32(out int modifiersValue)) + { + modifiers = (KeyModifiers)modifiersValue; + } + else if (Enum.TryParse(reader.GetString(), true, out KeyModifiers parsedModifiers)) + { + modifiers = parsedModifiers; + } + else + { + throw new JsonException($"Could not parse Modifiers value: {reader.GetString()}"); + } + break; + } + } + throw new JsonException("Expected EndObject token"); + } + + public override void Write(Utf8JsonWriter writer, Hotkey value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteNumber("Key", (int)value.Key); + writer.WriteNumber("Modifiers", (int)value.Modifiers); + writer.WriteEndObject(); + } +} diff --git a/src/ImageSort.Avalonia/Styles/Controls.xaml b/src/ImageSort.Avalonia/Styles/Controls.xaml new file mode 100644 index 00000000..dcd15b07 --- /dev/null +++ b/src/ImageSort.Avalonia/Styles/Controls.xaml @@ -0,0 +1,4 @@ + + + diff --git a/src/ImageSort.Avalonia/ViewLocator.cs b/src/ImageSort.Avalonia/ViewLocator.cs new file mode 100644 index 00000000..4c78c662 --- /dev/null +++ b/src/ImageSort.Avalonia/ViewLocator.cs @@ -0,0 +1,31 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using ImageSort.Avalonia.ViewModels; + +namespace ImageSort.Avalonia; + +public class ViewLocator : IDataTemplate +{ + + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/src/ImageSort.Avalonia/ViewModels/MainWindowViewModel.cs b/src/ImageSort.Avalonia/ViewModels/MainWindowViewModel.cs new file mode 100644 index 00000000..b6ff3e50 --- /dev/null +++ b/src/ImageSort.Avalonia/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,321 @@ +using ImageSort.FileSystem; // Added: For IFileSystem, IRecycleBin +using System.Reactive.Concurrency; // Added: For IScheduler +using ImageSort.ViewModels; +using ReactiveUI; +using Splat; +using System; +using System.Reactive; +using Avalonia.Platform.Storage; +using System.Linq; // Added for .Any() +using MsBox.Avalonia; // For message boxes +using MsBox.Avalonia.Enums; // For message box button/icon enums +using System.Threading.Tasks; // For Task +using ImageSort.Avalonia.Views; // For InputDialog +using ImageSort.Localization; // For Text resource +using ImageSort.Avalonia.Input; // Required for AppAction +using System.Reactive.Linq; // Required for ObserveOn +using Avalonia.Controls.ApplicationLifetimes; // Required for IClassicDesktopStyleApplicationLifetime +using Avalonia.Controls; // Required for TopLevel +using Application = Avalonia.Application; // Required for Application.Current + +namespace ImageSort.Avalonia.ViewModels; + +// Inherit from the core MainViewModel +public partial class MainWindowViewModel : MainViewModel +{ + // Commands for Settings, Keybindings, Credits - specific to Avalonia UI or to be handled here + public ReactiveCommand OpenSettings { get; } + public ReactiveCommand OpenKeybindings { get; } + public ReactiveCommand OpenCredits { get; } + + // Commands for AppActions that are not already in base ViewModels + public ReactiveCommand FocusSearchBoxCommand { get; } + public ReactiveCommand ToggleMetadataPanelCommand { get; } + // PinCurrentFolder is in FoldersViewModel + // PinSelectedTreeFolder is in FoldersViewModel + // UnpinSelectedTreeFolder is in FoldersViewModel + // CreateFolderInSelectedTreeFolder is in FoldersViewModel + + public MainWindowViewModel(FoldersViewModel foldersViewModel, ImagesViewModel imagesViewModel, ActionsViewModel actionsViewModel, + IFileSystem fileSystem, IRecycleBin recycleBin, IScheduler backgroundScheduler) + : base(foldersViewModel, imagesViewModel, actionsViewModel, fileSystem, recycleBin, backgroundScheduler) + { + // The base constructor will initialize Actions, Folders, and Images ViewModels. + // Any Avalonia-specific initialization for MainWindowViewModel can go here. + + // Initialize Avalonia-specific commands + OpenSettings = ReactiveCommand.Create(() => { /* TODO: Implement Avalonia Open Settings Logic */ }); + OpenKeybindings = ReactiveCommand.Create(() => { /* TODO: Implement Avalonia Open Keybindings Logic */ }); + OpenCredits = ReactiveCommand.Create(() => { /* TODO: Implement Avalonia Open Credits Logic */ }); + + FocusSearchBoxCommand = ReactiveCommand.Create(() => + { + // This needs to interact with the UI. One way is via an Interaction. + // Or, if the search box is a known element, perhaps a direct focus call if possible (less MVVM). + // For now, let's define an interaction. + RequestFocusSearchBox.Handle(Unit.Default); + }); + + ToggleMetadataPanelCommand = ReactiveCommand.Create(() => + { + // This will toggle a boolean property that the Metadata panel's visibility is bound to. + // Let's assume such a property exists or will be added, e.g., IsMetadataPanelVisible. + // For now, let's define an interaction or a direct property change. + // Let's add a property to ImagesViewModel for this. + Images.IsMetadataVisible = !Images.IsMetadataVisible; + }); + + // The OpenFolder command in MainViewModel uses PickFolder interaction. + // We need to register a handler for that interaction here. + PickFolder.RegisterHandler(async interaction => + { + var topLevel = TopLevel.GetTopLevel(null); // Ideally, pass a view reference or use a service to get TopLevel + if (topLevel == null) + { + // Attempt to get TopLevel from the Application.Current.ApplicationLifetime if it's an IClassicDesktopStyleApplicationLifetime + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + topLevel = desktopLifetime.MainWindow; + } + + if (topLevel == null) + { + interaction.SetOutput(null); // Cannot get TopLevel, so can't show picker + return; + } + } + + var result = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Select Folder", + AllowMultiple = false + }); + + if (result.Any()) + { + var uri = result[0].Path; + string path = uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString; + + if (Uri.TryCreate(path, UriKind.Absolute, out var fileUri) && fileUri.IsFile) + { + path = fileUri.LocalPath; + } + else if (path.StartsWith("/") && !path.StartsWith("//") && path.Length > 1 && path[1] == ':') // C:/ -> /C:/ + { + path = path.Substring(1); + } + // For Unix root, path might already be correct as "/" + interaction.SetOutput(path); + } + else + { + interaction.SetOutput(null); + } + }); + + // Handler for the FoldersViewModel.SelectFolder interaction (used by Pin command) + this.Folders.SelectFolder.RegisterHandler(async interaction => + { + var topLevel = TopLevel.GetTopLevel(null); + if (topLevel == null) + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) + { + topLevel = desktopLifetime.MainWindow; + } + + if (topLevel == null) + { + interaction.SetOutput(null); + return; + } + } + + var result = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = "Select Folder to Pin", + AllowMultiple = false + }); + + if (result.Any()) + { + var uri = result[0].Path; + string path = uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString; + + if (Uri.TryCreate(path, UriKind.Absolute, out var fileUri) && fileUri.IsFile) + { + path = fileUri.LocalPath; + } + else if (path.StartsWith("/") && !path.StartsWith("//") && path.Length > 1 && path[1] == ':') + { + path = path.Substring(1); + } + interaction.SetOutput(path); + } + else + { + interaction.SetOutput(null); + } + }); + + // Handler for ImagesViewModel.PromptForNewFileName + this.Images.PromptForNewFileName.RegisterHandler(async interaction => + { + var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (mainWindow == null) + { + interaction.SetOutput(null); + return; + } + + var dialog = new InputDialog // Assuming we'll create this view + { + Title = "Rename File", + // We can pass the current name as a default or placeholder if needed + }; + + var result = await dialog.ShowDialog(mainWindow); + + interaction.SetOutput(result); + }); + + // Handler for ImagesViewModel.NotifyUserOfError + this.Images.NotifyUserOfError.RegisterHandler(async interaction => + { + var message = interaction.Input; + var box = MessageBoxManager.GetMessageBoxStandard("Error", message, ButtonEnum.Ok, Icon.Error); + + var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (mainWindow != null) + { + await box.ShowWindowDialogAsync(mainWindow); + } + else + { + await box.ShowAsync(); // Show as a standalone window if main window not found + } + + interaction.SetOutput(Unit.Default); + }); + + // Handler for ActionsViewModel.NotifyUserOfError + this.Actions.NotifyUserOfError.RegisterHandler(async interaction => + { + var message = interaction.Input; + var box = MessageBoxManager.GetMessageBoxStandard("Error", message, ButtonEnum.Ok, Icon.Error); + + var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (mainWindow != null) + { + await box.ShowWindowDialogAsync(mainWindow); + } + else + { + await box.ShowAsync(); // Show as a standalone window if main window not found + } + + interaction.SetOutput(Unit.Default); + }); + } + + // Interaction for focusing the search box + public Interaction RequestFocusSearchBox { get; } = new Interaction(); + + public void ExecuteAppAction(AppAction action) + { + switch (action) + { + // Image Navigation + case AppAction.NextImage: + Images.SelectNextImage.Execute().Subscribe(); + break; + case AppAction.PreviousImage: + Images.SelectPreviousImage.Execute().Subscribe(); + break; + + // Action History + case AppAction.Undo: + Actions.Undo.Execute().Subscribe(); + break; + case AppAction.Redo: + Actions.Redo.Execute().Subscribe(); + break; + + // Image Operations + case AppAction.MoveImageToCurrentSelectedFolder: // This is the "Up Arrow" or similar action + // This action implies the currently selected folder in the main grid/view, not the tree. + // This maps to the "Move" command in ActionsViewModel, which uses the FoldersViewModel.CurrentFolder + // The "CurrentFolder" in FoldersViewModel is what the image should be moved to. + // We need to ensure Folders.SelectedFolder is set appropriately if this action means something different. + // For now, assuming it means move to Folders.CurrentFolder (which is the active one for dropping images) + Actions.Move.Execute().Subscribe(); // Move uses the current image from ImagesViewModel and current folder from FoldersViewModel + break; + case AppAction.DeleteImage: + Images.DeleteImageCommand.Execute().Subscribe(); // Corrected from Images.DeleteImage + break; + case AppAction.RenameImage: + Images.RenameImage.Execute().Subscribe(); + break; + + // Folder Tree Navigation/Selection + case AppAction.SelectNextFolderInTree: + Folders.SelectNextFolder.Execute().Subscribe(); + break; + case AppAction.SelectPreviousFolderInTree: + Folders.SelectPreviousFolder.Execute().Subscribe(); + break; + case AppAction.ExpandSelectedTreeFolder: + Folders.ExpandSelected.Execute().Subscribe(); + break; + case AppAction.CollapseSelectedTreeFolderOrGoToParent: + Folders.CollapseSelectedOrGoToParent.Execute().Subscribe(); + break; + case AppAction.SetSelectedTreeFolderAsCurrent: // Enter on tree item + Folders.SetSelectedFolderAsCurrentImplicitly.Execute().Subscribe(); // Or a new command if different behavior needed + break; + + // Pinned Folder Image Move Operations + case AppAction.MoveImageToPinnedFolder1: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[0]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder2: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[1]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder3: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[2]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder4: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[3]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder5: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[4]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder6: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[5]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder7: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[6]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder8: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[7]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder9: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[8]?.Path).Subscribe(); break; + case AppAction.MoveImageToPinnedFolder0: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[9]?.Path).Subscribe(); break; + // Note: Need to ensure PinnedFolders collection is accessed safely (e.g., check count or nulls) + // This is handled by the ?.Path and the command in ActionsViewModel should handle null path if necessary. + + // UI Control/Focus + case AppAction.FocusSearchBox: + FocusSearchBoxCommand.Execute().Subscribe(); + break; + case AppAction.ToggleMetadataPanel: + ToggleMetadataPanelCommand.Execute().Subscribe(); + break; + + // Application Level + case AppAction.OpenFolderDialog: // O key + OpenFolder.Execute().Subscribe(); // This is in MainViewModel base + break; + case AppAction.PinCurrentFolder: // P key + Folders.PinCurrentFolder.Execute().Subscribe(); + break; + case AppAction.PinSelectedTreeFolder: // F key + Folders.PinSelected.Execute().Subscribe(); // Corrected from PinSelectedFolder + break; + case AppAction.UnpinSelectedTreeFolder: // U key + Folders.UnpinSelected.Execute().Subscribe(); // Corrected from UnpinSelectedFolder + break; + case AppAction.CreateFolderInSelectedTreeFolder: // C key + Folders.CreateFolderUnderSelected.Execute().Subscribe(); + break; + + default: + // Log or handle unknown action + break; + } + } +} diff --git a/src/ImageSort.Avalonia/ViewModels/ViewModelBase.cs b/src/ImageSort.Avalonia/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..da87b956 --- /dev/null +++ b/src/ImageSort.Avalonia/ViewModels/ViewModelBase.cs @@ -0,0 +1,21 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using ReactiveUI; // Add this for IActivatableViewModel +using System.Reactive.Disposables; + +namespace ImageSort.Avalonia.ViewModels; + +public class ViewModelBase : ObservableObject, IActivatableViewModel // Implement IActivatableViewModel +{ + // Add the required ViewModelActivator property + public ViewModelActivator Activator { get; } = new ViewModelActivator(); + + public ViewModelBase() + { + this.WhenActivated(disposables => + { + // This is where you would typically put activation logic for the ViewModel + // For now, it can be empty if there's no specific base activation logic. + Disposable.Create(() => { /* Deactivation logic */ }).DisposeWith(disposables); + }); + } +} diff --git a/src/ImageSort.Avalonia/Views/ActionsView.axaml b/src/ImageSort.Avalonia/Views/ActionsView.axaml new file mode 100644 index 00000000..b935db7c --- /dev/null +++ b/src/ImageSort.Avalonia/Views/ActionsView.axaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ImageSort.Avalonia/Views/FoldersView.axaml.cs b/src/ImageSort.Avalonia/Views/FoldersView.axaml.cs new file mode 100644 index 00000000..1865b72e --- /dev/null +++ b/src/ImageSort.Avalonia/Views/FoldersView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.ReactiveUI; +using ImageSort.ViewModels; + +namespace ImageSort.Avalonia.Views; + +public partial class FoldersView : ReactiveUserControl +{ + public FoldersView() + { + InitializeComponent(); + } +} diff --git a/src/ImageSort.Avalonia/Views/ImagesView.axaml b/src/ImageSort.Avalonia/Views/ImagesView.axaml new file mode 100644 index 00000000..1a3d650c --- /dev/null +++ b/src/ImageSort.Avalonia/Views/ImagesView.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + +