diff --git a/MapWizard.Desktop/App.axaml.cs b/MapWizard.Desktop/App.axaml.cs index ccffd56..9a7f8fd 100644 --- a/MapWizard.Desktop/App.axaml.cs +++ b/MapWizard.Desktop/App.axaml.cs @@ -32,6 +32,7 @@ public override void Initialize() var settings = _services.GetRequiredService().GetMainSettings(); RequestedThemeVariant = ThemeService.ToThemeVariant(settings.ThemeMode); SmoothScrollViewer.SetGlobalSmoothScrollingEnabled(settings.EnableSmoothWheelScrolling); + AppearanceSettings.LoadFrom(settings); AvaloniaXamlLoader.Load(this); _services.GetRequiredService().Initialize(); diff --git a/MapWizard.Desktop/Controls/SongSelectVirtualizedList.cs b/MapWizard.Desktop/Controls/SongSelectVirtualizedList.cs new file mode 100644 index 0000000..b6b1bdb --- /dev/null +++ b/MapWizard.Desktop/Controls/SongSelectVirtualizedList.cs @@ -0,0 +1,798 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Layout; +using Avalonia.Threading; +using Avalonia.VisualTree; +using MapWizard.Desktop.ViewModels; +using MapWizard.Desktop.Views.Dialogs; +using MapWizard.Theme.Controls; + +namespace MapWizard.Desktop.Controls; + +public sealed class ViewportRangeChangedEventArgs : EventArgs +{ + public ViewportRangeChangedEventArgs(int firstVisibleIndex, int lastVisibleIndex) + { + FirstVisibleIndex = firstVisibleIndex; + LastVisibleIndex = lastVisibleIndex; + } + + public int FirstVisibleIndex { get; } + + public int LastVisibleIndex { get; } +} + +/// +/// Purpose-built virtualized list for the song select picker. +/// +/// Unlike VirtualizingStackPanel, item heights are recorded per item instead +/// of being estimated, so the extent and every index-to-offset mapping is exact no +/// matter how deep the list is scrolled. The picker's click-to-seek flow relies on +/// that exactness: the seek target (which must account for the card expanding and +/// for other cards collapsing) is computed up front from the height records, height +/// changes are applied to the bookkeeping immediately as they animate, and items +/// that change height while fully above the viewport get an explicit offset +/// compensation instead of relying on scroll anchoring. +/// +/// Place directly inside a ScrollViewer; SmoothScrollViewer supplies smooth wheel +/// scrolling through its compositor animation of the content visual. +/// +public class SongSelectVirtualizedList : Panel +{ + public static readonly StyledProperty ItemsSourceProperty = + AvaloniaProperty.Register(nameof(ItemsSource)); + + public static readonly StyledProperty HeaderContentProperty = + AvaloniaProperty.Register(nameof(HeaderContent)); + + public static readonly StyledProperty KeyboardFocusIndexProperty = + AvaloniaProperty.Register(nameof(KeyboardFocusIndex), -1); + + /// Measured height of a collapsed mapset card. + private const double CollapsedItemHeight = 96d; + + /// Height used for items whose real height has never been measured. + private const double EstimatedItemHeight = CollapsedItemHeight; + + /// Realized beyond the viewport by this many pixels on each side. + private const double ViewportBuffer = 320d; + + /// The seek glide matches the expansion animation (300 ms CubicEaseOut), + /// so the card grows while the list glides — one synchronized motion. + private static readonly TimeSpan SeekScrollDuration = TimeSpan.FromMilliseconds(300); + + private const double LayoutEpsilon = 0.5d; + + private double _offsetY; + private double _viewportHeight; + private double _headerHeight; + private double _extentHeight; + private readonly List _itemHeights = []; + private readonly Dictionary _realizedByIndex = []; + private readonly Dictionary _indexByContainer = []; + private readonly Stack _containerPool = []; + private INotifyCollectionChanged? _observedItems; + private bool _remeasureScheduled; + private int _lastFirstVisibleIndex = -1; + private int _lastLastVisibleIndex = -1; + + private DispatcherTimer? _scrollAnimationTimer; + private double _scrollAnimationFrom; + private double _scrollAnimationTarget; + private long _scrollAnimationStartTicks; + private long _scrollAnimationDurationTicks; + + static SongSelectVirtualizedList() + { + AffectsMeasure(ItemsSourceProperty); + AffectsMeasure(HeaderContentProperty); + ItemsSourceProperty.Changed.AddClassHandler((panel, _) => panel.OnItemsSourceChanged()); + HeaderContentProperty.Changed.AddClassHandler((panel, e) => panel.OnHeaderContentChanged(e)); + KeyboardFocusIndexProperty.Changed.AddClassHandler((panel, _) => panel.UpdateKeyboardFocusVisuals()); + } + + public SongSelectVirtualizedList() + { + EffectiveViewportChanged += OnEffectiveViewportChanged; + } + + /// Raised whenever the range of items intersecting the viewport changes. + public event EventHandler? ViewportRangeChanged; + + public IList? ItemsSource + { + get => GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public Control? HeaderContent + { + get => GetValue(HeaderContentProperty); + set => SetValue(HeaderContentProperty, value); + } + + /// + /// Index of the mapset card highlighted for keyboard navigation, or −1 when + /// none. The highlight is applied to realized containers here so the visual + /// state survives container pooling (a reused container never keeps it). + /// + public int KeyboardFocusIndex + { + get => GetValue(KeyboardFocusIndexProperty); + set => SetValue(KeyboardFocusIndexProperty, value); + } + + /// The scroller hosting this panel; used to compensate for height + /// changes of items that are fully above the viewport. + public ScrollViewer? ScrollOwner { get; set; } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + // The collection subscription must survive detach/attach cycles: re-attach + // idempotently here, otherwise appends are missed after re-attachment. + AttachItemObservation(); + InvalidateMeasure(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + StopScrollAnimation(); + DetachItemObservation(); + UnrealizeAll(returnToPool: false); + _containerPool.Clear(); + base.OnDetachedFromVisualTree(e); + } + + // ------------------------------------------------------------------ items + + private void OnItemsSourceChanged() + { + KeyboardFocusIndex = -1; + DetachItemObservation(); + UnrealizeAll(returnToPool: true); + _itemHeights.Clear(); + _lastFirstVisibleIndex = -1; + _lastLastVisibleIndex = -1; + _extentHeight = _headerHeight; + + if (ItemsSource is INotifyCollectionChanged notify) + { + _observedItems = notify; + notify.CollectionChanged += OnItemsCollectionChanged; + } + + SyncHeightRecords(); + AttachItemObservation(); + InvalidateMeasure(); + } + + private void OnHeaderContentChanged(AvaloniaPropertyChangedEventArgs e) + { + // HeaderContent must join the panel's children to participate in layout, + // styles and the logical tree. + if (e.OldValue is Control oldHeader) + { + Children.Remove(oldHeader); + } + + if (e.NewValue is Control newHeader) + { + Children.Add(newHeader); + } + + InvalidateMeasure(); + } + + private void AttachItemObservation() + { + DetachItemObservation(); + + if (ItemsSource is not { } items) + { + return; + } + + if (ItemsSource is INotifyCollectionChanged notify) + { + _observedItems = notify; + notify.CollectionChanged += OnItemsCollectionChanged; + } + + foreach (var item in items) + { + if (item is SongMapsetCardViewModel mapset) + { + mapset.PropertyChanged += OnItemPropertyChanged; + } + } + } + + private void DetachItemObservation() + { + if (_observedItems is { } observed) + { + observed.CollectionChanged -= OnItemsCollectionChanged; + _observedItems = null; + } + + if (ItemsSource is not { } items) + { + return; + } + + foreach (var item in items) + { + if (item is SongMapsetCardViewModel mapset) + { + mapset.PropertyChanged -= OnItemPropertyChanged; + } + } + } + + private void OnItemsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add when e.NewItems is { } added: + // Appends are the picker's paging path; extend the records without + // disturbing the realized window. + for (var i = 0; i < added.Count; i++) + { + _itemHeights.Add(EstimatedItemHeight); + _extentHeight += EstimatedItemHeight; + if (added[i] is SongMapsetCardViewModel mapset) + { + mapset.PropertyChanged += OnItemPropertyChanged; + } + } + + break; + default: + // Resets and structural changes: rebuild bookkeeping wholesale. + KeyboardFocusIndex = -1; + DetachItemObservation(); + UnrealizeAll(returnToPool: true); + _itemHeights.Clear(); + _extentHeight = _headerHeight; + SyncHeightRecords(); + AttachItemObservation(); + break; + } + + InvalidateMeasure(); + } + + private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SongMapsetCardViewModel.IsExpanded) || + sender is not SongMapsetCardViewModel item) + { + return; + } + + var index = GetIndexForItem(item); + if (index < 0 || _realizedByIndex.ContainsKey(index)) + { + // Realized items: the measure pass records the animated height. + return; + } + + // Unrealized card collapsed (only-one-expanded policy): its recorded height + // is the expanded one while the eventual height is the collapsed one. + if (!item.IsExpanded && _itemHeights[index] > CollapsedItemHeight + LayoutEpsilon) + { + ApplyHeightChange(index, CollapsedItemHeight); + InvalidateMeasure(); + } + } + + private void SyncHeightRecords() + { + var count = ItemsSource?.Count ?? 0; + + while (_itemHeights.Count > count) + { + _extentHeight -= _itemHeights[^1]; + _itemHeights.RemoveAt(_itemHeights.Count - 1); + } + + for (var i = _itemHeights.Count; i < count; i++) + { + _itemHeights.Add(EstimatedItemHeight); + _extentHeight += EstimatedItemHeight; + } + } + + // --------------------------------------------------------------- viewport + + private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) + { + var newOffset = Math.Max(0d, e.EffectiveViewport.Top); + var newViewport = e.EffectiveViewport.Height; + + if (Math.Abs(newOffset - _offsetY) <= LayoutEpsilon && + Math.Abs(newViewport - _viewportHeight) <= LayoutEpsilon) + { + return; + } + + _offsetY = newOffset; + _viewportHeight = newViewport; + InvalidateMeasure(); + } + + // ---------------------------------------------------------------- measure + + protected override Size MeasureOverride(Size availableSize) + { + var width = double.IsNaN(availableSize.Width) ? 0d : availableSize.Width; + + _headerHeight = MeasureHeader(width); + + RealizeItemsInWindow(_headerHeight, width); + + _extentHeight = _headerHeight + _itemHeights.Sum(); + + return new Size(Math.Max(width, 0d), Math.Max(_extentHeight, 0d)); + } + + private double MeasureHeader(double width) + { + if (HeaderContent is not { } header) + { + return 0d; + } + + header.Measure(new Size(Math.Max(0d, width), double.PositiveInfinity)); + return header.IsVisible ? header.DesiredSize.Height : 0d; + } + + private void RealizeItemsInWindow(double headerHeight, double width) + { + var windowStart = Math.Max(0d, _offsetY - ViewportBuffer); + var windowEnd = _offsetY + _viewportHeight + ViewportBuffer; + + var itemTop = headerHeight; + var firstVisible = -1; + var lastVisible = -1; + + var count = _itemHeights.Count; + for (var i = 0; i < count; i++) + { + var height = _itemHeights[i]; + var itemBottom = itemTop + height; + + if (itemBottom >= windowStart && itemTop <= windowEnd) + { + var container = Realize(i); + if (container is not null) + { + MeasureRealizedItem(i, container, width); + } + } + else + { + Unrealize(i); + } + + if (itemBottom > _offsetY && itemTop < _offsetY + _viewportHeight) + { + if (firstVisible < 0) + { + firstVisible = i; + } + + lastVisible = i; + } + + itemTop = itemBottom; + } + + PublishVisibleRange(firstVisible, lastVisible); + } + + private void PublishVisibleRange(int firstVisible, int lastVisible) + { + if (firstVisible == _lastFirstVisibleIndex && lastVisible == _lastLastVisibleIndex) + { + return; + } + + _lastFirstVisibleIndex = firstVisible; + _lastLastVisibleIndex = lastVisible; + + if (firstVisible >= 0) + { + ViewportRangeChanged?.Invoke(this, new ViewportRangeChangedEventArgs(firstVisible, lastVisible)); + } + } + + // ------------------------------------------------- realization bookkeeping + + private Control? Realize(int index) + { + if (_realizedByIndex.TryGetValue(index, out var existing)) + { + return existing; + } + + if (ItemsSource is not { } items || index >= items.Count || items[index] is not SongMapsetCardViewModel item) + { + return null; + } + + var container = _containerPool.Count > 0 ? _containerPool.Pop() : new SongMapsetCard(); + container.DataContext = item; + container.Classes.Set("kbd-focus", index == KeyboardFocusIndex); + _realizedByIndex[index] = container; + _indexByContainer[container] = index; + Children.Add(container); + return container; + } + + private void Unrealize(int index) + { + if (!_realizedByIndex.Remove(index, out var container)) + { + return; + } + + _indexByContainer.Remove(container); + container.DataContext = null; + container.Classes.Set("kbd-focus", false); + Children.Remove(container); + _containerPool.Push(container); + } + + private void UnrealizeAll(bool returnToPool) + { + foreach (var container in _realizedByIndex.Values) + { + _indexByContainer.Remove(container); + container.DataContext = null; + container.Classes.Set("kbd-focus", false); + Children.Remove(container); + if (returnToPool) + { + _containerPool.Push(container); + } + } + + _realizedByIndex.Clear(); + _indexByContainer.Clear(); + } + + /// Measures a realized card and records its height. + private void MeasureRealizedItem(int index, Control container, double width) + { + container.Measure(new Size(Math.Max(0d, width), double.PositiveInfinity)); + var measuredHeight = container.DesiredSize.Height; + + // A freshly created templated control measures ~0 until its template has + // been applied (right after it enters the attached tree). Keep the record + // until a real measurement arrives, then schedule one re-measure. + if (measuredHeight < 2d) + { + ScheduleTemplateRemeasure(); + return; + } + + ApplyHeightChange(index, measuredHeight); + } + + private void ScheduleTemplateRemeasure() + { + if (_remeasureScheduled) + { + return; + } + + _remeasureScheduled = true; + Dispatcher.UIThread.Post( + () => + { + _remeasureScheduled = false; + InvalidateMeasure(); + }, + DispatcherPriority.Loaded); + } + + /// Applies a height change to the bookkeeping, compensating the scroll + /// offset when the change happened to an item fully above the viewport. + private void ApplyHeightChange(int index, double newHeight) + { + var previous = _itemHeights[index]; + var delta = newHeight - previous; + if (Math.Abs(delta) <= LayoutEpsilon) + { + return; + } + + var itemExtentBottom = GetItemExtentTop(index) + previous; + + _itemHeights[index] = newHeight; + _extentHeight += delta; + + if (itemExtentBottom <= _offsetY + LayoutEpsilon) + { + // The item the scroller points at moved; keep the viewport stable. + _offsetY = Math.Max(0d, _offsetY + delta); + if (ScrollOwner is { } owner) + { + owner.Offset = new Vector(owner.Offset.X, Math.Max(0d, owner.Offset.Y + delta)); + } + } + } + + // -------------------------------------------------------------- geometry + + public double GetItemExtentTop(int index) + { + var top = _headerHeight; + for (var i = 0; i < index && i < _itemHeights.Count; i++) + { + top += _itemHeights[i]; + } + + return top; + } + + public int GetItemIndexForVisual(Visual visual) + { + var current = visual; + while (current is not null && !ReferenceEquals(current, this)) + { + if (current is Control control && _indexByContainer.TryGetValue(control, out var index)) + { + return index; + } + + current = current.GetVisualParent(); + } + + return -1; + } + + /// The realized container for an item index, or null when unrealized. + public Control? GetRealizedContainer(int index) + { + return _realizedByIndex.TryGetValue(index, out var container) ? container : null; + } + + /// Scrolls the item into view with a short glide if it sits outside the viewport. + public void EnsureItemVisible(int index) + { + if (index < 0 || index >= _itemHeights.Count || _viewportHeight <= 0d) + { + return; + } + + var top = GetItemExtentTop(index); + var bottom = top + _itemHeights[index]; + var viewportBottom = _offsetY + _viewportHeight; + + double target; + if (top < _offsetY) + { + target = top; + } + else if (bottom > viewportBottom) + { + target = bottom - _viewportHeight; + } + else + { + return; + } + + BeginScrollToOffset(Math.Max(0d, target), TimeSpan.FromMilliseconds(140)); + } + + private void UpdateKeyboardFocusVisuals() + { + var focusIndex = KeyboardFocusIndex; + foreach (var (index, container) in _realizedByIndex) + { + container.Classes.Set("kbd-focus", index == focusIndex); + } + } + + public int GetIndexForItem(SongMapsetCardViewModel item) + { if (ItemsSource is not { } items) + { + return -1; + } + + for (var i = 0; i < items.Count; i++) + { + if (ReferenceEquals(items[i], item)) + { + return i; + } + } + + return -1; + } + + /// Range of item indices intersecting the viewport, or (−1, −1) when empty. + public (int First, int Last) GetVisibleRange() => (_lastFirstVisibleIndex, _lastLastVisibleIndex); + + /// + /// Offset that centers the clicked card after the seek completes: the card at + /// expanded to its details height and every expanded + /// card above it collapsed back to its collapsed height. + /// + public bool TryComputeSeekTargetOffset(int index, out double offset) + { + offset = 0d; + if (index < 0 || index >= _itemHeights.Count || _viewportHeight <= 0d) + { + return false; + } + + var itemTop = _headerHeight; + for (var i = 0; i < index; i++) + { + itemTop += IsItemPendingCollapse(i) ? CollapsedItemHeight : _itemHeights[i]; + } + + var expandedHeight = ComputeExpandedItemHeight(index); + offset = Math.Max(0d, itemTop + (expandedHeight / 2d) - (_viewportHeight / 2d)); + return true; + } + + /// + /// Height the card will have once expanded: the current recorded height plus + /// the measured details content (the same formula AnimatedHeightBorder uses). + /// + private double ComputeExpandedItemHeight(int index) + { + var current = _itemHeights[index]; + if (_realizedByIndex.TryGetValue(index, out var container) && + container.GetVisualDescendants().OfType().FirstOrDefault() is { } border && + GetBorderContent(border) is { } content && + border.Bounds.Width > 0d) + { + var contentWidth = Math.Max(0d, border.Bounds.Width - border.Padding.Left - border.Padding.Right); + content.Measure(new Size(Math.Max(0d, contentWidth), double.PositiveInfinity)); + return current + content.DesiredSize.Height + border.Padding.Top + border.Padding.Bottom; + } + + return current; + } + + private static Control? GetBorderContent(AnimatedHeightBorder border) + { + return border.Child is ContentPresenter { Child: Control content } ? content : border.Child; + } + + /// Offset that centers the full (currently recorded) height of the item. + public bool TryComputeFullCardCenteredOffset(int index, out double offset) + { + offset = 0d; + if (index < 0 || index >= _itemHeights.Count || _viewportHeight <= 0d) + { + return false; + } + + var itemTop = GetItemExtentTop(index); + offset = Math.Max(0d, itemTop + (_itemHeights[index] / 2d) - (_viewportHeight / 2d)); + return true; + } + + private bool IsItemPendingCollapse(int index) + { + if (ItemsSource is not { } items || + index >= items.Count || + items[index] is not SongMapsetCardViewModel { IsExpanded: true }) + { + return false; + } + + return _itemHeights[index] > CollapsedItemHeight + 2d; + } + + // ------------------------------------------------------- scroll animation + + /// + /// Glides the scroller to over the expansion + /// duration with the same CubicEaseOut, so the card grows while the list moves + /// — one synchronized, natural motion. + /// + public void BeginScrollToOffset(double targetOffset, TimeSpan? duration = null) + { + if (ScrollOwner is not { } owner) + { + return; + } + + var maxOffset = Math.Max(0d, _extentHeight - _viewportHeight); + targetOffset = Math.Clamp(targetOffset, 0d, maxOffset); + + if (Math.Abs(owner.Offset.Y - targetOffset) < LayoutEpsilon) + { + return; + } + + _scrollAnimationFrom = owner.Offset.Y; + _scrollAnimationTarget = targetOffset; + _scrollAnimationStartTicks = Stopwatch.GetTimestamp(); + _scrollAnimationDurationTicks = (duration ?? SeekScrollDuration).Ticks; + + _scrollAnimationTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _scrollAnimationTimer.Tick -= OnScrollAnimationTick; + _scrollAnimationTimer.Tick += OnScrollAnimationTick; + _scrollAnimationTimer.Start(); + OnScrollAnimationTick(this, EventArgs.Empty); + } + + public void StopScrollAnimation() + { + _scrollAnimationTimer?.Stop(); + _scrollAnimationTimer = null; + } + + private void OnScrollAnimationTick(object? sender, EventArgs e) + { + if (ScrollOwner is not { } owner) + { + StopScrollAnimation(); + return; + } + + var elapsedTicks = Stopwatch.GetTimestamp() - _scrollAnimationStartTicks; + var progress = Math.Clamp(elapsedTicks / (double)_scrollAnimationDurationTicks, 0d, 1d); + var eased = 1d - Math.Pow(1d - progress, 3d); + + var offset = _scrollAnimationFrom + (_scrollAnimationTarget - _scrollAnimationFrom) * eased; + owner.Offset = new Vector(owner.Offset.X, offset); + + if (progress >= 1d) + { + StopScrollAnimation(); + } + } + + // --------------------------------------------------------------- arrange + + protected override Size ArrangeOverride(Size finalSize) + { + if (HeaderContent is { } header) + { + header.Arrange(new Rect(0d, 0d, finalSize.Width, Math.Max(0d, header.DesiredSize.Height))); + } + + var itemTop = _headerHeight; + for (var i = 0; i < _itemHeights.Count; i++) + { + if (_realizedByIndex.TryGetValue(i, out var container)) + { + container.Arrange(new Rect(0d, itemTop, finalSize.Width, Math.Max(0d, container.DesiredSize.Height))); + } + + itemTop += _itemHeights[i]; + } + + return finalSize; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == BoundsProperty) + { + // Width changes re-measure cards and can change recorded heights. + InvalidateMeasure(); + } + } +} diff --git a/MapWizard.Desktop/MapWizard.Desktop.csproj b/MapWizard.Desktop/MapWizard.Desktop.csproj index 5aae74e..a7199ec 100644 --- a/MapWizard.Desktop/MapWizard.Desktop.csproj +++ b/MapWizard.Desktop/MapWizard.Desktop.csproj @@ -7,6 +7,8 @@ Assets/app-icon.ico true latestmajor + + true 1.0.0 true true diff --git a/MapWizard.Desktop/Models/SelectedMap.cs b/MapWizard.Desktop/Models/SelectedMap.cs index 220c3e4..7a2fc8b 100644 --- a/MapWizard.Desktop/Models/SelectedMap.cs +++ b/MapWizard.Desktop/Models/SelectedMap.cs @@ -146,4 +146,13 @@ private void ReplaceBackgroundImage(Bitmap? newImage) OnPropertyChanged(nameof(HasBackgroundImage)); previous?.Dispose(); } + + /// + /// Releases the decoded background bitmap immediately instead of waiting for the + /// finalizer. Call when the entry is removed from a beatmap panel collection. + /// + public void Dispose() + { + BackgroundImage = null; + } } diff --git a/MapWizard.Desktop/Models/Settings/MainSettings.cs b/MapWizard.Desktop/Models/Settings/MainSettings.cs index 2ea5a15..d928c42 100644 --- a/MapWizard.Desktop/Models/Settings/MainSettings.cs +++ b/MapWizard.Desktop/Models/Settings/MainSettings.cs @@ -11,6 +11,12 @@ public class MainSettings [Setting("Appearance", "EnableSmoothWheelScrolling")] public bool EnableSmoothWheelScrolling { get; set; } = true; + [Setting("Appearance", "BlurModals")] + public bool BlurModals { get; set; } = true; + + [Setting("Appearance", "ReducedMotion")] + public bool ReducedMotion { get; set; } = false; + [Setting("General", "SongsPath")] public string SongsPath { get; set; } = string.Empty; diff --git a/MapWizard.Desktop/Models/Settings/ThemePalette.cs b/MapWizard.Desktop/Models/Settings/ThemePalette.cs index b7d2b64..4cf1b96 100644 --- a/MapWizard.Desktop/Models/Settings/ThemePalette.cs +++ b/MapWizard.Desktop/Models/Settings/ThemePalette.cs @@ -3,5 +3,6 @@ namespace MapWizard.Desktop.Models.Settings; public enum ThemePalette { MapWizardNoir, - MapWizardClassic + MapWizardClassic, + Gruvbox } diff --git a/MapWizard.Desktop/Services/AppearanceSettings.cs b/MapWizard.Desktop/Services/AppearanceSettings.cs new file mode 100644 index 0000000..32a7ba3 --- /dev/null +++ b/MapWizard.Desktop/Services/AppearanceSettings.cs @@ -0,0 +1,54 @@ +using System; +using MapWizard.Desktop.Models.Settings; + +namespace MapWizard.Desktop.Services; + +/// +/// Process-wide appearance switches (modal blur, reduced motion), persisted in +/// and applied live. Mirrors the SmoothScrollViewer +/// global-toggle pattern: loaded from settings at startup and updated by the +/// settings page, consumed by ModalHost and MainWindow. +/// +public static class AppearanceSettings +{ + private static bool _blurModals = true; + private static bool _reducedMotion; + + public static event EventHandler? Changed; + + public static bool BlurModals + { + get => _blurModals; + set + { + if (_blurModals == value) + { + return; + } + + _blurModals = value; + Changed?.Invoke(null, EventArgs.Empty); + } + } + + public static bool ReducedMotion + { + get => _reducedMotion; + set + { + if (_reducedMotion == value) + { + return; + } + + _reducedMotion = value; + Changed?.Invoke(null, EventArgs.Empty); + } + } + + public static void LoadFrom(MainSettings settings) + { + BlurModals = settings.BlurModals; + ReducedMotion = settings.ReducedMotion; + } +} diff --git a/MapWizard.Desktop/Services/ThemeService.cs b/MapWizard.Desktop/Services/ThemeService.cs index f194e76..3d252f2 100644 --- a/MapWizard.Desktop/Services/ThemeService.cs +++ b/MapWizard.Desktop/Services/ThemeService.cs @@ -114,9 +114,7 @@ private static void ApplyPaletteResources(ThemePalette palette, bool isDark) return; } - var paletteResources = MapWizardPaletteCatalog.GetResources( - palette == ThemePalette.MapWizardClassic, - isDark); + var paletteResources = MapWizardPaletteCatalog.GetResources(ToCatalogPalette(palette), isDark); var variant = isDark ? ThemeVariant.Dark : ThemeVariant.Light; ApplyVariantResources(appResources, variant, paletteResources); @@ -198,4 +196,11 @@ private static bool ResolveIsDarkTheme(ThemeVariant requestedVariant) private static ThemePalette NormalizePalette(ThemePalette palette) => Enum.IsDefined(palette) ? palette : ThemePalette.MapWizardNoir; + + private static MapWizardPalette ToCatalogPalette(ThemePalette palette) => palette switch + { + ThemePalette.MapWizardClassic => MapWizardPalette.Classic, + ThemePalette.Gruvbox => MapWizardPalette.Gruvbox, + _ => MapWizardPalette.Noir + }; } diff --git a/MapWizard.Desktop/Transitions/FadeTransition.cs b/MapWizard.Desktop/Transitions/FadeTransition.cs new file mode 100644 index 0000000..f101973 --- /dev/null +++ b/MapWizard.Desktop/Transitions/FadeTransition.cs @@ -0,0 +1,166 @@ +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; + +/// +/// Opacity-only page transition used when Reduced Motion is enabled: a short +/// fade out followed by a fade in, with no scale or movement. Opacity stays on +/// the compositor path to avoid redrawing page content. +/// +public sealed class FadeTransition : IPageTransition +{ + private readonly SemaphoreSlim _transitionGate = new(1, 1); + + public TimeSpan OutDuration { get; set; } = TimeSpan.FromMilliseconds(120); + + public TimeSpan InDuration { get; set; } = TimeSpan.FromMilliseconds(160); + + public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + { + // First page load has no outgoing page: show the content immediately. + 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 AnimateOpacityAsync(from, OutDuration, 1d, 0d, revealBeforeAnimation: false, cancellationToken); + from.IsVisible = false; + } + + if (to is not null) + { + await AnimateOpacityAsync(to, InDuration, 0d, 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 AnimateOpacityAsync( + Visual target, + TimeSpan duration, + double startOpacity, + double endOpacity, + bool revealBeforeAnimation, + CancellationToken cancellationToken) + { + var easing = new QuadraticEaseOut(); + var compositionVisual = ElementComposition.GetElementVisual(target); + if (compositionVisual is null) + { + await AnimateFallbackAsync(target, duration, easing, startOpacity, endOpacity, revealBeforeAnimation, cancellationToken); + return; + } + + var previousOpacity = compositionVisual.Opacity; + compositionVisual.StopAnimation("Opacity"); + compositionVisual.Opacity = (float)startOpacity; + + if (revealBeforeAnimation) + { + target.IsVisible = true; + } + + var opacity = compositionVisual.Compositor.CreateScalarKeyFrameAnimation(); + opacity.Target = "Opacity"; + opacity.Duration = duration; + opacity.InsertKeyFrame(0f, (float)startOpacity); + opacity.InsertKeyFrame(1f, (float)endOpacity, easing); + compositionVisual.StartAnimation("Opacity", opacity); + + try + { + await Task.Delay(duration, cancellationToken); + } + finally + { + compositionVisual.StopAnimation("Opacity"); + compositionVisual.Opacity = previousOpacity; + } + } + + private static async Task AnimateFallbackAsync( + Visual target, + TimeSpan duration, + Easing easing, + double startOpacity, + double endOpacity, + bool revealBeforeAnimation, + CancellationToken cancellationToken) + { + target.Opacity = startOpacity; + + 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); + await Task.Delay(stepDuration, cancellationToken); + } + } +} diff --git a/MapWizard.Desktop/Utils/BeatmapPanelViewModelUtils.cs b/MapWizard.Desktop/Utils/BeatmapPanelViewModelUtils.cs index 41568a5..ff88b85 100644 --- a/MapWizard.Desktop/Utils/BeatmapPanelViewModelUtils.cs +++ b/MapWizard.Desktop/Utils/BeatmapPanelViewModelUtils.cs @@ -26,6 +26,16 @@ public static ObservableCollection RemoveDestinationBeatmap( IReadOnlyCollection destinationBeatmaps, string path) { + // Entries filtered out here are dropped by the caller; release their decoded + // bitmaps deterministically instead of leaving them to the finalizer. + foreach (var removedBeatmap in destinationBeatmaps) + { + if (string.Equals(removedBeatmap.Path, path, StringComparison.OrdinalIgnoreCase)) + { + removedBeatmap.Dispose(); + } + } + return new ObservableCollection( destinationBeatmaps.Where(x => !string.Equals(x.Path, path, StringComparison.OrdinalIgnoreCase))); } diff --git a/MapWizard.Desktop/Utils/BitmapStorageHandler.cs b/MapWizard.Desktop/Utils/BitmapStorageHandler.cs new file mode 100644 index 0000000..69dcfaa --- /dev/null +++ b/MapWizard.Desktop/Utils/BitmapStorageHandler.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace MapWizard.Desktop.Utils; + +/// +/// Identifies one page (slot) of a . +/// +internal readonly record struct BitmapHandler(int Index); + +/// +/// Fixed-size, round-robin cache of decoded and resized thumbnails. The pixels live in unmanaged +/// pages () so the cache itself does not occupy GC heap space. +/// +public sealed class BitmapStorageHandler : IDisposable +{ + /// Width of a cached thumbnail, in pixels. + public const int BitmapWidth = 512; + + /// Height of a cached thumbnail, in pixels. + public const int BitmapHeight = 256; + + /// Bytes per row of a page: a 512 pixel row of 4 byte pixels. + public const int BitmapStride = BitmapWidth * 4; + + /// Size of a single page, in bytes. + public const int PageBytes = BitmapStride * BitmapHeight; + + /// Number of pages, i.e. of cached thumbnails. + public const int StorageSize = 20; + + private static readonly PixelSize TargetSize = new(BitmapWidth, BitmapHeight); + private static readonly Vector TargetDpi = new(96, 96); + + private static readonly Lazy SharedInstance = new(static () => + { + var handler = new BitmapStorageHandler(); + handler.Init(); + return handler; + }); + + /// + /// Process-wide cache, used by the UI (the map picker loads every visible card background through it). + /// Its pages are committed on first use and handed back to the OS at exit. + /// + public static BitmapStorageHandler Shared => SharedInstance.Value; + + // Thumbnails are a 2:1 letterbox, so a source that is not 2:1 is center-cropped (cover) rather than + // stretched. MediumQuality keeps the resize cheap; the decode itself is already scaled by the codec. + private const BitmapInterpolationMode Interpolation = BitmapInterpolationMode.MediumQuality; + + private readonly object gate = new(); + private readonly IntPtr[] pages = new IntPtr[StorageSize]; + private readonly BitmapStorageInfo[] infos = new BitmapStorageInfo[StorageSize]; + private int position; + private bool initialized; + + /// + /// Allocates all pages. Must be called before , e.g. during application startup. + /// + public void Init() + { + lock (gate) + { + if (initialized) + return; + + for (var i = 0; i < pages.Length; i++) + pages[i] = PageAllocator.Alloc(PageBytes); + + initialized = true; + } + } + + /// + /// Returns the 512x256 thumbnail of , decoding it into the next free page on a + /// miss and replaying the page on a hit. The returned bitmap is owned by the caller and stays valid + /// after its page is recycled, because copies the pixels out of the page. + /// + public Bitmap Load(string path) + { + ArgumentException.ThrowIfNullOrEmpty(path); + + lock (gate) + { + if (!initialized) + throw new InvalidOperationException($"{nameof(Init)} must be called before {nameof(Load)}."); + + for (var i = 0; i < infos.Length; i++) + { + if (infos[i].Path == path) + return CreateBitmap(new BitmapHandler(i)); + } + + var handle = new BitmapHandler(position); + position = position + 1 == StorageSize ? 0 : position + 1; + + using (var decoded = DecodeCoveringPage(path)) + { + var format = decoded.Format ?? throw new NotSupportedException($"Unable to read pixels of '{path}'."); + var alphaFormat = decoded.AlphaFormat ?? throw new NotSupportedException($"Unable to read pixels of '{path}'."); + + // Center-crop the decoded image down to the 2:1 page and blit it straight into the page. + var crop = new PixelRect( + (decoded.PixelSize.Width - BitmapWidth) / 2, + (decoded.PixelSize.Height - BitmapHeight) / 2, + BitmapWidth, + BitmapHeight); + decoded.CopyPixels(crop, pages[handle.Index], PageBytes, BitmapStride); + + infos[handle.Index] = new BitmapStorageInfo(path, format, alphaFormat); + } + + return CreateBitmap(handle); + } + } + + /// + /// Decodes at the thumbnail size. The codec scales during the decode, so the + /// full resolution image is never materialized. Aspect ratio is preserved and the result is guaranteed + /// to be at least as large as a page, either exactly wide or exactly + /// tall. + /// + private static Bitmap DecodeCoveringPage(string path) + { + using var stream = File.OpenRead(path); + + var decoded = Bitmap.DecodeToWidth(stream, BitmapWidth, Interpolation); + if (decoded.PixelSize.Height >= BitmapHeight) + return decoded; + + // Wider than 2:1 (a panorama), where the page width is the limiting side: decode by height instead. + decoded.Dispose(); + stream.Position = 0; + return Bitmap.DecodeToHeight(stream, BitmapHeight, Interpolation); + } + + /// + /// Materializes an Avalonia bitmap over a page. Avalonia has no public zero-copy bitmap that renders + /// from caller-owned memory, so this is one memcpy of the page. + /// + private Bitmap CreateBitmap(BitmapHandler handle) + { + var info = infos[handle.Index]; + return new Bitmap(info.Format, info.AlphaFormat, pages[handle.Index], TargetSize, TargetDpi, BitmapStride); + } + + public void Dispose() + { + lock (gate) + { + if (!initialized) + return; + + for (var i = 0; i < pages.Length; i++) + { + PageAllocator.Free(pages[i], PageBytes); + pages[i] = IntPtr.Zero; + } + + Array.Clear(infos); + position = 0; + initialized = false; + } + } + + /// State of one page: which file it holds and how its pixels are encoded. + private readonly struct BitmapStorageInfo(string path, PixelFormat format, AlphaFormat alphaFormat) + { + /// Source path, or null when the page has never been filled. + public string? Path { get; } = path; + + public PixelFormat Format { get; } = format; + + public AlphaFormat AlphaFormat { get; } = alphaFormat; + } +} diff --git a/MapWizard.Desktop/Utils/PageAllocator.cs b/MapWizard.Desktop/Utils/PageAllocator.cs new file mode 100644 index 0000000..9e2c789 --- /dev/null +++ b/MapWizard.Desktop/Utils/PageAllocator.cs @@ -0,0 +1,131 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace MapWizard.Desktop.Utils; + +/// +/// Allocates raw OS pages (VirtualAlloc / mmap) that live outside the GC heap. +/// +public static class PageAllocator +{ + /// + /// Reserves and commits bytes of zeroed, read/write memory. + /// + public static IntPtr Alloc(nuint size) + { + if (OperatingSystem.IsWindows()) + return PageAllocatorWindows.Alloc(size); + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + return PageAllocatorUNIX.Alloc(size); + + throw new PlatformNotSupportedException(); + } + + /// + /// Releases a block previously returned by . + /// + public static void Free(IntPtr address, nuint size) + { + if (address == IntPtr.Zero) + return; + + if (OperatingSystem.IsWindows()) + PageAllocatorWindows.Free(address); + else if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + PageAllocatorUNIX.Free(address, size); + else + throw new PlatformNotSupportedException(); + } +} + +[SupportedOSPlatform("windows")] +internal static partial class PageAllocatorWindows +{ + // MEM_LARGE_PAGES is deliberately not used: it requires SeLockMemoryPrivilege and a size that is a + // multiple of GetLargePageMinimum() (2 MiB on x64), so VirtualAlloc would fail for sub-2 MiB pages. + [Flags] + internal enum AllocationType + { + MEM_COMMIT = 0x00001000, + MEM_RESERVE = 0x00002000, + } + + internal enum ProtectionType + { + PAGE_READWRITE = 0x04, + } + + internal enum FreeType + { + // MEM_RELEASE must be passed alone and with dwSize == 0. + MEM_RELEASE = 0x00008000, + } + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial IntPtr VirtualAlloc( + IntPtr lpAddress, + nuint dwSize, + AllocationType flAllocationType, + ProtectionType flProtect); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool VirtualFree( + IntPtr lpAddress, + nuint dwSize, + FreeType dwFreeType); + + public static IntPtr Alloc(nuint size) + { + var address = VirtualAlloc(0, size, AllocationType.MEM_COMMIT | AllocationType.MEM_RESERVE, ProtectionType.PAGE_READWRITE); + if (address == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastPInvokeError(), $"VirtualAlloc failed for {size} bytes"); + + return address; + } + + public static void Free(IntPtr address) + { + if (!VirtualFree(address, 0, FreeType.MEM_RELEASE)) + throw new Win32Exception(Marshal.GetLastPInvokeError(), "VirtualFree failed"); + } +} + +internal static partial class PageAllocatorUNIX +{ + private const int ProtRead = 0x1; + private const int ProtWrite = 0x2; + private const int MapPrivate = 0x02; + + // MAP_ANONYMOUS: 0x20 on Linux, but 0x1000 on macOS/BSD. + private static readonly int MapAnon = OperatingSystem.IsMacOS() ? 0x1000 : 0x20; + + [LibraryImport("libc", SetLastError = true)] + private static partial IntPtr mmap( + IntPtr addr, + nuint length, + int prot, + int flags, + int fd, + long offset); + + [LibraryImport("libc", SetLastError = true)] + private static partial int munmap(IntPtr addr, nuint length); + + public static IntPtr Alloc(nuint size) + { + var address = mmap(0, size, ProtRead | ProtWrite, MapPrivate | MapAnon, -1, 0); + if (address == new IntPtr(-1)) + throw new Win32Exception(Marshal.GetLastPInvokeError(), $"mmap failed for {size} bytes"); + + return address; + } + + public static void Free(IntPtr address, nuint size) + { + if (munmap(address, size) != 0) + throw new Win32Exception(Marshal.GetLastPInvokeError(), "munmap failed"); + } +} diff --git a/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs b/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs index 8d5e7d1..60d2b93 100644 --- a/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs +++ b/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs @@ -564,7 +564,13 @@ private void OpenOriginFolder() [RelayCommand] private void ClearSelection() { + OriginBeatmap?.Dispose(); OriginBeatmap = new SelectedMap(); + foreach (var beatmap in DestinationBeatmaps) + { + beatmap.Dispose(); + } + DestinationBeatmaps = []; HasMultiple = false; PreferredDirectory = string.Empty; diff --git a/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs b/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs index a443864..71b92c3 100644 --- a/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs +++ b/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs @@ -177,7 +177,13 @@ private void OpenOriginFolder() [RelayCommand] private void ClearSelection() { + OriginBeatmap?.Dispose(); OriginBeatmap = new SelectedMap(); + foreach (var beatmap in DestinationBeatmaps) + { + beatmap.Dispose(); + } + DestinationBeatmaps = []; HasMultiple = false; PreferredDirectory = string.Empty; diff --git a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs index 0b64709..074b959 100644 --- a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs @@ -45,6 +45,7 @@ public partial class HitSoundVisualizerViewModel( private const int PlaybackDebugInfoUpdateIntervalMs = 250; private const int CursorColumnToleranceMs = 2; private const int HitsoundDebugActiveWindowMs = 200; + private const int MaxHistoryStates = 100; private readonly string[] _sampleExtensions = [".wav", ".ogg", ".mp3"]; private readonly ConcurrentDictionary _resolvedPlaybackSamplePathCache = new(StringComparer.OrdinalIgnoreCase); @@ -1721,10 +1722,12 @@ private void Undo() if (!RestoreHistoryState(targetState)) { _undoStack.Push(targetState); + TrimHistoryStacks(); return; } _redoStack.Push(currentState); + TrimHistoryStacks(); } [RelayCommand] @@ -1740,10 +1743,38 @@ private void Redo() if (!RestoreHistoryState(targetState)) { _redoStack.Push(targetState); + TrimHistoryStacks(); return; } _undoStack.Push(currentState); + TrimHistoryStacks(); + } + + private void TrimHistoryStacks() + { + TrimHistoryStack(_undoStack); + TrimHistoryStack(_redoStack); + } + + /// + /// Caps the number of retained history snapshots so long editing sessions cannot + /// grow the undo/redo stacks without limit. The newest entries are kept. + /// + private static void TrimHistoryStack(Stack stack) + { + if (stack.Count <= MaxHistoryStates) + { + return; + } + + // Stack enumeration is newest-first; keep the newest entries in order. + var retained = stack.ToArray(); + stack.Clear(); + for (var index = Math.Min(retained.Length, MaxHistoryStates) - 1; index >= 0; index--) + { + stack.Push(retained[index]); + } } private void StartPlaybackAt(int startTimeMs) @@ -2139,6 +2170,7 @@ private void PushUndoStateIfChanged(TimelineHistoryState? before) } _undoStack.Push(before); + TrimHistoryStacks(); _redoStack.Clear(); } diff --git a/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs b/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs index 0ec6655..4b469b2 100644 --- a/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs +++ b/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs @@ -335,7 +335,13 @@ private void OpenOriginFolder() [RelayCommand] private void ClearSelection() { + OriginBeatmap?.Dispose(); OriginBeatmap = new SelectedMap(); + foreach (var beatmap in DestinationBeatmaps) + { + beatmap.Dispose(); + } + DestinationBeatmaps = []; HasMultiple = false; PreferredDirectory = string.Empty; diff --git a/MapWizard.Desktop/ViewModels/SettingsViewModel.cs b/MapWizard.Desktop/ViewModels/SettingsViewModel.cs index 8677785..a90fd05 100644 --- a/MapWizard.Desktop/ViewModels/SettingsViewModel.cs +++ b/MapWizard.Desktop/ViewModels/SettingsViewModel.cs @@ -59,6 +59,12 @@ public partial class SettingsViewModel( [ObservableProperty] private bool _isSmoothWheelScrollingEnabled = true; + [ObservableProperty] + private bool _blurModals = true; + + [ObservableProperty] + private bool _reducedMotion; + [ObservableProperty] private int _audioPreviewSongVolumePercent = 80; @@ -154,7 +160,18 @@ private void OpenConfigDirectory() Avalonia.Media.Color.Parse("#F7F6FC"), Avalonia.Media.Color.Parse("#FFFFFF"), Avalonia.Media.Color.Parse("#483D8B"), - Avalonia.Media.Color.Parse("#272337")) + Avalonia.Media.Color.Parse("#272337")), + new( + ThemePalette.Gruvbox, + "Gruvbox", + Avalonia.Media.Color.Parse("#1D2021"), + Avalonia.Media.Color.Parse("#282828"), + Avalonia.Media.Color.Parse("#B8BB26"), + Avalonia.Media.Color.Parse("#EBDBB2"), + Avalonia.Media.Color.Parse("#F2E5BC"), + Avalonia.Media.Color.Parse("#FBF1C7"), + Avalonia.Media.Color.Parse("#98971A"), + Avalonia.Media.Color.Parse("#3C3836")) ]; public void Initialize() @@ -257,6 +274,42 @@ partial void OnIsSmoothWheelScrollingEnabledChanged(bool value) settingsService.SaveMainSettings(settings); } + partial void OnBlurModalsChanged(bool value) + { + AppearanceSettings.BlurModals = value; + if (_isLoadingMainSettings) + { + return; + } + + var settings = settingsService.GetMainSettings(); + if (settings.BlurModals == value) + { + return; + } + + settings.BlurModals = value; + settingsService.SaveMainSettings(settings); + } + + partial void OnReducedMotionChanged(bool value) + { + AppearanceSettings.ReducedMotion = value; + if (_isLoadingMainSettings) + { + return; + } + + var settings = settingsService.GetMainSettings(); + if (settings.ReducedMotion == value) + { + return; + } + + settings.ReducedMotion = value; + settingsService.SaveMainSettings(settings); + } + partial void OnAudioPreviewSongVolumePercentChanged(int value) { AudioPreviewSongVolumePercent = Math.Clamp(value, 0, 100); @@ -483,6 +536,8 @@ private void LoadMainSettingsValues() var settings = settingsService.GetMainSettings(); IsSmoothWheelScrollingEnabled = settings.EnableSmoothWheelScrolling; SmoothScrollViewer.SetGlobalSmoothScrollingEnabled(settings.EnableSmoothWheelScrolling); + BlurModals = settings.BlurModals; + ReducedMotion = settings.ReducedMotion; AudioPreviewSongVolumePercent = Math.Clamp(settings.AudioPreviewSongVolumePercent, 0, 100); AudioPreviewHitSoundVolumePercent = Math.Clamp(settings.AudioPreviewHitSoundVolumePercent, 0, 100); } diff --git a/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs b/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs index 7e11d5a..95b3a9a 100644 --- a/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs +++ b/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs @@ -28,7 +28,8 @@ public partial class SongSelectDialogViewModel( string? preferredMapsetDirectoryPath = null) : ViewModelBase, IDisposable { private const int PageSize = 20; - private const int BackgroundCacheSize = 20; + private const int BackgroundCacheSize = 12; + private const int MaxCachedMapsetCards = 256; private const int SearchDebounceMilliseconds = 2000; private const int StatusQueryMaxLength = 36; private const double EstimatedMapsetCardHeight = 104d; @@ -39,7 +40,9 @@ public partial class SongSelectDialogViewModel( private readonly List _filteredDirectoryEntries = []; private readonly Dictionary _mapsetViewModelCache = new(StringComparer.OrdinalIgnoreCase); + private readonly Queue _mapsetCardCacheOrder = new(); private readonly HashSet _visibleDirectoryPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _selectedOsuFilePaths = new(StringComparer.OrdinalIgnoreCase); private CancellationTokenSource? _searchDebounceCts; private CancellationTokenSource? _searchExecutionCts; private bool _isDisposed; @@ -117,7 +120,9 @@ public void Dispose() } _mapsetViewModelCache.Clear(); + _mapsetCardCacheOrder.Clear(); _visibleDirectoryPaths.Clear(); + _selectedOsuFilePaths.Clear(); _filteredDirectoryEntries.Clear(); _mapsetDirectoryEntries.Clear(); _mapsetDirectories.Clear(); @@ -254,7 +259,9 @@ private void SelectDifficulty(SongDifficultyItemViewModel difficulty) return; } - difficulty.IsSelected = !difficulty.IsSelected; + var isSelected = !difficulty.IsSelected; + difficulty.IsSelected = isSelected; + UpdateSelectionTracking(difficulty.OsuFilePath, isSelected); RecalculateSelectedDifficultyCount(); } @@ -309,12 +316,7 @@ private void ConfirmSelection() return; } - var selectedPaths = _mapsetViewModelCache.Values - .SelectMany(mapset => mapset.Difficulties) - .Where(difficulty => difficulty.IsSelected) - .Select(difficulty => difficulty.OsuFilePath) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + var selectedPaths = _selectedOsuFilePaths.ToList(); if (selectedPaths.Count > 0) { @@ -334,11 +336,24 @@ private void SelectAllDifficultiesInMapset(SongMapsetCardViewModel? mapset) foreach (var difficulty in mapset.Difficulties) { difficulty.IsSelected = shouldSelectAll; + UpdateSelectionTracking(difficulty.OsuFilePath, shouldSelectAll); } RecalculateSelectedDifficultyCount(); } + private void UpdateSelectionTracking(string osuFilePath, bool isSelected) + { + if (isSelected) + { + _selectedOsuFilePaths.Add(osuFilePath); + } + else + { + _selectedOsuFilePaths.Remove(osuFilePath); + } + } + public void TryLoadMoreFromScroll( double offsetY, double viewportHeight, @@ -457,9 +472,7 @@ private async Task StartSearchFromBeginningAsync(CancellationToken cancellationT mapset.IsExpanded = false; } - SelectedDifficultyCount = _mapsetViewModelCache.Values - .SelectMany(mapset => mapset.Difficulties) - .Count(difficulty => difficulty.IsSelected); + SelectedDifficultyCount = _selectedOsuFilePaths.Count; OnPropertyChanged(nameof(CanConfirmSelection)); NotifyListStateChanged(); @@ -535,13 +548,10 @@ private async Task LoadNextPageAsync(int queryVersion, CancellationToken cancell continue; } - mapsetViewModel = new SongMapsetCardViewModel( + mapsetViewModel = CreateMapsetCardViewModel( + directoryEntry.DirectoryPath, mapset, - IsPreferredMapsetDirectory(directoryEntry.DirectoryPath)) - { - Owner = this - }; - _mapsetViewModelCache[directoryEntry.DirectoryPath] = mapsetViewModel; + IsPreferredMapsetDirectory(directoryEntry.DirectoryPath)); } else { @@ -565,6 +575,7 @@ private async Task LoadNextPageAsync(int queryVersion, CancellationToken cancell RecalculateSelectedDifficultyCount(); UpdateBackgroundCacheForCurrentViewport(); + EvictExcessMapsetCards(); UpdateStatusMessage(); } catch (OperationCanceledException ex) @@ -615,16 +626,14 @@ private async Task LoadMountedLazerMapsetAsync(CancellationToken cancellationTok return; } - var mountedViewModel = new SongMapsetCardViewModel( + var mountedViewModel = CreateMapsetCardViewModel( + normalizedDirectory, mapset, isPreferredMapset: false, - isMountedInLazer: true) - { - Owner = this - }; + isMountedInLazer: true); mountedViewModel.SetBackgroundActive(true); - _mapsetViewModelCache[normalizedDirectory] = mountedViewModel; MountedLazerMapset = mountedViewModel; + EvictExcessMapsetCards(); } catch (OperationCanceledException) { @@ -661,13 +670,104 @@ private void UpdateStatusMessage() private void RecalculateSelectedDifficultyCount() { - SelectedDifficultyCount = _mapsetViewModelCache.Values - .SelectMany(mapset => mapset.Difficulties) - .Count(difficulty => difficulty.IsSelected); + SelectedDifficultyCount = _selectedOsuFilePaths.Count; OnPropertyChanged(nameof(CanConfirmSelection)); } + private SongMapsetCardViewModel CreateMapsetCardViewModel( + string directoryPath, + SongMapsetInfo mapset, + bool isPreferredMapset, + bool isMountedInLazer = false) + { + var mapsetViewModel = new SongMapsetCardViewModel(mapset, isPreferredMapset, isMountedInLazer) + { + Owner = this + }; + + // The mounted lazer mapset can share a directory with an already cached card; + // retire the orphaned entry so its difficulty VMs are released. + if (_mapsetViewModelCache.TryGetValue(directoryPath, out var existingCard) && + !_visibleDirectoryPaths.Contains(directoryPath) && + !ReferenceEquals(existingCard, MountedLazerMapset)) + { + _mapsetViewModelCache.Remove(directoryPath); + existingCard.Dispose(); + } + + if (_selectedOsuFilePaths.Count > 0) + { + foreach (var difficulty in mapsetViewModel.Difficulties) + { + if (_selectedOsuFilePaths.Contains(difficulty.OsuFilePath)) + { + difficulty.IsSelected = true; + } + } + } + + _mapsetViewModelCache[directoryPath] = mapsetViewModel; + _mapsetCardCacheOrder.Enqueue(directoryPath); + return mapsetViewModel; + } + + /// + /// Bounds the card view-model cache. Cards are tracked in insertion order and the + /// oldest entries that are neither the mounted lazer mapset nor currently listed + /// get disposed. Selection state survives eviction because it is tracked in + /// and rehydrated when a card is recreated. + /// + private void EvictExcessMapsetCards() + { + while (_mapsetViewModelCache.Count > MaxCachedMapsetCards) + { + string? evictablePath = null; + foreach (var candidatePath in _mapsetCardCacheOrder) + { + if (!_mapsetViewModelCache.TryGetValue(candidatePath, out var candidateCard)) + { + continue; + } + + if (_visibleDirectoryPaths.Contains(candidatePath) || + ReferenceEquals(candidateCard, MountedLazerMapset)) + { + continue; + } + + evictablePath = candidatePath; + break; + } + + if (evictablePath is null) + { + return; + } + + if (_mapsetViewModelCache.Remove(evictablePath, out var evictedCard)) + { + evictedCard.Dispose(); + } + + // Rebuild the order queue without stale and evicted entries. + var survivingPaths = new Queue(_mapsetViewModelCache.Count); + foreach (var candidatePath in _mapsetCardCacheOrder) + { + if (_mapsetViewModelCache.ContainsKey(candidatePath)) + { + survivingPaths.Enqueue(candidatePath); + } + } + + _mapsetCardCacheOrder.Clear(); + foreach (var survivingPath in survivingPaths) + { + _mapsetCardCacheOrder.Enqueue(survivingPath); + } + } + } + private List FilterByFolderName(string query) { if (query.Length == 0) @@ -865,7 +965,9 @@ private void ClearMapsetViewModelCache() } _mapsetViewModelCache.Clear(); + _mapsetCardCacheOrder.Clear(); _visibleDirectoryPaths.Clear(); + _selectedOsuFilePaths.Clear(); _filteredDirectoryEntries.Clear(); _mapsetDirectoryEntries.Clear(); _mapsetDirectories.Clear(); @@ -888,6 +990,8 @@ public partial class SongMapsetCardViewModel : ObservableObject, IDisposable private readonly string? _backgroundImagePath; private bool _backgroundLoadFailed; private bool _isBackgroundActive; + private int _backgroundGeneration; + private int? _backgroundDecodeGeneration; private bool _isDisposed; public SongMapsetCardViewModel( @@ -990,7 +1094,45 @@ private void EnsureBackgroundLoaded() return; } - var image = BuildBackgroundImage(_backgroundImagePath); + // A decode already running for this activation epoch will deliver the + // bitmap; decodes from an older epoch are discarded when they complete. + if (_backgroundDecodeGeneration == _backgroundGeneration) + { + return; + } + + var generation = _backgroundGeneration; + _backgroundDecodeGeneration = generation; + _ = DecodeBackgroundAsync(generation); + } + + private async Task DecodeBackgroundAsync(int generation) + { + Bitmap? image = null; + try + { + // The page cache serves hits with one memcpy and misses with a full + // decode; both happen off the UI thread so scroll bursts stay smooth. + image = await Task.Run(() => BuildBackgroundImage(_backgroundImagePath)); + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + } + + if (_backgroundDecodeGeneration == generation) + { + _backgroundDecodeGeneration = null; + } + + // Card state mutations happen on the UI thread; this continuation also + // resumes there, so the epoch and disposed checks below are race-free. + if (_isDisposed || generation != _backgroundGeneration || !_isBackgroundActive) + { + image?.Dispose(); + return; + } + if (image is null) { _backgroundLoadFailed = true; @@ -1002,6 +1144,9 @@ private void EnsureBackgroundLoaded() private void ReleaseBackground() { + // Invalidate any in-flight decode for this card. + _backgroundGeneration++; + if (BackgroundImage is null) { return; @@ -1027,7 +1172,7 @@ private void ReleaseBackground() return null; } - return ArtworkBitmapUtils.DecodePreview(fullPath, 480); + return BitmapStorageHandler.Shared.Load(fullPath); } catch (Exception ex) { diff --git a/MapWizard.Desktop/Views/Controls/ModalHost.axaml.cs b/MapWizard.Desktop/Views/Controls/ModalHost.axaml.cs index 38613ef..e953782 100644 --- a/MapWizard.Desktop/Views/Controls/ModalHost.axaml.cs +++ b/MapWizard.Desktop/Views/Controls/ModalHost.axaml.cs @@ -289,8 +289,10 @@ private TransformOperations GetOpenTransform() private void PrepareBackgroundBlur() { var target = BackgroundBlurTarget; - if (target is null) + if (target is null || !AppearanceSettings.BlurModals) { + // Dim-only: the dark backdrop still fades in, but no blur effect + // is attached to the shell. return; } diff --git a/MapWizard.Desktop/Views/Dialogs/BeatmapSourceCard.axaml.cs b/MapWizard.Desktop/Views/Dialogs/BeatmapSourceCard.axaml.cs index 22a5557..a520c54 100644 --- a/MapWizard.Desktop/Views/Dialogs/BeatmapSourceCard.axaml.cs +++ b/MapWizard.Desktop/Views/Dialogs/BeatmapSourceCard.axaml.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; @@ -57,8 +58,6 @@ public partial class BeatmapSourceCard : UserControl, IDisposable nameof(FallbackBackgroundBrush), new SolidColorBrush(Color.Parse("#141414"))); - private string? fallbackPath; - public string SourceLabel { get => GetValue(SourceLabelProperty); @@ -176,11 +175,37 @@ public BeatmapSourceCard(BeatmapSourceKind sourceKind, IReadOnlyList bea FallbackDetailLabel = IsFallback ? $"mapped by {creator}" : string.Empty; - BackgroundImage = TryLoadBackground(firstPath, backgroundFilename); - HasBackgroundImage = BackgroundImage is not null; - FallbackBackgroundBrush = BackgroundImage is null + + // The decoded preview arrives asynchronously; until then the card shows its + // default "no background" state (solid #141414 FallbackBackgroundBrush). + BeginLoadBackground(firstPath, backgroundFilename); + } + + private string? fallbackPath; + private bool _isDisposed; + + private void BeginLoadBackground(string beatmapPath, string? backgroundFilename) + { + _ = LoadBackgroundAsync(beatmapPath, backgroundFilename); + } + + private async Task LoadBackgroundAsync(string beatmapPath, string? backgroundFilename) + { + var image = await Task.Run(() => TryLoadBackground(beatmapPath, backgroundFilename)); + + // The owner disposes on the UI thread — the same thread this continuation + // resumes on — so this check is race-free. + if (_isDisposed) + { + image?.Dispose(); + return; + } + + BackgroundImage = image; + HasBackgroundImage = image is not null; + FallbackBackgroundBrush = image is null ? new SolidColorBrush(Color.Parse("#141414")) - : new ImageBrush(BackgroundImage) + : new ImageBrush(image) { Stretch = Stretch.UniformToFill, AlignmentX = AlignmentX.Center, @@ -190,7 +215,14 @@ public BeatmapSourceCard(BeatmapSourceKind sourceKind, IReadOnlyList bea public void Dispose() { + if (_isDisposed) + { + return; + } + + _isDisposed = true; BackgroundImage?.Dispose(); + BackgroundImage = null; } private void DifficultyButton_OnClick(object? sender, RoutedEventArgs e) diff --git a/MapWizard.Desktop/Views/Dialogs/SongMapsetCard.axaml b/MapWizard.Desktop/Views/Dialogs/SongMapsetCard.axaml index 45f4480..cd9278a 100644 --- a/MapWizard.Desktop/Views/Dialogs/SongMapsetCard.axaml +++ b/MapWizard.Desktop/Views/Dialogs/SongMapsetCard.axaml @@ -114,6 +114,12 @@ + + +