Compare commits

...

1 Commits

Author SHA1 Message Date
lincube
eb066b53f1 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.
2026-04-29 19:43:29 +08:00
24 changed files with 677 additions and 2026 deletions

View File

@@ -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);
}

View File

@@ -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<string>();
var activeCount = 0;
var maxActiveCount = 0;
Task<ComponentPreviewImageEntry> 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<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
Task<IImage?> 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<IImage?>(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;
}
}
}

View File

@@ -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<ProbeControl>(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<double, double>?)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;
}
}
}

View File

@@ -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<Control> EnumerateControls(Control root)
{
yield return root;
foreach (var descendant in root.GetVisualDescendants().OfType<Control>())
{
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)
{
}
}
}
}

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.ComponentSystem;
public enum DesktopComponentRenderMode
{
Live = 0,
LibraryPreview = 1
}

View File

@@ -13,4 +13,5 @@ public sealed record DesktopComponentRuntimeContext(
IAppearanceThemeService AppearanceTheme,
ComponentChromeContext Chrome,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore);
IComponentInstanceSettingsStore ComponentSettingsStore,
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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<ComponentPreviewKey, ComponentPreviewImageEntry> _entries = new(ComponentPreviewKeyComparer.Instance);
private readonly Dictionary<ComponentPreviewKey, Task<ComponentPreviewImageEntry>> _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<ComponentPreviewImageEntry> GetEntriesSnapshot()
{
lock (_gate)
{
return _entries.Values.ToArray();
}
}
public Task<ComponentPreviewImageEntry> QueueGenerationAsync(
ComponentPreviewKey key,
string visualSignature,
Func<CancellationToken, Task<IImage?>> 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<ComponentPreviewImageEntry> RunGenerationAsync(
Task previousTask,
ComponentPreviewKey key,
ComponentPreviewImageEntry entry,
long expectedRevision,
string visualSignature,
Func<CancellationToken, Task<IImage?>> 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();
}
}

View File

@@ -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<ComponentPreviewKey>
{
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();
}
}

View File

@@ -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
{

View File

@@ -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<ComponentPreviewImageEntry> GetEntriesSnapshot();
Task<ComponentPreviewImageEntry> QueueGenerationAsync(
ComponentPreviewKey key,
string visualSignature,
Func<CancellationToken, Task<IImage?>> 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);
}

View File

@@ -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));
}
}

View File

@@ -99,48 +99,11 @@
BorderThickness="1"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Padding="8">
<Grid>
<Image Source="{Binding PreviewBitmap}"
Stretch="Uniform"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RenderOptions.BitmapInterpolationMode="HighQuality"
IsVisible="{Binding IsPreviewReady}" />
<Border IsVisible="{Binding IsPreviewPending}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<ProgressBar Width="96"
IsIndeterminate="True" />
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="12"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding PreviewStatusText}" />
</StackPanel>
</Border>
<Border IsVisible="{Binding IsPreviewFailed}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding PreviewStatusText}" />
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding PreviewErrorMessage}" />
</StackPanel>
</Border>
</Grid>
<ContentControl Content="{Binding PreviewControl}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Focusable="False" />
</Border>
<TextBlock Grid.Row="1"

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using FluentIcons.Common;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
@@ -14,10 +15,6 @@ public partial class ComponentLibraryWindow : Window
private IComponentLibraryService? _componentLibraryService;
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
private Func<string, string, string>? _localize;
private Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? _previewKeyResolver;
private Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? _previewEntryResolver;
private Action<ComponentPreviewKey>? _warmPreviewRequested;
private Action<ComponentPreviewKey>? _renderPreviewRequested;
private readonly ComponentLibraryWindowViewModel _viewModel = new();
public ComponentLibraryWindow()
@@ -29,20 +26,12 @@ public partial class ComponentLibraryWindow : Window
public ComponentLibraryWindow(
IComponentLibraryService componentLibraryService,
Func<double, ComponentLibraryCreateContext> createContextFactory,
Func<string, string, string> localize,
Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? previewKeyResolver = null,
Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? previewEntryResolver = null,
Action<ComponentPreviewKey>? warmPreviewRequested = null,
Action<ComponentPreviewKey>? renderPreviewRequested = null)
Func<string, string, string> 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<ComponentLibraryItemViewModel> components)
private static void DisposePreviewControls(IEnumerable<ComponentLibraryItemViewModel> components)
{
if (_warmPreviewRequested is null && _renderPreviewRequested is null)
foreach (var control in components.Select(static component => component.PreviewControl).OfType<Control>())
{
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);
}
}

View File

@@ -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);

View File

@@ -1,24 +1,16 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:converters="using:Avalonia.Data.Converters"
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
x:DataType="vm:ComponentLibraryWindowViewModel">
<UserControl.Styles>
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍褜鍓欓崯顖炲Φ閸曨厽鍠嗛柛鏇ㄥ幖椤ュ酣鎮?- 闂傚倷绶¢崜鐔奉焽瑜旈獮?Fluent NavigationView 濠碉紕鍋涢鍛偓娑掓櫊閹?-->
<Style Selector="ListBoxItem.category-item">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00"/>
</Transitions>
</Setter>
</Style>
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
@@ -26,18 +18,6 @@
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}"/>
</Style>
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绲绘禍婊堟煟閻斿搫顣肩紒鍌氱墦閺屸€愁吋閸涱喗鎮欓梺纭呮腹閸楀啿顕i鍕倞鐟滃繘骞?-->
<Style Selector="ListBoxItem.category-item fi|FluentIcon.category-icon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item:selected fi|FluentIcon.category-icon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item TextBlock.category-text">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</Style>
@@ -47,14 +27,9 @@
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="0"
Margin="0">
<!-- 闁诲骸缍婂鑽ょ磽濮樿泛鐤鹃柛鎾茶閸嬫挻鎷呴崘顭戞闂佺硶鏅涢幊妯虹暦?- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍?+ 闂佸湱鍘ч悺銊ッ洪悢鐓庣??闂備礁鎼悮顐﹀磿閸欏鐝舵慨妞诲亾鐎殿喗鎸冲鍫曞箣椤撶啿鏌ょ紓鍌氬€风粈浣衡偓姘间簻閳? -->
<Border Width="280"
Background="Transparent">
<Grid ColumnDefinitions="Auto,*">
<Border Width="280" Background="Transparent">
<Grid RowDefinitions="*,Auto">
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍?-->
<ListBox x:Name="CategoryListBox"
Grid.Row="0"
Background="Transparent"
@@ -64,13 +39,10 @@
ItemsSource="{Binding Categories}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12"
Margin="12,10">
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12" Margin="12,10">
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Regular"
FontSize="18"
Classes="category-icon"/>
FontSize="18"/>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontSize="14"
@@ -81,9 +53,7 @@
</ListBox.ItemTemplate>
</ListBox>
<!-- 闂佸湱鍘ч悺銊ッ洪悢鐓庣??闂備礁鎼悮顐﹀磿閸欏鐝舵慨妞诲亾鐎殿喗鎸冲鍫曞箣椤撶啿鏌ょ紓鍌氬€风粈浣衡偓姘间簻閳? - 闂備線娼荤拹鐔煎礉鐏炲墽鈻曢煫鍥ㄦ⒒閻熷湱鎲稿澶樻晪闂侇剙绉甸崵瀣亜韫囨挸顏╅柣蹇旂懇楠炴牜鈧稒蓱缁€瀣煕?-->
<StackPanel Grid.Row="1"
Margin="12,8,8,12">
<StackPanel Grid.Row="1" Margin="12,8,8,12">
<Border Height="1"
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.4"
@@ -100,35 +70,26 @@
</Grid>
</Border>
<!-- 闂備礁鎲¢悷銉╁储閺嶎厼鐤鹃柛顐f礀缁€鍐煕濞戝崬寮鹃柛鐔锋喘閺屾盯寮介浣碘偓鍐磼濡も偓閼活垶顢欒箛娑欐櫆闁圭瀛╅悵鐑芥⒑濮瑰洤濡奸悗姘煎墴瀹曡鎯旈妸锔规寗闂佸搫鍟崐绋库枔?-->
<Border Grid.Column="1"
Width="1"
HorizontalAlignment="Left"
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.5"/>
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€閳藉懐鐭楅梺鍛婃处閸n喖顭囬弮鍫熺厱?(闂備礁鎲¢悷銉╁储閺嶎厼鐤? -->
<ScrollViewer Grid.Column="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,8,12,8"
Spacing="0">
<!-- 闂備礁鎼悧鍡浰囬悽绋跨劦妞ゆ巻鍋撴い锔诲櫍閹虫瑩骞嬮悩鐢碉紲闂佸憡娲︽禍婵嬵敃娴犲鐓涢柛鎰╁妼椤h櫕绻涢崼鐔风伌鐎殿喕鍗冲畷婊嗩槹濞?-->
<StackPanel Margin="16,8,12,8">
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€椤厽鍕冮梺鍝勬川婵増绂掑☉銏♀拻闁割偅绋戦悘顏呯節?- 闂備礁鎼悧鍡浰囨潏鈹惧亾濮樼厧骞樼紒顔规櫇閳ь剨缍嗛崢濂稿礈瑜版帗鐓涢柛婊€绀侀悘銉ヮ熆閻熷府韬柡浣哥Ф娴狅箓鎳栭埡鍐╁枦缂傚倷鐒﹂崝鏍€冮崨鑸汗婵炴垯鍨洪崵鍕倶閻愰潧浜鹃柣婵愬灣閹叉悂鎳滈鈧悘顏堟煕閵婏附鐨戝ù鐙呯畵瀹曟帒顭ㄩ崼銏犵闂備礁鎲$敮鎺懳涘☉銏犵柧?-->
<Border Classes="surface-translucent-panel"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="20">
<StackPanel Spacing="16">
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€閵堝懎鍞ㄩ梺鎼炲労閸擄箓寮?-->
<TextBlock FontSize="28"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.DisplayName}"/>
<!-- 闂備焦鎮堕崕閬嶅箹椤愶附鍋╅柣鎰靛墮缁剁偟鎲稿澶嬪剭妞ゆ帒瀚崕宥夋煕閺囥劌鐏遍柡鍡樻礋閹嘲鈻庤箛鏇烆暫閻庤娲熸禍鍫曞箖?-->
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
@@ -136,59 +97,14 @@
Width="420"
Height="300"
HorizontalAlignment="Center">
<Grid Margin="16">
<!-- 濠碘槅鍋呭妯尖偓姘煎灦閿濈偛顓兼径濠勫€為梺鍛婃寙閸愮偓姣?-->
<Image Source="{Binding SelectedComponent.PreviewBitmap}"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderOptions.BitmapInterpolationMode="HighQuality"
IsVisible="{Binding SelectedComponent.IsPreviewReady}"/>
<!-- 闂備礁鎲″缁樻叏閹灐褰掑炊閵娧€鏋栧銈嗘尵婵鐟ч梻?-->
<Border IsVisible="{Binding SelectedComponent.IsPreviewPending}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<ProgressBar Width="120"
IsIndeterminate="True"/>
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="14"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding SelectedComponent.PreviewStatusText}"/>
</StackPanel>
</Border>
<!-- 濠电姰鍨洪崕鑲╁垝閸撗勫枂闁挎洖鍊归崑鎰版煠閸濄儺鏆柛?-->
<Border IsVisible="{Binding SelectedComponent.IsPreviewFailed}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<fi:FluentIcon Icon="ImageOff"
IconVariant="Regular"
FontSize="48"
Opacity="0.5"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.PreviewStatusText}"/>
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding SelectedComponent.PreviewErrorMessage}"/>
</StackPanel>
</Border>
</Grid>
<ContentControl x:Name="SelectedComponentPreviewHost"
Margin="16"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Focusable="False"/>
</Border>
<!-- "婵犵數鍎戠紞鈧い鏇嗗嫭鍙忛柣鎰悁閻掑﹪鐓崶銊︾闁活厼顑呴湁?闂備礁婀遍…鍫ニ囬悽绋跨?- 闂備線娼荤拹鐔煎礉閹存繍鐒藉ù鍏兼綑缁狙囨煕椤垵鏋涢柡浣哥埣閹﹢鎮欓崣澶婃闂佺厧鐏氶崹鍧楀极瀹ュ洣娌柣鎾崇岸閺嬪繘姊哄ú缁樺▏闁告柨顑囬埀顒勬涧閺堫剟鏁嶉幇顑╃喖宕崟顓犵暢闂佽崵濮撮鍛村疮閾忣偆鐝?-->
<Button HorizontalAlignment="Center"
Classes="accent"
Padding="24,10"
@@ -203,12 +119,12 @@
</Border>
</Panel>
<!-- 缂傚倷绀侀惌浣糕枍閿濆棙鍙忛柟闂寸缁?-->
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MinHeight="400">
<StackPanel Spacing="16" HorizontalAlignment="Center"
<StackPanel Spacing="16"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<fi:FluentIcon Icon="Apps"
IconVariant="Regular"

View File

@@ -11,7 +11,6 @@ using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views.Components;
using Avalonia.Controls.ApplicationLifetimes;
namespace LanMountainDesktop.Views;
@@ -19,18 +18,19 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
{
public event EventHandler<string>? AddComponentRequested;
private readonly ComponentLibraryWindowViewModel _viewModel = new();
private List<DesktopComponentDefinition> _allDefinitions = new();
private static readonly LocalizationService LocalizationService = new();
private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
private readonly ComponentLibraryWindowViewModel _viewModel = new();
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
private readonly IWeatherInfoService _weatherDataService;
private readonly TimeZoneService _timeZoneService;
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private static readonly LocalizationService _localizationService = new();
private List<DesktopComponentDefinition> _allDefinitions = new();
private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
private Control? _selectedPreviewControl;
public FusedDesktopComponentLibraryControl()
{
@@ -43,10 +43,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
LoadRegistry();
LoadCategories();
// 为 ListBoxItem 添加 category-item 样式类
CategoryListBox.ContainerPrepared += OnCategoryListBoxContainerPrepared;
// 默认选择第一个分类
if (_viewModel.Categories.Count > 0)
{
CategoryListBox.SelectedIndex = 0;
@@ -55,6 +52,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private void OnCategoryListBoxContainerPrepared(object? sender, ContainerPreparedEventArgs e)
{
_ = sender;
if (e.Container is ListBoxItem listBoxItem)
{
listBoxItem.Classes.Add("category-item");
@@ -71,7 +69,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
_settingsFacade);
_allDefinitions = _componentRegistry.GetAll()
.Where(d => d.AllowDesktopPlacement)
.Where(static definition => definition.AllowDesktopPlacement)
.ToList();
}
@@ -80,8 +78,6 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
_viewModel.Categories.Clear();
var languageCode = _settingsFacade.Region.Get().LanguageCode;
// 添加"全部组件"分类
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
"all",
L(languageCode, "component_category.all", "All"),
@@ -89,32 +85,26 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
Array.Empty<ComponentLibraryItemViewModel>()));
var usedCategories = _allDefinitions
.Select(d => d.Category)
.Distinct()
.Where(c => !string.IsNullOrEmpty(c));
.Select(static definition => definition.Category)
.Where(static category => !string.IsNullOrWhiteSpace(category))
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var cat in usedCategories)
foreach (var category in usedCategories)
{
var icon = ResolveCategoryIcon(cat);
var title = GetLocalizedCategoryTitle(languageCode, cat);
var categoryComponents = _allDefinitions
.Where(d => string.Equals(d.Category, cat, StringComparison.OrdinalIgnoreCase))
.OrderBy(d => d.DisplayName)
.Select(d => CreateComponentItem(d))
.Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase))
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
.Select(CreateComponentItem)
.ToArray();
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
cat,
title,
icon,
category,
GetLocalizedCategoryTitle(languageCode, category),
ResolveCategoryIcon(category),
categoryComponents));
}
}
/// <summary>
/// 分类图标映射 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
/// </summary>
private static Symbol ResolveCategoryIcon(string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock;
@@ -129,9 +119,6 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
return Symbol.Apps;
}
/// <summary>
/// 分类本地化标题 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
/// </summary>
private string GetLocalizedCategoryTitle(string languageCode, string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock");
@@ -148,101 +135,123 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private string L(string languageCode, string key, string fallback)
{
return _localizationService.GetString(languageCode, key, fallback);
return LocalizationService.GetString(languageCode, key, fallback);
}
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
private static ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
{
var previewKey = ComponentPreviewKey.ForComponentType(
definition.Id,
definition.MinWidthCells,
definition.MinHeightCells);
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
ComponentPreviewImageEntry? previewEntry = null;
if (mainWindow is not null)
{
previewEntry = mainWindow.GetPreviewEntry(previewKey);
}
var item = new ComponentLibraryItemViewModel(
definition.Id,
definition.DisplayName,
previewKey,
description: null,
"正在加载预览...",
"预览不可用",
previewEntry);
if (mainWindow is not null && (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending))
{
mainWindow.RequestDetachedLibraryPreview(previewKey);
}
return item;
}
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
{
foreach (var category in _viewModel.Categories)
{
foreach (var component in category.Components)
{
if (component.PreviewKey.Equals(entry.Key))
{
component.UpdatePreviewImageEntry(entry);
}
}
}
return new ComponentLibraryItemViewModel(definition.Id, definition.DisplayName);
}
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
UpdateSelectedComponent();
}
private void UpdateSelectedComponent()
{
var selectedCategory = CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel;
if (selectedCategory is null)
if (CategoryListBox.SelectedItem is not ComponentLibraryCategoryViewModel selectedCategory)
{
_viewModel.SelectedComponent = null;
SetSelectedPreviewControl(null);
return;
}
// 获取该分类下的组件列表
IEnumerable<DesktopComponentDefinition> filtered;
if (selectedCategory.Id == "all")
{
filtered = _allDefinitions.OrderBy(d => d.DisplayName);
}
else
{
filtered = _allDefinitions
.Where(d => string.Equals(d.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
.OrderBy(d => d.DisplayName);
}
var filtered = selectedCategory.Id == "all"
? _allDefinitions.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
: _allDefinitions
.Where(definition => string.Equals(definition.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase);
// 选择该分类下的第一个组件作为默认选中
var firstComponent = filtered.FirstOrDefault();
if (firstComponent is not null)
{
// 查找或创建对应的 ViewModel
var existingComponent = selectedCategory.Components.FirstOrDefault(c => c.ComponentId == firstComponent.Id);
if (existingComponent is not null)
{
_viewModel.SelectedComponent = existingComponent;
}
else
{
_viewModel.SelectedComponent = CreateComponentItem(firstComponent);
}
}
else
if (firstComponent is null)
{
_viewModel.SelectedComponent = null;
SetSelectedPreviewControl(null);
return;
}
_viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id)
?? CreateComponentItem(firstComponent);
SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent));
}
private Control? CreateStaticPreviewControl(DesktopComponentDefinition definition)
{
if (_componentRuntimeRegistry is null ||
!_componentRuntimeRegistry.TryGetDescriptor(definition.Id, out var descriptor))
{
return null;
}
try
{
var control = descriptor.CreateControl(
ResolvePreviewCellSize(definition),
_timeZoneService,
_weatherDataService,
_recommendationInfoService,
_calculatorDataService,
_settingsFacade,
placementId: null,
renderMode: DesktopComponentRenderMode.LibraryPreview);
ComponentPreviewRuntimeQuiescer.Attach(control);
return control;
}
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
{
AppLogger.Warn(
"ComponentLibrary",
$"Failed to create static fused preview for component '{definition.Id}'.",
ex);
return null;
}
}
private static double ResolvePreviewCellSize(DesktopComponentDefinition definition)
{
const double maxWidth = 360d;
const double maxHeight = 240d;
return Math.Clamp(
Math.Min(
maxWidth / Math.Max(1, definition.MinWidthCells),
maxHeight / Math.Max(1, definition.MinHeightCells)),
32d,
96d);
}
private void SetSelectedPreviewControl(Control? control)
{
DisposeSelectedPreviewControl();
_selectedPreviewControl = control;
if (SelectedComponentPreviewHost is not null)
{
SelectedComponentPreviewHost.Content = control;
}
}
private void DisposeSelectedPreviewControl()
{
if (_selectedPreviewControl is null)
{
return;
}
ComponentPreviewRuntimeQuiescer.Detach(_selectedPreviewControl);
if (_selectedPreviewControl is IDisposable disposable)
{
disposable.Dispose();
}
_selectedPreviewControl = null;
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
DisposeSelectedPreviewControl();
base.OnDetachedFromVisualTree(e);
}
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
@@ -255,15 +264,11 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
{
// 打开设置窗口并导航到插件目录页面
if (Application.Current is App app)
{
app.OpenIndependentSettingsModule("FusedDesktopComponentLibrary", "plugin-catalog");
}
// 关闭所在窗口
var window = this.FindAncestorOfType<Window>();
var componentLibraryWindow = this.FindAncestorOfType<Window>();
componentLibraryWindow?.Close();
this.FindAncestorOfType<Window>()?.Close();
}
}

View File

@@ -118,9 +118,4 @@ public partial class FusedDesktopComponentLibraryWindow : Window
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
mainWindow?.UnregisterFusedLibraryWindow(this);
}
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
{
LibraryControl.UpdatePreviewImage(entry);
}
}

View File

@@ -1,15 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -18,412 +11,117 @@ namespace LanMountainDesktop.Views;
public partial class MainWindow : Window
{
private const double PreviewRenderCellSizeMin = 42;
private const double PreviewRenderCellSizeMax = 112;
private readonly IComponentPreviewImageService _componentPreviewImageService = new ComponentPreviewImageService();
private readonly Dictionary<ComponentPreviewKey, List<ComponentLibraryPreviewVisualTarget>> _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance);
private bool _componentLibraryPreviewWarmupStarted;
private FusedDesktopComponentLibraryWindow? _fusedLibraryWindow;
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<IImage?> EnsureComponentTypePreviewImageAsync(string componentId, int widthCells, int heightCells)
private Control CreateStaticComponentLibraryPreview(
string componentId,
double cellSize,
double previewWidth,
double previewHeight)
{
if (string.IsNullOrWhiteSpace(componentId))
{
return null;
return CreateStaticComponentPreviewFallback(previewWidth, previewHeight);
}
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
var cached = ResolvePreviewImageFromService(key);
if (cached is not null)
{
ApplyPreviewEntryToEmbeddedVisuals(key);
return cached;
}
var context = new ComponentLibraryCreateContext(
cellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService,
_calculatorDataService,
_settingsFacade,
PlacementId: null,
RenderMode: DesktopComponentRenderMode.LibraryPreview);
var entry = await QueuePreviewGenerationAsync(
key,
pageIndex: null,
action: "ComponentTypePreview",
forceRefresh: false);
return entry.Bitmap;
}
private async Task<IImage?> RefreshPlacementPreviewImageAsync(DesktopComponentPlacementSnapshot? placement, bool forceRefresh)
{
if (placement is null ||
string.IsNullOrWhiteSpace(placement.ComponentId) ||
string.IsNullOrWhiteSpace(placement.PlacementId))
if (!_componentLibraryService.TryCreateControl(componentId, context, out var control, out var exception) ||
control is null)
{
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)
if (exception is not null)
{
return cached;
AppLogger.Warn(
"ComponentLibrary",
$"Failed to create static preview for component '{componentId}'.",
exception);
}
}
else
{
_componentPreviewImageService.RemovePlacementPreviews(snapshot.PlacementId);
return CreateStaticComponentPreviewFallback(previewWidth, previewHeight);
}
var entry = await QueuePreviewGenerationAsync(
key,
snapshot.PageIndex,
action: "PlacementPreview",
forceRefresh: false);
if (!IsPlacementPresent(snapshot.PlacementId))
{
RemovePlacementPreviewImage(snapshot.PlacementId);
return null;
}
return entry.Bitmap;
control.Width = previewWidth;
control.Height = previewHeight;
ComponentPreviewRuntimeQuiescer.Attach(control);
return control;
}
private async Task<ComponentPreviewImageEntry> QueuePreviewGenerationAsync(
ComponentPreviewKey key,
int? pageIndex,
string action,
bool forceRefresh,
CancellationToken cancellationToken = default)
private Control CreateStaticComponentPreviewFallback(double previewWidth, double previewHeight)
{
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<IImage?> 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
return new Border
{
Width = previewWidth,
Height = previewHeight,
Background = Brushes.Transparent,
ClipToBounds = true,
Child = previewControl
Background = GetThemeBrush("AdaptiveCardBackgroundBrush"),
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
BorderThickness = new Avalonia.Thickness(1),
CornerRadius = new Avalonia.CornerRadius(Math.Clamp(Math.Min(previewWidth, previewHeight) * 0.18, 12, 28)),
IsHitTestVisible = false,
Child = new TextBlock
{
Text = L("component_library.preview_unavailable", "Preview unavailable"),
FontSize = 11,
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush"),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
}
};
}
Canvas.SetLeft(stage, -20000);
Canvas.SetTop(stage, -20000);
ComponentPreviewStagingHost.Children.Add(stage);
try
private static void DisposeStaticComponentLibraryPreviews(IEnumerable<Control> roots)
{
foreach (var control in roots.SelectMany(EnumerateControls))
{
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)
ComponentPreviewRuntimeQuiescer.Detach(control);
if (control is IDisposable disposable)
{
disposableControl.Dispose();
disposable.Dispose();
}
}
}
private static async Task WaitForPreviewRenderPassAsync()
private static IEnumerable<Control> EnumerateControls(Control root)
{
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Background);
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Render);
}
yield return root;
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.CornerRadiusStyle}|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)
if (root is Panel panel)
{
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)
foreach (var child in panel.Children.OfType<Control>())
{
return placementImage;
foreach (var descendant in EnumerateControls(child))
{
yield return descendant;
}
}
}
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)
if (root is ContentControl { Content: Control content })
{
return NormalizeComponentCellSpan(componentId, (widthCells.Value, heightCells.Value));
foreach (var descendant in EnumerateControls(content))
{
yield return descendant;
}
}
if (!string.IsNullOrWhiteSpace(placementId) &&
TryGetDesktopPlacementById(placementId, out var placement))
if (root is Decorator { Child: Control decoratorChild })
{
return NormalizeComponentCellSpan(componentId, (placement.WidthCells, placement.HeightCells));
foreach (var descendant in EnumerateControls(decoratorChild))
{
yield return descendant;
}
}
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(
@@ -432,9 +130,12 @@ public partial class MainWindow : Window
int? widthCells = null,
int? heightCells = null)
{
var span = ResolveOverlayPreviewSpan(componentId, placementId, widthCells, heightCells);
_ = componentId;
_ = placementId;
_ = widthCells;
_ = heightCells;
EnsureDesktopEditOverlayPresenter();
_desktopEditOverlayPresenter?.SetPreviewImage(ResolveDesktopEditPreviewImage(componentId, placementId, span.WidthCells, span.HeightCells));
_desktopEditOverlayPresenter?.SetPreviewImage(null);
}
private void PrimeDesktopEditPreviewImage(
@@ -444,164 +145,28 @@ public partial class MainWindow : Window
int widthCells,
int heightCells)
{
_ = componentId;
_ = placementId;
_ = 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);
}
_ = widthCells;
_ = heightCells;
}
private void QueuePlacementPreviewRefresh(DesktopComponentPlacementSnapshot? placement)
{
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: true);
_ = placement;
}
private void RemovePlacementPreviewImage(string? placementId)
{
if (string.IsNullOrWhiteSpace(placementId))
{
return;
}
_componentPreviewImageService.RemovePlacementPreviews(placementId);
_ = placementId;
}
private void RemovePlacementPreviewImages(IEnumerable<DesktopComponentPlacementSnapshot> placements)
{
foreach (var placementId in placements
.Select(placement => placement.PlacementId)
.Where(static id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
RemovePlacementPreviewImage(placementId);
}
_ = placements;
}
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);
_fusedLibraryWindow?.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);
}
// FusedDesktop 支持
public void RegisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
{
_fusedLibraryWindow = window;
@@ -614,15 +179,4 @@ public partial class MainWindow : Window
_fusedLibraryWindow = null;
}
}
public ComponentPreviewImageEntry? GetPreviewEntry(ComponentPreviewKey key)
{
return ResolvePreviewEntry(key);
}
public void RequestDetachedLibraryPreview(ComponentPreviewKey key)
{
RequestDetachedLibraryPreviewWarm(key);
RequestDetachedLibraryPreviewRender(key);
}
}

View File

@@ -1480,13 +1480,11 @@ public partial class MainWindow : Window
_weatherDataService,
_recommendationInfoService,
_calculatorDataService,
_settingsFacade);
_settingsFacade,
PlacementId: null,
RenderMode: DesktopComponentRenderMode.LibraryPreview);
},
L,
previewKeyResolver: ResolveDetachedLibraryPreviewKey,
previewEntryResolver: ResolveDetachedLibraryPreviewEntry,
warmPreviewRequested: RequestDetachedLibraryPreviewWarm,
renderPreviewRequested: RequestDetachedLibraryPreviewRender);
L);
window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested;
window.Closed += OnDetachedComponentLibraryClosed;
return window;
@@ -3620,7 +3618,6 @@ public partial class MainWindow : Window
var category = _componentLibraryCategories[_componentLibraryCategoryIndex];
_componentLibraryActiveCategoryId = category.Id;
_componentLibraryComponentIndex = 0;
_ = WarmComponentLibraryCategoryPreviewsAsync(category);
BuildComponentLibraryComponentPages(category);
ShowComponentLibraryComponentsView();
}
@@ -3638,10 +3635,10 @@ public partial class MainWindow : Window
var componentCount = _componentLibraryActiveComponents.Count;
ClearTimeZoneServiceBindings(ComponentLibraryComponentPagesContainer.Children.OfType<Control>().ToList());
DisposeStaticComponentLibraryPreviews(ComponentLibraryComponentPagesContainer.Children.OfType<Control>());
ComponentLibraryComponentPagesContainer.Children.Clear();
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
ClearComponentLibraryPreviewVisualTargets();
if (componentCount == 0)
{
_componentLibraryComponentIndex = 0;
@@ -3715,51 +3712,22 @@ public partial class MainWindow : Window
var previewWidth = previewSpan.WidthCells * previewCellSize;
var previewHeight = previewSpan.HeightCells * previewCellSize;
var previewKey = CreateComponentTypePreviewKey(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
var cachedPreviewImage = ResolveComponentTypePreviewImage(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
var previewControl = CreateStaticComponentLibraryPreview(
component.ComponentId,
previewCellSize,
previewWidth,
previewHeight);
var previewImage = new Image
var previewSurface = new Border
{
Width = previewWidth,
Height = previewHeight,
Stretch = Stretch.Uniform,
Source = cachedPreviewImage,
IsVisible = cachedPreviewImage is not null,
Background = Brushes.Transparent,
ClipToBounds = false,
Child = previewControl,
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
{
Width = previewWidth,
@@ -3807,15 +3775,6 @@ public partial class MainWindow : Window
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;
@@ -3837,10 +3796,10 @@ public partial class MainWindow : Window
}
ClearTimeZoneServiceBindings(ComponentLibraryComponentPagesContainer.Children.OfType<Control>().ToList());
DisposeStaticComponentLibraryPreviews(ComponentLibraryComponentPagesContainer.Children.OfType<Control>());
ComponentLibraryComponentPagesContainer.Children.Clear();
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
ClearComponentLibraryPreviewVisualTargets();
}
private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component)

View File

@@ -123,7 +123,7 @@ public partial class MainWindow : Window
return;
}
_componentLibraryCollapsePresenter.SyncExpandedState(ComponentLibraryWindow.Margin, ComponentLibraryWindow.Opacity);
_componentLibraryCollapsePresenter.SyncExpandedState(ComponentLibraryWindow.Margin);
}
private void CollapseComponentLibraryForDesktopEdit(string? title)

View File

@@ -225,14 +225,6 @@
<Canvas x:Name="DesktopEditDragLayer"
IsHitTestVisible="False" />
<Canvas x:Name="ComponentPreviewStagingHost"
Width="1"
Height="1"
Opacity="0"
ClipToBounds="True"
HorizontalAlignment="Left"
VerticalAlignment="Top"
IsHitTestVisible="False" />
</Grid>
</Border>
@@ -627,7 +619,7 @@
<Border x:Name="ComponentLibraryWindow"
IsVisible="False"
Opacity="0"
Classes="surface-translucent-strong"
Background="Transparent"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Width="620"
@@ -636,8 +628,6 @@
Height="320"
MinHeight="260"
Margin="24,24,24,100"
CornerRadius="36"
Padding="14"
PointerPressed="OnComponentLibraryWindowPointerPressed"
PointerMoved="OnComponentLibraryWindowPointerMoved"
PointerReleased="OnComponentLibraryWindowPointerReleased">
@@ -647,142 +637,146 @@
</Transitions>
</Border.Transitions>
<Grid RowDefinitions="Auto,*"
RowSpacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="ComponentLibraryTitleTextBlock"
VerticalAlignment="Center"
FontSize="16"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Widgets" />
<Button x:Name="CloseComponentLibraryButton"
Grid.Column="1"
Padding="8"
Width="32"
Height="32"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnCloseComponentLibraryClick">
<fi:SymbolIcon Classes="icon-s"
Symbol="Dismiss"
IconVariant="Regular" />
</Button>
</Grid>
<Border Grid.Row="1"
Classes="surface-translucent-panel"
CornerRadius="12"
Padding="14">
<Grid>
<!-- Category picker (outer) -->
<Grid x:Name="ComponentLibraryCategoriesView">
<Grid RowDefinitions="*">
<Border x:Name="ComponentLibraryCategoryViewport"
Background="Transparent"
ClipToBounds="True">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid x:Name="ComponentLibraryCategoryPagesHost"
HorizontalAlignment="Stretch"
VerticalAlignment="Top">
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
</Grid>
</ScrollViewer>
</Border>
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No components." />
</Grid>
</Grid>
<!-- Component picker (inner) -->
<Grid x:Name="ComponentLibraryComponentsView"
IsVisible="False"
RowDefinitions="Auto,*"
RowSpacing="10">
<Button x:Name="ComponentLibraryBackButton"
Grid.Row="0"
HorizontalAlignment="Left"
Padding="8,6"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnComponentLibraryBackClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Classes="icon-s" Symbol="ArrowLeft" IconVariant="Regular" />
<TextBlock x:Name="ComponentLibraryBackTextBlock"
VerticalAlignment="Center"
Text="Back" />
</StackPanel>
</Button>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<Button x:Name="ComponentLibraryPrevComponentButton"
Grid.Column="0"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryPrevComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronLeft"
IconVariant="Regular" />
</Button>
<Border x:Name="ComponentLibraryComponentViewport"
Grid.Column="1"
Background="Transparent"
ClipToBounds="True"
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
<Grid>
<Grid x:Name="ComponentLibraryComponentPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<Grid.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform>
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
</Grid>
</Grid>
</Border>
<Button x:Name="ComponentLibraryNextComponentButton"
Grid.Column="2"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryNextComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronRight"
IconVariant="Regular" />
</Button>
</Grid>
</Grid>
<Border Classes="surface-translucent-strong"
CornerRadius="36"
Padding="14">
<Grid RowDefinitions="Auto,*"
RowSpacing="10">
<Grid ColumnDefinitions="*,Auto">
<TextBlock x:Name="ComponentLibraryTitleTextBlock"
VerticalAlignment="Center"
FontSize="16"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="Widgets" />
<Button x:Name="CloseComponentLibraryButton"
Grid.Column="1"
Padding="8"
Width="32"
Height="32"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnCloseComponentLibraryClick">
<fi:SymbolIcon Classes="icon-s"
Symbol="Dismiss"
IconVariant="Regular" />
</Button>
</Grid>
</Border>
</Grid>
<Border Grid.Row="1"
Classes="surface-translucent-panel"
CornerRadius="12"
Padding="14">
<Grid>
<!-- Category picker (outer) -->
<Grid x:Name="ComponentLibraryCategoriesView">
<Grid RowDefinitions="*">
<Border x:Name="ComponentLibraryCategoryViewport"
Background="Transparent"
ClipToBounds="True">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid x:Name="ComponentLibraryCategoryPagesHost"
HorizontalAlignment="Stretch"
VerticalAlignment="Top">
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
</Grid>
</ScrollViewer>
</Border>
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No components." />
</Grid>
</Grid>
<!-- Component picker (inner) -->
<Grid x:Name="ComponentLibraryComponentsView"
IsVisible="False"
RowDefinitions="Auto,*"
RowSpacing="10">
<Button x:Name="ComponentLibraryBackButton"
Grid.Row="0"
HorizontalAlignment="Left"
Padding="8,6"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Click="OnComponentLibraryBackClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Classes="icon-s" Symbol="ArrowLeft" IconVariant="Regular" />
<TextBlock x:Name="ComponentLibraryBackTextBlock"
VerticalAlignment="Center"
Text="Back" />
</StackPanel>
</Button>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<Button x:Name="ComponentLibraryPrevComponentButton"
Grid.Column="0"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryPrevComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronLeft"
IconVariant="Regular" />
</Button>
<Border x:Name="ComponentLibraryComponentViewport"
Grid.Column="1"
Background="Transparent"
ClipToBounds="True"
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
<Grid>
<Grid x:Name="ComponentLibraryComponentPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<Grid.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform>
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
</Grid>
</Grid>
</Border>
<Button x:Name="ComponentLibraryNextComponentButton"
Grid.Column="2"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryNextComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronRight"
IconVariant="Regular" />
</Button>
</Grid>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</Border>
<Border x:Name="ComponentLibraryCollapsedChipHost"