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(@"^(?.+?) - (?.+?) \((?.+?)\) \[(?.+?)\]$",
- RegexOptions.Compiled);
+ [GeneratedRegex(@"^(?.+?) - (?.+?) \((?.+?)\) \[(?.+?)\]$")]
+ private static partial Regex FullPattern();
- private static readonly Regex ShortPattern =
- new(@"^(?.+?) - (?.+?) \[(?.+?)\]$",
- RegexOptions.Compiled);
+ [GeneratedRegex(@"^(?.+?) - (?.+?) \[(?.+?)\]$")]
+ 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 @@
true
latestmajor
1.0.0
+ true
+ true
+ true
@@ -26,7 +29,6 @@
-
@@ -49,6 +51,19 @@
+
+
+
+
+
+
+
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 Difficulties { get; } = new(difficulties);
public bool HasUnselectedDifficulties => Difficulties.Any(difficulty => !difficulty.IsSelected);
+
+ ///
+ /// Re-raises derived state after entries were
+ /// updated in place, so bindings like the select-all button stay current.
+ ///
+ 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 ReadRecords()
return [];
}
- return JsonSerializer.Deserialize>(json, JsonOptions) ?? [];
+ return JsonSerializer.Deserialize(json, ProjectJsonContext.Default.ListPersistedProjectRecord) ?? [];
}
private void WriteRecords(List 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 ColourSequence { get; set; } = [];
}
+
+ [JsonSourceGenerationOptions(WriteIndented = true)]
+ [JsonSerializable(typeof(List))]
+ 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> EnumParsers = new()
+ {
+ [typeof(ThemeMode)] = static rawValue => Enum.Parse(rawValue, true),
+ [typeof(ThemePalette)] = static rawValue => Enum.Parse(rawValue, true),
+ [typeof(UpdateStream)] = static rawValue => Enum.Parse(rawValue, true),
+ };
+
public string ConfigDirectoryPath { get; }
public SettingsService()
@@ -31,7 +41,8 @@ public void SaveMainSettings(MainSettings settings)
SaveSettings(settings, MainSettingsFileName);
}
- private TSettings LoadSettings(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 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 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;
-
-///
-/// 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.
-///
-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;
+
+///
+/// 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.
+///
+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(
- @"(?\d{1,4}):(?\d{1,2}):(?\d{1,3})",
- RegexOptions.Compiled);
+ [GeneratedRegex(@"(?\d{1,4}):(?\d{1,2}):(?\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> 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 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)
}
///
- /// 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 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.
///
- /// The ViewModel representing a View
- /// The View that matches the ViewModel. Null is no match found
- public static Window? ResolveViewFromViewModel(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 vm) where T : ViewModelBase
+
+ ///
+ /// 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.
+ ///
+ 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 Windows =>
- (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Windows ?? Array.Empty();
+ 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 Windows =>
+ (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Windows ?? Array.Empty();
}
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(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(
///
public void RequestStartupUpdateCheck() => _ = _welcomePageViewModel.CheckForUpdatesOnStartupAsync();
+ ///
+ /// 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 (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.
+ ///
+ 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(GetPageViewModels()), warmupHost),
+ DispatcherPriority.SystemIdle);
+ }
+
+ private void PreloadNextPage(Queue 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 GetPageViewModels()
+ {
+ // Welcome is already visible and cached; rebuilding it during the
+ // entrance transition would only add UI-thread contention.
+ yield return _hitSoundCopierViewModel ??= _services.GetRequiredService();
+ yield return _hitSoundVisualizerViewModel ??= _services.GetRequiredService();
+ yield return _metadataManagerViewModel ??= _services.GetRequiredService();
+ yield return _comboColourStudioViewModel ??= _services.GetRequiredService();
+ yield return _mapCleanerViewModel ??= _services.GetRequiredService();
+ 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(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; }
+
+ ///
+ /// The owning song select view model. Set at construction so the card can bind to
+ /// the dialog's commands with compiled bindings (NativeAOT-safe).
+ ///
+ 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;
+
+ ///
+ /// The owning mapset card. Set at construction so difficulty chips can bind to the
+ /// dialog's selection command with compiled bindings (NativeAOT-safe).
+ ///
+ 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 @@
@@ -155,9 +163,9 @@
+ SuggestedColours="{Binding DataContext.SuggestedColours, ElementName=Root}" />