From c8c3f51bfff3143d631d3c7b9cbf478c491de2e2 Mon Sep 17 00:00:00 2001 From: lincube Date: Sun, 22 Mar 2026 20:29:44 +0800 Subject: [PATCH] 0.7.5.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 精致 --- .../ComponentPreviewImageServiceTests.cs | 257 ++++++++ .../DesktopEditing/DesktopEditGhostView.cs | 185 +++++- .../DesktopEditOverlayPresenter.cs | 118 +++- .../Services/ComponentPreviewImageService.cs | 261 ++++++++ .../Services/ComponentPreviewTypes.cs | 281 ++++++++ .../Services/IComponentPreviewImageService.cs | 32 + .../ComponentLibraryWindowViewModel.cs | 136 +++- .../Views/ComponentLibraryWindow.axaml | 45 +- .../Views/ComponentLibraryWindow.axaml.cs | 105 ++- .../Views/Components/AnalogClockWidget.axaml | 2 +- .../Components/BilibiliHotSearchWidget.axaml | 2 +- .../Views/Components/BrowserWidget.axaml | 2 +- .../Components/ClassScheduleWidget.axaml | 2 +- .../Views/Components/DailyArtworkWidget.axaml | 2 +- .../Views/Components/DateWidget.axaml | 2 +- .../Components/ExtendedWeatherWidget.axaml | 2 +- .../Components/HolidayCalendarWidget.axaml | 2 +- .../Components/HourlyWeatherWidget.axaml | 2 +- .../Views/Components/IfengNewsWidget.axaml | 2 +- .../Components/LunarCalendarWidget.axaml | 2 +- .../Components/MonthCalendarWidget.axaml | 2 +- .../Components/MultiDayWeatherWidget.axaml | 2 +- .../Views/Components/MusicControlWidget.axaml | 2 +- .../Views/Components/RecordingWidget.axaml | 2 +- .../Components/StudyEnvironmentWidget.axaml | 2 +- .../Views/Components/TimerWidget.axaml | 2 +- .../Views/Components/WeatherClockWidget.axaml | 2 +- .../Views/Components/WeatherWidget.axaml | 2 +- .../MainWindow.ComponentPreviewImages.cs | 600 ++++++++++++++++++ .../Views/MainWindow.ComponentSystem.cs | 113 +++- .../Views/MainWindow.DesktopEditing.cs | 8 +- LanMountainDesktop/Views/MainWindow.axaml | 9 + 32 files changed, 2036 insertions(+), 152 deletions(-) create mode 100644 LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs create mode 100644 LanMountainDesktop/Services/ComponentPreviewImageService.cs create mode 100644 LanMountainDesktop/Services/ComponentPreviewTypes.cs create mode 100644 LanMountainDesktop/Services/IComponentPreviewImageService.cs create mode 100644 LanMountainDesktop/Views/MainWindow.ComponentPreviewImages.cs diff --git a/LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs b/LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs new file mode 100644 index 0000000..85c5bbe --- /dev/null +++ b/LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Media; +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class ComponentPreviewImageServiceTests +{ + [Fact] + public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys() + { + var service = new ComponentPreviewImageService(); + var executionOrder = new List(); + var activeCount = 0; + var maxActiveCount = 0; + + Task Queue(string componentTypeId) + { + var key = ComponentPreviewKey.ForComponentType(componentTypeId, widthCells: 2, heightCells: 2); + return service.QueueGenerationAsync( + key, + visualSignature: $"sig:{componentTypeId}", + async _ => + { + var activeNow = Interlocked.Increment(ref activeCount); + maxActiveCount = Math.Max(maxActiveCount, activeNow); + lock (executionOrder) + { + executionOrder.Add(componentTypeId); + } + + await Task.Delay(40); + Interlocked.Decrement(ref activeCount); + return CreateImage(); + }); + } + + var first = Queue("Clock"); + var second = Queue("Weather"); + var third = Queue("Calendar"); + + await Task.WhenAll(first, second, third); + + Assert.Equal(1, maxActiveCount); + Assert.Equal(["Clock", "Weather", "Calendar"], executionOrder); + } + + [Fact] + public async Task QueueGenerationAsync_DeduplicatesConcurrentRequestsForSameKey() + { + var service = new ComponentPreviewImageService(); + var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2); + var generationCount = 0; + var bitmap = CreateImage(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Task Generation(CancellationToken _) + { + Interlocked.Increment(ref generationCount); + return completion.Task; + } + + var first = service.QueueGenerationAsync(key, "clock-sig", Generation); + var second = service.QueueGenerationAsync(key, "clock-sig", Generation); + + Assert.Same(first, second); + + completion.SetResult(bitmap); + var entry = await first; + + Assert.Equal(1, generationCount); + Assert.Equal(ComponentPreviewImageState.Ready, entry.State); + Assert.Same(bitmap, entry.Bitmap); + } + + [Fact] + public void Invalidate_ResetsSingleKeyToPending() + { + var service = new ComponentPreviewImageService(); + var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2); + var image = CreateDisposableImage(); + var stored = service.Store(key, image, "clock-sig"); + var previousRevision = stored.Revision; + + var result = service.Invalidate(key); + + Assert.True(result); + Assert.Equal(ComponentPreviewImageState.Pending, stored.State); + Assert.Null(stored.Bitmap); + Assert.True(image.IsDisposed); + Assert.True(stored.Revision > previousRevision); + Assert.Equal("clock-sig", stored.VisualSignature); + } + + [Fact] + public void RemovePlacementPreviews_RemovesOnlyMatchingPlacementEntries() + { + var service = new ComponentPreviewImageService(); + + var removedClock = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2); + var removedWeather = ComponentPreviewKey.ForPlacementInstance("Weather", "desk-1", widthCells: 4, heightCells: 2); + var keptPlacement = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-2", widthCells: 2, heightCells: 2); + var keptType = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2); + var removedClockImage = CreateDisposableImage(); + var removedWeatherImage = CreateDisposableImage(); + var keptPlacementImage = CreateDisposableImage(); + var keptTypeImage = CreateDisposableImage(); + + service.Store(removedClock, removedClockImage, "sig-a"); + service.Store(removedWeather, removedWeatherImage, "sig-b"); + service.Store(keptPlacement, keptPlacementImage, "sig-c"); + service.Store(keptType, keptTypeImage, "sig-d"); + + var removedCount = service.RemovePlacementPreviews("desk-1"); + + Assert.Equal(2, removedCount); + Assert.False(service.TryGetEntry(removedClock, out _)); + Assert.False(service.TryGetEntry(removedWeather, out _)); + Assert.True(service.TryGetEntry(keptPlacement, out _)); + Assert.True(service.TryGetEntry(keptType, out _)); + Assert.True(removedClockImage.IsDisposed); + Assert.True(removedWeatherImage.IsDisposed); + Assert.False(keptPlacementImage.IsDisposed); + Assert.False(keptTypeImage.IsDisposed); + } + + [Fact] + public void InvalidateVisualSignature_InvalidatesEveryMatchingEntry() + { + var service = new ComponentPreviewImageService(); + const string matchingSignature = "shared-sig"; + const string otherSignature = "other-sig"; + + var first = service.Store( + ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2), + CreateImage(), + matchingSignature); + var second = service.Store( + ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2), + CreateImage(), + matchingSignature); + var third = service.Store( + ComponentPreviewKey.ForComponentType("Weather", widthCells: 2, heightCells: 1), + CreateImage(), + otherSignature); + + var invalidatedCount = service.InvalidateVisualSignature(matchingSignature); + + Assert.Equal(2, invalidatedCount); + Assert.Equal(ComponentPreviewImageState.Pending, first.State); + Assert.Equal(ComponentPreviewImageState.Pending, second.State); + Assert.Null(first.Bitmap); + Assert.Null(second.Bitmap); + Assert.Equal(ComponentPreviewImageState.Ready, third.State); + Assert.NotNull(third.Bitmap); + } + + [Fact] + public void Store_ReplacingBitmap_DisposesPreviousBitmap_WhenInstanceChanges() + { + var service = new ComponentPreviewImageService(); + var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2); + var first = CreateDisposableImage(); + var second = CreateDisposableImage(); + + service.Store(key, first, "sig-a"); + service.Store(key, second, "sig-b"); + + Assert.True(first.IsDisposed); + Assert.False(second.IsDisposed); + } + + [Fact] + public void Store_ReplacingBitmap_DoesNotDispose_WhenSameInstanceReused() + { + var service = new ComponentPreviewImageService(); + var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2); + var image = CreateDisposableImage(); + + service.Store(key, image, "sig-a"); + service.Store(key, image, "sig-b"); + + Assert.False(image.IsDisposed); + } + + [Fact] + public void StoreFailure_DisposesExistingBitmap() + { + var service = new ComponentPreviewImageService(); + var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2); + var image = CreateDisposableImage(); + + service.Store(key, image, "sig-a"); + var entry = service.StoreFailure(key, "sig-a", "failed"); + + Assert.True(image.IsDisposed); + Assert.Equal(ComponentPreviewImageState.Failed, entry.State); + Assert.Null(entry.Bitmap); + } + + [Fact] + public async Task QueueGenerationAsync_DisposesStaleGeneratedBitmap_WhenEntryWasInvalidated() + { + var service = new ComponentPreviewImageService(); + var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stale = CreateDisposableImage(); + + var generationTask = service.QueueGenerationAsync(key, "sig-a", _ => completion.Task); + _ = service.Invalidate(key); + completion.SetResult(stale); + var entry = await generationTask; + + Assert.True(stale.IsDisposed); + Assert.Equal(ComponentPreviewImageState.Pending, entry.State); + Assert.Null(entry.Bitmap); + } + + private static IImage CreateImage() => new TestImage(); + private static DisposableTestImage CreateDisposableImage() => new(); + + private sealed class TestImage : IImage + { + public Size Size => new(1, 1); + + public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) + { + _ = context; + _ = sourceRect; + _ = destRect; + } + } + + private sealed class DisposableTestImage : IImage, IDisposable + { + public Size Size => new(1, 1); + + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + + public void Draw(DrawingContext context, Rect sourceRect, Rect destRect) + { + _ = context; + _ = sourceRect; + _ = destRect; + } + } +} diff --git a/LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs b/LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs index fd5a510..ad33f0d 100644 --- a/LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs +++ b/LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs @@ -13,6 +13,9 @@ internal sealed class DesktopEditGhostView : Border private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120); private static readonly Easing StandardEasing = new CubicEaseOut(); + private readonly Image _previewImage; + private readonly Border _previewOverlay; + private readonly Border _fallbackCard; private readonly Border _accentDot; private readonly TextBlock _titleTextBlock; private readonly TextBlock _detailTextBlock; @@ -33,6 +36,9 @@ internal sealed class DesktopEditGhostView : Border private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D")); private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676")); + private bool _hasPreviewImage; + private bool _isInvalid; + public DesktopEditGhostView() { HorizontalAlignment = HorizontalAlignment.Stretch; @@ -47,27 +53,12 @@ internal sealed class DesktopEditGhostView : Border RenderTransform = _scaleTransform; Transitions = new Transitions { - new DoubleTransition - { - Property = Visual.OpacityProperty, - Duration = FastDuration, - Easing = StandardEasing - } + CreateOpacityTransition(FastDuration) }; _scaleTransform.Transitions = new Transitions { - new DoubleTransition - { - Property = ScaleTransform.ScaleXProperty, - Duration = FastDuration, - Easing = StandardEasing - }, - new DoubleTransition - { - Property = ScaleTransform.ScaleYProperty, - Duration = FastDuration, - Easing = StandardEasing - } + CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration), + CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration) }; _accentDot = new Border @@ -119,6 +110,18 @@ internal sealed class DesktopEditGhostView : Border Child = _badgeTextBlock }; + _previewImage = new Image + { + Stretch = Stretch.UniformToFill, + IsVisible = false + }; + + _previewOverlay = new Border + { + Background = new SolidColorBrush(Color.Parse("#1A000000")), + IsVisible = false + }; + var headerPanel = new StackPanel { Orientation = Orientation.Horizontal, @@ -140,7 +143,7 @@ internal sealed class DesktopEditGhostView : Border } }; - var rootGrid = new Grid + var fallbackGrid = new Grid { RowDefinitions = new RowDefinitions { @@ -149,16 +152,31 @@ internal sealed class DesktopEditGhostView : Border }, RowSpacing = 8 }; - rootGrid.Children.Add(contentPanel); - rootGrid.Children.Add(_badgeBorder); + fallbackGrid.Children.Add(contentPanel); + fallbackGrid.Children.Add(_badgeBorder); Grid.SetRow(contentPanel, 0); Grid.SetRow(_badgeBorder, 1); _badgeBorder.Margin = new Thickness(0, 2, 0, 0); - Child = rootGrid; + _fallbackCard = new Border + { + Background = Brushes.Transparent, + Child = fallbackGrid + }; + + Child = new Grid + { + Children = + { + _previewImage, + _previewOverlay, + _fallbackCard + } + }; UpdatePreviewMetrics(180, 120); UpdateContent(null, null, null); + ApplyShellChrome(); } public void UpdateContent(string? title, string? detail, string? badgeText) @@ -170,18 +188,36 @@ internal sealed class DesktopEditGhostView : Border _badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText); } + public void SetPreviewImage(IImage? image) + { + _previewImage.Source = image; + _hasPreviewImage = image is not null; + _previewImage.IsVisible = _hasPreviewImage; + _previewOverlay.IsVisible = false; + _fallbackCard.IsVisible = !_hasPreviewImage; + ApplyShellChrome(); + } + public void UpdatePreviewMetrics(double width, double height) { var normalizedWidth = Math.Max(1, width); var normalizedHeight = Math.Max(1, height); var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight)); - CornerRadius = new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28)); - Padding = new Thickness( - Math.Clamp(minSide * 0.10, 10, 18), - Math.Clamp(minSide * 0.10, 10, 18), - Math.Clamp(minSide * 0.10, 10, 18), - Math.Clamp(minSide * 0.09, 10, 16)); + CornerRadius = _hasPreviewImage + ? new CornerRadius(Math.Clamp(minSide * 0.14, 14, 24)) + : new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28)); + Padding = _hasPreviewImage + ? new Thickness( + Math.Clamp(minSide * 0.02, 1, 4), + Math.Clamp(minSide * 0.02, 1, 4), + Math.Clamp(minSide * 0.02, 1, 4), + Math.Clamp(minSide * 0.02, 1, 4)) + : new Thickness( + Math.Clamp(minSide * 0.10, 10, 18), + Math.Clamp(minSide * 0.10, 10, 18), + Math.Clamp(minSide * 0.10, 10, 18), + Math.Clamp(minSide * 0.09, 10, 16)); var titleFontSize = Math.Clamp(minSide * 0.12, 12, 18); var detailFontSize = Math.Clamp(minSide * 0.085, 10, 13); @@ -200,29 +236,47 @@ internal sealed class DesktopEditGhostView : Border public void SetInvalid(bool isInvalid) { + _isInvalid = isInvalid; + if (isInvalid) { - Background = _invalidBackgroundBrush; - BorderBrush = _invalidBorderBrush; _accentDot.Background = _invalidAccentBrush; _badgeBorder.Background = _invalidBadgeBackgroundBrush; _badgeBorder.BorderBrush = _invalidBadgeBorderBrush; _titleTextBlock.Foreground = _invalidBorderBrush; _detailTextBlock.Foreground = _invalidBorderBrush; _badgeTextBlock.Foreground = _invalidBorderBrush; - Opacity = 0.9; + if (!_hasPreviewImage) + { + Background = _invalidBackgroundBrush; + BorderBrush = _invalidBorderBrush; + BorderThickness = new Thickness(1); + Opacity = 0.9; + } + else + { + ApplyShellChrome(); + } return; } - Background = _normalBackgroundBrush; - BorderBrush = _normalBorderBrush; _accentDot.Background = _normalAccentBrush; _badgeBorder.Background = _normalBadgeBackgroundBrush; _badgeBorder.BorderBrush = _normalBadgeBorderBrush; _titleTextBlock.Foreground = _normalTextBrush; _detailTextBlock.Foreground = _normalMutedTextBrush; _badgeTextBlock.Foreground = _normalTextBrush; - Opacity = 1.0; + if (!_hasPreviewImage) + { + Background = _normalBackgroundBrush; + BorderBrush = _normalBorderBrush; + BorderThickness = new Thickness(1); + Opacity = 1.0; + } + else + { + ApplyShellChrome(); + } } public void SetRestingScale(double scale) @@ -238,4 +292,67 @@ internal sealed class DesktopEditGhostView : Border _scaleTransform.ScaleX = clampedScale; _scaleTransform.ScaleY = clampedScale; } + + internal bool HasPreviewImage => _hasPreviewImage; + + internal void SetScaleTransitionDuration(TimeSpan duration) + { + _scaleTransform.Transitions = new Transitions + { + CreateScaleTransition(ScaleTransform.ScaleXProperty, duration), + CreateScaleTransition(ScaleTransform.ScaleYProperty, duration) + }; + } + + internal void SetOpacityTransitionDuration(TimeSpan duration) + { + Transitions = new Transitions + { + CreateOpacityTransition(duration) + }; + } + + private void ApplyShellChrome() + { + if (_hasPreviewImage) + { + Background = Brushes.Transparent; + BorderBrush = Brushes.Transparent; + BorderThickness = new Thickness(0); + BoxShadow = BoxShadows.Parse("0 14 32 #1A000000"); + Opacity = 1.0; + return; + } + + BoxShadow = default; + if (_isInvalid) + { + Background = _invalidBackgroundBrush; + BorderBrush = _invalidBorderBrush; + BorderThickness = new Thickness(1); + Opacity = 0.9; + return; + } + + Background = _normalBackgroundBrush; + BorderBrush = _normalBorderBrush; + BorderThickness = new Thickness(1); + Opacity = 1.0; + } + + private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) => + new() + { + Property = property, + Duration = duration, + Easing = StandardEasing + }; + + private static DoubleTransition CreateOpacityTransition(TimeSpan duration) => + new() + { + Property = Visual.OpacityProperty, + Duration = duration, + Easing = StandardEasing + }; } diff --git a/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs b/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs index f10cc88..cd488b6 100644 --- a/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs +++ b/LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs @@ -18,6 +18,9 @@ internal enum DesktopEditGhostVisualStyle internal sealed class DesktopEditOverlayPresenter { private static readonly TimeSpan FastDuration = FluttermotionToken.Fast; + private static readonly TimeSpan PickupDuration = TimeSpan.FromMilliseconds(160); + private static readonly TimeSpan CommitSettleDuration = TimeSpan.FromMilliseconds(160); + private static readonly TimeSpan CancelSettleDuration = TimeSpan.FromMilliseconds(120); private static readonly Easing StandardEasing = new CubicEaseOut(); private readonly Canvas _root; @@ -31,10 +34,10 @@ internal sealed class DesktopEditOverlayPresenter private bool _isVisible; private int _dismissVersion; - private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF4F8EF7")); - private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF6B6B")); - private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#224F8EF7")); - private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#22FF6B6B")); + private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF")); + private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF3B30")); + private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#140A84FF")); + private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30")); public DesktopEditOverlayPresenter() { @@ -66,18 +69,8 @@ internal sealed class DesktopEditOverlayPresenter }; _candidateScale.Transitions = new Transitions { - new DoubleTransition - { - Property = ScaleTransform.ScaleXProperty, - Duration = FastDuration, - Easing = StandardEasing - }, - new DoubleTransition - { - Property = ScaleTransform.ScaleYProperty, - Duration = FastDuration, - Easing = StandardEasing - } + CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration), + CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration) }; _candidateOutline.SetValue(Panel.ZIndexProperty, 0); @@ -98,12 +91,7 @@ internal sealed class DesktopEditOverlayPresenter _root.Transitions = new Transitions { - new DoubleTransition - { - Property = Visual.OpacityProperty, - Duration = FastDuration, - Easing = StandardEasing - } + CreateOpacityTransition(FastDuration) }; } @@ -132,6 +120,11 @@ internal sealed class DesktopEditOverlayPresenter _ghostView.UpdateContent(title, detail, badge); } + public void SetPreviewImage(IImage? image) + { + _ghostView.SetPreviewImage(image); + } + public void SetInvalid(bool isInvalid) { _isInvalid = isInvalid; @@ -146,12 +139,40 @@ internal sealed class DesktopEditOverlayPresenter _root.IsVisible = true; _root.Opacity = 0; _ghostView.Opacity = 0; - var initialGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.02 : 0.985; - var targetGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.06 : 1; + var imageMode = _ghostView.HasPreviewImage; + var initialGhostScale = 0.985; + var targetGhostScale = 1.0; + + if (visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary) + { + initialGhostScale = 1.02; + targetGhostScale = 1.06; + } + else if (imageMode) + { + initialGhostScale = 0.992; + targetGhostScale = 1.03; + } + + _root.Transitions = new Transitions + { + CreateOpacityTransition(PickupDuration) + }; + _ghostView.SetOpacityTransitionDuration(PickupDuration); + _ghostView.SetScaleTransitionDuration(PickupDuration); + _candidateScale.Transitions = new Transitions + { + CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration), + CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration) + }; + _candidateOutline.Transitions = new Transitions + { + CreateOpacityTransition(PickupDuration) + }; _ghostView.SetRestingScale(initialGhostScale); _candidateOutline.Opacity = 0; - _candidateScale.ScaleX = 0.96; - _candidateScale.ScaleY = 0.96; + _candidateScale.ScaleX = 0.97; + _candidateScale.ScaleY = 0.97; Dispatcher.UIThread.Post(() => { @@ -182,6 +203,7 @@ internal sealed class DesktopEditOverlayPresenter _candidateScale.ScaleX = 0.96; _candidateScale.ScaleY = 0.96; _ghostView.SetRestingScale(0.96); + _ghostView.SetPreviewImage(null); _root.IsVisible = false; } @@ -204,11 +226,29 @@ internal sealed class DesktopEditOverlayPresenter var version = ++_dismissVersion; _isVisible = false; + var settleDuration = isCancel ? CancelSettleDuration : CommitSettleDuration; + _root.Transitions = new Transitions + { + CreateOpacityTransition(settleDuration) + }; + _ghostView.SetOpacityTransitionDuration(settleDuration); + _ghostView.SetScaleTransitionDuration(settleDuration); + _candidateScale.Transitions = new Transitions + { + CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration), + CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration) + }; + _candidateOutline.Transitions = new Transitions + { + CreateOpacityTransition(settleDuration) + }; + var targetScale = _ghostView.HasPreviewImage + ? 1.00 + : isCancel ? 0.96 : 1.04; + _candidateOutline.Opacity = 0; _ghostView.Opacity = 0; _root.Opacity = 0; - - var targetScale = isCancel ? 0.96 : 1.04; _ghostView.AnimateToScale(targetScale); _candidateScale.ScaleX = targetScale; _candidateScale.ScaleY = targetScale; @@ -257,13 +297,13 @@ internal sealed class DesktopEditOverlayPresenter Canvas.SetLeft(_candidateOutline, rect.X); Canvas.SetTop(_candidateOutline, rect.Y); - var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.12, 14, 28); + var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26); _candidateOutline.CornerRadius = new CornerRadius(cornerRadius); _candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush; _candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush; _candidateOutline.Opacity = _isVisible ? 1 : 0; - _candidateScale.ScaleX = _isVisible ? 1 : 0.96; - _candidateScale.ScaleY = _isVisible ? 1 : 0.96; + _candidateScale.ScaleX = _isVisible ? 1 : 0.97; + _candidateScale.ScaleY = _isVisible ? 1 : 0.97; UpdateCandidateAppearance(); } @@ -284,4 +324,20 @@ internal sealed class DesktopEditOverlayPresenter var height = Math.Max(1, rect.Height); return new Rect(rect.X, rect.Y, width, height); } + + private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) => + new() + { + Property = property, + Duration = duration, + Easing = StandardEasing + }; + + private static DoubleTransition CreateOpacityTransition(TimeSpan duration) => + new() + { + Property = Visual.OpacityProperty, + Duration = duration, + Easing = StandardEasing + }; } diff --git a/LanMountainDesktop/Services/ComponentPreviewImageService.cs b/LanMountainDesktop/Services/ComponentPreviewImageService.cs new file mode 100644 index 0000000..7245d96 --- /dev/null +++ b/LanMountainDesktop/Services/ComponentPreviewImageService.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media; + +namespace LanMountainDesktop.Services; + +public sealed class ComponentPreviewImageService : IComponentPreviewImageService +{ + private readonly object _gate = new(); + private readonly Dictionary _entries = new(ComponentPreviewKeyComparer.Instance); + private readonly Dictionary> _inFlightRequests = new(ComponentPreviewKeyComparer.Instance); + private Task _queueTail = Task.CompletedTask; + + public ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null) + { + lock (_gate) + { + if (_entries.TryGetValue(key, out var existing)) + { + return existing; + } + + var created = new ComponentPreviewImageEntry(key, visualSignature); + _entries[key] = created; + return created; + } + } + + public bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry) + { + lock (_gate) + { + if (_entries.TryGetValue(key, out var existing)) + { + entry = existing; + return true; + } + + entry = null; + return false; + } + } + + public IReadOnlyCollection GetEntriesSnapshot() + { + lock (_gate) + { + return _entries.Values.ToArray(); + } + } + + public Task QueueGenerationAsync( + ComponentPreviewKey key, + string visualSignature, + Func> generationWork, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(generationWork); + + var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature)); + lock (_gate) + { + var entry = GetOrCreateEntryCore(key); + + if (entry.State == ComponentPreviewImageState.Ready && + entry.Bitmap is not null && + StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature)) + { + return Task.FromResult(entry); + } + + if (_inFlightRequests.TryGetValue(key, out var inFlight)) + { + return inFlight; + } + + var expectedRevision = entry.BeginGeneration(normalizedSignature); + var previousTask = _queueTail; + var queuedTask = RunGenerationAsync( + previousTask, + key, + entry, + expectedRevision, + normalizedSignature, + generationWork, + cancellationToken); + + _inFlightRequests[key] = queuedTask; + _queueTail = queuedTask.ContinueWith( + static _ => { }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + return queuedTask; + } + } + + public ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature) + { + ArgumentNullException.ThrowIfNull(bitmap); + + var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature)); + lock (_gate) + { + var entry = GetOrCreateEntryCore(key); + entry.StoreBitmap(bitmap, normalizedSignature); + _inFlightRequests.Remove(key); + return entry; + } + } + + public ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null) + { + var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature)); + lock (_gate) + { + var entry = GetOrCreateEntryCore(key); + entry.StoreFailure(normalizedSignature, errorMessage); + _inFlightRequests.Remove(key); + return entry; + } + } + + public bool Invalidate(ComponentPreviewKey key, string? visualSignature = null) + { + lock (_gate) + { + if (!_entries.TryGetValue(key, out var entry)) + { + return false; + } + + entry.Invalidate(visualSignature); + _inFlightRequests.Remove(key); + return true; + } + } + + public int RemovePlacementPreviews(string placementId) + { + var normalizedPlacementId = NormalizeRequired(placementId, nameof(placementId)); + lock (_gate) + { + var entriesToRemove = _entries + .Where(static pair => pair.Key.Kind == ComponentPreviewKeyKind.PlacementInstance) + .Where(pair => StringComparer.OrdinalIgnoreCase.Equals(pair.Key.PlacementId, normalizedPlacementId)) + .ToArray(); + + foreach (var pair in entriesToRemove) + { + pair.Value.DisposeBitmap(); + _entries.Remove(pair.Key); + _inFlightRequests.Remove(pair.Key); + } + + return entriesToRemove.Length; + } + } + + public int InvalidateVisualSignature(string visualSignature) + { + var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature)); + lock (_gate) + { + var entriesToInvalidate = _entries.Values + .Where(entry => StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature)) + .ToArray(); + + foreach (var entry in entriesToInvalidate) + { + entry.Invalidate(normalizedSignature); + _inFlightRequests.Remove(entry.Key); + } + + return entriesToInvalidate.Length; + } + } + + private async Task RunGenerationAsync( + Task previousTask, + ComponentPreviewKey key, + ComponentPreviewImageEntry entry, + long expectedRevision, + string visualSignature, + Func> generationWork, + CancellationToken cancellationToken) + { + try + { + try + { + await previousTask.ConfigureAwait(false); + } + catch + { + // Keep serial queue processing even if previous work faulted. + } + + IImage? bitmap; + try + { + bitmap = await generationWork(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + lock (_gate) + { + entry.TryApplyFailure(expectedRevision, visualSignature, ex.Message); + } + + return entry; + } + + lock (_gate) + { + if (bitmap is null) + { + entry.TryApplyFailure(expectedRevision, visualSignature, "Preview generation returned no bitmap."); + } + else + { + entry.TryApplyGeneratedBitmap(expectedRevision, bitmap, visualSignature); + } + } + + return entry; + } + finally + { + lock (_gate) + { + _inFlightRequests.Remove(key); + } + } + } + + private ComponentPreviewImageEntry GetOrCreateEntryCore(ComponentPreviewKey key) + { + if (_entries.TryGetValue(key, out var existing)) + { + return existing; + } + + var created = new ComponentPreviewImageEntry(key); + _entries[key] = created; + return created; + } + + private static string NormalizeRequired(string? value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be null or whitespace.", paramName); + } + + return value.Trim(); + } +} diff --git a/LanMountainDesktop/Services/ComponentPreviewTypes.cs b/LanMountainDesktop/Services/ComponentPreviewTypes.cs new file mode 100644 index 0000000..6a64cb3 --- /dev/null +++ b/LanMountainDesktop/Services/ComponentPreviewTypes.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace LanMountainDesktop.Services; + +public enum ComponentPreviewKeyKind +{ + ComponentType = 0, + PlacementInstance = 1 +} + +public readonly record struct ComponentPreviewKey +{ + private ComponentPreviewKey( + ComponentPreviewKeyKind kind, + string componentTypeId, + string? placementId, + int widthCells, + int heightCells) + { + Kind = kind; + ComponentTypeId = NormalizeRequired(componentTypeId, nameof(componentTypeId)); + PlacementId = kind == ComponentPreviewKeyKind.PlacementInstance + ? NormalizeRequired(placementId, nameof(placementId)) + : null; + WidthCells = NormalizeSpan(widthCells, nameof(widthCells)); + HeightCells = NormalizeSpan(heightCells, nameof(heightCells)); + } + + public ComponentPreviewKeyKind Kind { get; } + + public string ComponentTypeId { get; } + + public string? PlacementId { get; } + + public int WidthCells { get; } + + public int HeightCells { get; } + + public static ComponentPreviewKey ForComponentType(string componentTypeId, int widthCells, int heightCells) + { + return new ComponentPreviewKey(ComponentPreviewKeyKind.ComponentType, componentTypeId, null, widthCells, heightCells); + } + + public static ComponentPreviewKey ForPlacementInstance(string componentTypeId, string placementId, int widthCells, int heightCells) + { + return new ComponentPreviewKey( + ComponentPreviewKeyKind.PlacementInstance, + componentTypeId, + placementId, + widthCells, + heightCells); + } + + public override string ToString() + { + return Kind == ComponentPreviewKeyKind.ComponentType + ? $"Type:{ComponentTypeId}[{WidthCells}x{HeightCells}]" + : $"Placement:{ComponentTypeId}@{PlacementId}[{WidthCells}x{HeightCells}]"; + } + + private static string NormalizeRequired(string? value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be null or whitespace.", paramName); + } + + return value.Trim(); + } + + private static int NormalizeSpan(int value, string paramName) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(paramName, value, "Span must be greater than zero."); + } + + return value; + } +} + +public enum ComponentPreviewImageState +{ + Pending = 0, + Ready = 1, + Failed = 2 +} + +public sealed class ComponentPreviewImageEntry : ObservableObject +{ + private IImage? _bitmap; + private ComponentPreviewImageState _state = ComponentPreviewImageState.Pending; + private string _visualSignature = string.Empty; + private string? _errorMessage; + private long _revision; + private DateTimeOffset _lastUpdatedUtc = DateTimeOffset.UtcNow; + + public ComponentPreviewImageEntry(ComponentPreviewKey key, string? visualSignature = null) + { + Key = key; + VisualSignature = NormalizeSignature(visualSignature); + } + + public ComponentPreviewKey Key { get; } + + public IImage? Bitmap + { + get => _bitmap; + private set => SetProperty(ref _bitmap, value); + } + + public ComponentPreviewImageState State + { + get => _state; + private set => SetProperty(ref _state, value); + } + + public string VisualSignature + { + get => _visualSignature; + private set => SetProperty(ref _visualSignature, value); + } + + public string? ErrorMessage + { + get => _errorMessage; + private set => SetProperty(ref _errorMessage, value); + } + + public long Revision + { + get => _revision; + private set => SetProperty(ref _revision, value); + } + + public DateTimeOffset LastUpdatedUtc + { + get => _lastUpdatedUtc; + private set => SetProperty(ref _lastUpdatedUtc, value); + } + + internal long BeginGeneration(string visualSignature) + { + var normalizedVisualSignature = NormalizeSignature(visualSignature); + var nextRevision = Revision + 1; + Revision = nextRevision; + VisualSignature = normalizedVisualSignature; + State = ComponentPreviewImageState.Pending; + ReplaceBitmap(null); + ErrorMessage = null; + LastUpdatedUtc = DateTimeOffset.UtcNow; + return nextRevision; + } + + internal bool TryApplyGeneratedBitmap(long expectedRevision, IImage bitmap, string visualSignature) + { + ArgumentNullException.ThrowIfNull(bitmap); + + if (Revision != expectedRevision) + { + DisposeIfNeeded(bitmap); + return false; + } + + VisualSignature = NormalizeSignature(visualSignature); + State = ComponentPreviewImageState.Ready; + ReplaceBitmap(bitmap); + ErrorMessage = null; + LastUpdatedUtc = DateTimeOffset.UtcNow; + return true; + } + + internal bool TryApplyFailure(long expectedRevision, string visualSignature, string? errorMessage) + { + if (Revision != expectedRevision) + { + return false; + } + + VisualSignature = NormalizeSignature(visualSignature); + State = ComponentPreviewImageState.Failed; + ReplaceBitmap(null); + ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim(); + LastUpdatedUtc = DateTimeOffset.UtcNow; + return true; + } + + internal void StoreBitmap(IImage bitmap, string visualSignature) + { + ArgumentNullException.ThrowIfNull(bitmap); + + Revision += 1; + VisualSignature = NormalizeSignature(visualSignature); + State = ComponentPreviewImageState.Ready; + ReplaceBitmap(bitmap); + ErrorMessage = null; + LastUpdatedUtc = DateTimeOffset.UtcNow; + } + + internal void StoreFailure(string visualSignature, string? errorMessage) + { + Revision += 1; + VisualSignature = NormalizeSignature(visualSignature); + State = ComponentPreviewImageState.Failed; + ReplaceBitmap(null); + ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim(); + LastUpdatedUtc = DateTimeOffset.UtcNow; + } + + internal void Invalidate(string? visualSignature = null) + { + Revision += 1; + if (visualSignature is not null) + { + VisualSignature = NormalizeSignature(visualSignature); + } + + State = ComponentPreviewImageState.Pending; + ReplaceBitmap(null); + ErrorMessage = null; + LastUpdatedUtc = DateTimeOffset.UtcNow; + } + + internal void DisposeBitmap() + { + ReplaceBitmap(null); + } + + private void ReplaceBitmap(IImage? bitmap) + { + var previous = _bitmap; + if (ReferenceEquals(previous, bitmap)) + { + return; + } + + Bitmap = bitmap; + DisposeIfNeeded(previous); + } + + private static void DisposeIfNeeded(IImage? bitmap) + { + if (bitmap is IDisposable disposable) + { + disposable.Dispose(); + } + } + + private static string NormalizeSignature(string? visualSignature) + { + return visualSignature?.Trim() ?? string.Empty; + } +} + +internal sealed class ComponentPreviewKeyComparer : IEqualityComparer +{ + public static ComponentPreviewKeyComparer Instance { get; } = new(); + + public bool Equals(ComponentPreviewKey x, ComponentPreviewKey y) + { + return x.Kind == y.Kind && + StringComparer.OrdinalIgnoreCase.Equals(x.ComponentTypeId, y.ComponentTypeId) && + StringComparer.OrdinalIgnoreCase.Equals(x.PlacementId, y.PlacementId) && + x.WidthCells == y.WidthCells && + x.HeightCells == y.HeightCells; + } + + public int GetHashCode(ComponentPreviewKey obj) + { + var hash = new HashCode(); + hash.Add(obj.Kind); + hash.Add(obj.ComponentTypeId, StringComparer.OrdinalIgnoreCase); + hash.Add(obj.PlacementId, StringComparer.OrdinalIgnoreCase); + hash.Add(obj.WidthCells); + hash.Add(obj.HeightCells); + return hash.ToHashCode(); + } +} diff --git a/LanMountainDesktop/Services/IComponentPreviewImageService.cs b/LanMountainDesktop/Services/IComponentPreviewImageService.cs new file mode 100644 index 0000000..b64fd06 --- /dev/null +++ b/LanMountainDesktop/Services/IComponentPreviewImageService.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media; + +namespace LanMountainDesktop.Services; + +public interface IComponentPreviewImageService +{ + ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null); + + bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry); + + IReadOnlyCollection GetEntriesSnapshot(); + + Task QueueGenerationAsync( + ComponentPreviewKey key, + string visualSignature, + Func> generationWork, + CancellationToken cancellationToken = default); + + ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature); + + ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null); + + bool Invalidate(ComponentPreviewKey key, string? visualSignature = null); + + int RemovePlacementPreviews(string placementId); + + int InvalidateVisualSignature(string visualSignature); +} diff --git a/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs b/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs index f68d294..2168f58 100644 --- a/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs +++ b/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs @@ -1,13 +1,21 @@ using System.Collections.Generic; using System.Collections.ObjectModel; -using Avalonia.Controls; +using System.ComponentModel; +using LanMountainDesktop.Services; using FluentIcons.Common; +using CommunityToolkit.Mvvm.ComponentModel; namespace LanMountainDesktop.ViewModels; public sealed class ComponentLibraryWindowViewModel : ViewModelBase { - public string Title { get; set; } = "Widgets"; + private string _title = "Widgets"; + + public string Title + { + get => _title; + set => SetProperty(ref _title, value); + } public ObservableCollection Categories { get; } = []; @@ -38,20 +46,134 @@ public sealed class ComponentLibraryCategoryViewModel } public sealed class ComponentLibraryItemViewModel + : ObservableObject { + private readonly string _loadingPreviewText; + private readonly string _previewUnavailableText; + private string _displayName; + private ComponentPreviewKey _previewKey; + private ComponentPreviewImageEntry? _previewImageEntry; + private ComponentPreviewImageState _previewState; + private string? _previewErrorMessage; + private string _previewStatusText; + public ComponentLibraryItemViewModel( string componentId, string displayName, - Control? previewControl) + ComponentPreviewKey previewKey, + string loadingPreviewText = "Loading preview...", + string previewUnavailableText = "Preview unavailable", + ComponentPreviewImageEntry? previewImageEntry = null) { ComponentId = componentId; - DisplayName = displayName; - PreviewControl = previewControl; + _displayName = displayName; + _previewKey = previewKey; + _loadingPreviewText = loadingPreviewText; + _previewUnavailableText = previewUnavailableText; + _previewStatusText = loadingPreviewText; + UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false); } public string ComponentId { get; } - public string DisplayName { get; } + public string DisplayName + { + get => _displayName; + set => SetProperty(ref _displayName, value); + } - public Control? PreviewControl { get; } + public ComponentPreviewKey PreviewKey + { + get => _previewKey; + set => SetProperty(ref _previewKey, value); + } + + public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry; + + public object? PreviewBitmap => _previewImageEntry?.Bitmap; + + public ComponentPreviewImageState PreviewState => _previewState; + + public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending; + + public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null; + + public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed; + + public string? PreviewErrorMessage => _previewErrorMessage; + + public string PreviewStatusText => _previewStatusText; + + public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry) + { + UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true); + } + + private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged) + { + if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry)) + { + return; + } + + if (_previewImageEntry is not null) + { + _previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged; + } + + _previewImageEntry = previewImageEntry; + _previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending; + _previewErrorMessage = previewImageEntry?.ErrorMessage; + + _previewStatusText = _previewState switch + { + ComponentPreviewImageState.Ready => string.Empty, + ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage) + ? _previewUnavailableText + : _previewErrorMessage!, + _ => _loadingPreviewText + }; + + if (_previewImageEntry is not null) + { + _previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged; + } + + RaisePreviewDependentProperties(); + } + + private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + _ = sender; + if (string.IsNullOrWhiteSpace(e.PropertyName) || + e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or + nameof(ComponentPreviewImageEntry.State) or + nameof(ComponentPreviewImageEntry.ErrorMessage)) + { + _previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending; + _previewErrorMessage = _previewImageEntry?.ErrorMessage; + _previewStatusText = _previewState switch + { + ComponentPreviewImageState.Ready => string.Empty, + ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage) + ? _previewUnavailableText + : _previewErrorMessage!, + _ => _loadingPreviewText + }; + + RaisePreviewDependentProperties(); + } + } + + private void RaisePreviewDependentProperties() + { + OnPropertyChanged(nameof(PreviewImageEntry)); + OnPropertyChanged(nameof(PreviewBitmap)); + OnPropertyChanged(nameof(PreviewState)); + OnPropertyChanged(nameof(IsPreviewPending)); + OnPropertyChanged(nameof(IsPreviewReady)); + OnPropertyChanged(nameof(IsPreviewFailed)); + OnPropertyChanged(nameof(PreviewErrorMessage)); + OnPropertyChanged(nameof(PreviewStatusText)); + } } diff --git a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml index 8d837c1..5f8dbc6 100644 --- a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml +++ b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml @@ -99,9 +99,48 @@ BorderThickness="1" BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}" Padding="8"> - + + + + + + + + + + + + + + + + + ? _createContextFactory; private Func? _localize; + private Func? _previewKeyResolver; + private Func? _previewEntryResolver; + private Action? _warmPreviewRequested; + private Action? _renderPreviewRequested; private readonly ComponentLibraryWindowViewModel _viewModel = new(); public ComponentLibraryWindow() @@ -25,12 +29,20 @@ public partial class ComponentLibraryWindow : Window public ComponentLibraryWindow( IComponentLibraryService componentLibraryService, Func createContextFactory, - Func localize) + Func localize, + Func? previewKeyResolver = null, + Func? previewEntryResolver = null, + Action? warmPreviewRequested = null, + Action? renderPreviewRequested = null) : this() { _componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService)); _createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory)); _localize = localize ?? throw new ArgumentNullException(nameof(localize)); + _previewKeyResolver = previewKeyResolver; + _previewEntryResolver = previewEntryResolver; + _warmPreviewRequested = warmPreviewRequested; + _renderPreviewRequested = renderPreviewRequested; Reload(); } @@ -38,9 +50,7 @@ public partial class ComponentLibraryWindow : Window public void Reload() { - if (_componentLibraryService is null || - _createContextFactory is null || - _localize is null) + if (_componentLibraryService is null || _localize is null) { return; } @@ -75,32 +85,26 @@ public partial class ComponentLibraryWindow : Window private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry) { - if (_componentLibraryService is null || - _createContextFactory is null || - _localize is null) + var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey) + ? entry.DisplayName + : _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName; + var previewKey = ResolvePreviewKey(entry); + var previewEntry = _previewEntryResolver?.Invoke(previewKey); + var item = new ComponentLibraryItemViewModel( + entry.ComponentId, + displayName, + previewKey, + _localize?.Invoke("component_library.preview.loading", "Loading preview...") ?? "Loading preview...", + _localize?.Invoke("component_library.preview.unavailable", "Preview unavailable") ?? "Preview unavailable", + previewEntry); + + if (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending) { - return new ComponentLibraryItemViewModel(entry.ComponentId, entry.DisplayName, previewControl: null); + _warmPreviewRequested?.Invoke(previewKey); + _renderPreviewRequested?.Invoke(previewKey); } - Control? previewControl = null; - _componentLibraryService.TryCreateControl( - entry.ComponentId, - _createContextFactory(42), - out previewControl, - out _); - - if (previewControl is not null) - { - previewControl.IsHitTestVisible = false; - previewControl.Focusable = false; - } - - return new ComponentLibraryItemViewModel( - entry.ComponentId, - string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey) - ? entry.DisplayName - : _localize(entry.DisplayNameLocalizationKey, entry.DisplayName), - previewControl); + return item; } private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -118,6 +122,8 @@ public partial class ComponentLibraryWindow : Window { _viewModel.Components.Add(component); } + + RequestPreviewWarmup(selectedCategory.Components); } private void OnAddComponentClick(object? sender, RoutedEventArgs e) @@ -140,6 +146,51 @@ public partial class ComponentLibraryWindow : Window Hide(); } + public void UpdatePreviewImage(ComponentPreviewImageEntry previewImageEntry) + { + ArgumentNullException.ThrowIfNull(previewImageEntry); + + foreach (var category in _viewModel.Categories) + { + foreach (var component in category.Components) + { + if (component.PreviewKey.Equals(previewImageEntry.Key)) + { + component.UpdatePreviewImageEntry(previewImageEntry); + } + } + } + } + + private ComponentPreviewKey ResolvePreviewKey(ComponentLibraryComponentEntry entry) + { + if (_previewKeyResolver is not null) + { + return _previewKeyResolver(entry); + } + + return ComponentPreviewKey.ForComponentType(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells); + } + + private void RequestPreviewWarmup(IEnumerable components) + { + if (_warmPreviewRequested is null && _renderPreviewRequested is null) + { + return; + } + + foreach (var component in components) + { + if (!component.IsPreviewPending) + { + continue; + } + + _warmPreviewRequested?.Invoke(component.PreviewKey); + _renderPreviewRequested?.Invoke(component.PreviewKey); + } + } + private Symbol ResolveCategoryIcon(string categoryId) { if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) diff --git a/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml b/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml index 615362d..f5a5a75 100644 --- a/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml +++ b/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml @@ -1,4 +1,4 @@ - > _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance); + private bool _componentLibraryPreviewWarmupStarted; + + private sealed record ComponentLibraryPreviewVisualTarget(Image Image, Control Fallback); + + private void EnsureComponentLibraryPreviewWarmup() + { + if (_componentLibraryCategories.Count == 0) + { + return; + } + + var activeCategoryId = _componentLibraryActiveCategoryId ?? + _componentLibraryCategories[Math.Clamp(_componentLibraryCategoryIndex, 0, _componentLibraryCategories.Count - 1)].Id; + if (!_componentLibraryPreviewWarmupStarted) + { + _componentLibraryPreviewWarmupStarted = true; + _ = WarmComponentLibraryPreviewsSeriallyAsync(activeCategoryId); + return; + } + + var activeCategory = _componentLibraryCategories.FirstOrDefault(category => + string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase)); + if (activeCategory is not null) + { + _ = WarmComponentLibraryCategoryPreviewsAsync(activeCategory); + } + } + + private async Task WarmComponentLibraryPreviewsSeriallyAsync(string activeCategoryId) + { + var prioritized = _componentLibraryCategories + .OrderBy(category => string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ToList(); + + foreach (var category in prioritized) + { + await WarmComponentLibraryCategoryPreviewsAsync(category); + } + } + + private async Task WarmComponentLibraryCategoryPreviewsAsync(ComponentLibraryCategory category) + { + foreach (var component in category.Components) + { + var span = NormalizeComponentCellSpan( + component.ComponentId, + (component.MinWidthCells, component.MinHeightCells)); + await EnsureComponentTypePreviewImageAsync(component.ComponentId, span.WidthCells, span.HeightCells); + } + } + + private async Task EnsureComponentTypePreviewImageAsync(string componentId, int widthCells, int heightCells) + { + if (string.IsNullOrWhiteSpace(componentId)) + { + return null; + } + + var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells); + var cached = ResolvePreviewImageFromService(key); + if (cached is not null) + { + ApplyPreviewEntryToEmbeddedVisuals(key); + return cached; + } + + var entry = await QueuePreviewGenerationAsync( + key, + pageIndex: null, + action: "ComponentTypePreview", + forceRefresh: false); + return entry.Bitmap; + } + + private async Task RefreshPlacementPreviewImageAsync(DesktopComponentPlacementSnapshot? placement, bool forceRefresh) + { + if (placement is null || + string.IsNullOrWhiteSpace(placement.ComponentId) || + string.IsNullOrWhiteSpace(placement.PlacementId)) + { + return null; + } + + if (!IsPlacementPresent(placement.PlacementId)) + { + return null; + } + + var snapshot = ClonePlacementSnapshot(placement); + var key = CreatePlacementPreviewKey( + snapshot.ComponentId, + snapshot.PlacementId, + snapshot.WidthCells, + snapshot.HeightCells); + if (!forceRefresh) + { + var cached = ResolvePreviewImageFromService(key); + if (cached is not null) + { + return cached; + } + } + else + { + _componentPreviewImageService.RemovePlacementPreviews(snapshot.PlacementId); + } + + var entry = await QueuePreviewGenerationAsync( + key, + snapshot.PageIndex, + action: "PlacementPreview", + forceRefresh: false); + if (!IsPlacementPresent(snapshot.PlacementId)) + { + RemovePlacementPreviewImage(snapshot.PlacementId); + return null; + } + + return entry.Bitmap; + } + + private async Task QueuePreviewGenerationAsync( + ComponentPreviewKey key, + int? pageIndex, + string action, + bool forceRefresh, + CancellationToken cancellationToken = default) + { + var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells); + var visualSignature = BuildPreviewVisualSignature(key, renderCellSize); + if (forceRefresh) + { + _componentPreviewImageService.Invalidate(key, visualSignature); + } + + var entry = await _componentPreviewImageService.QueueGenerationAsync( + key, + visualSignature, + async ct => + { + _ = ct; + if (key.Kind == ComponentPreviewKeyKind.PlacementInstance && + !IsPlacementPresent(key.PlacementId)) + { + return null; + } + + var bitmap = await CapturePreviewImageAsync( + key.ComponentTypeId, + key.PlacementId, + pageIndex, + key.WidthCells, + key.HeightCells, + renderCellSize, + action); + if (key.Kind == ComponentPreviewKeyKind.PlacementInstance && + !IsPlacementPresent(key.PlacementId)) + { + DisposeImageIfNeeded(bitmap); + return null; + } + + return bitmap; + }, + cancellationToken); + NotifyPreviewEntryUpdated(entry); + return entry; + } + + private async Task CapturePreviewImageAsync( + string componentId, + string? placementId, + int? pageIndex, + int widthCells, + int heightCells, + double renderCellSize, + string action) + { + if (ComponentPreviewStagingHost is null) + { + return null; + } + + var safeWidthCells = Math.Max(1, widthCells); + var safeHeightCells = Math.Max(1, heightCells); + var safeCellSize = Math.Clamp(renderCellSize, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax); + var previewWidth = safeWidthCells * safeCellSize; + var previewHeight = safeHeightCells * safeCellSize; + + var previewControl = CreateDesktopComponentControl( + componentId, + safeCellSize, + placementId, + pageIndex, + action); + if (previewControl is null) + { + return null; + } + + previewControl.IsHitTestVisible = false; + previewControl.Focusable = false; + + var stage = new Border + { + Width = previewWidth, + Height = previewHeight, + Background = Brushes.Transparent, + ClipToBounds = true, + Child = previewControl + }; + + Canvas.SetLeft(stage, -20000); + Canvas.SetTop(stage, -20000); + ComponentPreviewStagingHost.Children.Add(stage); + + try + { + stage.Measure(new Size(previewWidth, previewHeight)); + stage.Arrange(new Rect(0, 0, previewWidth, previewHeight)); + stage.UpdateLayout(); + await WaitForPreviewRenderPassAsync(); + + var renderScale = RenderScaling > 0 ? RenderScaling : 1d; + var pixelSize = new PixelSize( + Math.Max(1, (int)Math.Ceiling(previewWidth * renderScale)), + Math.Max(1, (int)Math.Ceiling(previewHeight * renderScale))); + var bitmap = new RenderTargetBitmap(pixelSize, new Vector(96 * renderScale, 96 * renderScale)); + bitmap.Render(stage); + return bitmap; + } + catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex)) + { + AppLogger.Warn( + "ComponentPreview", + $"Action={action}; ComponentId={componentId}; PlacementId={placementId ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false", + ex); + return null; + } + finally + { + ComponentPreviewStagingHost.Children.Remove(stage); + ClearTimeZoneServiceBindings(stage); + if (previewControl is IDisposable disposableControl) + { + disposableControl.Dispose(); + } + } + } + + private static async Task WaitForPreviewRenderPassAsync() + { + await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Background); + await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Render); + } + + private double ResolvePreviewRenderCellSize(int widthCells, int heightCells) + { + var baseCellSize = _currentDesktopCellSize > 0 + ? _currentDesktopCellSize * 1.10 + : 74; + var densityBoost = Math.Max(widthCells, heightCells) >= 4 ? 8 : 0; + return Math.Clamp(baseCellSize + densityBoost, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax); + } + + private string BuildPreviewVisualSignature(ComponentPreviewKey key, double renderCellSize) + { + var appearance = _appearanceThemeService.GetCurrent(); + var renderScale = RenderScaling > 0 ? RenderScaling : 1d; + return string.Create( + CultureInfo.InvariantCulture, + $"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.GlobalCornerRadiusScale:F3}|Accent={FormatSignatureColor(appearance.AccentColor)}"); + } + + private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells) + { + var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells)); + return ComponentPreviewKey.ForComponentType(componentId, span.WidthCells, span.HeightCells); + } + + private ComponentPreviewKey CreatePlacementPreviewKey(string componentId, string placementId, int widthCells, int heightCells) + { + var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells)); + return ComponentPreviewKey.ForPlacementInstance(componentId, placementId, span.WidthCells, span.HeightCells); + } + + private bool IsPlacementPresent(string? placementId) + { + return !string.IsNullOrWhiteSpace(placementId) && + _desktopComponentPlacements.Any(candidate => + string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)); + } + + private string BuildCurrentVisualSignature(ComponentPreviewKey key) + { + var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells); + return BuildPreviewVisualSignature(key, renderCellSize); + } + + private bool TryGetReusablePreviewEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry) + { + if (!_componentPreviewImageService.TryGetEntry(key, out entry) || + entry is null || + entry.State != ComponentPreviewImageState.Ready || + entry.Bitmap is null) + { + entry = null; + return false; + } + + var expectedSignature = BuildCurrentVisualSignature(key); + if (!string.Equals(entry.VisualSignature, expectedSignature, StringComparison.Ordinal)) + { + entry = null; + return false; + } + + return true; + } + + private IImage? ResolvePreviewImageFromService(ComponentPreviewKey key) + { + if (!TryGetReusablePreviewEntry(key, out var entry) || entry is null) + { + return null; + } + + return entry.Bitmap; + } + + private ComponentPreviewImageEntry? ResolvePreviewEntry(ComponentPreviewKey key) + { + if (!_componentPreviewImageService.TryGetEntry(key, out var entry) || entry is null) + { + return null; + } + + if (entry.State != ComponentPreviewImageState.Ready) + { + return entry; + } + + return TryGetReusablePreviewEntry(key, out var reusable) ? reusable : null; + } + + private IImage? ResolveComponentTypePreviewImage(string componentId, int widthCells, int heightCells) + { + var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells); + return ResolvePreviewImageFromService(key); + } + + private IImage? ResolveDesktopEditPreviewImage(string componentId, string? placementId, int widthCells, int heightCells) + { + if (!string.IsNullOrWhiteSpace(placementId)) + { + var placementKey = CreatePlacementPreviewKey(componentId, placementId, widthCells, heightCells); + var placementImage = ResolvePreviewImageFromService(placementKey); + if (placementImage is not null) + { + return placementImage; + } + } + + var componentTypeKey = CreateComponentTypePreviewKey(componentId, widthCells, heightCells); + return ResolvePreviewImageFromService(componentTypeKey); + } + + private (int WidthCells, int HeightCells) ResolveOverlayPreviewSpan( + string componentId, + string? placementId, + int? widthCells, + int? heightCells) + { + if (widthCells is > 0 && heightCells is > 0) + { + return NormalizeComponentCellSpan(componentId, (widthCells.Value, heightCells.Value)); + } + + if (!string.IsNullOrWhiteSpace(placementId) && + TryGetDesktopPlacementById(placementId, out var placement)) + { + return NormalizeComponentCellSpan(componentId, (placement.WidthCells, placement.HeightCells)); + } + + if (!string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) && + string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase) && + _desktopEditSession.WidthCells > 0 && + _desktopEditSession.HeightCells > 0) + { + return NormalizeComponentCellSpan(componentId, (_desktopEditSession.WidthCells, _desktopEditSession.HeightCells)); + } + + if (_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor)) + { + return NormalizeComponentCellSpan( + componentId, + (descriptor.Definition.MinWidthCells, descriptor.Definition.MinHeightCells)); + } + + return (1, 1); + } + + private void ApplyDesktopEditOverlayPreviewImage( + string componentId, + string? placementId, + int? widthCells = null, + int? heightCells = null) + { + var span = ResolveOverlayPreviewSpan(componentId, placementId, widthCells, heightCells); + EnsureDesktopEditOverlayPresenter(); + _desktopEditOverlayPresenter?.SetPreviewImage(ResolveDesktopEditPreviewImage(componentId, placementId, span.WidthCells, span.HeightCells)); + } + + private void PrimeDesktopEditPreviewImage( + string componentId, + string? placementId, + int pageIndex, + int widthCells, + int heightCells) + { + _ = pageIndex; + var normalized = NormalizeComponentCellSpan(componentId, (widthCells, heightCells)); + _ = EnsureComponentTypePreviewImageAsync(componentId, normalized.WidthCells, normalized.HeightCells); + + if (!string.IsNullOrWhiteSpace(placementId) && + TryGetDesktopPlacementById(placementId, out var placement)) + { + _ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: false); + } + } + + private void QueuePlacementPreviewRefresh(DesktopComponentPlacementSnapshot? placement) + { + _ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: true); + } + + private void RemovePlacementPreviewImage(string? placementId) + { + if (string.IsNullOrWhiteSpace(placementId)) + { + return; + } + + _componentPreviewImageService.RemovePlacementPreviews(placementId); + } + + private void RemovePlacementPreviewImages(IEnumerable placements) + { + foreach (var placementId in placements + .Select(placement => placement.PlacementId) + .Where(static id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + RemovePlacementPreviewImage(placementId); + } + } + + private void RegisterComponentLibraryPreviewVisual(ComponentPreviewKey key, Image image, Control fallback) + { + if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var visuals)) + { + visuals = []; + _componentLibraryPreviewVisualTargets[key] = visuals; + } + + visuals.Add(new ComponentLibraryPreviewVisualTarget(image, fallback)); + } + + private void ClearComponentLibraryPreviewVisualTargets() + { + _componentLibraryPreviewVisualTargets.Clear(); + } + + private void ApplyPreviewEntryToEmbeddedVisuals(ComponentPreviewKey key) + { + if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var targets)) + { + return; + } + + var previewImage = ResolvePreviewImageFromService(key); + foreach (var target in targets) + { + target.Image.Source = previewImage; + target.Image.IsVisible = previewImage is not null; + target.Fallback.IsVisible = previewImage is null; + } + } + + private void NotifyPreviewEntryUpdated(ComponentPreviewImageEntry entry) + { + Dispatcher.UIThread.Post( + () => + { + ApplyPreviewEntryToEmbeddedVisuals(entry.Key); + _detachedComponentLibraryWindow?.UpdatePreviewImage(entry); + + if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance) + { + RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, entry.Key.PlacementId); + } + else + { + RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, placementId: null); + } + }, + DispatcherPriority.Background); + } + + private static void DisposeImageIfNeeded(IImage? image) + { + if (image is IDisposable disposable) + { + disposable.Dispose(); + } + } + + private static string FormatSignatureColor(Color color) + { + return string.Create( + CultureInfo.InvariantCulture, + $"{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}"); + } + + private void RefreshDesktopEditOverlayPreviewIfActive(string componentId, string? placementId) + { + if (_desktopEditOverlayPresenter is null || + (!_desktopEditSession.IsActive && !_isDesktopEditCommitPending) || + string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) || + !string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!string.IsNullOrWhiteSpace(placementId) && + !string.Equals(_desktopEditSession.PlacementId, placementId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + ApplyDesktopEditOverlayPreviewImage( + _desktopEditSession.ComponentId, + _desktopEditSession.PlacementId, + _desktopEditSession.WidthCells, + _desktopEditSession.HeightCells); + } + + private ComponentPreviewKey ResolveDetachedLibraryPreviewKey(ComponentLibraryComponentEntry entry) + { + return CreateComponentTypePreviewKey(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells); + } + + private ComponentPreviewImageEntry? ResolveDetachedLibraryPreviewEntry(ComponentPreviewKey key) + { + return ResolvePreviewEntry(key); + } + + private void RequestDetachedLibraryPreviewWarm(ComponentPreviewKey key) + { + _ = QueuePreviewGenerationAsync( + key, + pageIndex: null, + action: "DetachedLibraryWarm", + forceRefresh: false); + } + + private void RequestDetachedLibraryPreviewRender(ComponentPreviewKey key) + { + _ = QueuePreviewGenerationAsync( + key, + pageIndex: null, + action: "DetachedLibraryRender", + forceRefresh: false); + } +} diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 09a16d5..046b57e 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -555,7 +555,11 @@ public partial class MainWindow _calculatorDataService, _settingsFacade); }, - L); + L, + previewKeyResolver: ResolveDetachedLibraryPreviewKey, + previewEntryResolver: ResolveDetachedLibraryPreviewEntry, + warmPreviewRequested: RequestDetachedLibraryPreviewWarm, + renderPreviewRequested: RequestDetachedLibraryPreviewRender); window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested; window.Closed += OnDetachedComponentLibraryClosed; return window; @@ -867,6 +871,7 @@ public partial class MainWindow _desktopComponentPlacements.Remove(placement); _componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId); + RemovePlacementPreviewImage(placement.PlacementId); ClearDesktopComponentSelection(); @@ -935,6 +940,7 @@ public partial class MainWindow { RestoreDesktopPageComponents(placement.PageIndex); ApplyTaskbarActionVisibility(GetCurrentTaskbarContext()); + QueuePlacementPreviewRefresh(placement); return; } @@ -961,6 +967,8 @@ public partial class MainWindow { ApplySelectionStateToHost(host, true); } + + QueuePlacementPreviewRefresh(placement); } private static void DisposeComponentIfNeeded(Border host) @@ -1017,6 +1025,7 @@ public partial class MainWindow _desktopComponentPlacements.Remove(placement); _componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId); } + RemovePlacementPreviewImages(placementsToRemove); _desktopPageCount = Math.Clamp(_desktopPageCount - 1, MinDesktopPageCount, MaxDesktopPageCount); @@ -1197,6 +1206,7 @@ public partial class MainWindow pageGrid.Children.Add(host); _desktopComponentPlacements.Add(placement); + QueuePlacementPreviewRefresh(placement); InvalidateDesktopPageAwareComponentContextCache(); UpdateDesktopPageAwareComponentContext(); PersistSettings(); @@ -2063,6 +2073,13 @@ public partial class MainWindow SetDesktopEditSourceHost(sourceHost, 0.22); EnsureDesktopEditOverlayPresenter(); UpdateDesktopEditOverlayMetadata(placement.ComponentId, widthCells, heightCells, L("component.move", "Move")); + ApplyDesktopEditOverlayPreviewImage(placement.ComponentId, placement.PlacementId, widthCells, heightCells); + PrimeDesktopEditPreviewImage( + placement.ComponentId, + placement.PlacementId, + placement.PageIndex, + widthCells, + heightCells); _desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect); _desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect); _desktopEditOverlayPresenter?.SetInvalid(false); @@ -2109,6 +2126,13 @@ public partial class MainWindow EnsureDesktopEditOverlayPresenter(); UpdateDesktopEditOverlayMetadata(componentId, widthCells, heightCells, L("component_library.drag_hint", "Drag to place")); + ApplyDesktopEditOverlayPreviewImage(componentId, placementId: null, widthCells, heightCells); + PrimeDesktopEditPreviewImage( + componentId, + placementId: null, + _currentDesktopSurfaceIndex, + widthCells, + heightCells); _desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect); _desktopEditOverlayPresenter?.SetCandidateRect(null); _desktopEditOverlayPresenter?.SetInvalid(false); @@ -2216,6 +2240,13 @@ public partial class MainWindow SetDesktopEditSourceHost(sourceHost, 0.22); EnsureDesktopEditOverlayPresenter(); UpdateDesktopEditOverlayMetadata(placement.ComponentId, startSpan.WidthCells, startSpan.HeightCells, L("component.resize", "Resize")); + ApplyDesktopEditOverlayPreviewImage(placement.ComponentId, placement.PlacementId, startSpan.WidthCells, startSpan.HeightCells); + PrimeDesktopEditPreviewImage( + placement.ComponentId, + placement.PlacementId, + placement.PageIndex, + startSpan.WidthCells, + startSpan.HeightCells); _desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect); _desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect); _desktopEditOverlayPresenter?.SetInvalid(false); @@ -2484,6 +2515,8 @@ public partial class MainWindow { ComponentLibraryBackTextBlock.Text = L("common.back", "Back"); } + + EnsureComponentLibraryPreviewWarmup(); } private IReadOnlyList GetComponentLibraryCategories() @@ -2659,6 +2692,7 @@ public partial class MainWindow var category = _componentLibraryCategories[_componentLibraryCategoryIndex]; _componentLibraryActiveCategoryId = category.Id; _componentLibraryComponentIndex = 0; + _ = WarmComponentLibraryCategoryPreviewsAsync(category); BuildComponentLibraryComponentPages(category); ShowComponentLibraryComponentsView(); } @@ -2679,6 +2713,7 @@ public partial class MainWindow ComponentLibraryComponentPagesContainer.Children.Clear(); ComponentLibraryComponentPagesContainer.RowDefinitions.Clear(); ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear(); + ClearComponentLibraryPreviewVisualTargets(); if (componentCount == 0) { _componentLibraryComponentIndex = 0; @@ -2752,37 +2787,49 @@ public partial class MainWindow var previewWidth = previewSpan.WidthCells * previewCellSize; var previewHeight = previewSpan.HeightCells * previewCellSize; - var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110); + var previewKey = CreateComponentTypePreviewKey(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells); + var cachedPreviewImage = ResolveComponentTypePreviewImage(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells); - var previewControl = CreateDesktopComponentControl( - component.ComponentId, - renderCellSize, - placementId: null, - pageIndex: null, - action: "ComponentLibraryPreview"); - if (previewControl is null) - { - continue; - } - // Component library previews must stay non-interactive so drag gesture is reliable. - previewControl.IsHitTestVisible = false; - previewControl.Focusable = false; - - var previewSurface = new Border - { - Width = previewSpan.WidthCells * renderCellSize, - Height = previewSpan.HeightCells * renderCellSize, - Background = Brushes.Transparent, - IsHitTestVisible = false, - Child = previewControl - }; - - var previewViewbox = new Viewbox + var previewImage = new Image { Width = previewWidth, Height = previewHeight, Stretch = Stretch.Uniform, - Child = previewSurface + Source = cachedPreviewImage, + IsVisible = cachedPreviewImage is not null, + IsHitTestVisible = false + }; + + var previewFallback = new Border + { + Width = previewWidth, + Height = previewHeight, + Background = GetThemeBrush("AdaptiveCardBackgroundBrush"), + BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(Math.Clamp(Math.Min(previewWidth, previewHeight) * 0.18, 12, 28)), + IsVisible = cachedPreviewImage is null, + Child = new TextBlock + { + Text = L("component_library.preview_loading", "Preparing preview"), + FontSize = 11, + Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush"), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + RegisterComponentLibraryPreviewVisual(previewKey, previewImage, previewFallback); + + var previewSurface = new Grid + { + Width = previewWidth, + Height = previewHeight, + IsHitTestVisible = false, + Children = + { + previewImage, + previewFallback + } }; var previewBorder = new Border @@ -2792,7 +2839,7 @@ public partial class MainWindow ClipToBounds = false, Background = Brushes.Transparent, BorderThickness = new Thickness(0), - Child = previewViewbox, + Child = previewSurface, Tag = component.ComponentId }; previewBorder.PointerPressed += OnComponentLibraryComponentPreviewPointerPressed; @@ -2832,6 +2879,15 @@ public partial class MainWindow Grid.SetRow(page, 0); Grid.SetColumn(page, i); ComponentLibraryComponentPagesContainer.Children.Add(page); + + if (cachedPreviewImage is null) + { + _ = EnsureComponentTypePreviewImageAsync(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells); + } + else + { + ApplyPreviewEntryToEmbeddedVisuals(previewKey); + } } _componentLibraryComponentHostTransform = ComponentLibraryComponentPagesHost.RenderTransform as TranslateTransform; @@ -2856,6 +2912,7 @@ public partial class MainWindow ComponentLibraryComponentPagesContainer.Children.Clear(); ComponentLibraryComponentPagesContainer.RowDefinitions.Clear(); ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear(); + ClearComponentLibraryPreviewVisualTargets(); } private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component) diff --git a/LanMountainDesktop/Views/MainWindow.DesktopEditing.cs b/LanMountainDesktop/Views/MainWindow.DesktopEditing.cs index c7a7337..2d0f65f 100644 --- a/LanMountainDesktop/Views/MainWindow.DesktopEditing.cs +++ b/LanMountainDesktop/Views/MainWindow.DesktopEditing.cs @@ -14,7 +14,8 @@ namespace LanMountainDesktop.Views; public partial class MainWindow { - private static readonly TimeSpan DesktopEditOverlayAnimationDuration = FluttermotionToken.Fast; + private static readonly TimeSpan DesktopEditCommitAnimationDuration = FluttermotionToken.Standard; + private static readonly TimeSpan DesktopEditCancelAnimationDuration = FluttermotionToken.Fast; private DesktopEditSession _desktopEditSession; private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter; @@ -328,7 +329,7 @@ public partial class MainWindow ResetDesktopEditState(); }, - DesktopEditOverlayAnimationDuration); + DesktopEditCancelAnimationDuration); return; } @@ -369,7 +370,7 @@ public partial class MainWindow RestoreComponentLibraryAfterDesktopEdit(); ResetDesktopEditState(); }, - DesktopEditOverlayAnimationDuration); + DesktopEditCommitAnimationDuration); } private void UpdateDesktopEditSession(Point pointerInViewport) @@ -707,6 +708,7 @@ public partial class MainWindow return; } + QueuePlacementPreviewRefresh(placement); PersistSettings(); TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize"); } diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index ef41412..b3f9c7e 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -243,6 +243,15 @@ + +