diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5f3851a..db82ae42 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.x + dotnet-version: 10 - name: Restore dependencies run: dotnet restore diff --git a/README.md b/README.md index 2a77731c..696d611c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ I have created it for my personal use to play games and demos. It is a lot of fu - [x] Audio and video recording - [x] Selection of alternative ROMs, custom ROM can also be loaded - [x] Built-in debugger +- [x] Favourites manager - [x] And more features will come Spectron relies on several custom libraries that I created for this project: @@ -202,6 +203,11 @@ Debugger is available in the emulator. It is a simple debugger that allows you t memory and disassembly. You can step through the code, set breakpoints. More information can be found [here](docs/Debugger.md). +## Favourites Manager +Favourites manager allows you to organise your favourite games, demos and other files. +You can access it from the main menu. +More information can be found [here](docs/Favorites.md). + ### Resources - [Avalonia UI](https://avaloniaui.net/) - [FFmpeg wrapper](https://github.com/rosenbjerg/FFMpegCore) diff --git a/docs/Favorites.md b/docs/Favorites.md new file mode 100644 index 00000000..0f9b3d6f --- /dev/null +++ b/docs/Favorites.md @@ -0,0 +1,19 @@ +## Favourites + +Favourites manager allows you to organise your favourite games, demos and other files. +You can group them by category. + +[Favourites Manager](FavoritesEditor.png?raw=true "Favourites Manager") + +Favourites are easily accessible from the main menu. + +## Context menu + +All available actions are available from the context menu. + +[Context Menu](FavoritesContextMenu.png?raw=true "Context Menu") + +## Settings Overrides + +For tape files (tap or tzx) you can override some default emulator settings. For example, if a program requires specific machine type, +or hardware, you can set it here. This will temporarily override the settings for the current file when loaded. diff --git a/docs/FavoritesContextMenu.png b/docs/FavoritesContextMenu.png new file mode 100644 index 00000000..565a1d08 Binary files /dev/null and b/docs/FavoritesContextMenu.png differ diff --git a/docs/FavoritesEditor.png b/docs/FavoritesEditor.png new file mode 100644 index 00000000..250622a8 Binary files /dev/null and b/docs/FavoritesEditor.png differ diff --git a/src/Spectron.Emulation/Extensions/FileTypeExtensions.cs b/src/Spectron.Emulation/Extensions/FileTypeExtensions.cs new file mode 100644 index 00000000..363c71c8 --- /dev/null +++ b/src/Spectron.Emulation/Extensions/FileTypeExtensions.cs @@ -0,0 +1,21 @@ +using OldBit.Spectron.Emulation.Files; + +namespace OldBit.Spectron.Emulation.Extensions; + +public static class FileTypeExtensions +{ + extension(FileType fileType) + { + public bool IsSnapshot() => + fileType is FileType.Sna or FileType.Z80 or FileType.Szx or FileType.Spectron; + + public bool IsTape() => + fileType is FileType.Tap or FileType.Tzx; + + public bool IsArchive() => + fileType is FileType.Zip; + + public bool IsMicrodrive() => + fileType is FileType.Mdr; + } +} \ No newline at end of file diff --git a/src/Spectron.Emulation/Files/FileType.cs b/src/Spectron.Emulation/Files/FileType.cs index 29c2f587..967d85c8 100644 --- a/src/Spectron.Emulation/Files/FileType.cs +++ b/src/Spectron.Emulation/Files/FileType.cs @@ -25,19 +25,4 @@ public enum FileType Spectron, Unsupported -} - -public static class FileTypeExtensions -{ - public static bool IsSnapshot(this FileType fileType) => - fileType is FileType.Sna or FileType.Z80 or FileType.Szx or FileType.Spectron; - - public static bool IsTape(this FileType fileType) => - fileType is FileType.Tap or FileType.Tzx; - - public static bool IsArchive(this FileType fileType) => - fileType is FileType.Zip; - - public static bool IsMicrodrive(this FileType fileType) => - fileType is FileType.Mdr; } \ No newline at end of file diff --git a/src/Spectron.Emulation/Files/FileTypes.cs b/src/Spectron.Emulation/Files/FileTypeResolver.cs similarity index 75% rename from src/Spectron.Emulation/Files/FileTypes.cs rename to src/Spectron.Emulation/Files/FileTypeResolver.cs index 1668dc83..e392ddd6 100644 --- a/src/Spectron.Emulation/Files/FileTypes.cs +++ b/src/Spectron.Emulation/Files/FileTypeResolver.cs @@ -1,8 +1,8 @@ namespace OldBit.Spectron.Emulation.Files; -public static class FileTypes +public static class FileTypeResolver { - public static FileType GetFileType(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch + public static FileType FromPath(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch { ".sna" => FileType.Sna, ".szx" => FileType.Szx, diff --git a/src/Spectron.Emulation/Files/ZipFileReader.cs b/src/Spectron.Emulation/Files/ZipArchiveReader.cs similarity index 67% rename from src/Spectron.Emulation/Files/ZipFileReader.cs rename to src/Spectron.Emulation/Files/ZipArchiveReader.cs index 840395d1..7d5a2efd 100644 --- a/src/Spectron.Emulation/Files/ZipFileReader.cs +++ b/src/Spectron.Emulation/Files/ZipArchiveReader.cs @@ -1,20 +1,21 @@ using System.IO.Compression; +using OldBit.Spectron.Emulation.Extensions; namespace OldBit.Spectron.Emulation.Files; public record ArchiveEntry(string Name, FileType FileType); -public sealed class ZipFileReader(string filePath) : IDisposable +public sealed class ZipArchiveReader(string filePath) : IDisposable { private readonly ZipArchive _zip = ZipFile.OpenRead(filePath); - public List GetFiles() + public List GetSupportedFiles() { var entries = new List(); foreach (var entry in _zip.Entries) { - var fileType = FileTypes.GetFileType(entry.FullName); + var fileType = FileTypeResolver.FromPath(entry.FullName); if (fileType.IsSnapshot() || fileType.IsTape() || fileType.IsMicrodrive()) { @@ -25,6 +26,8 @@ public List GetFiles() return entries; } + public bool ContainsTapeFile() => _zip.Entries.Any(file => FileTypeResolver.FromPath(file.FullName).IsTape()); + public Stream? GetFile(string fullName) => _zip.Entries.FirstOrDefault(e => e.FullName == fullName)?.Open(); diff --git a/src/Spectron.Emulation/Snapshot/SnapshotManager.cs b/src/Spectron.Emulation/Snapshot/SnapshotManager.cs index a5c3b6f6..e83fb8e5 100644 --- a/src/Spectron.Emulation/Snapshot/SnapshotManager.cs +++ b/src/Spectron.Emulation/Snapshot/SnapshotManager.cs @@ -23,7 +23,7 @@ public Emulator Load(Stream stream, FileType fileType) public static void Save(string filePath, Emulator emulator) { - var fileType = FileTypes.GetFileType(filePath); + var fileType = FileTypeResolver.FromPath(filePath); switch (fileType) { diff --git a/src/Spectron.Emulation/Tape/TapeManager.cs b/src/Spectron.Emulation/Tape/TapeManager.cs index e9d42dba..1e702697 100644 --- a/src/Spectron.Emulation/Tape/TapeManager.cs +++ b/src/Spectron.Emulation/Tape/TapeManager.cs @@ -1,5 +1,4 @@ using OldBit.Spectron.Emulation.Files; -using OldBit.Spectron.Emulation.Rom; using OldBit.Z80Cpu; using OldBit.Spectron.Files.Tzx; @@ -100,7 +99,7 @@ public void InsertTape(Stream stream, FileType fileType, bool autoPlay = false) public void InsertTape(string fileName) { - var fileType = FileTypes.GetFileType(fileName); + var fileType = FileTypeResolver.FromPath(fileName); var stream = File.OpenRead(fileName); InsertTape(stream, fileType); diff --git a/src/Spectron/Controls/MainMenu.axaml b/src/Spectron/Controls/MainMenu.axaml index 1ca95559..9d17426a 100644 --- a/src/Spectron/Controls/MainMenu.axaml +++ b/src/Spectron/Controls/MainMenu.axaml @@ -128,6 +128,10 @@ + + + + diff --git a/src/Spectron/Controls/MainMenu.axaml.cs b/src/Spectron/Controls/MainMenu.axaml.cs index 7d8122b7..3cc77245 100644 --- a/src/Spectron/Controls/MainMenu.axaml.cs +++ b/src/Spectron/Controls/MainMenu.axaml.cs @@ -23,11 +23,21 @@ protected override void OnDataContextChanged(EventArgs e) private void RecentFilesSubmenuOpened(object? sender, RoutedEventArgs e) { - if (sender is not MenuItem menuItem) + if (sender is not MenuItem menuItem || !ReferenceEquals(sender, e.Source)) { return; } _viewModel?.RecentFilesViewModel.Opening(menuItem.Items); } + + private void FavoritesSubmenuOpened(object? sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem || !ReferenceEquals(sender, e.Source)) + { + return; + } + + _viewModel?.FavoritesViewModel.Opening(menuItem.Items); + } } \ No newline at end of file diff --git a/src/Spectron/Controls/Validation/DataValidationErrors.axaml b/src/Spectron/Controls/Validation/DataValidationErrors.axaml index 4f296c1a..eb6f18ba 100644 --- a/src/Spectron/Controls/Validation/DataValidationErrors.axaml +++ b/src/Spectron/Controls/Validation/DataValidationErrors.axaml @@ -21,9 +21,15 @@ + + + + + + + > OpenEmulatorFileAsync() => await OpenFileAsync("Select File", [ - FileTypes.All, - FileTypes.TapeFiles, - FileTypes.SnapshotFiles, - FileTypes.DiskFiles, - FileTypes.Spectron, - FileTypes.Sna, - FileTypes.Szx, - FileTypes.Tap, - FileTypes.Tzx, - FileTypes.Z80, - FileTypes.Zip, - FileTypes.Pok, - FileTypes.Mdr, - FileTypes.Trd, - FileTypes.Scl, + FilePickerType.All, + FilePickerType.TapeFiles, + FilePickerType.SnapshotFiles, + FilePickerType.DiskFiles, + FilePickerType.Spectron, + FilePickerType.Sna, + FilePickerType.Szx, + FilePickerType.Tap, + FilePickerType.Tzx, + FilePickerType.Z80, + FilePickerType.Zip, + FilePickerType.Pok, + FilePickerType.Mdr, + FilePickerType.Trd, + FilePickerType.Scl, ]); public static async Task> OpenTapeFileAsync() => await OpenFileAsync("Select Tape File", [ - FileTypes.TapeFiles, - FileTypes.Tap, - FileTypes.Tzx + FilePickerType.TapeFiles, + FilePickerType.Tap, + FilePickerType.Tzx ]); public static async Task> OpenCustomRomFileAsync() => await OpenFileAsync("Select Custom ROM File", [ - FileTypes.Rom, - FileTypes.Bin, - FileTypes.Any, + FilePickerType.Rom, + FilePickerType.Bin, + FilePickerType.Any, ]); public static async Task> OpenDiskImageFileAsync() => await OpenFileAsync("Select Disk Image", [ - FileTypes.Img, - FileTypes.Any, + FilePickerType.Img, + FilePickerType.Any, ]); public static async Task> OpenMicrodriveFileAsync() => await OpenFileAsync("Select Microdrive File", [ - FileTypes.Mdr, - FileTypes.Any, + FilePickerType.Mdr, + FilePickerType.Any, ]); public static async Task> OpenDiskFileAsync() => await OpenFileAsync("Select Disk File", [ - FileTypes.DiskFiles, - FileTypes.Trd, - FileTypes.Scl, - FileTypes.Any, + FilePickerType.DiskFiles, + FilePickerType.Trd, + FilePickerType.Scl, + FilePickerType.Any, ]); public static async Task SaveTapeFileAsync(string? suggestedFileName = null) @@ -83,7 +83,7 @@ await OpenFileAsync("Select Disk File", DefaultExtension = ".tzx", SuggestedFileName = suggestedFileName, ShowOverwritePrompt = true, - FileTypeChoices = [FileTypes.Tzx, FileTypes.Tap] + FileTypeChoices = [FilePickerType.Tzx, FilePickerType.Tap] }); } @@ -102,7 +102,7 @@ await OpenFileAsync("Select Disk File", DefaultExtension = ".mdr", SuggestedFileName = suggestedFileName, ShowOverwritePrompt = true, - FileTypeChoices = [FileTypes.Mdr] + FileTypeChoices = [FilePickerType.Mdr] }); } @@ -121,7 +121,7 @@ await OpenFileAsync("Select Disk File", DefaultExtension = ".trd", SuggestedFileName = suggestedFileName, ShowOverwritePrompt = true, - FileTypeChoices = [FileTypes.Trd, FileTypes.Scl] + FileTypeChoices = [FilePickerType.Trd, FilePickerType.Scl] }); } @@ -140,7 +140,7 @@ await OpenFileAsync("Select Disk File", DefaultExtension = ".spectron", SuggestedFileName = suggestedFileName, ShowOverwritePrompt = true, - FileTypeChoices = [FileTypes.Spectron, FileTypes.Szx, FileTypes.Z80, FileTypes.Sna], + FileTypeChoices = [FilePickerType.Spectron, FilePickerType.Szx, FilePickerType.Z80, FilePickerType.Sna], }); } @@ -159,7 +159,7 @@ await OpenFileAsync("Select Disk File", DefaultExtension = ".wav", SuggestedFileName = suggestedFileName, ShowOverwritePrompt = true, - FileTypeChoices = [FileTypes.Wav], + FileTypeChoices = [FilePickerType.Wav], }); } @@ -178,7 +178,7 @@ await OpenFileAsync("Select Disk File", DefaultExtension = ".mp4", SuggestedFileName = suggestedFileName, ShowOverwritePrompt = true, - FileTypeChoices = [FileTypes.Mp4], + FileTypeChoices = [FilePickerType.Mp4], }); } @@ -197,7 +197,7 @@ await OpenFileAsync("Select Disk File", DefaultExtension = ".png", SuggestedFileName = suggestedFileName, ShowOverwritePrompt = true, - FileTypeChoices = [FileTypes.Png], + FileTypeChoices = [FilePickerType.Png], }); } diff --git a/src/Spectron/Dialogs/FileTypes.cs b/src/Spectron/Dialogs/FilePickerType.cs similarity index 98% rename from src/Spectron/Dialogs/FileTypes.cs rename to src/Spectron/Dialogs/FilePickerType.cs index 3fd791bd..77e1729c 100644 --- a/src/Spectron/Dialogs/FileTypes.cs +++ b/src/Spectron/Dialogs/FilePickerType.cs @@ -2,7 +2,7 @@ namespace OldBit.Spectron.Dialogs; -public static class FileTypes +public static class FilePickerType { public static FilePickerFileType All { get; } = new("All Files") { diff --git a/src/Spectron/Extensions/EmulatorExtensions.cs b/src/Spectron/Extensions/EmulatorExtensions.cs index f13b68e1..db91ac93 100644 --- a/src/Spectron/Extensions/EmulatorExtensions.cs +++ b/src/Spectron/Extensions/EmulatorExtensions.cs @@ -17,10 +17,10 @@ public void ConfigureTape(TapeSettings tapeSettings) emulator?.TapeManager.TapeLoadSpeed = tapeSettings.LoadSpeed; } - public void ConfigureAudio(AudioSettings audioSettings) + public void ConfigureAudio(AudioSettings audioSettings, FavoriteProgram? favorite = null) { emulator?.AudioManager.IsBeeperEnabled = audioSettings.IsBeeperEnabled; - emulator?.AudioManager.IsAyEnabled = audioSettings.IsAyAudioEnabled; + emulator?.AudioManager.IsAyEnabled = favorite?.IsAyEnabled ?? audioSettings.IsAyAudioEnabled; emulator?.AudioManager.IsAySupportedStandardSpectrum = audioSettings.IsAySupportedStandardSpectrum; emulator?.AudioManager.StereoMode = audioSettings.StereoMode; } diff --git a/src/Spectron/Extensions/TreeViewExtensions.cs b/src/Spectron/Extensions/TreeViewExtensions.cs new file mode 100644 index 00000000..9b4a32c7 --- /dev/null +++ b/src/Spectron/Extensions/TreeViewExtensions.cs @@ -0,0 +1,31 @@ +using Avalonia.Controls; + +namespace OldBit.Spectron.Extensions; + +public static class TreeViewExtensions +{ + public static TreeViewItem? FindContainer(this ItemsControl parent, object targetItem) + { + if (parent.ContainerFromItem(targetItem) is TreeViewItem rootContainer) + { + return rootContainer; + } + + foreach (var item in parent.Items) + { + if (item is null || parent.ContainerFromItem(item) is not TreeViewItem childContainer) + { + continue; + } + + var result = childContainer.FindContainer(targetItem); + + if (result != null) + { + return result; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Spectron/Messages/OpenFavoriteMessage.cs b/src/Spectron/Messages/OpenFavoriteMessage.cs new file mode 100644 index 00000000..4a2ed9b0 --- /dev/null +++ b/src/Spectron/Messages/OpenFavoriteMessage.cs @@ -0,0 +1,5 @@ +using OldBit.Spectron.Settings; + +namespace OldBit.Spectron.Messages; + +public record OpenFavoriteMessage(FavoriteProgram Favorite); \ No newline at end of file diff --git a/src/Spectron/Messages/ShowFavoritesViewMessage.cs b/src/Spectron/Messages/ShowFavoritesViewMessage.cs new file mode 100644 index 00000000..6dffff24 --- /dev/null +++ b/src/Spectron/Messages/ShowFavoritesViewMessage.cs @@ -0,0 +1,10 @@ +using CommunityToolkit.Mvvm.Messaging.Messages; +using OldBit.Spectron.Settings; +using OldBit.Spectron.ViewModels; + +namespace OldBit.Spectron.Messages; + +public class ShowFavoritesViewMessage(FavoritesViewModel viewModel) : AsyncRequestMessage +{ + public FavoritesViewModel ViewModel { get; } = viewModel; +} \ No newline at end of file diff --git a/src/Spectron/Messages/UpdateFavoritesMessage.cs b/src/Spectron/Messages/UpdateFavoritesMessage.cs new file mode 100644 index 00000000..5c90969e --- /dev/null +++ b/src/Spectron/Messages/UpdateFavoritesMessage.cs @@ -0,0 +1,5 @@ +using OldBit.Spectron.Settings; + +namespace OldBit.Spectron.Messages; + +public record UpdateFavoritesMessage(FavoritePrograms Favorites); \ No newline at end of file diff --git a/src/Spectron/Services/FavoritesService.cs b/src/Spectron/Services/FavoritesService.cs new file mode 100644 index 00000000..1647ffe3 --- /dev/null +++ b/src/Spectron/Services/FavoritesService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using OldBit.Spectron.Settings; + +namespace OldBit.Spectron.Services; + +public class FavoritesService(ApplicationDataService applicationDataService) +{ + public async Task SaveAsync(FavoritePrograms favorites) => await applicationDataService.SaveAsync(favorites); + + public async Task LoadAsync() => await applicationDataService.LoadAsync(); +} \ No newline at end of file diff --git a/src/Spectron/Services/PreferencesService.cs b/src/Spectron/Services/PreferencesService.cs index 5b2ced9a..df16da78 100644 --- a/src/Spectron/Services/PreferencesService.cs +++ b/src/Spectron/Services/PreferencesService.cs @@ -5,7 +5,7 @@ namespace OldBit.Spectron.Services; public class PreferencesService(ApplicationDataService applicationDataService) { - public async Task SaveAsync(object settings) => await applicationDataService.SaveAsync(settings); + public async Task SaveAsync(Preferences settings) => await applicationDataService.SaveAsync(settings); public async Task LoadAsync() => await applicationDataService.LoadAsync(); } \ No newline at end of file diff --git a/src/Spectron/Services/ServiceCollectionExtensions.cs b/src/Spectron/Services/ServiceCollectionExtensions.cs index ff1096fd..555b1b62 100644 --- a/src/Spectron/Services/ServiceCollectionExtensions.cs +++ b/src/Spectron/Services/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static void AddServices(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Spectron/Settings/FavoritePrograms.cs b/src/Spectron/Settings/FavoritePrograms.cs new file mode 100644 index 00000000..5064405e --- /dev/null +++ b/src/Spectron/Settings/FavoritePrograms.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using OldBit.Spectron.Emulation; +using OldBit.Spectron.Emulation.Devices.Joystick; +using OldBit.Spectron.Emulation.Devices.Mouse; +using OldBit.Spectron.Emulation.Tape; + +namespace OldBit.Spectron.Settings; + +public class FavoriteProgram +{ + public string Path { get; init; } = string.Empty; + public string Title { get; init; } = string.Empty; + public bool IsFolder { get; init; } + public List Items { get; init; } = []; + public ComputerType? ComputerType { get; init; } + public JoystickType? JoystickType { get; init; } + public MouseType? MouseType { get; init; } + public TapeSpeed? TapeLoadSpeed { get; init; } + public bool? IsUlaPlusEnabled { get; init; } + public bool? IsAyEnabled { get; init; } + public bool? IsInterface1Enabled { get; init; } + public bool? IsBeta128Enabled { get; init; } + public bool? IsDivMmcEnabled { get; init; } +} + +public class FavoritePrograms +{ + public List Items { get; init; } = []; +} \ No newline at end of file diff --git a/src/Spectron/Settings/RecentFilesSettings.cs b/src/Spectron/Settings/RecentFilesSettings.cs index 3e03d39d..9ca96891 100644 --- a/src/Spectron/Settings/RecentFilesSettings.cs +++ b/src/Spectron/Settings/RecentFilesSettings.cs @@ -8,5 +8,5 @@ public class RecentFilesSettings public string CurrentFileName { get; set; } = string.Empty; - public int MaxRecentFiles { get; set; } = 10; + public int MaxRecentFiles { get; set; } = 15; } \ No newline at end of file diff --git a/src/Spectron/ViewModels/FavoriteItemViewModel.cs b/src/Spectron/ViewModels/FavoriteItemViewModel.cs new file mode 100644 index 00000000..f3f234f8 --- /dev/null +++ b/src/Spectron/ViewModels/FavoriteItemViewModel.cs @@ -0,0 +1,160 @@ +using System.Collections.ObjectModel; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OldBit.Spectron.Dialogs; +using OldBit.Spectron.Emulation.Files; +using OldBit.Spectron.Settings; + +namespace OldBit.Spectron.ViewModels; + +public partial class FavoriteItemViewModel : ObservableValidator +{ + public ObservableCollection Nodes { get; } = []; + + public FavoriteSettingsViewModel SettingsViewModel { get; set; } = new(); + + [ObservableProperty] + [Required] + [NotifyDataErrorInfo] + private string _title = string.Empty; + + [ObservableProperty] + [Required] + [CustomValidation(typeof(FavoriteItemViewModel), nameof(ValidatePath))] + [NotifyDataErrorInfo] + [NotifyPropertyChangedFor(nameof(IsTapeFile))] + private string _path = string.Empty; + + [ObservableProperty] + private bool _isFolder; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTapeFile))] + private bool _isFile; + + [ObservableProperty] + private bool _isRoot; + + [ObservableProperty] + private bool _isCutItem; + + public bool IsTapeFile + { + get + { + if (!IsFile) + { + return false; + } + + var fileType = FileTypeResolver.FromPath(Path); + + if (fileType == FileType.Zip) + { + try + { + using var archive = new ZipArchiveReader(Path); + + return archive.ContainsTapeFile(); + } + catch + { + return false; + } + } + + return fileType is FileType.Tap or FileType.Tzx; + } + } + + public FavoriteItemViewModel Clone() + { + var copy = new FavoriteItemViewModel + { + Title = Title, + Path = Path, + IsFile = IsFile, + IsRoot = IsRoot, + IsFolder = IsFolder + }; + + if (IsFolder) + { + foreach (var node in Nodes) + { + copy.Nodes.Add(node.Clone()); + } + } + + return copy; + } + + [RelayCommand] + private async Task GetFile() + { + var files = await FileDialogs.OpenEmulatorFileAsync(); + + if (files.Count <= 0) + { + return; + } + + var filePath = files[0].Path.LocalPath; + + var fileType = FileTypeResolver.FromPath(filePath); + + if (fileType == FileType.Unsupported) + { + await MessageDialogs.Warning($"Unsupported file type: {fileType}."); + return; + } + + Path = filePath; + } + + public static ValidationResult? ValidatePath(string s, ValidationContext context) + { + var fileType = FileTypeResolver.FromPath(s); + + if (fileType == FileType.Unsupported) + { + return new ValidationResult("Unsupported file type."); + } + + if (!System.IO.File.Exists(s)) + { + return new ValidationResult("File does not exist."); + } + + return ValidationResult.Success;; + } + + public FavoriteProgram ToFavoriteProgram() + { + if (IsFolder) + { + return new FavoriteProgram + { + Title = Title, + IsFolder = true + }; + } + + return new FavoriteProgram + { + Title = Title, + Path = Path, + ComputerType = SettingsViewModel.ComputerType.Value, + JoystickType = SettingsViewModel.JoystickType.Value, + MouseType = SettingsViewModel.MouseType.Value, + TapeLoadSpeed = SettingsViewModel.TapeLoadSpeed.Value, + IsUlaPlusEnabled = SettingsViewModel.IsUlaPlusEnabled, + IsAyEnabled = SettingsViewModel.IsAyEnabled, + IsInterface1Enabled = SettingsViewModel.IsInterface1Enabled, + IsBeta128Enabled = SettingsViewModel.IsBeta128Enabled, + IsDivMmcEnabled = SettingsViewModel.IsDivMmcEnabled + }; + } +} \ No newline at end of file diff --git a/src/Spectron/ViewModels/FavoriteSettingsViewModel.cs b/src/Spectron/ViewModels/FavoriteSettingsViewModel.cs new file mode 100644 index 00000000..524cdb1b --- /dev/null +++ b/src/Spectron/ViewModels/FavoriteSettingsViewModel.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using OldBit.Spectron.Emulation; +using OldBit.Spectron.Emulation.Devices.Joystick; +using OldBit.Spectron.Emulation.Devices.Mouse; +using OldBit.Spectron.Emulation.Tape; + +namespace OldBit.Spectron.ViewModels; + +public partial class FavoriteSettingsViewModel : ObservableObject +{ + [ObservableProperty] + private NameValuePair _computerType; + + [ObservableProperty] + private NameValuePair _joystickType; + + [ObservableProperty] + private NameValuePair _mouseType; + + [ObservableProperty] + private NameValuePair _tapeLoadSpeed; + + [ObservableProperty] + private bool? _isUlaPlusEnabled; + + [ObservableProperty] + private bool? _isAyEnabled; + + [ObservableProperty] + private bool? _isInterface1Enabled; + + [ObservableProperty] + private bool? _isBeta128Enabled; + + [ObservableProperty] + private bool? _isDivMmcEnabled; + + public FavoriteSettingsViewModel( + ComputerType? computerType = null, + JoystickType? joystickType = null, + MouseType? mouseType = null, + TapeSpeed? tapeLoadingSpeed = null, + bool? isUlaPlusEnabled = null, + bool? isAyEnabled = null, + bool? isInterface1Enabled = null, + bool? isBeta128Enabled = null, + bool? isDivMmcEnabled = null) + { + ComputerType = ComputerTypes.FirstOrDefault(x => x.Value == computerType, ComputerTypes[0]); + JoystickType = JoystickTypes.FirstOrDefault(x => x.Value == joystickType, JoystickTypes[0]); + MouseType = MouseTypes.FirstOrDefault(x => x.Value == mouseType, MouseTypes[0]); + TapeLoadSpeed = TapeLoadSpeeds.FirstOrDefault(x => x.Value == tapeLoadingSpeed, TapeLoadSpeeds[0]); + IsUlaPlusEnabled = isUlaPlusEnabled; + IsAyEnabled = isAyEnabled; + IsInterface1Enabled = isInterface1Enabled; + IsBeta128Enabled = isBeta128Enabled; + IsDivMmcEnabled = isDivMmcEnabled; + } + + public List> ComputerTypes { get; } = + [ + new("Default", null), + new("ZX Spectrum 16k", OldBit.Spectron.Emulation.ComputerType.Spectrum16K), + new("ZX Spectrum 48k", OldBit.Spectron.Emulation.ComputerType.Spectrum48K), + new("ZX Spectrum 128k", OldBit.Spectron.Emulation.ComputerType.Spectrum128K), + ]; + + public List> JoystickTypes { get; } = + [ + new("Default", null), + new("None", OldBit.Spectron.Emulation.Devices.Joystick.JoystickType.None), + new("Kempston", OldBit.Spectron.Emulation.Devices.Joystick.JoystickType.Kempston), + new("Sinclair 1", OldBit.Spectron.Emulation.Devices.Joystick.JoystickType.Sinclair1), + new("Sinclair 2", OldBit.Spectron.Emulation.Devices.Joystick.JoystickType.Sinclair2), + new("Cursor", OldBit.Spectron.Emulation.Devices.Joystick.JoystickType.Cursor), + new("Fuller", OldBit.Spectron.Emulation.Devices.Joystick.JoystickType.Fuller), + ]; + + public List> MouseTypes { get; } = + [ + new("Default", null), + new("None", OldBit.Spectron.Emulation.Devices.Mouse.MouseType.None), + new("Kempston", OldBit.Spectron.Emulation.Devices.Mouse.MouseType.Kempston), + ]; + + public List> TapeLoadSpeeds { get; } = + [ + new("Default", null), + new("Normal", TapeSpeed.Normal), + new("Instant", TapeSpeed.Instant), + new("Accelerated", TapeSpeed.Accelerated), + ]; +} \ No newline at end of file diff --git a/src/Spectron/ViewModels/FavoritesViewModel.cs b/src/Spectron/ViewModels/FavoritesViewModel.cs new file mode 100644 index 00000000..e9885375 --- /dev/null +++ b/src/Spectron/ViewModels/FavoritesViewModel.cs @@ -0,0 +1,390 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using OldBit.Spectron.Extensions; +using OldBit.Spectron.Messages; +using OldBit.Spectron.Settings; + +namespace OldBit.Spectron.ViewModels; + +public partial class FavoritesViewModel : ObservableObject +{ + private FavoriteItemViewModel? _cutItem; + private FavoriteItemViewModel? _copyItem; + + [ObservableProperty] + private FavoriteItemViewModel? _selectedItem; + + public ObservableCollection Nodes { get; } = []; + + public TreeView? FavoritesTreeView { get; set; } + + public FavoritePrograms Favorites + { + private get; + set + { + field = value; + RefreshFavorites(); + } + } = new(); + + public void Opening(ItemCollection menuItems) + { + while (menuItems.Count > 1) + { + menuItems.RemoveAt(1); + } + + if (Nodes.Count == 0 || Nodes[0].Nodes is { Count: 0 }) + { + return; + } + + menuItems.Add(new Separator()); + + AddFavoriteItems(menuItems, Nodes[0].Nodes!); + } + + private FavoritePrograms GetFavorites() + { + return new FavoritePrograms + { + Items = Convert(Nodes[0].Nodes) + }; + + static List Convert(IEnumerable nodes) + { + var result = new List(); + + foreach (var node in nodes) + { + var favorite = node.ToFavoriteProgram(); + + if (node.IsFolder) + { + favorite.Items.AddRange(Convert(node.Nodes)); + } + + result.Add(favorite); + } + + return result; + } + } + + public void UpdateFavorites() + { + var favorites = GetFavorites(); + WeakReferenceMessenger.Default.Send(new UpdateFavoritesMessage(favorites)); + } + + private void RefreshFavorites() + { + Nodes.Clear(); + Nodes.Add(new FavoriteItemViewModel { Title = "Favorites", IsFolder = true, IsRoot = true }); + + AddFavorites(Favorites.Items, Nodes[0]); + } + + private static void AddFavorites(List favorites, FavoriteItemViewModel parent) + { + foreach (var favorite in favorites) + { + if (favorite.IsFolder) + { + var folder = new FavoriteItemViewModel { Title = favorite.Title, IsFolder = true }; + parent.Nodes.Add(folder); + + AddFavorites(favorite.Items, folder); + + continue; + } + + parent.Nodes.Add(new FavoriteItemViewModel + { + Title = favorite.Title, + Path = favorite.Path, + IsFile = true, + SettingsViewModel = new FavoriteSettingsViewModel + ( + favorite.ComputerType, + favorite.JoystickType, + favorite.MouseType, + favorite.TapeLoadSpeed, + favorite.IsUlaPlusEnabled, + favorite.IsAyEnabled, + favorite.IsInterface1Enabled, + favorite.IsBeta128Enabled, + favorite.IsDivMmcEnabled + ) + }); + } + } + + [RelayCommand] + private static void OpenFavorite(FavoriteItemViewModel favorite) => + WeakReferenceMessenger.Default.Send(new OpenFavoriteMessage(favorite.ToFavoriteProgram())); + + [RelayCommand(CanExecute = nameof(CanExecuteRemove))] + private void RemoveItem() + { + if (SelectedItem is null) + { + return; + } + + Remove(Nodes, SelectedItem); + } + + [RelayCommand(CanExecute = nameof(CanExecuteInsert))] + private void InsertFolder() => InsertItem(new FavoriteItemViewModel { Title = "New Folder", IsFolder = true }); + + [RelayCommand(CanExecute = nameof(CanExecuteInsert))] + private void InsertItem() => InsertItem(new FavoriteItemViewModel { Title = "New File", IsFile = true }); + + [RelayCommand(CanExecute = nameof(CanMoveItemUp))] + private void MoveItemUp() + { + if (SelectedItem is null) + { + return; + } + + var parentNode = FindParent(Nodes, SelectedItem)?.Nodes; + var nodeIndex = parentNode?.IndexOf(SelectedItem) ?? - 1; + + if (parentNode is null || nodeIndex < 0) + { + return; + } + + parentNode.Move(nodeIndex, nodeIndex - 1); + FavoritesTreeView?.FindContainer(SelectedItem)?.Focus(); + } + + [RelayCommand(CanExecute = nameof(CanMoveItemDown))] + private void MoveItemDown() + { + if (SelectedItem is null) + { + return; + } + + var parentNode = FindParent(Nodes, SelectedItem)?.Nodes; + var nodeIndex = parentNode?.IndexOf(SelectedItem) ?? -1; + + if (parentNode is null || nodeIndex < 0 || nodeIndex >= parentNode.Count - 1) + { + return; + } + + parentNode.Move(nodeIndex, nodeIndex + 1); + FavoritesTreeView?.FindContainer(SelectedItem)?.Focus(); + } + + [RelayCommand(CanExecute = nameof(CanExecuteCutCopy))] + private void CutSelectedItem() + { + _cutItem?.IsCutItem = false; + _cutItem = SelectedItem; + _cutItem?.IsCutItem = true; + _copyItem = null; + } + + [RelayCommand(CanExecute = nameof(CanExecuteCutCopy))] + private void CopySelectedItem() + { + _cutItem?.IsCutItem = false; + _cutItem = null; + _copyItem = SelectedItem; + } + + [RelayCommand(CanExecute = nameof(CanExecutePaste))] + private void PasteItem() + { + if (SelectedItem is null) + { + return; + } + + if (_cutItem is not null) + { + CutItem(_cutItem, SelectedItem); + } + else if (_copyItem is not null) + { + CopyItem(_copyItem, SelectedItem); + } + } + + private void InsertItem(FavoriteItemViewModel item) + { + if (SelectedItem is null || !SelectedItem.IsFolder) + { + return; + } + + SelectedItem.Nodes.Add(item); + SelectedItem = item; + } + + private void CopyItem(FavoriteItemViewModel copyItem, FavoriteItemViewModel selectedItem) + { + var copy = copyItem.Clone(); + copy.Title = $"{copyItem.Title} - copy"; + + if (selectedItem.IsFolder && !copyItem.IsFolder) + { + selectedItem.Nodes.Insert(0, copy); + } + else + { + var parent = FindParent(Nodes, selectedItem); + + if (parent is null) + { + return; + } + + var index = parent.Nodes.IndexOf(selectedItem); + parent.Nodes.Insert(index + 1, copy); + } + + SelectedItem = copy; + } + + private void CutItem(FavoriteItemViewModel cutItem, FavoriteItemViewModel selectedItem) + { + var cutItemParent = FindParent(Nodes, cutItem); + var selectedItemParent = FindParent(Nodes, selectedItem); + + if (cutItemParent is null || selectedItem.IsFile && cutItemParent == selectedItemParent) + { + return; + } + + if (IsDescendantOf(cutItem.Nodes, selectedItem)) + { + return; + } + + var cutItemIndex = cutItemParent.Nodes.IndexOf(cutItem); + cutItemParent.Nodes.RemoveAt(cutItemIndex); + + if (selectedItem.IsFolder) + { + selectedItem.Nodes.Insert(0, cutItem); + } + else + { + selectedItemParent?.Nodes.Insert(selectedItemParent.Nodes.IndexOf(selectedItem) + 1, cutItem); + } + + SelectedItem = cutItem; + + _cutItem?.IsCutItem = false; + _cutItem = null; + } + + private void AddFavoriteItems(ItemCollection menuItems, IEnumerable favorites) + { + foreach (var favorite in favorites) + { + var favoriteMenuItem = new MenuItem + { + Header = favorite.Title, + }; + + if (string.IsNullOrWhiteSpace(favorite.Title)) + { + continue; + } + + if (favorite.IsFolder) + { + if (favorite.Nodes.Count == 0) + { + continue; + } + + AddFavoriteItems(favoriteMenuItem.Items, favorite.Nodes); + } + else + { + if (string.IsNullOrWhiteSpace(favorite.Path)) + { + continue; + } + + favoriteMenuItem.Command = OpenFavoriteCommand; + favoriteMenuItem.CommandParameter = favorite; + } + + menuItems.Add(favoriteMenuItem); + } + } + + private static void Remove(ObservableCollection nodes, FavoriteItemViewModel item) + { + if (nodes.Remove(item)) + { + return; + } + + foreach (var node in nodes) + { + Remove(node.Nodes, item); + } + } + + private static FavoriteItemViewModel? FindParent(IEnumerable nodes, FavoriteItemViewModel item) + { + foreach (var node in nodes) + { + if (node.Nodes.Contains(item)) + { + return node; + } + + var parent = FindParent(node.Nodes, item); + + if (parent is not null) + { + return parent; + } + } + + return null; + } + + private static bool IsDescendantOf(IEnumerable nodes, FavoriteItemViewModel item) + { + foreach (var node in nodes) + { + if (node == item) + { + return true; + } + + if (IsDescendantOf(node.Nodes, item)) + { + return true; + } + } + + return false; + } + + private bool CanExecuteRemove => SelectedItem is not null && !SelectedItem.IsRoot; + private bool CanExecuteInsert => SelectedItem is not null && (SelectedItem.IsFolder || SelectedItem.IsRoot); + private bool CanExecuteCutCopy => SelectedItem is not null && !SelectedItem.IsRoot; + private bool CanExecutePaste => SelectedItem is not null && (_cutItem is not null || _copyItem is not null); + private bool CanMoveItemUp => SelectedItem is not null && !SelectedItem.IsRoot && + FindParent(Nodes, SelectedItem)?.Nodes.IndexOf(SelectedItem) > 0; + private bool CanMoveItemDown => SelectedItem is not null && !SelectedItem.IsRoot && + FindParent(Nodes, SelectedItem)?.Nodes.IndexOf(SelectedItem) < FindParent(Nodes, SelectedItem)?.Nodes.Count - 1; +} \ No newline at end of file diff --git a/src/Spectron/ViewModels/MainWindowViewModel.Debugger.cs b/src/Spectron/ViewModels/MainWindowViewModel.Debugger.cs index 8772db88..7e988bfc 100644 --- a/src/Spectron/ViewModels/MainWindowViewModel.Debugger.cs +++ b/src/Spectron/ViewModels/MainWindowViewModel.Debugger.cs @@ -1,4 +1,3 @@ -using System; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; @@ -78,13 +77,5 @@ private void DebuggerWindowClosed() private void PauseForDebug() => Pause(showOverlay: false); - partial void OnBreakpointsEnabledChanged(bool value) - { - if (_breakpointHandler == null) - { - return; - } - - _breakpointHandler.IsEnabled = value; - } + partial void OnBreakpointsEnabledChanged(bool value) => _breakpointHandler?.IsEnabled = value; } \ No newline at end of file diff --git a/src/Spectron/ViewModels/MainWindowViewModel.Dialogs.cs b/src/Spectron/ViewModels/MainWindowViewModel.Dialogs.cs index 488f4b03..61e4f9c7 100644 --- a/src/Spectron/ViewModels/MainWindowViewModel.Dialogs.cs +++ b/src/Spectron/ViewModels/MainWindowViewModel.Dialogs.cs @@ -25,6 +25,8 @@ private async Task OpenPreferencesWindow() if (preferences != null) { + await _preferencesService.SaveAsync(preferences); + _preferences = preferences; Emulator?.IsUlaPlusEnabled = _preferences.IsUlaPlusEnabled; @@ -136,4 +138,10 @@ private void OpenTrainersWindow() => private void OpenPrintOutputViewer() => WeakReferenceMessenger.Default.Send(new ShowPrintOutputViewMessage(Emulator!.Printer)); + + private async Task OpenFavoritesWindow() + { + FavoritesViewModel.Favorites = _favorites; + await WeakReferenceMessenger.Default.Send(new ShowFavoritesViewMessage(FavoritesViewModel)); + } } \ No newline at end of file diff --git a/src/Spectron/ViewModels/MainWindowViewModel.Emulator.cs b/src/Spectron/ViewModels/MainWindowViewModel.Emulator.cs index 702ebc2b..f294748d 100644 --- a/src/Spectron/ViewModels/MainWindowViewModel.Emulator.cs +++ b/src/Spectron/ViewModels/MainWindowViewModel.Emulator.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Logging; using OldBit.Spectron.Dialogs; using OldBit.Spectron.Emulation; -using OldBit.Spectron.Emulation.Devices.Interface1; using OldBit.Spectron.Emulation.Extensions; using OldBit.Spectron.Emulation.Files; using OldBit.Spectron.Emulation.Rom; @@ -28,33 +27,33 @@ private void CreateEmulator(ComputerType computerType, RomType romType, byte[]? Initialize(emulator); } - private void ApplyEmulatorDefaults(Emulator emulator, bool hardReset = false) + private void ApplyEmulatorDefaults(Emulator emulator, bool hardReset = false, FavoriteProgram? favorite = null) { - emulator.IsUlaPlusEnabled = hardReset ? _preferences.IsUlaPlusEnabled : IsUlaPlusEnabled; + emulator.IsUlaPlusEnabled = hardReset ? _preferences.IsUlaPlusEnabled : favorite?.IsUlaPlusEnabled ?? IsUlaPlusEnabled; emulator.IsFloatingBusEnabled = _preferences.IsFloatingBusEnabled; - emulator.JoystickManager.Configure(hardReset ? _preferences.Joystick.JoystickType : JoystickType); + emulator.JoystickManager.Configure(hardReset ? _preferences.Joystick.JoystickType : favorite?.JoystickType ?? JoystickType); emulator.Printer.IsEnabled = _preferences.Printer.IsZxPrinterEnabled; - emulator.MouseManager.Configure(hardReset ? _preferences.Mouse.MouseType : MouseType); - emulator.TapeManager.TapeLoadSpeed = hardReset ? _preferences.Tape.LoadSpeed : TapeLoadSpeed; + emulator.MouseManager.Configure(hardReset ? _preferences.Mouse.MouseType : favorite?.MouseType ?? MouseType); + emulator.TapeManager.TapeLoadSpeed = hardReset ? _preferences.Tape.LoadSpeed : favorite?.TapeLoadSpeed ?? TapeLoadSpeed; emulator.TapeManager.TapeSaveSpeed = _preferences.Tape.SaveSpeed; - if (_preferences.DivMmc.IsEnabled) + if (favorite?.IsDivMmcEnabled ?? _preferences.DivMmc.IsEnabled) { emulator.DivMmc.Enable(); emulator.ConfigureDivMMc(_preferences.DivMmc); } - if (_preferences.Beta128.IsEnabled) + if (favorite?.IsBeta128Enabled ?? _preferences.Beta128.IsEnabled) { emulator.Beta128.Enable(); } - if (_preferences.Interface1.IsEnabled) + if (favorite?.IsInterface1Enabled ?? _preferences.Interface1.IsEnabled) { emulator.Interface1.Enable(); } - emulator.ConfigureAudio(_preferences.Audio); + emulator.ConfigureAudio(_preferences.Audio, favorite); _mouseHelper = new MouseHelper(emulator.MouseManager); } @@ -80,7 +79,7 @@ private bool CreateEmulator(StateSnapshot snapshot, bool shouldResume = true) return false; } - private bool CreateEmulator(Stream stream, FileType fileType) + private bool CreateEmulator(Stream stream, FileType fileType, FavoriteProgram? favorite) { Emulator? emulator = null; @@ -90,11 +89,11 @@ private bool CreateEmulator(Stream stream, FileType fileType) } else if (fileType.IsTape()) { - emulator = _loader.EnterLoadCommand(ComputerType); + emulator = _loader.EnterLoadCommand(favorite?.ComputerType ?? ComputerType); emulator.TapeManager.InsertTape(stream, fileType, - _preferences.Tape.IsAutoPlayEnabled && TapeLoadSpeed != TapeSpeed.Instant); + _preferences.Tape.IsAutoPlayEnabled && (favorite?.TapeLoadSpeed ?? TapeLoadSpeed) != TapeSpeed.Instant); - ApplyEmulatorDefaults(emulator); + ApplyEmulatorDefaults(emulator, favorite: favorite); } if (emulator != null) diff --git a/src/Spectron/ViewModels/MainWindowViewModel.Files.cs b/src/Spectron/ViewModels/MainWindowViewModel.Files.cs index d795b2ee..9ec6c668 100644 --- a/src/Spectron/ViewModels/MainWindowViewModel.Files.cs +++ b/src/Spectron/ViewModels/MainWindowViewModel.Files.cs @@ -5,11 +5,12 @@ using OldBit.Spectron.Dialogs; using OldBit.Spectron.Emulation.Devices.Beta128.Drive; using OldBit.Spectron.Emulation.Devices.Interface1.Microdrives; +using OldBit.Spectron.Emulation.Extensions; using OldBit.Spectron.Emulation.Files; using OldBit.Spectron.Emulation.Snapshot; using OldBit.Spectron.Files.Pok; using OldBit.Spectron.Messages; -using FileTypes = OldBit.Spectron.Emulation.Files.FileTypes; +using OldBit.Spectron.Settings; namespace OldBit.Spectron.ViewModels; @@ -17,7 +18,7 @@ partial class MainWindowViewModel { private async Task HandleLoadFileAsync() => await HandleLoadFileAsync(null); - private async Task HandleLoadFileAsync(string? filePath) + private async Task HandleLoadFileAsync(string? filePath, FavoriteProgram? favorite = null) { Stream? stream = null; var shouldResume = !IsPaused; @@ -37,7 +38,7 @@ private async Task HandleLoadFileAsync(string? filePath) filePath = files[0].Path.LocalPath; } - var fileType = FileTypes.GetFileType(filePath); + var fileType = FileTypeResolver.FromPath(filePath); if (fileType == FileType.Unsupported) { await MessageDialogs.Warning($"Unsupported file type: {fileType}."); @@ -76,7 +77,7 @@ private async Task HandleLoadFileAsync(string? filePath) break; } - if (CreateEmulator(stream, fileType)) + if (CreateEmulator(stream, fileType, favorite)) { RecentFilesViewModel.Add(filePath); Title = $"{DefaultTitle} [{RecentFilesViewModel.CurrentFileName}]"; @@ -98,14 +99,22 @@ private async Task HandleLoadFileAsync(string? filePath) } } + private async Task OpenFavorite(FavoriteProgram favorite) + { + if (!string.IsNullOrWhiteSpace(favorite.Path)) + { + await HandleLoadFileAsync(favorite.Path, favorite); + } + } + private static async Task<(Stream? Stream, FileType FileType)> LoadFileAsync(string filePath, FileType fileType) { Stream? stream = null; if (fileType.IsArchive()) { - var archive = new ZipFileReader(filePath); - var files = archive.GetFiles(); + var archive = new ZipArchiveReader(filePath); + var files = archive.GetSupportedFiles(); switch (files.Count) { diff --git a/src/Spectron/ViewModels/MainWindowViewModel.Window.cs b/src/Spectron/ViewModels/MainWindowViewModel.Window.cs index 686dd0dc..427f09e1 100644 --- a/src/Spectron/ViewModels/MainWindowViewModel.Window.cs +++ b/src/Spectron/ViewModels/MainWindowViewModel.Window.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using OldBit.Spectron.Extensions; +using OldBit.Spectron.Settings; using OldBit.Spectron.Theming; namespace OldBit.Spectron.ViewModels; @@ -42,6 +43,9 @@ internal void WindowDeactivated(ActivatedEventArgs args) private async Task WindowOpenedAsync() { _preferences = await _preferencesService.LoadAsync(); + _favorites = await _favoritesService.LoadAsync(); + + FavoritesViewModel.Favorites = _favorites; ThemeManager.SelectTheme(CommandLineArgs?.Theme ?? _preferences.Theme); @@ -189,6 +193,7 @@ private async Task WindowClosingAsync(WindowClosingEventArgs args) await Task.WhenAll( _preferencesService.SaveAsync(_preferences), + _favoritesService.SaveAsync(_favorites), RecentFilesViewModel.SaveAsync(), _sessionService.SaveAsync(Emulator, _preferences.Resume)); diff --git a/src/Spectron/ViewModels/MainWindowViewModel.cs b/src/Spectron/ViewModels/MainWindowViewModel.cs index bc12acd5..046d8a77 100644 --- a/src/Spectron/ViewModels/MainWindowViewModel.cs +++ b/src/Spectron/ViewModels/MainWindowViewModel.cs @@ -31,6 +31,7 @@ using OldBit.Spectron.Files.Pok; using OldBit.Spectron.Input; using OldBit.Spectron.Logging; +using OldBit.Spectron.Messages; using OldBit.Spectron.Models; using OldBit.Spectron.Services; using OldBit.Spectron.Settings; @@ -55,6 +56,7 @@ public partial class MainWindowViewModel : ObservableObject private readonly Loader _loader; private readonly PreferencesService _preferencesService; + private readonly FavoritesService _favoritesService; private readonly SessionService _sessionService; private readonly DebuggerContext _debuggerContext; private readonly QuickSaveService _quickSaveService; @@ -68,6 +70,7 @@ public partial class MainWindowViewModel : ObservableObject private Emulator? Emulator { get; set; } private Preferences _preferences = new(); + private FavoritePrograms _favorites = new(); private TimeSpan _lastScreenRender = TimeSpan.Zero; private MediaRecorder? _mediaRecorder; private bool _canClose; @@ -87,6 +90,7 @@ public partial class MainWindowViewModel : ObservableObject public MicrodriveMenuViewModel MicrodriveMenuViewModel { get; } public DiskDriveMenuViewModel DiskDriveMenuViewModel { get; } public RecentFilesViewModel RecentFilesViewModel { get; } + public FavoritesViewModel FavoritesViewModel { get; } #region Observable properties [ObservableProperty] @@ -279,6 +283,10 @@ private async Task TakeScreenshot() [RelayCommand] private void ShowPrintOutput() => OpenPrintOutputViewer(); + // Favorites + [RelayCommand] + private void ShowFavoritesView() => OpenFavoritesWindow(); + // Tape [RelayCommand] private void SetTapeLoadSpeed(TapeSpeed tapeSpeed) => HandleSetTapeLoadingSpeed(tapeSpeed); @@ -313,8 +321,10 @@ public MainWindowViewModel( StateManager stateManager, Loader loader, PreferencesService preferencesService, + FavoritesService favoritesService, SessionService sessionService, RecentFilesViewModel recentFilesViewModel, + FavoritesViewModel favoritesViewModel, TapeMenuViewModel tapeMenuViewModel, MicrodriveMenuViewModel microdriveMenuViewModel, DiskDriveMenuViewModel diskDriveMenuViewModel, @@ -333,6 +343,7 @@ public MainWindowViewModel( _stateManager = stateManager; _loader = loader; _preferencesService = preferencesService; + _favoritesService = favoritesService; _sessionService = sessionService; _debuggerContext = debuggerContext; _quickSaveService = quickSaveService; @@ -340,6 +351,7 @@ public MainWindowViewModel( _logger = logger; RecentFilesViewModel = recentFilesViewModel; + FavoritesViewModel = favoritesViewModel; TapeMenuViewModel = tapeMenuViewModel; MicrodriveMenuViewModel = microdriveMenuViewModel; DiskDriveMenuViewModel = diskDriveMenuViewModel; @@ -365,6 +377,31 @@ public MainWindowViewModel( WeakReferenceMessenger.Default.Register(this, (_, _) => PauseForDebug()); + WeakReferenceMessenger.Default.Register(this, async void (_, message) => + { + try + { + _favorites = message.Favorites; + await _favoritesService.SaveAsync(_favorites); + } + catch + { + // Ignore + } + }); + + WeakReferenceMessenger.Default.Register(this, async void (_, message) => + { + try + { + await OpenFavorite(message.Favorite); + } + catch + { + // Ignore + } + }); + _frameRateCalculator.FrameRateChanged = fps => { Dispatcher.UIThread.Post(() => diff --git a/src/Spectron/ViewModels/Observable.cs b/src/Spectron/ViewModels/Observable.cs index de6fcf12..af812b03 100644 --- a/src/Spectron/ViewModels/Observable.cs +++ b/src/Spectron/ViewModels/Observable.cs @@ -5,20 +5,18 @@ namespace OldBit.Spectron.ViewModels; public class Observable(T value) : INotifyPropertyChanged { - private T _value = value; - public T Value { - get => _value; + get; set { - if (!Equals(value, _value)) + if (!Equals(value, field)) { - _value = value; + field = value; OnPropertyChanged(); } } - } + } = value; public event PropertyChangedEventHandler? PropertyChanged; diff --git a/src/Spectron/ViewModels/RecentFilesViewModel.cs b/src/Spectron/ViewModels/RecentFilesViewModel.cs index 1dc01bf4..47ee6dfb 100644 --- a/src/Spectron/ViewModels/RecentFilesViewModel.cs +++ b/src/Spectron/ViewModels/RecentFilesViewModel.cs @@ -9,9 +9,8 @@ namespace OldBit.Spectron.ViewModels; -public partial class RecentFilesViewModel : ObservableObject +public partial class RecentFilesViewModel(RecentFilesService recentFilesService) : ObservableObject { - private readonly RecentFilesService _recentFilesService; private RecentFilesSettings _recentFilesSettings = new(); public string CurrentFileName @@ -22,11 +21,6 @@ public string CurrentFileName public Func? OpenRecentFileAsync; - public RecentFilesViewModel(RecentFilesService recentFilesService) - { - _recentFilesService = recentFilesService; - } - [RelayCommand] private async Task OpenRecentFile(string fileName) { @@ -36,9 +30,9 @@ private async Task OpenRecentFile(string fileName) } } - public async Task LoadAsync() => _recentFilesSettings = await _recentFilesService.LoadAsync(); + public async Task LoadAsync() => _recentFilesSettings = await recentFilesService.LoadAsync(); - public async Task SaveAsync() => await _recentFilesService.SaveAsync(_recentFilesSettings); + public async Task SaveAsync() => await recentFilesService.SaveAsync(_recentFilesSettings); public void Opening(ItemCollection items) { diff --git a/src/Spectron/ViewModels/ServiceCollectionExtensions.cs b/src/Spectron/ViewModels/ServiceCollectionExtensions.cs index 809b7ab2..d17a6af1 100644 --- a/src/Spectron/ViewModels/ServiceCollectionExtensions.cs +++ b/src/Spectron/ViewModels/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ public static void AddViewModels(this IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Spectron/ViewModels/TapeMenuViewModel.cs b/src/Spectron/ViewModels/TapeMenuViewModel.cs index cb2fa74d..18006504 100644 --- a/src/Spectron/ViewModels/TapeMenuViewModel.cs +++ b/src/Spectron/ViewModels/TapeMenuViewModel.cs @@ -5,11 +5,11 @@ using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using OldBit.Spectron.Dialogs; +using OldBit.Spectron.Emulation.Extensions; using OldBit.Spectron.Emulation.Files; using OldBit.Spectron.Emulation.Tape; using OldBit.Spectron.Files.Extensions; using OldBit.Spectron.Messages; -using FileTypes = OldBit.Spectron.Emulation.Files.FileTypes; namespace OldBit.Spectron.ViewModels; @@ -59,7 +59,7 @@ private async Task Save() { var fileName = string.Empty; - var fileType = FileTypes.GetFileType(_recentFilesViewModel.CurrentFileName); + var fileType = FileTypeResolver.FromPath(_recentFilesViewModel.CurrentFileName); if (fileType.IsTape()) { @@ -73,7 +73,7 @@ private async Task Save() return; } - fileType = FileTypes.GetFileType(file.Path.LocalPath); + fileType = FileTypeResolver.FromPath(file.Path.LocalPath); if (fileType == FileType.Tap) { diff --git a/src/Spectron/Views/FavoritesView.axaml b/src/Spectron/Views/FavoritesView.axaml new file mode 100644 index 00000000..e9b3f1be --- /dev/null +++ b/src/Spectron/Views/FavoritesView.axaml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Spectron/Views/FavoritesView.axaml.cs b/src/Spectron/Views/FavoritesView.axaml.cs new file mode 100644 index 00000000..ac06b92c --- /dev/null +++ b/src/Spectron/Views/FavoritesView.axaml.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using OldBit.Spectron.Emulation.Files; +using OldBit.Spectron.ViewModels; + +namespace OldBit.Spectron.Views; + +public partial class FavoritesView : Window +{ + private FavoritesViewModel? _viewModel; + + public FavoritesView() => InitializeComponent(); + + protected override void OnDataContextChanged(EventArgs e) + { + if (DataContext is not FavoritesViewModel viewModel) + { + return; + } + + _viewModel = viewModel; + _viewModel.SelectedItem = viewModel.Nodes.FirstOrDefault(); + _viewModel.FavoritesTreeView = FavoritesTreeView; + } + + protected override void OnClosed(EventArgs e) + { + _viewModel?.UpdateFavorites(); + base.OnClosed(e); + } + + private void OnFileDragOver(object? sender, DragEventArgs e) + { + e.DragEffects = DragDropEffects.None; + + if (IsValidFileDrop(e)) + { + e.DragEffects = DragDropEffects.Copy; + } + + e.Handled = true; + } + + private void OnFileDrop(object? sender, DragEventArgs e) + { + if (!IsValidFileDrop(e)) + { + return; + } + + var filePath = GetDroppedFilePath(e); + + if (!IsFileSupported(filePath)) + { + return; + } + + _viewModel?.SelectedItem?.Path = filePath!; + } + + private static bool IsValidFileDrop(DragEventArgs e) + { + if (!e.DataTransfer.Contains(DataFormat.File) || e.DataTransfer.Items.Count != 1) + { + return false; + } + + var filePath = GetDroppedFilePath(e); + + return IsFileSupported(filePath); + } + + private static bool IsFileSupported(string? filePath) + { + if (filePath == null) + { + return false; + } + + var fileType = FileTypeResolver.FromPath(filePath); + + return fileType != FileType.Unsupported; + } + + private static string? GetDroppedFilePath(DragEventArgs e) + { + var items = e.DataTransfer.GetItems(DataFormat.File).FirstOrDefault(); + var file = items?.TryGetFile(); + + return file?.Path.LocalPath; + } +} \ No newline at end of file diff --git a/src/Spectron/Views/MainWindow.axaml.cs b/src/Spectron/Views/MainWindow.axaml.cs index ce04d94b..45387b15 100644 --- a/src/Spectron/Views/MainWindow.axaml.cs +++ b/src/Spectron/Views/MainWindow.axaml.cs @@ -30,17 +30,20 @@ public MainWindow() WeakReferenceMessenger.Default.Register(this, (window, message) => ShowDialog(window, new DiskViewModel(message.DiskDriveManager, message.DriveId))); - WeakReferenceMessenger.Default.Register(this, (window, m) => - Show(window, m.ViewModel!)); + WeakReferenceMessenger.Default.Register(this, (window, message) => + Show(window, message.ViewModel)); - WeakReferenceMessenger.Default.Register(this, (window, m) => - Show(window, m.ViewModel)); + WeakReferenceMessenger.Default.Register(this, (window, message) => + Show(window, message.ViewModel)); - WeakReferenceMessenger.Default.Register(this, (_, m) => - Show(null, m.ViewModel)); + WeakReferenceMessenger.Default.Register(this, (window, message) => + Show(window, message.ViewModel)); - WeakReferenceMessenger.Default.Register(this, (window, m) => - Show(window, m.ViewModel!)); + WeakReferenceMessenger.Default.Register(this, (_, message) => + Show(null, message.ViewModel)); + + WeakReferenceMessenger.Default.Register(this, (window, message) => + Show(window, message.ViewModel)); WeakReferenceMessenger.Default.Register(this, (window, message) => { diff --git a/src/Spectron/Views/PreferencesView.axaml b/src/Spectron/Views/PreferencesView.axaml index 9830b41e..11e40e25 100644 --- a/src/Spectron/Views/PreferencesView.axaml +++ b/src/Spectron/Views/PreferencesView.axaml @@ -448,19 +448,19 @@ @@ -489,14 +489,14 @@