diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 445ee0a..e222cb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Checkout code @@ -26,8 +26,20 @@ jobs: run: dotnet tool install -g vpk - name: Add .NET tools to PATH + if: runner.os != 'Windows' run: echo "${HOME}/.dotnet/tools" >> $GITHUB_PATH + - name: Add .NET tools to PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: echo "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install NativeAOT prerequisites (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y clang zlib1g-dev + - name: Restore dependencies run: dotnet restore @@ -36,9 +48,11 @@ jobs: - name: Extract clean version id: version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + shell: bash + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" - - name: Make platform script executable (Linux/macOS only) + - name: Make platform scripts executable (Linux/macOS) + if: runner.os != 'Windows' run: | chmod +x MapWizard.Desktop/build-linux.sh chmod +x MapWizard.Desktop/build-osx.sh @@ -49,7 +63,6 @@ jobs: run: | cd MapWizard.Desktop ./build-linux.sh ${{ env.VERSION }} - ./build-win.sh ${{ env.VERSION }} - name: Build with Velopack - macOS if: runner.os == 'macOS' @@ -57,6 +70,13 @@ jobs: cd MapWizard.Desktop ./build-osx.sh ${{ env.VERSION }} + - name: Build with Velopack - Windows + if: runner.os == 'Windows' + shell: cmd + run: | + cd MapWizard.Desktop + build-win.bat %VERSION% + - name: Upload Artifacts uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 05d7f95..502ed2f 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -185,7 +185,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Checkout code @@ -202,26 +202,38 @@ jobs: run: dotnet tool install -g vpk - name: Add .NET tools to PATH + if: runner.os != 'Windows' run: echo "${HOME}/.dotnet/tools" >> $GITHUB_PATH + - name: Add .NET tools to PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: echo "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install NativeAOT prerequisites (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y clang zlib1g-dev + - name: Restore dependencies run: dotnet restore - name: Run tests run: dotnet test MapWizard.Tests/MapWizard.Tests.csproj --no-restore --verbosity normal - - name: Make platform script executable + - name: Make platform script executable (Linux/macOS) + if: runner.os != 'Windows' run: | chmod +x MapWizard.Desktop/build-linux.sh chmod +x MapWizard.Desktop/build-osx.sh chmod +x MapWizard.Desktop/build-win.sh - - name: Build with Velopack - Linux and Windows + - name: Build with Velopack - Linux if: runner.os == 'Linux' run: | cd MapWizard.Desktop ./build-linux.sh ${{ needs.prepare.outputs.next_version }} - ./build-win.sh ${{ needs.prepare.outputs.next_version }} - name: Build with Velopack - macOS if: runner.os == 'macOS' @@ -229,6 +241,13 @@ jobs: cd MapWizard.Desktop ./build-osx.sh ${{ needs.prepare.outputs.next_version }} + - name: Build with Velopack - Windows + if: runner.os == 'Windows' + shell: cmd + run: | + cd MapWizard.Desktop + build-win.bat ${{ needs.prepare.outputs.next_version }} + - name: Upload artifacts uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1487ab9..aad7ede 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,3 +24,42 @@ jobs: - name: Run tests run: dotnet test MapWizard.Tests/MapWizard.Tests.csproj --no-restore --verbosity normal + + aot: + name: NativeAOT Publish (${{ matrix.name }}) + # Publish checks only run on merge to main, not on PRs. + # PRs only run the Linux test job above. + if: github.event_name == 'push' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: Linux + rid: linux-x64 + - os: windows-latest + name: Windows + rid: win-x64 + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Install NativeAOT prerequisites (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y clang zlib1g-dev + + - name: Restore dependencies + run: dotnet restore + + # MapWizard.Desktop.csproj sets PublishAot=true, so this catches AOT/trim + # regressions before they reach a release build. + - name: Publish with NativeAOT + run: dotnet publish MapWizard.Desktop/MapWizard.Desktop.csproj -c Release -r ${{ matrix.rid }} -o "${{ runner.temp }}/mapwizard-aot" diff --git a/MapWizard.CLI/MapWizard.CLI.csproj b/MapWizard.CLI/MapWizard.CLI.csproj index 74802f8..c3c8a0e 100644 --- a/MapWizard.CLI/MapWizard.CLI.csproj +++ b/MapWizard.CLI/MapWizard.CLI.csproj @@ -14,6 +14,7 @@ enable enable true + true latestmajor diff --git a/MapWizard.Desktop/App.axaml.cs b/MapWizard.Desktop/App.axaml.cs index 1d1e24f..ccffd56 100644 --- a/MapWizard.Desktop/App.axaml.cs +++ b/MapWizard.Desktop/App.axaml.cs @@ -43,7 +43,11 @@ public override void OnFrameworkInitializationCompleted() var services = _services ?? throw new InvalidOperationException("Application services were not initialized."); var mainWindow = services.GetRequiredService(); - mainWindow.Opened += (_, _) => mainWindow.GetViewModel().RequestStartupUpdateCheck(); + mainWindow.Opened += (_, _) => + { + mainWindow.GetViewModel().RequestStartupUpdateCheck(); + mainWindow.GetViewModel().PreloadPages(mainWindow.GetPreloadWarmupHost()); + }; switch (ApplicationLifetime) { diff --git a/MapWizard.Desktop/Controls/SmoothScrollViewer.cs b/MapWizard.Desktop/Controls/SmoothScrollViewer.cs index 089d7c7..5321c03 100644 --- a/MapWizard.Desktop/Controls/SmoothScrollViewer.cs +++ b/MapWizard.Desktop/Controls/SmoothScrollViewer.cs @@ -8,6 +8,7 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Rendering.Composition; +using Avalonia.Reactive; using Avalonia.Threading; using Avalonia.VisualTree; using MapWizard.Theme.Motion; @@ -144,7 +145,7 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { _presenterBoundsSubscription = _presenter .GetObservable(BoundsProperty) - .Subscribe(_ => UpdateFade()); + .Subscribe(new AnonymousObserver(_ => UpdateFade())); } ResolveContentVisual(); diff --git a/MapWizard.Desktop/Converters/MapPathSummaryConverter.cs b/MapWizard.Desktop/Converters/MapPathSummaryConverter.cs index 467fa46..8f4d381 100644 --- a/MapWizard.Desktop/Converters/MapPathSummaryConverter.cs +++ b/MapWizard.Desktop/Converters/MapPathSummaryConverter.cs @@ -7,15 +7,13 @@ namespace MapWizard.Desktop.Converters; -public sealed class MapPathSummaryConverter : IValueConverter +public sealed partial class MapPathSummaryConverter : IValueConverter { - private static readonly Regex FullPattern = - new(@"^(?.+?) - (?.+?) \((?<mapper>.+?)\) \[(?<diff>.+?)\]$", - RegexOptions.Compiled); + [GeneratedRegex(@"^(?<artist>.+?) - (?<title>.+?) \((?<mapper>.+?)\) \[(?<diff>.+?)\]$")] + private static partial Regex FullPattern(); - private static readonly Regex ShortPattern = - new(@"^(?<artist>.+?) - (?<title>.+?) \[(?<diff>.+?)\]$", - RegexOptions.Compiled); + [GeneratedRegex(@"^(?<artist>.+?) - (?<title>.+?) \[(?<diff>.+?)\]$")] + private static partial Regex ShortPattern(); public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { @@ -46,7 +44,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture return "No map selected"; } - var fullMatch = FullPattern.Match(fileName); + var fullMatch = FullPattern().Match(fileName); if (fullMatch.Success) { var artist = fullMatch.Groups["artist"].Value.Trim(); @@ -55,7 +53,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture return $"{artist} - {title} [{diff}]"; } - var shortMatch = ShortPattern.Match(fileName); + var shortMatch = ShortPattern().Match(fileName); if (shortMatch.Success) { var artist = shortMatch.Groups["artist"].Value.Trim(); diff --git a/MapWizard.Desktop/MapWizard.Desktop.csproj b/MapWizard.Desktop/MapWizard.Desktop.csproj index aef2a45..5aae74e 100644 --- a/MapWizard.Desktop/MapWizard.Desktop.csproj +++ b/MapWizard.Desktop/MapWizard.Desktop.csproj @@ -8,6 +8,9 @@ <SelfContained>true</SelfContained> <LangVersion>latestmajor</LangVersion> <Version>1.0.0</Version> + <PublishAot>true</PublishAot> + <IsAotCompatible>true</IsAotCompatible> + <StripSymbols>true</StripSymbols> </PropertyGroup> <ItemGroup> @@ -26,7 +29,6 @@ <PackageReference Include="OsuMemoryDataProvider" Version="0.12.2" /> <PackageReference Include="OsuWineMemReader" Version="1.0.1" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="4.152.1" /> - <PackageReference Include="System.Reactive" Version="7.0.0" /> <PackageReference Include="Velopack" Version="1.2.0" /> <PackageReference Include="Xaml.Behaviors" Version="12.0.7" /> </ItemGroup> @@ -49,6 +51,19 @@ <Folder Include="Assets\Skins\Legacy\" /> </ItemGroup> + <!-- + These libraries resolve types and members via reflection, so NativeAOT trimming + must keep their metadata intact. + - BeatmapParser: reads/writes .osu sections by enumerating properties. + - OsuMemoryDataProvider/ProcessMemoryDataFinder: structured memory reader maps + model properties via MemoryAddressAttribute. + --> + <ItemGroup> + <TrimmerRootAssembly Include="BeatmapParser" /> + <TrimmerRootAssembly Include="OsuMemoryDataProvider" Condition="'$(RuntimeIdentifier)' == 'win-x64'" /> + <TrimmerRootAssembly Include="ProcessMemoryDataFinder" Condition="'$(RuntimeIdentifier)' == 'win-x64'" /> + </ItemGroup> + <ItemGroup> <None Include="Native\Bass\linux-x64\libbass.so" Link="libbass.so" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> <None Include="Native\Bass\win-x64\bass.dll" Link="bass.dll" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> diff --git a/MapWizard.Desktop/Models/DestinationMapsetCard.cs b/MapWizard.Desktop/Models/DestinationMapsetCard.cs index 8ce8754..7415934 100644 --- a/MapWizard.Desktop/Models/DestinationMapsetCard.cs +++ b/MapWizard.Desktop/Models/DestinationMapsetCard.cs @@ -18,4 +18,13 @@ public partial class DestinationMapsetCard( public SelectedMap ReferenceBeatmap { get; } = referenceBeatmap; public ObservableCollection<MapsetDifficultyCard> Difficulties { get; } = new(difficulties); public bool HasUnselectedDifficulties => Difficulties.Any(difficulty => !difficulty.IsSelected); + + /// <summary> + /// Re-raises derived state after <see cref="Difficulties"/> entries were + /// updated in place, so bindings like the select-all button stay current. + /// </summary> + public void RefreshDifficultySelectionState() + { + OnPropertyChanged(nameof(HasUnselectedDifficulties)); + } } diff --git a/MapWizard.Desktop/Services/ComboColourService/ComboColourProjectStore.cs b/MapWizard.Desktop/Services/ComboColourService/ComboColourProjectStore.cs index 7ec71bd..b63b70f 100644 --- a/MapWizard.Desktop/Services/ComboColourService/ComboColourProjectStore.cs +++ b/MapWizard.Desktop/Services/ComboColourService/ComboColourProjectStore.cs @@ -4,21 +4,17 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Text.Json.Serialization; using MapWizard.Tools.ComboColourStudio; namespace MapWizard.Desktop.Services.ComboColourService; -public class ComboColourProjectStore : IComboColourProjectStore +public partial class ComboColourProjectStore : IComboColourProjectStore { private const string AppDirectoryName = "MapWizard"; private const string ProjectDirectoryName = "ComboColourStudio"; private const string StorageFileName = "projects.json"; - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true - }; - private readonly object _syncRoot = new(); private readonly string _storageFilePath; @@ -98,13 +94,13 @@ private List<PersistedProjectRecord> ReadRecords() return []; } - return JsonSerializer.Deserialize<List<PersistedProjectRecord>>(json, JsonOptions) ?? []; + return JsonSerializer.Deserialize(json, ProjectJsonContext.Default.ListPersistedProjectRecord) ?? []; } private void WriteRecords(List<PersistedProjectRecord> records) { var tempPath = _storageFilePath + ".tmp"; - var json = JsonSerializer.Serialize(records, JsonOptions); + var json = JsonSerializer.Serialize(records, ProjectJsonContext.Default.ListPersistedProjectRecord); File.WriteAllText(tempPath, json); File.Move(tempPath, _storageFilePath, overwrite: true); @@ -241,4 +237,8 @@ private sealed class PersistedColourPoint public ColourPointMode Mode { get; set; } = ColourPointMode.Normal; public List<int> ColourSequence { get; set; } = []; } + + [JsonSourceGenerationOptions(WriteIndented = true)] + [JsonSerializable(typeof(List<PersistedProjectRecord>))] + private sealed partial class ProjectJsonContext : JsonSerializerContext; } diff --git a/MapWizard.Desktop/Services/Playback/ManagedBassPlaybackService.cs b/MapWizard.Desktop/Services/Playback/ManagedBassPlaybackService.cs index dee30c6..ef8ebd0 100644 --- a/MapWizard.Desktop/Services/Playback/ManagedBassPlaybackService.cs +++ b/MapWizard.Desktop/Services/Playback/ManagedBassPlaybackService.cs @@ -24,6 +24,9 @@ public sealed class ManagedBassPlaybackService : IAudioPlaybackService, IDisposa new(StringComparer.OrdinalIgnoreCase); // Prevent the delegate from being garbage-collected while BASS holds a reference to it. + // This static root is the delegate handed to native BASS_ChannelSetSync, which matters + // because ManagedBass only roots sync callbacks when dynamic code is supported + // (see ChannelReferences.Add) - under NativeAOT that is false. private static readonly SyncProcedure HitsoundStreamEndSync = OnHitsoundStreamEnd; private static void OnHitsoundStreamEnd(int handle, int channel, int data, IntPtr user) @@ -298,7 +301,16 @@ public bool PlayHitsound(string filePath, float volumeMultiplier = 1f, string pl // Register a sync to automatically free the stream channel when playback ends, // preventing handle leaks since stream channels are not auto-freed. - Bass.ChannelSetSync(channelHandle, SyncFlags.End | SyncFlags.Onetime, 0, + // + // NOTE: SyncFlags.Onetime must NOT be used here. ManagedBass.ChannelSetSync wraps the + // procedure in a fresh delegate when Onetime is set, and roots that wrapper only when + // RuntimeFeature.IsDynamicCodeSupported is true - which is false under NativeAOT. + // The wrapper then has no managed root, so the GC can collect it while BASS still + // holds its native function pointer, and the audio thread segfaults when the sync + // fires (reproduced with NativeAOT builds). Without Onetime, ManagedBass passes the + // statically-rooted delegate straight through. End syncs fire once per channel + // lifetime anyway, and freeing the channel in the callback tears down its syncs. + Bass.ChannelSetSync(channelHandle, SyncFlags.End, 0, HitsoundStreamEndSync); if (!Bass.ChannelPlay(channelHandle, true)) diff --git a/MapWizard.Desktop/Services/SettingsService.cs b/MapWizard.Desktop/Services/SettingsService.cs index 7e121eb..66146cd 100644 --- a/MapWizard.Desktop/Services/SettingsService.cs +++ b/MapWizard.Desktop/Services/SettingsService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -13,6 +14,15 @@ public class SettingsService : ISettingsService private const string AppDirectoryName = "MapWizard"; private const string MainSettingsFileName = "MainSettings.ini"; + // Explicit enum parsing keeps NativeAOT from depending on reflection over + // enum metadata (Enum.Parse(Type, ...) cannot be statically analyzed). + private static readonly Dictionary<Type, Func<string, object>> EnumParsers = new() + { + [typeof(ThemeMode)] = static rawValue => Enum.Parse<ThemeMode>(rawValue, true), + [typeof(ThemePalette)] = static rawValue => Enum.Parse<ThemePalette>(rawValue, true), + [typeof(UpdateStream)] = static rawValue => Enum.Parse<UpdateStream>(rawValue, true), + }; + public string ConfigDirectoryPath { get; } public SettingsService() @@ -31,7 +41,8 @@ public void SaveMainSettings(MainSettings settings) SaveSettings(settings, MainSettingsFileName); } - private TSettings LoadSettings<TSettings>(string settingsFileName) where TSettings : new() + private TSettings LoadSettings<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TSettings>( + string settingsFileName) where TSettings : new() { var filePath = GetSettingsFilePath(settingsFileName); var document = ReadIniDocument(filePath); @@ -59,7 +70,9 @@ public void SaveMainSettings(MainSettings settings) return settings; } - private void SaveSettings<TSettings>(TSettings settings, string settingsFileName) + private void SaveSettings<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TSettings>( + TSettings settings, + string settingsFileName) { var filePath = GetSettingsFilePath(settingsFileName); var document = ReadIniDocument(filePath); @@ -80,7 +93,8 @@ private void SaveSettings<TSettings>(TSettings settings, string settingsFileName WriteIniDocument(filePath, document); } - private static IEnumerable<(PropertyInfo Property, SettingAttribute Attribute)> GetAnnotatedProperties(Type settingsType) + private static IEnumerable<(PropertyInfo Property, SettingAttribute Attribute)> GetAnnotatedProperties( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type settingsType) { return settingsType .GetProperties(BindingFlags.Instance | BindingFlags.Public) @@ -196,9 +210,9 @@ private static bool TryConvert(string rawValue, Type targetType, out object? val return true; } - if (nonNullableType.IsEnum) + if (EnumParsers.TryGetValue(nonNullableType, out var enumParser)) { - value = Enum.Parse(nonNullableType, rawValue, true); + value = enumParser(rawValue); return true; } diff --git a/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs b/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs deleted file mode 100644 index c7424f9..0000000 --- a/MapWizard.Desktop/Transitions/BlurSlideFadeTransition.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System; -using System.Numerics; -using System.Threading; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Animation; -using Avalonia.Animation.Easings; -using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Rendering.Composition; -using Avalonia.Styling; - -namespace MapWizard.Desktop.Transitions; - -/// <summary> -/// Page transition that blurs and slides the old page out while fading, -/// then unblurs and settles the new page in. All animated properties are -/// render-only; the blur is interpolated as an Avalonia effect and rendered -/// by the Skia backend. -/// </summary> -public sealed class BlurSlideFadeTransition : IPageTransition -{ - private readonly SemaphoreSlim _transitionGate = new(1, 1); - - public TimeSpan OutDuration { get; set; } = TimeSpan.FromMilliseconds(160); - - public TimeSpan InDuration { get; set; } = TimeSpan.FromMilliseconds(300); - - public double SlideDistance { get; set; } = 32d; - - public double MaxBlurRadius { get; set; } = 9d; - - public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) - { - // TransitioningContentControl can start the replacement transition before - // the canceled transition has unwound. Let the old transition restore its - // compositor state first so it cannot leave (or later reapply) an offset. - await _transitionGate.WaitAsync(cancellationToken); - - var direction = forward ? 1d : -1d; - - try - { - // TransitioningContentControl adds the incoming presenter before calling Start. - // Hide it synchronously so it cannot paint a frame over the outgoing page. - if (to is not null) - { - ResetVisualState(to); - to.IsVisible = false; - } - - if (from is not null) - { - await AnimateOutAsync(from, direction, cancellationToken); - from.Effect = null; - from.IsVisible = false; - } - - if (to is not null) - { - await AnimateInAsync(to, direction, cancellationToken); - } - } - finally - { - if (from is not null) - { - ResetVisualState(from); - } - - if (to is not null) - { - ResetVisualState(to); - to.IsVisible = true; - } - - _transitionGate.Release(); - } - } - - private static void ResetVisualState(Visual visual) - { - visual.Effect = null; - visual.Opacity = 1d; - visual.RenderTransform = new TranslateTransform(0d, 0d); - - if (ElementComposition.GetElementVisual(visual) is { } compositionVisual) - { - compositionVisual.StopAnimation("Opacity"); - compositionVisual.StopAnimation("Translation"); - compositionVisual.Opacity = 1f; - compositionVisual.Translation = default; - } - } - - private async Task AnimateOutAsync(Visual from, double direction, CancellationToken cancellationToken) - { - await AnimateAsync( - from, - OutDuration, - new CubicEaseIn(), - startOpacity: 1d, - endOpacity: 0d, - startOffset: 0d, - endOffset: -direction * SlideDistance, - startBlurRadius: 0d, - endBlurRadius: MaxBlurRadius, - revealBeforeAnimation: false, - cancellationToken); - } - - private async Task AnimateInAsync(Visual to, double direction, CancellationToken cancellationToken) - { - await AnimateAsync( - to, - InDuration, - new CubicEaseOut(), - startOpacity: 0d, - endOpacity: 1d, - startOffset: direction * SlideDistance, - endOffset: 0d, - startBlurRadius: MaxBlurRadius, - endBlurRadius: 0d, - revealBeforeAnimation: true, - cancellationToken); - } - - private static async Task AnimateAsync( - Visual target, - TimeSpan duration, - Easing easing, - double startOpacity, - double endOpacity, - double startOffset, - double endOffset, - double startBlurRadius, - double endBlurRadius, - bool revealBeforeAnimation, - CancellationToken cancellationToken) - { - var compositionVisual = ElementComposition.GetElementVisual(target); - if (compositionVisual is null) - { - await AnimateFallbackAsync( - target, - duration, - easing, - startOpacity, - endOpacity, - startOffset, - endOffset, - startBlurRadius, - endBlurRadius, - revealBeforeAnimation, - cancellationToken); - return; - } - - var previousOpacity = compositionVisual.Opacity; - var previousTranslation = compositionVisual.Translation; - var startTranslation = new Vector3( - (float)(previousTranslation.X + startOffset), - (float)previousTranslation.Y, - (float)previousTranslation.Z); - var endTranslation = new Vector3( - (float)(previousTranslation.X + endOffset), - (float)previousTranslation.Y, - (float)previousTranslation.Z); - - compositionVisual.StopAnimation("Opacity"); - compositionVisual.StopAnimation("Translation"); - compositionVisual.Opacity = (float)startOpacity; - compositionVisual.Translation = startTranslation; - - // The effect animation is the Skia-rendered path. Opacity and translation - // remain compositor animations because they do not redraw or invalidate layout. - target.Effect = new BlurEffect { Radius = endBlurRadius }; - var blurAnimation = CreateBlurAnimation(startBlurRadius, endBlurRadius, duration, easing) - .RunAsync(target, cancellationToken); - - if (revealBeforeAnimation) - { - target.IsVisible = true; - } - - StartCompositionAnimation( - compositionVisual, - duration, - easing, - (float)startOpacity, - (float)endOpacity, - startTranslation, - endTranslation); - - try - { - await Task.WhenAll( - blurAnimation, - Task.Delay(duration, cancellationToken)); - target.Effect = new BlurEffect { Radius = endBlurRadius }; - } - finally - { - compositionVisual.StopAnimation("Opacity"); - compositionVisual.StopAnimation("Translation"); - compositionVisual.Opacity = previousOpacity; - compositionVisual.Translation = previousTranslation; - } - } - - private static void StartCompositionAnimation( - CompositionVisual visual, - TimeSpan duration, - Easing easing, - float startOpacity, - float endOpacity, - Vector3 startTranslation, - Vector3 endTranslation) - { - var opacity = visual.Compositor.CreateScalarKeyFrameAnimation(); - opacity.Target = "Opacity"; - opacity.Duration = duration; - opacity.InsertKeyFrame(0f, startOpacity); - opacity.InsertKeyFrame(1f, endOpacity, easing); - visual.StartAnimation("Opacity", opacity); - - var translation = visual.Compositor.CreateVector3KeyFrameAnimation(); - translation.Target = "Translation"; - translation.Duration = duration; - translation.InsertKeyFrame(0f, startTranslation); - translation.InsertKeyFrame(1f, endTranslation, easing); - visual.StartAnimation("Translation", translation); - } - - private static async Task AnimateFallbackAsync( - Visual target, - TimeSpan duration, - Easing easing, - double startOpacity, - double endOpacity, - double startOffset, - double endOffset, - double startBlurRadius, - double endBlurRadius, - bool revealBeforeAnimation, - CancellationToken cancellationToken) - { - var translation = new TranslateTransform(startOffset, 0d); - target.Opacity = startOpacity; - target.RenderTransform = translation; - target.Effect = new BlurEffect { Radius = endBlurRadius }; - - var blurAnimation = CreateBlurAnimation(startBlurRadius, endBlurRadius, duration, easing) - .RunAsync(target, cancellationToken); - - if (revealBeforeAnimation) - { - target.IsVisible = true; - } - - var steps = Math.Max(1, (int)Math.Ceiling(duration.TotalMilliseconds / 16d)); - var stepDuration = TimeSpan.FromTicks(duration.Ticks / steps); - - for (var step = 1; step <= steps; step++) - { - var progress = easing.Ease((double)step / steps); - target.Opacity = startOpacity + ((endOpacity - startOpacity) * progress); - translation.X = startOffset + ((endOffset - startOffset) * progress); - await Task.Delay(stepDuration, cancellationToken); - } - - await blurAnimation; - } - - private static Animation CreateBlurAnimation( - double startRadius, - double endRadius, - TimeSpan duration, - Easing easing) - { - return new Animation - { - Duration = duration, - Easing = easing, - FillMode = FillMode.Forward, - Children = - { - new KeyFrame - { - Cue = new Cue(0d), - Setters = - { - new Setter( - Visual.EffectProperty, - new BlurEffect { Radius = startRadius }), - }, - }, - new KeyFrame - { - Cue = new Cue(1d), - Setters = - { - new Setter( - Visual.EffectProperty, - new BlurEffect { Radius = endRadius }), - }, - }, - }, - }; - } -} diff --git a/MapWizard.Desktop/Transitions/ScaleFadeTransition.cs b/MapWizard.Desktop/Transitions/ScaleFadeTransition.cs new file mode 100644 index 0000000..f057a5d --- /dev/null +++ b/MapWizard.Desktop/Transitions/ScaleFadeTransition.cs @@ -0,0 +1,248 @@ +using System; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Media; +using Avalonia.Rendering.Composition; + +namespace MapWizard.Desktop.Transitions; + +/// <summary> +/// Scales and fades the old page away before bringing the new page forward. +/// Scale and opacity stay on the compositor path to avoid redrawing page content. +/// </summary> +public sealed class ScaleFadeTransition : IPageTransition +{ + private readonly SemaphoreSlim _transitionGate = new(1, 1); + + public TimeSpan OutDuration { get; set; } = TimeSpan.FromMilliseconds(200); + + public TimeSpan InDuration { get; set; } = TimeSpan.FromMilliseconds(500); + + public double InStartScale { get; set; } = 0.96d; + + public double OutEndScale { get; set; } = 1.05d; + + public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + // First page load has no outgoing page. Showing the content + // immediately avoids a 300ms fade/scale that delays first paint + // and competes with startup work (window show, preload, toasts). + if (from is null) + { + if (to is not null) + { + ResetVisualState(to); + to.IsVisible = true; + } + + return; + } + + // TransitioningContentControl can start a replacement transition before + // the canceled transition has restored its compositor state. + await _transitionGate.WaitAsync(cancellationToken); + + try + { + // The incoming presenter is already in the visual tree when Start is + // called, so hide it before the outgoing page begins to animate. + if (to is not null) + { + ResetVisualState(to); + to.IsVisible = false; + } + + if (from is not null) + { + await AnimateAsync( + from, + OutDuration, + new QuadraticEaseIn(), + startOpacity: 1d, + endOpacity: 0d, + startScale: 1d, + endScale: OutEndScale, + revealBeforeAnimation: false, + cancellationToken); + from.IsVisible = false; + } + + if (to is not null) + { + await AnimateAsync( + to, + InDuration, + new CubicEaseOut(), + startOpacity: 0d, + endOpacity: 1d, + startScale: InStartScale, + endScale: 1d, + revealBeforeAnimation: true, + cancellationToken); + } + } + finally + { + if (from is not null) + { + ResetVisualState(from); + } + + if (to is not null) + { + ResetVisualState(to); + to.IsVisible = true; + } + + _transitionGate.Release(); + } + } + + private static void ResetVisualState(Visual visual) + { + visual.Opacity = 1d; + visual.RenderTransformOrigin = RelativePoint.Center; + visual.RenderTransform = new ScaleTransform(1d, 1d); + + if (ElementComposition.GetElementVisual(visual) is { } compositionVisual) + { + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Scale"); + compositionVisual.Opacity = 1f; + compositionVisual.Scale = Vector3.One; + } + } + + private static async Task AnimateAsync( + Visual target, + TimeSpan duration, + Easing easing, + double startOpacity, + double endOpacity, + double startScale, + double endScale, + bool revealBeforeAnimation, + CancellationToken cancellationToken) + { + var compositionVisual = ElementComposition.GetElementVisual(target); + if (compositionVisual is null) + { + await AnimateFallbackAsync( + target, + duration, + easing, + startOpacity, + endOpacity, + startScale, + endScale, + revealBeforeAnimation, + cancellationToken); + return; + } + + var previousOpacity = compositionVisual.Opacity; + var previousScale = compositionVisual.Scale; + var previousCenterPoint = compositionVisual.CenterPoint; + var startScaleVector = new Vector3((float)startScale, (float)startScale, 1f); + var endScaleVector = new Vector3((float)endScale, (float)endScale, 1f); + + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Scale"); + compositionVisual.Opacity = (float)startOpacity; + compositionVisual.Scale = startScaleVector; + compositionVisual.CenterPoint = new Vector3( + (float)(target.Bounds.Width / 2d), + (float)(target.Bounds.Height / 2d), + 0f); + + if (revealBeforeAnimation) + { + target.IsVisible = true; + } + + StartCompositionAnimation( + compositionVisual, + duration, + easing, + (float)startOpacity, + (float)endOpacity, + startScaleVector, + endScaleVector); + + try + { + await Task.Delay(duration, cancellationToken); + } + finally + { + compositionVisual.StopAnimation("Opacity"); + compositionVisual.StopAnimation("Scale"); + compositionVisual.Opacity = previousOpacity; + compositionVisual.Scale = previousScale; + compositionVisual.CenterPoint = previousCenterPoint; + } + } + + private static void StartCompositionAnimation( + CompositionVisual visual, + TimeSpan duration, + Easing easing, + float startOpacity, + float endOpacity, + Vector3 startScale, + Vector3 endScale) + { + var opacity = visual.Compositor.CreateScalarKeyFrameAnimation(); + opacity.Target = "Opacity"; + opacity.Duration = duration; + opacity.InsertKeyFrame(0f, startOpacity); + opacity.InsertKeyFrame(1f, endOpacity, easing); + visual.StartAnimation("Opacity", opacity); + + var scale = visual.Compositor.CreateVector3KeyFrameAnimation(); + scale.Target = "Scale"; + scale.Duration = duration; + scale.InsertKeyFrame(0f, startScale); + scale.InsertKeyFrame(1f, endScale, easing); + visual.StartAnimation("Scale", scale); + } + + private static async Task AnimateFallbackAsync( + Visual target, + TimeSpan duration, + Easing easing, + double startOpacity, + double endOpacity, + double startScale, + double endScale, + bool revealBeforeAnimation, + CancellationToken cancellationToken) + { + var scale = new ScaleTransform(startScale, startScale); + target.Opacity = startOpacity; + target.RenderTransformOrigin = RelativePoint.Center; + target.RenderTransform = scale; + + if (revealBeforeAnimation) + { + target.IsVisible = true; + } + + var steps = Math.Max(1, (int)Math.Ceiling(duration.TotalMilliseconds / 16d)); + var stepDuration = TimeSpan.FromTicks(duration.Ticks / steps); + + for (var step = 1; step <= steps; step++) + { + var progress = easing.Ease((double)step / steps); + var currentScale = startScale + ((endScale - startScale) * progress); + target.Opacity = startOpacity + ((endOpacity - startOpacity) * progress); + scale.ScaleX = currentScale; + scale.ScaleY = currentScale; + await Task.Delay(stepDuration, cancellationToken); + } + } +} diff --git a/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs b/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs index d7c1e48..9918229 100644 --- a/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs +++ b/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs @@ -120,10 +120,9 @@ private static StackPanel BuildFooterPanel( useSelectedButton.Classes.Add("Compact"); useSelectedButton.Bind( Button.IsEnabledProperty, - new Binding(nameof(SongSelectDialogViewModel.CanConfirmSelection)) - { - Source = songSelectViewModel - }); + CompiledBinding.Create( + (SongSelectDialogViewModel viewModel) => viewModel.CanConfirmSelection, + songSelectViewModel)); useSelectedButton.Click += (_, _) => songSelectViewModel.ConfirmSelectionCommand.Execute(null); footerPanel.Children.Add(useSelectedButton); } diff --git a/MapWizard.Desktop/Utils/MillisecondParser.cs b/MapWizard.Desktop/Utils/MillisecondParser.cs index 8e02a7d..d059b82 100644 --- a/MapWizard.Desktop/Utils/MillisecondParser.cs +++ b/MapWizard.Desktop/Utils/MillisecondParser.cs @@ -4,11 +4,10 @@ namespace MapWizard.Desktop.Utils; -public static class MillisecondParser +public static partial class MillisecondParser { - private static readonly Regex OsuTimestampRegex = new( - @"(?<min>\d{1,4}):(?<sec>\d{1,2}):(?<ms>\d{1,3})", - RegexOptions.Compiled); + [GeneratedRegex(@"(?<min>\d{1,4}):(?<sec>\d{1,2}):(?<ms>\d{1,3})")] + private static partial Regex OsuTimestampRegex(); public static bool TryParseMillisecondInput(string? input, out double value) { @@ -36,7 +35,7 @@ public static bool TryParseMillisecondInput(string? input, out double value) private static bool TryParseOsuTimestamp(string input, out double value) { value = 0; - var match = OsuTimestampRegex.Match(input); + var match = OsuTimestampRegex().Match(input); if (!match.Success) { return false; diff --git a/MapWizard.Desktop/Utils/WaylandDispatcherTimerWorkaround.cs b/MapWizard.Desktop/Utils/WaylandDispatcherTimerWorkaround.cs index 3e75a40..801dfee 100644 --- a/MapWizard.Desktop/Utils/WaylandDispatcherTimerWorkaround.cs +++ b/MapWizard.Desktop/Utils/WaylandDispatcherTimerWorkaround.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Avalonia.Threading; @@ -15,6 +16,11 @@ internal static class WaylandDispatcherTimerWorkaround private const string PlatformImplementationFieldName = "_impl"; private const long MinimumClockSkewMilliseconds = 5; + [DynamicDependency(DynamicallyAccessedMemberTypes.NonPublicFields, typeof(Dispatcher))] + [UnconditionalSuppressMessage( + "Trimming", + "IL2075", + Justification = "Reflection targets Avalonia internals best-effort; missing metadata only skips the workaround.")] public static void ApplyIfNeeded() { if (!OperatingSystem.IsLinux() || @@ -65,10 +71,10 @@ public static void ApplyIfNeeded() Trace.WriteLine( $"Applied Avalonia Wayland timer workaround (dispatcher clock skew: {clockSkew} ms)."); } - catch (Exception exception) when (exception is MemberAccessException or TargetException) + catch (Exception exception) { - // This relies on an Avalonia private field and must not prevent startup if - // a future framework release changes or removes that implementation detail. + // This relies on Avalonia private fields and must not prevent startup if + // a future framework release changes, trims, or removes that implementation detail. Trace.TraceWarning($"Could not apply the Avalonia Wayland timer workaround: {exception.Message}"); } } diff --git a/MapWizard.Desktop/ViewLocator.cs b/MapWizard.Desktop/ViewLocator.cs index 73b4793..2a2413c 100644 --- a/MapWizard.Desktop/ViewLocator.cs +++ b/MapWizard.Desktop/ViewLocator.cs @@ -1,35 +1,58 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Templates; +using Avalonia.Threading; +using Avalonia.VisualTree; using MapWizard.Desktop.ViewModels; +using MapWizard.Desktop.Views; +using MapWizard.Desktop.Views.ComboColourStudio; +using MapWizard.Desktop.Views.HitSoundVisualizer; +using MapWizard.Desktop.Views.MapCleaner; +using MapWizard.Desktop.Views.MetadataManager; +using MapWizard.Desktop.Views.Settings; +using MapWizard.Desktop.Views.WelcomePage; namespace MapWizard.Desktop; public class ViewLocator : IDataTemplate { + // Explicit mapping keeps view resolution free of reflection so NativeAOT can + // trim and compile the whole application ahead of time. + private static readonly Dictionary<Type, Func<Control>> ViewFactories = new() + { + [typeof(WelcomePageViewModel)] = static () => new WelcomePageView(), + [typeof(HitSoundCopierViewModel)] = static () => new HitSoundCopierView(), + [typeof(HitSoundVisualizerViewModel)] = static () => new HitSoundVisualizerView(), + [typeof(MetadataManagerViewModel)] = static () => new MetadataManagerView(), + [typeof(ComboColourStudioViewModel)] = static () => new ComboColourStudioView(), + [typeof(MapCleanerViewModel)] = static () => new MapCleanerView(), + [typeof(SettingsViewModel)] = static () => new SettingsView(), + }; + + // Page views have large XAML object graphs, so each one is built once and then + // reused for the lifetime of the app. A cached entry is replaced only when it + // is still attached to the visual tree (possible during a page transition). + private static readonly Dictionary<Type, Control> ViewCache = new(); + public Control? Build(object? data) { if (data is null) + { return null; + } - string name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); - Type? type = Type.GetType(name); - - if (type != null && data is ViewModelBase) + if (data is ViewModelBase) { - // pass the data context to the view directly at construction - - Control? control = (Control?)Activator.CreateInstance(type, data); - - return control; + return GetOrCreate(data); } - return new TextBlock { Text = "Not Found: " + name }; + return new TextBlock { Text = "Not Found: " + data.GetType().FullName }; } public bool Match(object? data) @@ -38,27 +61,84 @@ public bool Match(object? data) } /// <summary> - /// Finds a view from a given ViewModel + /// Builds (or returns the cached) view for a page view model without attaching + /// it to the visual tree, so the first navigation to that page is instant. + /// When <paramref name="warmupHost"/> is given, the view is additionally + /// attached to that panel (inside the live window) at opacity 0 for one + /// dispatcher turn: control themes/styles resolve against the real style + /// host and the first measure/arrange/render happen off-screen, so the + /// first navigation only pays a cheap re-layout instead of a UI freeze + /// that starves the page transition. /// </summary> - /// <param name="vm">The ViewModel representing a View</param> - /// <returns>The View that matches the ViewModel. Null is no match found</returns> - public static Window? ResolveViewFromViewModel<T>(T vm) where T : ViewModelBase + public static Control? Preload(ViewModelBase viewModel, Panel? warmupHost = null) { - string name = vm.GetType().AssemblyQualifiedName!.Replace("ViewModel", "View"); - Type? type = Type.GetType(name); - return type != null ? (Window?)Activator.CreateInstance(type) : null; + var view = GetOrCreate(viewModel); + + if (warmupHost is not null && + view.Parent is null && + view.GetVisualParent() is null) + { + WarmUpView(view, warmupHost); + } + + return view; } - - public static UserControl? ResolveControlViewFromViewModel<T>(T vm) where T : ViewModelBase + + /// <summary> + /// Briefly hosts a pre-built view inside the live window at opacity 0 so it + /// completes its first style application, measure/arrange, and frame while + /// invisible. The Background-priority detach runs after the queued layout + /// (Layout priority) and render (Render priority) work for that pass. + /// </summary> + private static void WarmUpView(Control view, Panel warmupHost) { - string name = vm.GetType().AssemblyQualifiedName!.Replace("ViewModel", "View"); - Type? type = Type.GetType(name); - return type != null ? (UserControl?)Activator.CreateInstance(type) : null; - } + var wrapper = new Border + { + Opacity = 0, + IsHitTestVisible = false, + ClipToBounds = true, + }; + wrapper.Child = view; + warmupHost.Children.Insert(0, wrapper); - private static IEnumerable<Window> Windows => - (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Windows ?? Array.Empty<Window>(); + Dispatcher.UIThread.Post( + () => + { + wrapper.Child = null; + warmupHost.Children.Remove(wrapper); + }, + DispatcherPriority.Background); + } public static Window? FindWindowByViewModel(INotifyPropertyChanged viewModel) => Windows.FirstOrDefault(x => ReferenceEquals(viewModel, x.DataContext)); + + private static Control GetOrCreate(object dataContext) + { + var viewModelType = dataContext.GetType(); + + if (ViewCache.TryGetValue(viewModelType, out var cached) && + cached.Parent is null && + cached.GetVisualParent() is null) + { + cached.DataContext = dataContext; + return cached; + } + + if (!ViewFactories.TryGetValue(viewModelType, out var factory)) + { + return new TextBlock { Text = "Not Found: " + viewModelType.FullName }; + } + + var stopwatch = Stopwatch.StartNew(); + var control = factory(); + control.DataContext = dataContext; + ViewCache[viewModelType] = control; + Debug.WriteLine($"[ViewLocator] Built {viewModelType.Name} in {stopwatch.Elapsed.TotalMilliseconds:F1}ms"); + + return control; + } + + private static IEnumerable<Window> Windows => + (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Windows ?? Array.Empty<Window>(); } diff --git a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs index 05f3dda..0b64709 100644 --- a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Avalonia.Controls.Notifications; @@ -462,11 +463,6 @@ private Task PickDestinationFile(CancellationToken _) return Task.CompletedTask; } - [RelayCommand] - private void AddDestinationFromMemory() - { - } - [RelayCommand] private void OpenOriginFolder() { @@ -1117,7 +1113,7 @@ public bool TryBuildPointClipboardPayload(out string payload) }).ToList() }; - payload = JsonSerializer.Serialize(clipboard); + payload = JsonSerializer.Serialize(clipboard, PointClipboardJsonContext.Default.PointClipboardPayload); return true; } @@ -1138,7 +1134,7 @@ public void PastePointClipboardPayload(string? clipboardText) PointClipboardPayload? payload; try { - payload = JsonSerializer.Deserialize<PointClipboardPayload>(clipboardText); + payload = JsonSerializer.Deserialize(clipboardText, PointClipboardJsonContext.Default.PointClipboardPayload); } catch (Exception ex) { @@ -3440,6 +3436,9 @@ private sealed class PointClipboardItem public string? HitSound { get; set; } } + [JsonSerializable(typeof(PointClipboardPayload))] + private sealed partial class PointClipboardJsonContext : JsonSerializerContext; + private static bool IsAutoSampleSetDisplay(string value) { return string.Equals(value, "Auto", StringComparison.OrdinalIgnoreCase); diff --git a/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs b/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs index 5e6f926..5ea9541 100644 --- a/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs +++ b/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Threading.Tasks; using Avalonia.Controls.Notifications; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MapWizard.Desktop.Enums; @@ -23,6 +26,7 @@ public partial class MainWindowViewModel : ObservableObject private MapCleanerViewModel? _mapCleanerViewModel; private readonly WelcomePageViewModel _welcomePageViewModel; private readonly SettingsViewModel _settingsViewModel; + private bool _pagesPreloaded; [ObservableProperty] private string _version = "MapWizard-localdev"; @@ -75,6 +79,85 @@ public MainWindowViewModel( /// </summary> public void RequestStartupUpdateCheck() => _ = _welcomePageViewModel.CheckForUpdatesOnStartupAsync(); + /// <summary> + /// Builds every page view after startup, so the first navigation to + /// each page does not pay the XAML/control construction cost. Startup + /// is deferred past the entrance transition and first frames: building + /// heavy pages (notably the 900-line HitSound Editor) on the UI thread + /// during the 300ms entrance animation starves the compositor and makes + /// the first paint look sluggish. One page is built per idle dispatcher + /// turn to keep later interaction responsive. + /// When <paramref name="warmupHost"/> (a panel inside the window) is + /// given, each built page is also briefly attached at opacity 0 so its + /// first style/measure/arrange/render work happens off-screen instead + /// of blocking the transition the first time it is opened. + /// </summary> + public void PreloadPages(Avalonia.Controls.Panel? warmupHost = null) + { + if (_pagesPreloaded) + { + return; + } + + _pagesPreloaded = true; + _ = PreloadPagesAfterFirstRenderAsync(warmupHost); + } + + private async Task PreloadPagesAfterFirstRenderAsync(Avalonia.Controls.Panel? warmupHost) + { + try + { + // Entrance transition is 300ms; leave headroom for first render, + // update-check toast, and compositor warm-up before spending UI + // thread time on speculative XAML inflation. + await Task.Delay(TimeSpan.FromMilliseconds(800)); + } + catch + { + return; + } + + // View construction must run on the UI thread; SystemIdle keeps it + // from stealing frames from input, render, or later transitions. + // If the user already navigated, ViewLocator hits the cache and skips. + Dispatcher.UIThread.Post( + () => PreloadNextPage(new Queue<ViewModelBase>(GetPageViewModels()), warmupHost), + DispatcherPriority.SystemIdle); + } + + private void PreloadNextPage(Queue<ViewModelBase> pendingPages, Avalonia.Controls.Panel? warmupHost) + { + if (pendingPages.Count == 0) + { + return; + } + + try + { + ViewLocator.Preload(pendingPages.Dequeue(), warmupHost); + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + } + + Dispatcher.UIThread.Post( + () => PreloadNextPage(pendingPages, warmupHost), + DispatcherPriority.SystemIdle); + } + + private IEnumerable<ViewModelBase> GetPageViewModels() + { + // Welcome is already visible and cached; rebuilding it during the + // entrance transition would only add UI-thread contention. + yield return _hitSoundCopierViewModel ??= _services.GetRequiredService<HitSoundCopierViewModel>(); + yield return _hitSoundVisualizerViewModel ??= _services.GetRequiredService<HitSoundVisualizerViewModel>(); + yield return _metadataManagerViewModel ??= _services.GetRequiredService<MetadataManagerViewModel>(); + yield return _comboColourStudioViewModel ??= _services.GetRequiredService<ComboColourStudioViewModel>(); + yield return _mapCleanerViewModel ??= _services.GetRequiredService<MapCleanerViewModel>(); + yield return _settingsViewModel; + } + public void NavigateToWelcome() => SetPage(NavigationPage.Welcome); public void NavigateToHitSoundCopier() => SetPage(NavigationPage.HitSoundCopier); diff --git a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs index 4f944ff..1bb2e30 100644 --- a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs @@ -142,11 +142,6 @@ private async Task SetOriginFromMemory(CancellationToken token) LoadOriginBeatmapHeader(); } - [RelayCommand] - private void AddDestinationFromMemory() - { - } - [RelayCommand] private void OpenOriginFolder() { diff --git a/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs b/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs index 7da6047..7e11d5a 100644 --- a/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs +++ b/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs @@ -537,7 +537,10 @@ private async Task LoadNextPageAsync(int queryVersion, CancellationToken cancell mapsetViewModel = new SongMapsetCardViewModel( mapset, - IsPreferredMapsetDirectory(directoryEntry.DirectoryPath)); + IsPreferredMapsetDirectory(directoryEntry.DirectoryPath)) + { + Owner = this + }; _mapsetViewModelCache[directoryEntry.DirectoryPath] = mapsetViewModel; } else @@ -615,7 +618,10 @@ private async Task LoadMountedLazerMapsetAsync(CancellationToken cancellationTok var mountedViewModel = new SongMapsetCardViewModel( mapset, isPreferredMapset: false, - isMountedInLazer: true); + isMountedInLazer: true) + { + Owner = this + }; mountedViewModel.SetBackgroundActive(true); _mapsetViewModelCache[normalizedDirectory] = mountedViewModel; MountedLazerMapset = mountedViewModel; @@ -898,7 +904,10 @@ public SongMapsetCardViewModel( IsMountedInLazer = isMountedInLazer; Difficulties = new ObservableCollection<SongDifficultyItemViewModel>(mapset.Difficulties .OrderByDescending(difficulty => difficulty.LastEditUtc) - .Select(difficulty => new SongDifficultyItemViewModel(difficulty))); + .Select(difficulty => new SongDifficultyItemViewModel(difficulty) + { + Mapset = this + })); foreach (var difficulty in Difficulties) { difficulty.PropertyChanged += OnDifficultyPropertyChanged; @@ -909,6 +918,12 @@ public SongMapsetCardViewModel( [ObservableProperty] private bool _isExpanded; [ObservableProperty] private bool _isPreferredMapset; public bool IsMountedInLazer { get; } + + /// <summary> + /// The owning song select view model. Set at construction so the card can bind to + /// the dialog's commands with compiled bindings (NativeAOT-safe). + /// </summary> + public SongSelectDialogViewModel? Owner { get; set; } public string Artist { get; } public string Title { get; } public string Creator { get; } @@ -1028,4 +1043,10 @@ public partial class SongDifficultyItemViewModel(SongDifficultyInfo difficulty) public string Name { get; } = difficulty.Name; public string OsuFilePath { get; } = difficulty.OsuFilePath; + + /// <summary> + /// The owning mapset card. Set at construction so difficulty chips can bind to the + /// dialog's selection command with compiled bindings (NativeAOT-safe). + /// </summary> + public SongMapsetCardViewModel? Mapset { get; set; } } diff --git a/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml b/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml index fdcc6ef..5cd887e 100644 --- a/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml +++ b/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml @@ -137,6 +137,14 @@ <controls:BeatmapSelectionPanel OriginMap="{Binding OriginBeatmap}" DestinationMaps="{Binding DestinationBeatmaps}" + ClearSelectionCommand="{Binding ClearSelectionCommand}" + OpenOriginFolderCommand="{Binding OpenOriginFolderCommand}" + SetOriginFromMemoryCommand="{Binding SetOriginFromMemoryCommand}" + PickOriginFileCommand="{Binding PickOriginFileCommand}" + AddDestinationFromMemoryCommand="{Binding AddDestinationFromMemoryCommand}" + PickDestinationFileCommand="{Binding PickDestinationFileCommand}" + SelectOriginMapCommand="{Binding SelectOriginMapCommand}" + ToggleDestinationMapCommand="{Binding ToggleDestinationMapCommand}" OriginMemoryToolTip="Use currently selected osu! beatmap" DestinationMemoryToolTip="Add currently selected osu! beatmap" /> @@ -155,9 +163,9 @@ <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"> <controls:ModernColorPicker SelectedColor="{Binding Colour, Converter={StaticResource AvaloniaColorToColorConverter}, Mode=TwoWay}" - SuggestedColours="{ReflectionBinding DataContext.SuggestedColours, ElementName=Root}" /> + SuggestedColours="{Binding DataContext.SuggestedColours, ElementName=Root}" /> <Button Classes="Basic" - Command="{ReflectionBinding DataContext.RemoveColourCommand, ElementName=Root}" + Command="{Binding DataContext.RemoveColourCommand, ElementName=Root}" CommandParameter="{Binding}" ToolTip.Tip="Remove this combo colour"> <LucideIcon Kind="X" Size="20" /> @@ -257,9 +265,9 @@ DragDrop.DragOver="SequenceToken_OnDragOver" DragDrop.Drop="SequenceToken_OnDrop"> <controls:ComboColourDropdown - Options="{ReflectionBinding DataContext.ColourDropdownOptions, ElementName=Root}" + Options="{Binding DataContext.ColourDropdownOptions, ElementName=Root}" SelectedComboNumber="{Binding ComboNumber, Mode=TwoWay}" - RemoveTokenCommand="{ReflectionBinding DataContext.RemoveColourFromPointCommand, ElementName=Root}" + RemoveTokenCommand="{Binding DataContext.RemoveColourFromPointCommand, ElementName=Root}" RemoveTokenCommandParameter="{Binding}" /> </Border> </DataTemplate> @@ -272,21 +280,21 @@ Spacing="6" HorizontalAlignment="Right"> <Button Classes="Basic" - Command="{ReflectionBinding DataContext.AddColourToPointCommand, ElementName=Root}" + Command="{Binding DataContext.AddColourToPointCommand, ElementName=Root}" CommandParameter="{Binding}" ToolTip.Tip="Add combo colour to this point sequence"> <LucideIcon Kind="Plus" Size="16" /> </Button> <Button Classes="Basic" - Command="{ReflectionBinding DataContext.AddColourPointFromSequenceCommand, ElementName=Root}" + Command="{Binding DataContext.AddColourPointFromSequenceCommand, ElementName=Root}" CommandParameter="{Binding}" ToolTip.Tip="Create a new colour point from this sequence"> <LucideIcon Kind="Copy" Size="16" /> </Button> <Button Classes="Basic" - Command="{ReflectionBinding DataContext.RemoveColourPointCommand, ElementName=Root}" + Command="{Binding DataContext.RemoveColourPointCommand, ElementName=Root}" CommandParameter="{Binding}" ToolTip.Tip="Remove this colour point"> <LucideIcon Kind="Trash" Size="18" /> diff --git a/MapWizard.Desktop/Views/Controls/AppDropdown.axaml.cs b/MapWizard.Desktop/Views/Controls/AppDropdown.axaml.cs index b888eaf..1c8058f 100644 --- a/MapWizard.Desktop/Views/Controls/AppDropdown.axaml.cs +++ b/MapWizard.Desktop/Views/Controls/AppDropdown.axaml.cs @@ -1,6 +1,5 @@ using System; using System.Collections; -using System.Reflection; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -21,9 +20,6 @@ public partial class AppDropdown : UserControl public static readonly StyledProperty<string> PlaceholderTextProperty = AvaloniaProperty.Register<AppDropdown, string>(nameof(PlaceholderText), "Select..."); - public static readonly StyledProperty<string?> DisplayMemberPathProperty = - AvaloniaProperty.Register<AppDropdown, string?>(nameof(DisplayMemberPath)); - public static readonly StyledProperty<double> FlyoutMinWidthProperty = AvaloniaProperty.Register<AppDropdown, double>(nameof(FlyoutMinWidth), 180d); @@ -51,12 +47,6 @@ public string PlaceholderText set => SetValue(PlaceholderTextProperty, value); } - public string? DisplayMemberPath - { - get => GetValue(DisplayMemberPathProperty); - set => SetValue(DisplayMemberPathProperty, value); - } - public double FlyoutMinWidth { get => GetValue(FlyoutMinWidthProperty); @@ -86,8 +76,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang base.OnPropertyChanged(change); if (change.Property == SelectedItemProperty || - change.Property == PlaceholderTextProperty || - change.Property == DisplayMemberPathProperty) + change.Property == PlaceholderTextProperty) { UpdateSelectedText(); } @@ -111,29 +100,8 @@ private void UpdateSelectedText() SelectedText = GetItemDisplayText(SelectedItem) ?? PlaceholderText; } - private string? GetItemDisplayText(object? item) + private static string? GetItemDisplayText(object? item) { - if (item is null) - { - return null; - } - - var path = DisplayMemberPath; - if (string.IsNullOrWhiteSpace(path)) - { - return item.ToString(); - } - - try - { - var property = item.GetType().GetProperty(path, BindingFlags.Public | BindingFlags.Instance); - var value = property?.GetValue(item); - return value?.ToString() ?? item.ToString(); - } - catch (Exception ex) - { - MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); - return item.ToString(); - } + return item?.ToString(); } } diff --git a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml index 97d5414..4ba718e 100644 --- a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml +++ b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml @@ -226,8 +226,10 @@ <Setter Property="HorizontalAlignment" Value="Stretch" /> </Style> + <!-- Over-artwork header actions: keep enough scrim alpha that the + label/icon stay readable on light artwork. --> <Style Selector="Button.TargetRemoveButton"> - <Setter Property="Background" Value="#33E06C75" /> + <Setter Property="Background" Value="#66000000" /> <Setter Property="BorderBrush" Value="Transparent" /> <Setter Property="BorderThickness" Value="0" /> <Setter Property="CornerRadius" Value="8" /> @@ -237,16 +239,20 @@ </Style> <Style Selector="Button.TargetRemoveButton:pointerover"> - <Setter Property="Background" Value="#4DE06C75" /> + <Setter Property="Background" Value="#CCE06C75" /> </Style> <Style Selector="Button.TargetRemoveButton LucideIcon"> <Setter Property="Foreground" Value="#E06C75" /> </Style> + <Style Selector="Button.TargetRemoveButton:pointerover LucideIcon"> + <Setter Property="Foreground" Value="#FFF8F9FA" /> + </Style> + <Style Selector="Button.TargetSelectAllButton"> - <Setter Property="Background" Value="#33000000" /> - <Setter Property="BorderBrush" Value="#66FFFFFF" /> + <Setter Property="Background" Value="#66000000" /> + <Setter Property="BorderBrush" Value="#80FFFFFF" /> <Setter Property="BorderThickness" Value="1" /> <Setter Property="CornerRadius" Value="8" /> <Setter Property="Padding" Value="8,4" /> @@ -259,7 +265,8 @@ </Style> <Style Selector="Button.TargetSelectAllButton:pointerover"> - <Setter Property="Background" Value="#44FFFFFF" /> + <Setter Property="Background" Value="#99222B32" /> + <Setter Property="BorderBrush" Value="#99FFFFFF" /> </Style> <Style Selector="LucideIcon.MapsetChevron"> @@ -439,7 +446,7 @@ Orientation="Horizontal" Spacing="8"> <Button Classes="Basic Compact" - Command="{ReflectionBinding ClearSelectionCommand}" + Command="{Binding ClearSelectionCommand, ElementName=Root}" IsEnabled="{Binding CanClearSelection, ElementName=Root}" ToolTip.Tip="Clear the current source and targets"> <StackPanel Orientation="Horizontal" Spacing="5"> @@ -449,7 +456,7 @@ </Button> <Button Classes="Basic Compact" - Command="{ReflectionBinding OpenOriginFolderCommand}" + Command="{Binding OpenOriginFolderCommand, ElementName=Root}" IsEnabled="{Binding HasOriginSelection, ElementName=Root}" ToolTip.Tip="Open the folder of the selected source beatmap"> <StackPanel Orientation="Horizontal" Spacing="5"> @@ -512,39 +519,39 @@ Spacing="8" VerticalAlignment="Center"> <Button Classes="Compact MemoryActionButton MapSourceActionButton" - Command="{ReflectionBinding SetOriginFromMemoryCommand}" + Command="{Binding SetOriginFromMemoryCommand, ElementName=Root}" ToolTip.Tip="{Binding OriginMemoryToolTip, ElementName=Root}"> <StackPanel Orientation="Horizontal" Spacing="5"> <Grid Width="16" Height="16" Classes="AsyncActionActivity" - Classes.busy="{ReflectionBinding SetOriginFromMemoryCommand.IsRunning}"> + Classes.busy="{Binding SetOriginFromMemoryCommand.IsRunning, ElementName=Root}"> <LucideIcon Kind="Import" Size="16" /> <theme:BusyIndicator Classes="AsyncActionIndicator" - IsRunning="{ReflectionBinding SetOriginFromMemoryCommand.IsRunning}" + IsRunning="{Binding SetOriginFromMemoryCommand.IsRunning, ElementName=Root}" VerticalAlignment="Center" /> </Grid> <TextBlock Text="From osu!" VerticalAlignment="Center" /> </StackPanel> </Button> <Button Classes="Flat Compact MapSourceActionButton" - Command="{ReflectionBinding PickOriginFileCommand}" + Command="{Binding PickOriginFileCommand, ElementName=Root}" ToolTip.Tip="Open the Map Picker"> <StackPanel Orientation="Horizontal" Spacing="5"> <Grid Width="16" Height="16" Classes="AsyncActionActivity" - Classes.busy="{ReflectionBinding PickOriginFileCommand.IsRunning}"> + Classes.busy="{Binding PickOriginFileCommand.IsRunning, ElementName=Root}"> <LucideIcon Kind="FileSearch" Size="16" /> <theme:BusyIndicator Classes="AsyncActionIndicator" - IsRunning="{ReflectionBinding PickOriginFileCommand.IsRunning}" + IsRunning="{Binding PickOriginFileCommand.IsRunning, ElementName=Root}" VerticalAlignment="Center" /> </Grid> <TextBlock Text="Map Picker" VerticalAlignment="Center" /> </StackPanel> </Button> <Button Classes="Basic Compact SourceActionButton" - Command="{ReflectionBinding ClearSelectionCommand}" + Command="{Binding ClearSelectionCommand, ElementName=Root}" IsEnabled="{Binding CanClearSelection, ElementName=Root}" IsVisible="{Binding !ShowDestinationSection, ElementName=Root}" ToolTip.Tip="Clear the selected beatmap"> @@ -554,7 +561,7 @@ </StackPanel> </Button> <Button Classes="Basic Compact SourceActionButton" - Command="{ReflectionBinding OpenOriginFolderCommand}" + Command="{Binding OpenOriginFolderCommand, ElementName=Root}" IsEnabled="{Binding HasOriginSelection, ElementName=Root}" IsVisible="{Binding !ShowDestinationSection, ElementName=Root}" ToolTip.Tip="Open the selected beatmap's folder"> @@ -628,7 +635,7 @@ <DataTemplate x:DataType="models:MapsetDifficultyCard"> <ToggleButton Classes="DiffRow OnArtwork" IsChecked="{Binding IsSelected, Mode=OneWay}" - Command="{ReflectionBinding DataContext.SelectOriginMapCommand, RelativeSource={RelativeSource AncestorType=controls:BeatmapSelectionPanel}}" + Command="{Binding SelectOriginMapCommand, ElementName=Root}" CommandParameter="{Binding Path}"> <Grid ColumnDefinitions="Auto,*" ColumnSpacing="10"> @@ -728,32 +735,32 @@ VerticalAlignment="Center" IsVisible="{Binding !HasVisibleDestinationCards, ElementName=Root}"> <Button Classes="Compact MemoryActionButton MapSourceActionButton" - Command="{ReflectionBinding AddDestinationFromMemoryCommand}" + Command="{Binding AddDestinationFromMemoryCommand, ElementName=Root}" ToolTip.Tip="{Binding DestinationMemoryToolTip, ElementName=Root}"> <StackPanel Orientation="Horizontal" Spacing="5"> <Grid Width="16" Height="16" Classes="AsyncActionActivity" - Classes.busy="{ReflectionBinding AddDestinationFromMemoryCommand.IsRunning}"> + Classes.busy="{Binding AddDestinationFromMemoryCommand.IsRunning, ElementName=Root}"> <LucideIcon Kind="Import" Size="16" /> <theme:BusyIndicator Classes="AsyncActionIndicator" - IsRunning="{ReflectionBinding AddDestinationFromMemoryCommand.IsRunning}" + IsRunning="{Binding AddDestinationFromMemoryCommand.IsRunning, ElementName=Root}" VerticalAlignment="Center" /> </Grid> <TextBlock Text="From osu!" VerticalAlignment="Center" /> </StackPanel> </Button> <Button Classes="Flat Compact MapSourceActionButton" - Command="{ReflectionBinding PickDestinationFileCommand}" + Command="{Binding PickDestinationFileCommand, ElementName=Root}" ToolTip.Tip="Open the Map Picker"> <StackPanel Orientation="Horizontal" Spacing="5"> <Grid Width="16" Height="16" Classes="AsyncActionActivity" - Classes.busy="{ReflectionBinding PickDestinationFileCommand.IsRunning}"> + Classes.busy="{Binding PickDestinationFileCommand.IsRunning, ElementName=Root}"> <LucideIcon Kind="FileSearch" Size="16" /> <theme:BusyIndicator Classes="AsyncActionIndicator" - IsRunning="{ReflectionBinding PickDestinationFileCommand.IsRunning}" + IsRunning="{Binding PickDestinationFileCommand.IsRunning, ElementName=Root}" VerticalAlignment="Center" /> </Grid> <TextBlock Text="Map Picker" VerticalAlignment="Center" /> @@ -867,7 +874,7 @@ <DataTemplate x:DataType="models:MapsetDifficultyCard"> <ToggleButton Classes="DiffRow OnArtwork" IsChecked="{Binding IsSelected, Mode=OneWay}" - Command="{ReflectionBinding DataContext.ToggleDestinationMapCommand, RelativeSource={RelativeSource AncestorType=controls:BeatmapSelectionPanel}}" + Command="{Binding ToggleDestinationMapCommand, ElementName=Root}" CommandParameter="{Binding Path}"> <Grid ColumnDefinitions="Auto,*" ColumnSpacing="10"> @@ -933,7 +940,7 @@ <Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="8"> <Button Classes="Basic Compact" - Command="{ReflectionBinding PickDestinationFileCommand}" + Command="{Binding PickDestinationFileCommand, ElementName=Root}" ToolTip.Tip="Add a beatmap to the targets"> <StackPanel Orientation="Horizontal" Spacing="5"> <LucideIcon Kind="Plus" Size="16" /> @@ -943,16 +950,16 @@ <Button Grid.Column="2" Classes="Compact MemoryActionButton MapSourceActionButton" - Command="{ReflectionBinding AddDestinationFromMemoryCommand}" + Command="{Binding AddDestinationFromMemoryCommand, ElementName=Root}" ToolTip.Tip="{Binding DestinationMemoryToolTip, ElementName=Root}"> <StackPanel Orientation="Horizontal" Spacing="5"> <Grid Width="16" Height="16" Classes="AsyncActionActivity" - Classes.busy="{ReflectionBinding AddDestinationFromMemoryCommand.IsRunning}"> + Classes.busy="{Binding AddDestinationFromMemoryCommand.IsRunning, ElementName=Root}"> <LucideIcon Kind="Import" Size="16" /> <theme:BusyIndicator Classes="AsyncActionIndicator" - IsRunning="{ReflectionBinding AddDestinationFromMemoryCommand.IsRunning}" + IsRunning="{Binding AddDestinationFromMemoryCommand.IsRunning, ElementName=Root}" VerticalAlignment="Center" /> </Grid> <TextBlock Text="From osu!" VerticalAlignment="Center" /> @@ -961,16 +968,16 @@ <Button Grid.Column="3" Classes="Flat Compact MapSourceActionButton" - Command="{ReflectionBinding PickDestinationFileCommand}" + Command="{Binding PickDestinationFileCommand, ElementName=Root}" ToolTip.Tip="Open the Map Picker"> <StackPanel Orientation="Horizontal" Spacing="5"> <Grid Width="16" Height="16" Classes="AsyncActionActivity" - Classes.busy="{ReflectionBinding PickDestinationFileCommand.IsRunning}"> + Classes.busy="{Binding PickDestinationFileCommand.IsRunning, ElementName=Root}"> <LucideIcon Kind="FileSearch" Size="16" /> <theme:BusyIndicator Classes="AsyncActionIndicator" - IsRunning="{ReflectionBinding PickDestinationFileCommand.IsRunning}" + IsRunning="{Binding PickDestinationFileCommand.IsRunning, ElementName=Root}" VerticalAlignment="Center" /> </Grid> <TextBlock Text="Map Picker" VerticalAlignment="Center" /> diff --git a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml.cs b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml.cs index e6ac208..ef2ef11 100644 --- a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml.cs +++ b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml.cs @@ -11,6 +11,7 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.VisualTree; +using CommunityToolkit.Mvvm.Input; using MapWizard.Desktop.Models; using MapWizard.Desktop.Utils; @@ -112,6 +113,30 @@ public partial class BeatmapSelectionPanel : UserControl public static readonly StyledProperty<ICommand?> OriginPathChangedCommandProperty = AvaloniaProperty.Register<BeatmapSelectionPanel, ICommand?>(nameof(OriginPathChangedCommand)); + public static readonly StyledProperty<ICommand?> ClearSelectionCommandProperty = + AvaloniaProperty.Register<BeatmapSelectionPanel, ICommand?>(nameof(ClearSelectionCommand)); + + public static readonly StyledProperty<ICommand?> OpenOriginFolderCommandProperty = + AvaloniaProperty.Register<BeatmapSelectionPanel, ICommand?>(nameof(OpenOriginFolderCommand)); + + public static readonly StyledProperty<IAsyncRelayCommand?> SetOriginFromMemoryCommandProperty = + AvaloniaProperty.Register<BeatmapSelectionPanel, IAsyncRelayCommand?>(nameof(SetOriginFromMemoryCommand)); + + public static readonly StyledProperty<IAsyncRelayCommand?> PickOriginFileCommandProperty = + AvaloniaProperty.Register<BeatmapSelectionPanel, IAsyncRelayCommand?>(nameof(PickOriginFileCommand)); + + public static readonly StyledProperty<IAsyncRelayCommand?> PickDestinationFileCommandProperty = + AvaloniaProperty.Register<BeatmapSelectionPanel, IAsyncRelayCommand?>(nameof(PickDestinationFileCommand)); + + public static readonly StyledProperty<IAsyncRelayCommand?> AddDestinationFromMemoryCommandProperty = + AvaloniaProperty.Register<BeatmapSelectionPanel, IAsyncRelayCommand?>(nameof(AddDestinationFromMemoryCommand)); + + public static readonly StyledProperty<ICommand?> SelectOriginMapCommandProperty = + AvaloniaProperty.Register<BeatmapSelectionPanel, ICommand?>(nameof(SelectOriginMapCommand)); + + public static readonly StyledProperty<ICommand?> ToggleDestinationMapCommandProperty = + AvaloniaProperty.Register<BeatmapSelectionPanel, ICommand?>(nameof(ToggleDestinationMapCommand)); + public static readonly StyledProperty<SelectedMap?> OriginMapProperty = AvaloniaProperty.Register<BeatmapSelectionPanel, SelectedMap?>(nameof(OriginMap)); @@ -350,6 +375,54 @@ public ICommand? OriginPathChangedCommand set => SetValue(OriginPathChangedCommandProperty, value); } + public ICommand? ClearSelectionCommand + { + get => GetValue(ClearSelectionCommandProperty); + set => SetValue(ClearSelectionCommandProperty, value); + } + + public ICommand? OpenOriginFolderCommand + { + get => GetValue(OpenOriginFolderCommandProperty); + set => SetValue(OpenOriginFolderCommandProperty, value); + } + + public IAsyncRelayCommand? SetOriginFromMemoryCommand + { + get => GetValue(SetOriginFromMemoryCommandProperty); + set => SetValue(SetOriginFromMemoryCommandProperty, value); + } + + public IAsyncRelayCommand? PickOriginFileCommand + { + get => GetValue(PickOriginFileCommandProperty); + set => SetValue(PickOriginFileCommandProperty, value); + } + + public IAsyncRelayCommand? PickDestinationFileCommand + { + get => GetValue(PickDestinationFileCommandProperty); + set => SetValue(PickDestinationFileCommandProperty, value); + } + + public IAsyncRelayCommand? AddDestinationFromMemoryCommand + { + get => GetValue(AddDestinationFromMemoryCommandProperty); + set => SetValue(AddDestinationFromMemoryCommandProperty, value); + } + + public ICommand? SelectOriginMapCommand + { + get => GetValue(SelectOriginMapCommandProperty); + set => SetValue(SelectOriginMapCommandProperty, value); + } + + public ICommand? ToggleDestinationMapCommand + { + get => GetValue(ToggleDestinationMapCommandProperty); + set => SetValue(ToggleDestinationMapCommandProperty, value); + } + public SelectedMap? OriginMap { get => GetValue(OriginMapProperty); @@ -773,10 +846,9 @@ private void RefreshMapsetDifficultySelectionState() private void RebuildVisibleDestinationMaps() { - VisibleDestinationMapsets.Clear(); - if (DestinationMaps is null) { + VisibleDestinationMapsets.Clear(); HasVisibleDestinationCards = false; return; } @@ -794,6 +866,7 @@ private void RebuildVisibleDestinationMaps() .OrderBy(group => group.First().DisplayTitle, System.StringComparer.OrdinalIgnoreCase); var visibleMapsetKeys = new HashSet<string>(System.StringComparer.OrdinalIgnoreCase); + var desiredCards = new List<DestinationMapsetCard>(); foreach (var group in groupedByMapset) { @@ -801,27 +874,20 @@ private void RebuildVisibleDestinationMaps() visibleMapsetKeys.Add(mapsetKey); - var referenceMap = group.First(); - var mapsetDifficultyPaths = GetMapsetDifficultyPaths(referenceMap.Path, group.Select(map => map.Path)); - var difficulties = mapsetDifficultyPaths - .Select(path => new MapsetDifficultyCard(path) - { - IsSelected = selectedPaths.Contains(path) - }) - .ToList(); - - var card = new DestinationMapsetCard( + desiredCards.Add(ResolveDestinationMapsetCard( mapsetKey, - referenceMap, - difficulties) - { - IsExpanded = !_destinationMapsetExpansionStates.TryGetValue(mapsetKey, out var isExpanded) || isExpanded - }; + group.First(), + group.Select(map => map.Path), + selectedPaths)); + } - VisibleDestinationMapsets.Add(card); + var suggestedCard = ResolveSuggestedSourceMapsetCard(visibleMapsetKeys, selectedPaths); + if (suggestedCard is not null) + { + desiredCards.Insert(0, suggestedCard); } - MaybeAddSuggestedSourceMapset(visibleMapsetKeys); + SyncVisibleDestinationMapsets(desiredCards); var staleExpansionKeys = _destinationMapsetExpansionStates.Keys .Where(key => !visibleMapsetKeys.Contains(key)) @@ -834,36 +900,166 @@ private void RebuildVisibleDestinationMaps() HasVisibleDestinationCards = VisibleDestinationMapsets.Count > 0; } - private void MaybeAddSuggestedSourceMapset(HashSet<string> visibleMapsetKeys) + /// <summary> + /// Returns an existing card for the mapset (updating it in place) or creates + /// a new one. Reusing cards keeps their bound containers alive so toggling a + /// difficulty never replays the expand animation. + /// </summary> + private DestinationMapsetCard ResolveDestinationMapsetCard( + string mapsetKey, + SelectedMap referenceMap, + IEnumerable<string> mapPaths, + HashSet<string> selectedPaths, + bool isSuggested = false) + { + var difficultyPaths = GetMapsetDifficultyPaths(referenceMap.Path, mapPaths); + + var card = VisibleDestinationMapsets.FirstOrDefault(existing => + string.Equals(existing.MapsetDirectoryPath, mapsetKey, System.StringComparison.OrdinalIgnoreCase)); + + if (card is null) + { + return new DestinationMapsetCard( + mapsetKey, + referenceMap, + difficultyPaths.Select(path => new MapsetDifficultyCard(path) + { + IsSelected = selectedPaths.Contains(path) + }), + isSuggested) + { + IsExpanded = !_destinationMapsetExpansionStates.TryGetValue(mapsetKey, out var isExpanded) || isExpanded + }; + } + + SyncDestinationMapsetDifficulties(card, difficultyPaths, selectedPaths); + card.IsSuggested = isSuggested; + return card; + } + + private DestinationMapsetCard? ResolveSuggestedSourceMapsetCard( + HashSet<string> visibleMapsetKeys, + HashSet<string> selectedPaths) { if (OriginMap is null || !OriginMap.HasPath) { - return; + return null; } var originMapsetKey = GetDestinationMapsetKey(OriginMap.Path); if (visibleMapsetKeys.Contains(originMapsetKey)) { - return; + return null; } - var suggestedPaths = GetMapsetDifficultyPaths(OriginMap.Path, []).ToList(); + var suggestedPaths = GetMapsetDifficultyPaths(OriginMap.Path, []); if (suggestedPaths.Count == 0) { - return; + return null; + } + + var card = ResolveDestinationMapsetCard( + originMapsetKey, + OriginMap, + [], + selectedPaths, + isSuggested: true); + + visibleMapsetKeys.Add(originMapsetKey); + return card; + } + + /// <summary> + /// Aligns <see cref="DestinationMapsetCard.Difficulties"/> with the desired + /// paths, reusing difficulty cards so their rows keep their containers. + /// </summary> + private static void SyncDestinationMapsetDifficulties( + DestinationMapsetCard card, + IReadOnlyList<string> difficultyPaths, + HashSet<string> selectedPaths) + { + var existingByPath = new Dictionary<string, MapsetDifficultyCard>(System.StringComparer.OrdinalIgnoreCase); + foreach (var difficulty in card.Difficulties) + { + existingByPath.TryAdd(difficulty.Path, difficulty); } - var suggestions = suggestedPaths - .Select(path => new MapsetDifficultyCard(path) { IsSelected = false }) + var desiredDifficulties = difficultyPaths + .Select(path => existingByPath.TryGetValue(path, out var existing) + ? existing + : new MapsetDifficultyCard(path)) .ToList(); - var card = new DestinationMapsetCard(originMapsetKey, OriginMap, suggestions, isSuggested: true) + var desiredSet = new HashSet<MapsetDifficultyCard>(desiredDifficulties); + + foreach (var difficulty in desiredDifficulties) { - IsExpanded = !_destinationMapsetExpansionStates.TryGetValue(originMapsetKey, out var isExpanded) || isExpanded - }; + difficulty.IsSelected = selectedPaths.Contains(difficulty.Path); + } - VisibleDestinationMapsets.Insert(0, card); - visibleMapsetKeys.Add(originMapsetKey); + for (var i = card.Difficulties.Count - 1; i >= 0; i--) + { + if (!desiredSet.Contains(card.Difficulties[i])) + { + card.Difficulties.RemoveAt(i); + } + } + + for (var i = 0; i < desiredDifficulties.Count; i++) + { + if (i < card.Difficulties.Count && ReferenceEquals(card.Difficulties[i], desiredDifficulties[i])) + { + continue; + } + + var currentIndex = card.Difficulties.IndexOf(desiredDifficulties[i]); + if (currentIndex >= 0) + { + card.Difficulties.Move(currentIndex, i); + } + else + { + card.Difficulties.Insert(i, desiredDifficulties[i]); + } + } + + card.RefreshDifficultySelectionState(); + } + + /// <summary> + /// Applies the desired card list to the observable collection with minimal + /// add/move/remove operations so bound containers are preserved. + /// </summary> + private void SyncVisibleDestinationMapsets(List<DestinationMapsetCard> desiredCards) + { + var desiredSet = new HashSet<DestinationMapsetCard>(desiredCards); + + for (var i = VisibleDestinationMapsets.Count - 1; i >= 0; i--) + { + if (!desiredSet.Contains(VisibleDestinationMapsets[i])) + { + VisibleDestinationMapsets.RemoveAt(i); + } + } + + for (var i = 0; i < desiredCards.Count; i++) + { + var card = desiredCards[i]; + if (i < VisibleDestinationMapsets.Count && ReferenceEquals(VisibleDestinationMapsets[i], card)) + { + continue; + } + + var currentIndex = VisibleDestinationMapsets.IndexOf(card); + if (currentIndex >= 0) + { + VisibleDestinationMapsets.Move(currentIndex, i); + } + else + { + VisibleDestinationMapsets.Insert(i, card); + } + } } private static string GetDestinationMapsetKey(string beatmapPath) diff --git a/MapWizard.Desktop/Views/Dialogs/SongMapsetCard.axaml b/MapWizard.Desktop/Views/Dialogs/SongMapsetCard.axaml index dafdfee..45f4480 100644 --- a/MapWizard.Desktop/Views/Dialogs/SongMapsetCard.axaml +++ b/MapWizard.Desktop/Views/Dialogs/SongMapsetCard.axaml @@ -4,7 +4,6 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:MapWizard.Desktop.ViewModels" - xmlns:dialogs="clr-namespace:MapWizard.Desktop.Views.Dialogs" xmlns:theme="clr-namespace:MapWizard.Theme.Controls;assembly=MapWizard.Theme" x:Class="MapWizard.Desktop.Views.Dialogs.SongMapsetCard" x:DataType="vm:SongMapsetCardViewModel" @@ -284,7 +283,7 @@ </Grid> <Button Classes="CardHeaderHit" - Command="{ReflectionBinding DataContext.ToggleMapsetExpansionCommand, RelativeSource={RelativeSource AncestorType=dialogs:SongSelectDialog}}" + Command="{Binding Owner.ToggleMapsetExpansionCommand}" CommandParameter="{Binding}" /> </Grid> @@ -302,8 +301,8 @@ Classes="SelectMapsetDiffsButton" HorizontalAlignment="Right" Classes.selected="{Binding AllDifficultiesSelected}" - IsVisible="{ReflectionBinding DataContext.AllowMultipleSelection, RelativeSource={RelativeSource AncestorType=dialogs:SongSelectDialog}}" - Command="{ReflectionBinding DataContext.SelectAllDifficultiesInMapsetCommand, RelativeSource={RelativeSource AncestorType=dialogs:SongSelectDialog}}" + IsVisible="{Binding Owner.AllowMultipleSelection}" + Command="{Binding Owner.SelectAllDifficultiesInMapsetCommand}" CommandParameter="{Binding}" ToolTip.Tip="Select or clear all difficulties in this mapset"> <StackPanel Orientation="Horizontal" Spacing="6"> @@ -323,7 +322,7 @@ <DataTemplate x:DataType="vm:SongDifficultyItemViewModel"> <ToggleButton Classes="DifficultyChip" IsChecked="{Binding IsSelected, Mode=OneWay}" - Command="{ReflectionBinding DataContext.SelectDifficultyCommand, RelativeSource={RelativeSource AncestorType=dialogs:SongSelectDialog}}" + Command="{Binding Mapset.Owner.SelectDifficultyCommand}" CommandParameter="{Binding}"> <StackPanel Orientation="Horizontal" Spacing="7"> <LucideIcon Kind="CircleCheck" diff --git a/MapWizard.Desktop/Views/HitSoundCopier/HitSoundCopierView.axaml b/MapWizard.Desktop/Views/HitSoundCopier/HitSoundCopierView.axaml index 7b908d9..bdb60ad 100644 --- a/MapWizard.Desktop/Views/HitSoundCopier/HitSoundCopierView.axaml +++ b/MapWizard.Desktop/Views/HitSoundCopier/HitSoundCopierView.axaml @@ -44,6 +44,14 @@ <controls:BeatmapSelectionPanel OriginMap="{Binding OriginBeatmap}" DestinationMaps="{Binding DestinationBeatmaps}" + ClearSelectionCommand="{Binding ClearSelectionCommand}" + OpenOriginFolderCommand="{Binding OpenOriginFolderCommand}" + SetOriginFromMemoryCommand="{Binding SetOriginFromMemoryCommand}" + PickOriginFileCommand="{Binding PickOriginFileCommand}" + AddDestinationFromMemoryCommand="{Binding AddDestinationFromMemoryCommand}" + PickDestinationFileCommand="{Binding PickDestinationFileCommand}" + SelectOriginMapCommand="{Binding SelectOriginMapCommand}" + ToggleDestinationMapCommand="{Binding ToggleDestinationMapCommand}" OriginMemoryToolTip="Get the current beatmap selected in the osu! client." DestinationMemoryToolTip="Add the current beatmap selected in the osu! client." /> diff --git a/MapWizard.Desktop/Views/HitSoundCopier/HitSoundCopierView.axaml.cs b/MapWizard.Desktop/Views/HitSoundCopier/HitSoundCopierView.axaml.cs index c1f5a51..ac838c5 100644 --- a/MapWizard.Desktop/Views/HitSoundCopier/HitSoundCopierView.axaml.cs +++ b/MapWizard.Desktop/Views/HitSoundCopier/HitSoundCopierView.axaml.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Avalonia.Controls; namespace MapWizard.Desktop.Views; @@ -6,6 +7,11 @@ public partial class HitSoundCopierView : UserControl { public HitSoundCopierView() { + + var sw = Stopwatch.StartNew(); + InitializeComponent(); + Debug.WriteLine( + $"HsCopier InitializeComponent: {sw.Elapsed.TotalMilliseconds:F2}ms"); } } \ No newline at end of file diff --git a/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml b/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml index 80c5ecf..06ec0c7 100644 --- a/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml +++ b/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml @@ -239,6 +239,11 @@ OriginMap="{Binding OriginBeatmap}" DestinationMaps="{Binding DestinationBeatmaps}" ShowDestinationSelection="False" + ClearSelectionCommand="{Binding ClearSelectionCommand}" + OpenOriginFolderCommand="{Binding OpenOriginFolderCommand}" + SetOriginFromMemoryCommand="{Binding SetOriginFromMemoryCommand}" + PickOriginFileCommand="{Binding PickOriginFileCommand}" + SelectOriginMapCommand="{Binding SelectOriginMapCommand}" OriginMemoryToolTip="Get the current beatmap selected in the osu! client." /> <theme:BusyArea IsBusy="{Binding IsPreparingPlayback}" diff --git a/MapWizard.Desktop/Views/MainWindow.axaml b/MapWizard.Desktop/Views/MainWindow.axaml index 99c3399..30b6cf0 100644 --- a/MapWizard.Desktop/Views/MainWindow.axaml +++ b/MapWizard.Desktop/Views/MainWindow.axaml @@ -2,14 +2,7 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:MapWizard.Desktop.ViewModels" - xmlns:views="clr-namespace:MapWizard.Desktop.Views" xmlns:theme="clr-namespace:MapWizard.Theme.Controls;assembly=MapWizard.Theme" - xmlns:welcomePage="clr-namespace:MapWizard.Desktop.Views.WelcomePage" - xmlns:hitSoundVisualizer="clr-namespace:MapWizard.Desktop.Views.HitSoundVisualizer" - xmlns:metadataManager="clr-namespace:MapWizard.Desktop.Views.MetadataManager" - xmlns:comboColourStudio="clr-namespace:MapWizard.Desktop.Views.ComboColourStudio" - xmlns:mapCleaner="clr-namespace:MapWizard.Desktop.Views.MapCleaner" - xmlns:settings="clr-namespace:MapWizard.Desktop.Views.Settings" xmlns:viewControls="clr-namespace:MapWizard.Desktop.Views.Controls" xmlns:transitions="clr-namespace:MapWizard.Desktop.Transitions" x:Class="MapWizard.Desktop.Views.MainWindow" @@ -48,11 +41,8 @@ <Setter Property="Background" Value="{DynamicResource MapWizardAccentBrush}" /> <Setter Property="Opacity" Value="0" /> <Setter Property="CornerRadius" Value="15" /> - <Setter Property="Transitions"> - <Transitions> - <DoubleTransition Property="Opacity" Duration="0:0:0.12" /> - </Transitions> - </Setter> + <!-- Hover feedback is instant (no transitions) so the nav feels responsive. --> + <Setter Property="Transitions"><Transitions /></Setter> </Style> <Style Selector="theme|NavigationButton.NavButton:pointerover Border.NavIconHoverLayer"> <Setter Property="Opacity" Value="0.14" /> @@ -151,30 +141,6 @@ </Style> </theme:MapWizardWindow.Styles> - <theme:MapWizardWindow.DataTemplates> - <DataTemplate DataType="{x:Type vm:WelcomePageViewModel}"> - <welcomePage:WelcomePageView /> - </DataTemplate> - <DataTemplate DataType="{x:Type vm:HitSoundCopierViewModel}"> - <views:HitSoundCopierView /> - </DataTemplate> - <DataTemplate DataType="{x:Type vm:HitSoundVisualizerViewModel}"> - <hitSoundVisualizer:HitSoundVisualizerView /> - </DataTemplate> - <DataTemplate DataType="{x:Type vm:MetadataManagerViewModel}"> - <metadataManager:MetadataManagerView /> - </DataTemplate> - <DataTemplate DataType="{x:Type vm:ComboColourStudioViewModel}"> - <comboColourStudio:ComboColourStudioView /> - </DataTemplate> - <DataTemplate DataType="{x:Type vm:MapCleanerViewModel}"> - <mapCleaner:MapCleanerView /> - </DataTemplate> - <DataTemplate DataType="{x:Type vm:SettingsViewModel}"> - <settings:SettingsView /> - </DataTemplate> - </theme:MapWizardWindow.DataTemplates> - <VisualLayerManager> <Panel> <!-- Linux keeps the app-drawn shell; MainWindow removes this radius on @@ -415,7 +381,8 @@ </Border> <Panel Grid.Column="1" - Margin="0,0,10,10"> + Margin="0,0,10,10" + x:Name="ContentIslandHost"> <!-- Card chrome layer. A separate sibling from the content host so it can fade/scale without dragging the page content with it, and so its box shadow is not clipped by the content's rounded clip. --> @@ -424,7 +391,7 @@ <Border Classes="ContentIsland"> <TransitioningContentControl Content="{Binding CurrentPageViewModel}"> <TransitioningContentControl.PageTransition> - <transitions:BlurSlideFadeTransition InDuration="0:0:0.3" OutDuration="0:0:0.16" SlideDistance="32" MaxBlurRadius="9" /> + <transitions:ScaleFadeTransition InDuration="0:0:0.3" OutDuration="0:0:0.16" /> </TransitioningContentControl.PageTransition> </TransitioningContentControl> </Border> diff --git a/MapWizard.Desktop/Views/MainWindow.axaml.cs b/MapWizard.Desktop/Views/MainWindow.axaml.cs index 7b9d293..a1e5eeb 100644 --- a/MapWizard.Desktop/Views/MainWindow.axaml.cs +++ b/MapWizard.Desktop/Views/MainWindow.axaml.cs @@ -43,6 +43,13 @@ public MainWindow( /// <summary>Exposes the view-model to startup plumbing in App.axaml.cs.</summary> internal MainWindowViewModel GetViewModel() => ViewModel; + /// <summary> + /// Panel hosting the page content: used as the opacity-0 warm-up host + /// during page preloading, so warm pages measure with the exact live + /// constraints they will get on the first navigation. + /// </summary> + internal Panel GetPreloadWarmupHost() => ContentIslandHost; + public void NavigateToStart() => ViewModel.NavigateToWelcome(); public void NavigateToHitSoundCopier() => ViewModel.NavigateToHitSoundCopier(); diff --git a/MapWizard.Desktop/Views/MapCleaner/MapCleanerView.axaml b/MapWizard.Desktop/Views/MapCleaner/MapCleanerView.axaml index 150ea8a..f3355c3 100644 --- a/MapWizard.Desktop/Views/MapCleaner/MapCleanerView.axaml +++ b/MapWizard.Desktop/Views/MapCleaner/MapCleanerView.axaml @@ -85,6 +85,11 @@ OriginMap="{Binding OriginBeatmap}" DestinationMaps="{Binding DestinationBeatmaps}" ShowDestinationSelection="False" + ClearSelectionCommand="{Binding ClearSelectionCommand}" + OpenOriginFolderCommand="{Binding OpenOriginFolderCommand}" + SetOriginFromMemoryCommand="{Binding SetOriginFromMemoryCommand}" + PickOriginFileCommand="{Binding PickOriginFileCommand}" + SelectOriginMapCommand="{Binding SelectOriginMapCommand}" OriginPathChangedCommand="{Binding ReanalyzeOriginAfterPathChangeCommand}" /> <theme:CollapsibleSection Header="Beat Snaps"> @@ -105,7 +110,7 @@ <StackPanel Orientation="Horizontal" Spacing="6"> <TextBlock VerticalAlignment="Center" Text="{Binding}" /> <Button Classes="SnapChipClose" - Command="{ReflectionBinding DataContext.RemoveSnapCommand, ElementName=Root}" + Command="{Binding DataContext.RemoveSnapCommand, ElementName=Root}" CommandParameter="{Binding}" ToolTip.Tip="Remove snap"> <LucideIcon Kind="X" Size="14" /> @@ -132,7 +137,7 @@ FontSize="13" /> <Separator /> <appControls:SmoothScrollViewer MaxHeight="220"> - <ItemsControl ItemsSource="{ReflectionBinding DataContext.SnapPresets, ElementName=Root}"> + <ItemsControl ItemsSource="{Binding DataContext.SnapPresets, ElementName=Root}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <WrapPanel /> @@ -142,7 +147,7 @@ <DataTemplate> <Button Classes="SnapPresetBadge Compact" Margin="0,0,6,6" - Command="{ReflectionBinding DataContext.AddSnapFromFlyoutCommand, ElementName=Root}" + Command="{Binding DataContext.AddSnapFromFlyoutCommand, ElementName=Root}" CommandParameter="{Binding}"> <TextBlock Text="{Binding}" /> </Button> @@ -154,9 +159,9 @@ <StackPanel Orientation="Horizontal" Spacing="8"> <TextBox Width="170" PlaceholderText="Custom 1/x" - Text="{ReflectionBinding DataContext.CustomSnapInput, Mode=TwoWay, ElementName=Root}" /> + Text="{Binding DataContext.CustomSnapInput, Mode=TwoWay, ElementName=Root}" /> <Button Classes="Basic Compact" - Command="{ReflectionBinding DataContext.AddCustomSnapCommand, ElementName=Root}"> + Command="{Binding DataContext.AddCustomSnapCommand, ElementName=Root}"> <TextBlock Text="Add" /> </Button> </StackPanel> diff --git a/MapWizard.Desktop/Views/MetadataManager/MetadataManagerView.axaml b/MapWizard.Desktop/Views/MetadataManager/MetadataManagerView.axaml index a0f49bb..0b1da47 100644 --- a/MapWizard.Desktop/Views/MetadataManager/MetadataManagerView.axaml +++ b/MapWizard.Desktop/Views/MetadataManager/MetadataManagerView.axaml @@ -54,7 +54,15 @@ <controls:BeatmapSelectionPanel OriginMap="{Binding OriginBeatmap}" - DestinationMaps="{Binding DestinationBeatmaps}" /> + DestinationMaps="{Binding DestinationBeatmaps}" + ClearSelectionCommand="{Binding ClearSelectionCommand}" + OpenOriginFolderCommand="{Binding OpenOriginFolderCommand}" + SetOriginFromMemoryCommand="{Binding SetOriginFromMemoryCommand}" + PickOriginFileCommand="{Binding PickOriginFileCommand}" + AddDestinationFromMemoryCommand="{Binding AddDestinationFromMemoryCommand}" + PickDestinationFileCommand="{Binding PickDestinationFileCommand}" + SelectOriginMapCommand="{Binding SelectOriginMapCommand}" + ToggleDestinationMapCommand="{Binding ToggleDestinationMapCommand}" /> <!-- METADATA --> <Grid ColumnDefinitions="*,*" Margin="0,8,0,0"> @@ -179,7 +187,7 @@ <controls:ModernColorPicker VerticalAlignment="Center" SelectedColor="{Binding Colour, Converter={StaticResource AvaloniaColorToColorConverter}, Mode=TwoWay}" /> <Button Classes="Basic" - Command="{ReflectionBinding DataContext.RemoveColourCommand, ElementName=Root}" + Command="{Binding DataContext.RemoveColourCommand, ElementName=Root}" CommandParameter="{Binding}" ToolTip.Tip="Remove this colour"> <LucideIcon Kind="X" Size="24" /> diff --git a/MapWizard.Desktop/build-linux.sh b/MapWizard.Desktop/build-linux.sh index 5d4191b..47dc578 100755 --- a/MapWizard.Desktop/build-linux.sh +++ b/MapWizard.Desktop/build-linux.sh @@ -22,6 +22,10 @@ echo "" echo "Compiling MapWizard with dotnet..." dotnet publish -c Release --self-contained -r linux-x64 -o "$PUBLISH_DIR" +# NativeAOT emits debug symbols next to the binary; they are not needed in the +# installer payload. +find "$PUBLISH_DIR" -maxdepth 1 \( -name '*.dbg' -o -name '*.pdb' \) -delete + echo "" echo "Building Velopack Release v$BUILD_VERSION" vpk pack --runtime linux-x64 -u MapWizard.Desktop --packTitle "MapWizard" -v $BUILD_VERSION -o "$RELEASE_DIR" -p "$PUBLISH_DIR" -e "MapWizard.Desktop" -i "$ICON_PATH" \ No newline at end of file diff --git a/MapWizard.Desktop/build-osx.sh b/MapWizard.Desktop/build-osx.sh index 78c5c6c..90feaa3 100755 --- a/MapWizard.Desktop/build-osx.sh +++ b/MapWizard.Desktop/build-osx.sh @@ -21,6 +21,10 @@ echo "" echo "Compiling MapWizardDesktop with dotnet..." dotnet publish -c Release --self-contained -r osx-x64 -o "$PUBLISH_DIR" +# NativeAOT emits debug symbols next to the binary; they are not needed in the +# installer payload. +find "$PUBLISH_DIR" -maxdepth 1 \( -name '*.dbg' -o -name '*.dwarf' -o -name '*.pdb' \) -delete + echo "" echo "Building Velopack Release v$BUILD_VERSION" vpk [osx] pack -u MapWizard.Desktop --packTitle "MapWizard" -v $BUILD_VERSION -o "$RELEASE_DIR" -p "$PUBLISH_DIR" -e "MapWizard.Desktop" -i "$ICON_PATH" \ No newline at end of file diff --git a/MapWizard.Desktop/build-win.bat b/MapWizard.Desktop/build-win.bat index ffd0ee6..e0712ed 100644 --- a/MapWizard.Desktop/build-win.bat +++ b/MapWizard.Desktop/build-win.bat @@ -3,16 +3,34 @@ setlocal enabledelayedexpansion if "%~1"=="" ( echo Version number is required. - echo Usage: build.bat [version] [extra_args...] + echo Usage: build-win.bat [version] exit /b 1 ) set "version=%~1" +set "SCRIPT_DIR=%~dp0" +set "RELEASE_DIR=%SCRIPT_DIR%releases" +set "PUBLISH_DIR=%SCRIPT_DIR%publish" +set "ICON_PATH=%SCRIPT_DIR%Assets\app-icon.ico" echo. -echo Compiling MapWizard.Desktop with dotnet... -dotnet publish -c Release --no-self-contained -r win-x64 -o %~dp0publish +echo Cleaning up previous build... +dotnet clean "%SCRIPT_DIR%MapWizard.Desktop.csproj" +if errorlevel 1 exit /b %errorlevel% + +echo. +echo Compiling MapWizard with dotnet (NativeAOT)... +rem NativeAOT cannot cross-compile between operating systems, so this script +rem must run on Windows. The publish is AOT because MapWizard.Desktop.csproj +rem sets PublishAot=true. +dotnet publish "%SCRIPT_DIR%MapWizard.Desktop.csproj" -c Release --self-contained -r win-x64 -o "%PUBLISH_DIR%" +if errorlevel 1 exit /b %errorlevel% + +rem NativeAOT emits debug symbols next to the binary; they are not needed in +rem the installer payload. +del /q "%PUBLISH_DIR%\*.pdb" 2>nul +del /q "%PUBLISH_DIR%\*.dbg" 2>nul echo. echo Building Velopack Release v%version% -vpk pack -u MapWizard -o %~dp0releases -p %~dp0publish -f net8-x64-desktop -v %* -i "%~dp0Assets\app-icon.ico" +vpk [win] pack --runtime win-x64 -u MapWizard.Desktop --packTitle "MapWizard" -v %version% -o "%RELEASE_DIR%" -p "%PUBLISH_DIR%" -e "MapWizard.Desktop.exe" -i "%ICON_PATH%" diff --git a/MapWizard.Desktop/build-win.sh b/MapWizard.Desktop/build-win.sh index 63b5df7..c283ed6 100755 --- a/MapWizard.Desktop/build-win.sh +++ b/MapWizard.Desktop/build-win.sh @@ -1,5 +1,9 @@ #!/bin/bash +# NativeAOT cannot cross-compile between operating systems, so this script must +# run on Windows (for example from Git Bash). On Windows you can also use +# build-win.bat, which does the same thing. + # Find the absolute path of the script SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -21,6 +25,10 @@ echo "" echo "Compiling MapWizard with dotnet..." dotnet publish -c Release --self-contained -r win-x64 -o "$PUBLISH_DIR" +# NativeAOT emits debug symbols next to the binary; they are not needed in the +# installer payload. +find "$PUBLISH_DIR" -maxdepth 1 \( -name '*.dbg' -o -name '*.pdb' \) -delete + echo "" echo "Building Velopack Release v$BUILD_VERSION" vpk [win] pack --runtime win-x64 -u MapWizard.Desktop --packTitle "MapWizard" -v $BUILD_VERSION -o "$RELEASE_DIR" -p "$PUBLISH_DIR" -e "MapWizard.Desktop.exe" -i "$ICON_PATH" diff --git a/MapWizard.Theme/MapWizard.Theme.csproj b/MapWizard.Theme/MapWizard.Theme.csproj index 61f46e9..d6a9653 100644 --- a/MapWizard.Theme/MapWizard.Theme.csproj +++ b/MapWizard.Theme/MapWizard.Theme.csproj @@ -4,6 +4,7 @@ <Nullable>enable</Nullable> <LangVersion>latestmajor</LangVersion> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> + <IsAotCompatible>true</IsAotCompatible> </PropertyGroup> <ItemGroup> diff --git a/MapWizard.Tools/MapWizard.Tools.csproj b/MapWizard.Tools/MapWizard.Tools.csproj index dadff60..dba18a6 100644 --- a/MapWizard.Tools/MapWizard.Tools.csproj +++ b/MapWizard.Tools/MapWizard.Tools.csproj @@ -5,6 +5,7 @@ <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <LangVersion>latestmajor</LangVersion> + <IsAotCompatible>true</IsAotCompatible> </PropertyGroup> <ItemGroup> diff --git a/readme.md b/readme.md index 9c2ff94..603c2a8 100644 --- a/readme.md +++ b/readme.md @@ -46,6 +46,10 @@ You can install MapWizard by downloading and running the `.exe` installer from t - .NET SDK `10.0.0` or later (see `global.json`). - Velopack CLI (`vpk`) for release packaging. +- NativeAOT toolchain for release builds: + - Windows: Visual Studio 2022 with the "Desktop development with C++" workload. + - Linux: `clang` and zlib headers (`sudo apt-get install clang zlib1g-dev` on Debian/Ubuntu). + - macOS: Xcode command line tools. ## Run from Source @@ -77,12 +81,17 @@ dotnet test MapWizard.Tests/MapWizard.Tests.csproj ## Release Builds +Release packages are compiled with NativeAOT (`PublishAot` in +`MapWizard.Desktop.csproj`), so no .NET runtime is required on user machines. +NativeAOT cannot cross-compile between operating systems: each platform's +package must be built on that platform. Regular `dotnet build`/`dotnet run` +still uses the JIT for fast local iteration. + Packaging scripts are in `MapWizard.Desktop/`: -- `build-linux.sh` -- `build-osx.sh` -- `build-win.sh` -- `build-win.bat` +- `build-linux.sh` (Linux) +- `build-osx.sh` (macOS) +- `build-win.sh` / `build-win.bat` (Windows) `MapWizard.Desktop/Assets/mapwizard.svg` is the source for the app logo and all packaged icons. After changing it, regenerate the PNG, ICO, and ICNS files with