From eb066b53f190d19d51071a725670657e1b3d0b37 Mon Sep 17 00:00:00 2001 From: lincube Date: Wed, 29 Apr 2026 19:43:29 +0800 Subject: [PATCH] Introduce render mode & static component previews Add DesktopComponentRenderMode and thread the render mode through runtime context and creation APIs so controls can be created for library previews. Replace image-based preview system with static preview Controls: viewmodels and ComponentLibraryWindow now use PreviewControl, and ComponentPreviewImageService/related types and tests were removed. Add ComponentPreviewRuntimeQuiescer to attach/detach preview controls (stop timers, disable input) for safe static previews. Simplify component-library collapse state/presenter by removing transient expanded opacity handling. Update runtime registry, services, views and tests to support the new flow. --- .../ComponentLibraryCollapseStateTests.cs | 14 +- .../ComponentPreviewImageServiceTests.cs | 257 -------- .../DesktopComponentRenderModeTests.cs | 135 ++++ .../ComponentPreviewRuntimeQuiescer.cs | 90 +++ .../DesktopComponentRenderMode.cs | 7 + .../DesktopComponentRuntimeContext.cs | 3 +- .../ComponentLibraryCollapsePresenter.cs | 31 +- .../ComponentLibraryCollapseState.cs | 4 +- .../Services/ComponentLibraryServices.cs | 3 +- .../Services/ComponentPreviewImageService.cs | 261 -------- .../Services/ComponentPreviewTypes.cs | 281 -------- .../Services/IComponentLibraryService.cs | 3 +- .../Services/IComponentPreviewImageService.cs | 32 - .../ComponentLibraryWindowViewModel.cs | 116 +--- .../Views/ComponentLibraryWindow.axaml | 47 +- .../Views/ComponentLibraryWindow.axaml.cs | 102 ++- .../DesktopComponentRuntimeRegistry.cs | 12 +- .../FusedDesktopComponentLibraryControl.axaml | 112 +--- ...sedDesktopComponentLibraryControl.axaml.cs | 223 +++---- ...usedDesktopComponentLibraryWindow.axaml.cs | 5 - .../MainWindow.ComponentPreviewImages.cs | 606 +++--------------- .../Views/MainWindow.ComponentSystem.cs | 71 +- .../Views/MainWindow.DesktopEditing.cs | 2 +- LanMountainDesktop/Views/MainWindow.axaml | 286 ++++----- 24 files changed, 677 insertions(+), 2026 deletions(-) delete mode 100644 LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs create mode 100644 LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs create mode 100644 LanMountainDesktop/ComponentSystem/ComponentPreviewRuntimeQuiescer.cs create mode 100644 LanMountainDesktop/ComponentSystem/DesktopComponentRenderMode.cs delete mode 100644 LanMountainDesktop/Services/ComponentPreviewImageService.cs delete mode 100644 LanMountainDesktop/Services/ComponentPreviewTypes.cs delete mode 100644 LanMountainDesktop/Services/IComponentPreviewImageService.cs diff --git a/LanMountainDesktop.Tests/ComponentLibraryCollapseStateTests.cs b/LanMountainDesktop.Tests/ComponentLibraryCollapseStateTests.cs index 22dcd9f..538dca0 100644 --- a/LanMountainDesktop.Tests/ComponentLibraryCollapseStateTests.cs +++ b/LanMountainDesktop.Tests/ComponentLibraryCollapseStateTests.cs @@ -10,11 +10,10 @@ public sealed class ComponentLibraryCollapseStateTests public void CreateExpanded_InitializesExpandedStateAndHidesChip() { var margin = new Thickness(24, 24, 24, 100); - var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75); + var state = ComponentLibraryCollapseState.CreateExpanded(margin); Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState); Assert.Equal(margin, state.ExpandedMargin); - Assert.Equal(0.75, state.ExpandedOpacity, 3); Assert.False(state.IsChipVisible); } @@ -22,7 +21,7 @@ public sealed class ComponentLibraryCollapseStateTests public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions() { var margin = new Thickness(20, 18, 20, 96); - var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1); + var expanded = ComponentLibraryCollapseState.CreateExpanded(margin); var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true); var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true); @@ -36,24 +35,19 @@ public sealed class ComponentLibraryCollapseStateTests Assert.Equal(margin, collapsed.ExpandedMargin); Assert.Equal(margin, restoring.ExpandedMargin); - Assert.Equal(1, collapsing.ExpandedOpacity, 3); - Assert.Equal(1, collapsed.ExpandedOpacity, 3); - Assert.Equal(1, restoring.ExpandedOpacity, 3); - Assert.True(collapsing.IsChipVisible); Assert.True(collapsed.IsChipVisible); Assert.False(restoring.IsChipVisible); } [Fact] - public void CreateExpanded_ProducesRestorableSnapshotEvenWhenOriginalOpacityIsLow() + public void CreateExpanded_DoesNotCaptureTransientOpacityAsRestorableState() { var margin = new Thickness(18, 22, 18, 88); - var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15); + var expanded = ComponentLibraryCollapseState.CreateExpanded(margin); var restored = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false); Assert.Equal(margin, restored.ExpandedMargin); - Assert.Equal(0.15, restored.ExpandedOpacity, 3); Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, restored.VisualState); Assert.False(restored.IsChipVisible); } diff --git a/LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs b/LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs deleted file mode 100644 index 85c5bbe..0000000 --- a/LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs +++ /dev/null @@ -1,257 +0,0 @@ -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.Tests/DesktopComponentRenderModeTests.cs b/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs new file mode 100644 index 0000000..7d7a23b --- /dev/null +++ b/LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs @@ -0,0 +1,135 @@ +using Avalonia.Controls; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Views.Components; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class DesktopComponentRenderModeTests +{ + private const string ComponentId = "RenderModeProbe"; + + [Fact] + public void DescriptorCreateControl_DefaultsToLiveRenderMode() + { + var descriptor = CreateDescriptor(); + var control = (ProbeControl)descriptor.CreateControl( + cellSize: 64, + CreateTimeZoneService(), + CreateWeatherInfoService(), + new RecommendationDataService(), + new CalculatorDataService(), + CreateSettingsFacade(), + placementId: "desktop-placement"); + + Assert.Equal(DesktopComponentRenderMode.Live, control.RuntimeContext?.RenderMode); + Assert.Equal("desktop-placement", control.RuntimeContext?.PlacementId); + } + + [Fact] + public void DescriptorCreateControl_CanCreateLibraryPreviewRenderModeWithoutPlacement() + { + var descriptor = CreateDescriptor(); + var control = (ProbeControl)descriptor.CreateControl( + cellSize: 64, + CreateTimeZoneService(), + CreateWeatherInfoService(), + new RecommendationDataService(), + new CalculatorDataService(), + CreateSettingsFacade(), + placementId: null, + renderMode: DesktopComponentRenderMode.LibraryPreview); + + Assert.Equal(DesktopComponentRenderMode.LibraryPreview, control.RuntimeContext?.RenderMode); + Assert.Null(control.RuntimeContext?.PlacementId); + } + + [Fact] + public void ComponentLibraryService_CreatesLibraryPreviewRenderMode() + { + var service = new ComponentLibraryService( + CreateComponentRegistry(), + CreateRuntimeRegistry()); + + var created = service.TryCreateControl( + ComponentId, + new ComponentLibraryCreateContext( + 64, + CreateTimeZoneService(), + CreateWeatherInfoService(), + new RecommendationDataService(), + new CalculatorDataService(), + CreateSettingsFacade(), + PlacementId: null, + RenderMode: DesktopComponentRenderMode.LibraryPreview), + out var control, + out var exception); + + Assert.True(created, exception?.ToString()); + var probe = Assert.IsType(control); + Assert.Equal(DesktopComponentRenderMode.LibraryPreview, probe.RuntimeContext?.RenderMode); + Assert.Null(probe.RuntimeContext?.PlacementId); + } + + private static DesktopComponentRuntimeDescriptor CreateDescriptor() + { + Assert.True(CreateRuntimeRegistry().TryGetDescriptor(ComponentId, out var descriptor)); + return descriptor; + } + + private static DesktopComponentRuntimeRegistry CreateRuntimeRegistry() + { + return new DesktopComponentRuntimeRegistry( + CreateComponentRegistry(), + [ + new DesktopComponentRuntimeRegistration( + ComponentId, + displayNameLocalizationKey: null, + _ => new ProbeControl(), + cornerRadiusResolver: (System.Func?)null) + ]); + } + + private static ComponentRegistry CreateComponentRegistry() + { + return new ComponentRegistry( + [ + new DesktopComponentDefinition( + ComponentId, + "Render Mode Probe", + "Apps", + "Test", + MinWidthCells: 1, + MinHeightCells: 1, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true) + ]); + } + + private static ISettingsFacadeService CreateSettingsFacade() + { + return HostSettingsFacadeProvider.GetOrCreate(); + } + + private static TimeZoneService CreateTimeZoneService() + { + return CreateSettingsFacade().Region.GetTimeZoneService(); + } + + private static IWeatherInfoService CreateWeatherInfoService() + { + return CreateSettingsFacade().Weather.GetWeatherInfoService(); + } + + private sealed class ProbeControl : Control, IComponentRuntimeContextAware + { + public DesktopComponentRuntimeContext? RuntimeContext { get; private set; } + + public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context) + { + RuntimeContext = context; + } + } +} diff --git a/LanMountainDesktop/ComponentSystem/ComponentPreviewRuntimeQuiescer.cs b/LanMountainDesktop/ComponentSystem/ComponentPreviewRuntimeQuiescer.cs new file mode 100644 index 0000000..22dc9bd --- /dev/null +++ b/LanMountainDesktop/ComponentSystem/ComponentPreviewRuntimeQuiescer.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace LanMountainDesktop.ComponentSystem; + +internal static class ComponentPreviewRuntimeQuiescer +{ + private static readonly BindingFlags TimerMemberFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + public static void Attach(Control control) + { + ArgumentNullException.ThrowIfNull(control); + + control.IsHitTestVisible = false; + control.Focusable = false; + control.AttachedToVisualTree += (_, _) => + Dispatcher.UIThread.Post(() => Quiesce(control), DispatcherPriority.Background); + control.DetachedFromVisualTree += (_, _) => Quiesce(control); + Quiesce(control); + } + + public static void Detach(Control control) + { + ArgumentNullException.ThrowIfNull(control); + + Quiesce(control); + } + + public static void Quiesce(Control control) + { + ArgumentNullException.ThrowIfNull(control); + + foreach (var candidate in EnumerateControls(control)) + { + StopDispatcherTimers(candidate); + candidate.IsHitTestVisible = false; + candidate.Focusable = false; + } + } + + private static IEnumerable EnumerateControls(Control root) + { + yield return root; + + foreach (var descendant in root.GetVisualDescendants().OfType()) + { + yield return descendant; + } + } + + private static void StopDispatcherTimers(object target) + { + var type = target.GetType(); + foreach (var field in type.GetFields(TimerMemberFlags)) + { + if (typeof(DispatcherTimer).IsAssignableFrom(field.FieldType) && + field.GetValue(target) is DispatcherTimer timer) + { + timer.Stop(); + } + } + + foreach (var property in type.GetProperties(TimerMemberFlags)) + { + if (!property.CanRead || + property.GetIndexParameters().Length != 0 || + !typeof(DispatcherTimer).IsAssignableFrom(property.PropertyType)) + { + continue; + } + + try + { + if (property.GetValue(target) is DispatcherTimer timer) + { + timer.Stop(); + } + } + catch (TargetInvocationException) + { + } + } + } +} diff --git a/LanMountainDesktop/ComponentSystem/DesktopComponentRenderMode.cs b/LanMountainDesktop/ComponentSystem/DesktopComponentRenderMode.cs new file mode 100644 index 0000000..40dbe06 --- /dev/null +++ b/LanMountainDesktop/ComponentSystem/DesktopComponentRenderMode.cs @@ -0,0 +1,7 @@ +namespace LanMountainDesktop.ComponentSystem; + +public enum DesktopComponentRenderMode +{ + Live = 0, + LibraryPreview = 1 +} diff --git a/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs b/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs index 168bde4..df73f22 100644 --- a/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs +++ b/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs @@ -13,4 +13,5 @@ public sealed record DesktopComponentRuntimeContext( IAppearanceThemeService AppearanceTheme, ComponentChromeContext Chrome, IComponentSettingsAccessor ComponentSettingsAccessor, - IComponentInstanceSettingsStore ComponentSettingsStore); + IComponentInstanceSettingsStore ComponentSettingsStore, + DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live); diff --git a/LanMountainDesktop/DesktopEditing/ComponentLibraryCollapsePresenter.cs b/LanMountainDesktop/DesktopEditing/ComponentLibraryCollapsePresenter.cs index 630b21c..afabf2b 100644 --- a/LanMountainDesktop/DesktopEditing/ComponentLibraryCollapsePresenter.cs +++ b/LanMountainDesktop/DesktopEditing/ComponentLibraryCollapsePresenter.cs @@ -12,7 +12,6 @@ internal sealed class ComponentLibraryCollapsePresenter { private static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(150); private static readonly Easing TransitionEasing = new CubicEaseOut(); - private const double StableOpacityThreshold = 0.01; private readonly Border _componentLibraryWindow; private readonly Border _collapsedChipHost; @@ -37,9 +36,7 @@ internal sealed class ComponentLibraryCollapsePresenter _collapsedChipIcon = collapsedChipIcon; EnsureTransforms(); - _state = ComponentLibraryCollapseState.CreateExpanded( - _componentLibraryWindow.Margin, - _componentLibraryWindow.Opacity <= 0 ? 1 : _componentLibraryWindow.Opacity); + _state = ComponentLibraryCollapseState.CreateExpanded(_componentLibraryWindow.Margin); ApplyExpandedSnapshot(); _collapsedChipHost.IsVisible = false; _collapsedChipHost.IsHitTestVisible = false; @@ -50,19 +47,16 @@ internal sealed class ComponentLibraryCollapsePresenter public ComponentLibraryCollapseVisualState VisualState => _state.VisualState; - public void SyncExpandedState(Thickness margin, double opacity) + public void SyncExpandedState(Thickness margin) { - var hasStableOpacity = IsStableExpandedOpacity(opacity); - var nextExpandedOpacity = hasStableOpacity ? Math.Clamp(opacity, 0, 1) : _state.ExpandedOpacity; _state = _state with { - ExpandedMargin = margin, - ExpandedOpacity = nextExpandedOpacity + ExpandedMargin = margin }; if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded or ComponentLibraryCollapseVisualState.Restoring) { - ApplyExpandedSnapshot(applyOpacity: hasStableOpacity); + ApplyExpandedSnapshot(); } } @@ -122,7 +116,7 @@ internal sealed class ComponentLibraryCollapsePresenter return; } - _componentLibraryWindow.Opacity = _state.ExpandedOpacity; + _componentLibraryWindow.Opacity = 1; _windowTranslate.Y = 0; }, DispatcherPriority.Background); @@ -190,14 +184,10 @@ internal sealed class ComponentLibraryCollapsePresenter }; } - private void ApplyExpandedSnapshot(bool applyOpacity = true) + private void ApplyExpandedSnapshot() { _componentLibraryWindow.Margin = _state.ExpandedMargin; - if (applyOpacity) - { - _componentLibraryWindow.Opacity = _state.ExpandedOpacity; - } - + _componentLibraryWindow.Opacity = 1; _componentLibraryWindow.IsVisible = true; _componentLibraryWindow.IsHitTestVisible = true; _windowTranslate.Y = 0; @@ -270,11 +260,4 @@ internal sealed class ComponentLibraryCollapsePresenter _componentLibraryWindow.Opacity = 0; _windowTranslate.Y = 28; } - - private static bool IsStableExpandedOpacity(double opacity) - { - return !double.IsNaN(opacity) && - !double.IsInfinity(opacity) && - opacity > StableOpacityThreshold; - } } diff --git a/LanMountainDesktop/DesktopEditing/ComponentLibraryCollapseState.cs b/LanMountainDesktop/DesktopEditing/ComponentLibraryCollapseState.cs index 57ec7f8..c2129dc 100644 --- a/LanMountainDesktop/DesktopEditing/ComponentLibraryCollapseState.cs +++ b/LanMountainDesktop/DesktopEditing/ComponentLibraryCollapseState.cs @@ -13,15 +13,13 @@ internal enum ComponentLibraryCollapseVisualState internal readonly record struct ComponentLibraryCollapseState( ComponentLibraryCollapseVisualState VisualState, Thickness ExpandedMargin, - double ExpandedOpacity, bool IsChipVisible) { - public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin, double expandedOpacity) + public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin) { return new( ComponentLibraryCollapseVisualState.Expanded, expandedMargin, - expandedOpacity, IsChipVisible: false); } diff --git a/LanMountainDesktop/Services/ComponentLibraryServices.cs b/LanMountainDesktop/Services/ComponentLibraryServices.cs index e932067..4398e4e 100644 --- a/LanMountainDesktop/Services/ComponentLibraryServices.cs +++ b/LanMountainDesktop/Services/ComponentLibraryServices.cs @@ -92,7 +92,8 @@ internal sealed class ComponentLibraryService : IComponentLibraryService context.RecommendationInfoService, context.CalculatorDataService, context.SettingsFacade, - context.PlacementId); + context.PlacementId, + context.RenderMode); return true; } catch (Exception ex) diff --git a/LanMountainDesktop/Services/ComponentPreviewImageService.cs b/LanMountainDesktop/Services/ComponentPreviewImageService.cs deleted file mode 100644 index 7245d96..0000000 --- a/LanMountainDesktop/Services/ComponentPreviewImageService.cs +++ /dev/null @@ -1,261 +0,0 @@ -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 deleted file mode 100644 index 6a64cb3..0000000 --- a/LanMountainDesktop/Services/ComponentPreviewTypes.cs +++ /dev/null @@ -1,281 +0,0 @@ -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/IComponentLibraryService.cs b/LanMountainDesktop/Services/IComponentLibraryService.cs index fc08ed4..1e3e437 100644 --- a/LanMountainDesktop/Services/IComponentLibraryService.cs +++ b/LanMountainDesktop/Services/IComponentLibraryService.cs @@ -25,7 +25,8 @@ public sealed record ComponentLibraryCreateContext( IRecommendationInfoService RecommendationInfoService, ICalculatorDataService CalculatorDataService, ISettingsFacadeService SettingsFacade, - string? PlacementId = null); + string? PlacementId = null, + DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live); public interface IComponentLibraryService { diff --git a/LanMountainDesktop/Services/IComponentPreviewImageService.cs b/LanMountainDesktop/Services/IComponentPreviewImageService.cs deleted file mode 100644 index b64fd06..0000000 --- a/LanMountainDesktop/Services/IComponentPreviewImageService.cs +++ /dev/null @@ -1,32 +0,0 @@ -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 9f3aeab..3ede26d 100644 --- a/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs +++ b/LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; -using System.ComponentModel; -using LanMountainDesktop.Services; +using Avalonia.Controls; using FluentIcons.Common; using CommunityToolkit.Mvvm.ComponentModel; @@ -55,33 +54,20 @@ public sealed class ComponentLibraryCategoryViewModel public sealed class ComponentLibraryItemViewModel : ObservableObject { - private readonly string _loadingPreviewText; - private readonly string _previewUnavailableText; private string _displayName; private string? _description; - private ComponentPreviewKey _previewKey; - private ComponentPreviewImageEntry? _previewImageEntry; - private ComponentPreviewImageState _previewState; - private string? _previewErrorMessage; - private string _previewStatusText; + private Control? _previewControl; public ComponentLibraryItemViewModel( string componentId, string displayName, - ComponentPreviewKey previewKey, string? description = null, - string loadingPreviewText = "Loading preview...", - string previewUnavailableText = "Preview unavailable", - ComponentPreviewImageEntry? previewImageEntry = null) + Control? previewControl = null) { ComponentId = componentId; _displayName = displayName; _description = description; - _previewKey = previewKey; - _loadingPreviewText = loadingPreviewText; - _previewUnavailableText = previewUnavailableText; - _previewStatusText = loadingPreviewText; - UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false); + _previewControl = previewControl; } public string ComponentId { get; } @@ -98,98 +84,10 @@ public sealed class ComponentLibraryItemViewModel set => SetProperty(ref _description, value); } - public ComponentPreviewKey PreviewKey + public Control? PreviewControl { - get => _previewKey; - set => SetProperty(ref _previewKey, value); + get => _previewControl; + set => SetProperty(ref _previewControl, 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 01dc56b..6589f1f 100644 --- a/LanMountainDesktop/Views/ComponentLibraryWindow.axaml +++ b/LanMountainDesktop/Views/ComponentLibraryWindow.axaml @@ -99,48 +99,11 @@ 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() @@ -29,20 +26,12 @@ public partial class ComponentLibraryWindow : Window public ComponentLibraryWindow( IComponentLibraryService componentLibraryService, Func createContextFactory, - Func localize, - Func? previewKeyResolver = null, - Func? previewEntryResolver = null, - Action? warmPreviewRequested = null, - Action? renderPreviewRequested = null) + Func localize) : 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(); } @@ -56,6 +45,7 @@ public partial class ComponentLibraryWindow : Window } _viewModel.Title = _localize("component_library.title", "Widgets"); + DisposePreviewControls(_viewModel.Categories.SelectMany(static category => category.Components)); _viewModel.Categories.Clear(); _viewModel.Components.Clear(); @@ -88,24 +78,12 @@ public partial class ComponentLibraryWindow : Window 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( + var previewControl = CreateStaticPreviewControl(entry); + return new ComponentLibraryItemViewModel( entry.ComponentId, displayName, - previewKey, description: null, - _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) - { - _warmPreviewRequested?.Invoke(previewKey); - _renderPreviewRequested?.Invoke(previewKey); - } - - return item; + previewControl); } private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -124,7 +102,7 @@ public partial class ComponentLibraryWindow : Window _viewModel.Components.Add(component); } - RequestPreviewWarmup(selectedCategory.Components); + ComponentPreviewRuntimeQuiescer.Quiesce(this); } private void OnAddComponentClick(object? sender, RoutedEventArgs e) @@ -147,48 +125,54 @@ public partial class ComponentLibraryWindow : Window Hide(); } - public void UpdatePreviewImage(ComponentPreviewImageEntry previewImageEntry) + private Control? CreateStaticPreviewControl(ComponentLibraryComponentEntry entry) { - ArgumentNullException.ThrowIfNull(previewImageEntry); - - foreach (var category in _viewModel.Categories) + if (_componentLibraryService is null || _createContextFactory is null) { - foreach (var component in category.Components) - { - if (component.PreviewKey.Equals(previewImageEntry.Key)) - { - component.UpdatePreviewImageEntry(previewImageEntry); - } - } + return null; } + + var cellSize = ResolvePreviewCellSize(entry); + var context = _createContextFactory(cellSize) with + { + PlacementId = null, + RenderMode = DesktopComponentRenderMode.LibraryPreview + }; + + if (!_componentLibraryService.TryCreateControl(entry.ComponentId, context, out var control, out _)) + { + return null; + } + + if (control is not null) + { + ComponentPreviewRuntimeQuiescer.Attach(control); + } + + return control; } - private ComponentPreviewKey ResolvePreviewKey(ComponentLibraryComponentEntry entry) + private static double ResolvePreviewCellSize(ComponentLibraryComponentEntry entry) { - if (_previewKeyResolver is not null) - { - return _previewKeyResolver(entry); - } - - return ComponentPreviewKey.ForComponentType(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells); + var maxWidth = 180d; + var maxHeight = 120d; + return Math.Clamp( + Math.Min( + maxWidth / Math.Max(1, entry.MinWidthCells), + maxHeight / Math.Max(1, entry.MinHeightCells)), + 24d, + 72d); } - private void RequestPreviewWarmup(IEnumerable components) + private static void DisposePreviewControls(IEnumerable components) { - if (_warmPreviewRequested is null && _renderPreviewRequested is null) + foreach (var control in components.Select(static component => component.PreviewControl).OfType()) { - return; - } - - foreach (var component in components) - { - if (!component.IsPreviewPending) + ComponentPreviewRuntimeQuiescer.Detach(control); + if (control is IDisposable disposable) { - continue; + disposable.Dispose(); } - - _warmPreviewRequested?.Invoke(component.PreviewKey); - _renderPreviewRequested?.Invoke(component.PreviewKey); } } diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 1f4ac08..77002a0 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -24,7 +24,8 @@ public sealed record DesktopComponentControlFactoryContext( ISettingsService SettingsService, IComponentInstanceSettingsStore ComponentSettingsStore, IComponentSettingsAccessor ComponentSettingsAccessor, - string? PlacementId = null); + string? PlacementId = null, + DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live); public sealed class DesktopComponentRuntimeRegistration { @@ -115,7 +116,8 @@ public sealed class DesktopComponentRuntimeDescriptor IRecommendationInfoService recommendationInfoService, ICalculatorDataService calculatorDataService, ISettingsFacadeService settingsFacade, - string? placementId = null) + string? placementId = null, + DesktopComponentRenderMode renderMode = DesktopComponentRenderMode.Live) { ArgumentNullException.ThrowIfNull(settingsFacade); @@ -141,7 +143,8 @@ public sealed class DesktopComponentRuntimeDescriptor settingsService, componentSettingsStore, componentAccessor, - placementId)); + placementId, + renderMode)); var runtimeContext = new DesktopComponentRuntimeContext( Definition.Id, placementId, @@ -150,7 +153,8 @@ public sealed class DesktopComponentRuntimeDescriptor appearanceTheme, chromeContext, componentAccessor, - componentSettingsStore); + componentSettingsStore, + renderMode); ApplySettingsDependencies(control, settingsService, componentSettingsStore); diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml index 3982ffd..811f83a 100644 --- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml +++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml @@ -1,24 +1,16 @@ - - - - - - - - @@ -47,14 +27,9 @@ - - - + + - - + + FontSize="18"/> - - + - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +