mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.7.5.1
精致
This commit is contained in:
257
LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs
Normal file
257
LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentPreviewImageServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var executionOrder = new List<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ internal sealed class DesktopEditGhostView : Border
|
||||
private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120);
|
||||
private static readonly Easing StandardEasing = new CubicEaseOut();
|
||||
|
||||
private readonly Image _previewImage;
|
||||
private readonly Border _previewOverlay;
|
||||
private readonly Border _fallbackCard;
|
||||
private readonly Border _accentDot;
|
||||
private readonly TextBlock _titleTextBlock;
|
||||
private readonly TextBlock _detailTextBlock;
|
||||
@@ -33,6 +36,9 @@ internal sealed class DesktopEditGhostView : Border
|
||||
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
|
||||
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
|
||||
|
||||
private bool _hasPreviewImage;
|
||||
private bool _isInvalid;
|
||||
|
||||
public DesktopEditGhostView()
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
@@ -47,27 +53,12 @@ internal sealed class DesktopEditGhostView : Border
|
||||
RenderTransform = _scaleTransform;
|
||||
Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = Visual.OpacityProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
CreateOpacityTransition(FastDuration)
|
||||
};
|
||||
_scaleTransform.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleXProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
},
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleYProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||
};
|
||||
|
||||
_accentDot = new Border
|
||||
@@ -119,6 +110,18 @@ internal sealed class DesktopEditGhostView : Border
|
||||
Child = _badgeTextBlock
|
||||
};
|
||||
|
||||
_previewImage = new Image
|
||||
{
|
||||
Stretch = Stretch.UniformToFill,
|
||||
IsVisible = false
|
||||
};
|
||||
|
||||
_previewOverlay = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#1A000000")),
|
||||
IsVisible = false
|
||||
};
|
||||
|
||||
var headerPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
@@ -140,7 +143,7 @@ internal sealed class DesktopEditGhostView : Border
|
||||
}
|
||||
};
|
||||
|
||||
var rootGrid = new Grid
|
||||
var fallbackGrid = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions
|
||||
{
|
||||
@@ -149,16 +152,31 @@ internal sealed class DesktopEditGhostView : Border
|
||||
},
|
||||
RowSpacing = 8
|
||||
};
|
||||
rootGrid.Children.Add(contentPanel);
|
||||
rootGrid.Children.Add(_badgeBorder);
|
||||
fallbackGrid.Children.Add(contentPanel);
|
||||
fallbackGrid.Children.Add(_badgeBorder);
|
||||
Grid.SetRow(contentPanel, 0);
|
||||
Grid.SetRow(_badgeBorder, 1);
|
||||
_badgeBorder.Margin = new Thickness(0, 2, 0, 0);
|
||||
|
||||
Child = rootGrid;
|
||||
_fallbackCard = new Border
|
||||
{
|
||||
Background = Brushes.Transparent,
|
||||
Child = fallbackGrid
|
||||
};
|
||||
|
||||
Child = new Grid
|
||||
{
|
||||
Children =
|
||||
{
|
||||
_previewImage,
|
||||
_previewOverlay,
|
||||
_fallbackCard
|
||||
}
|
||||
};
|
||||
|
||||
UpdatePreviewMetrics(180, 120);
|
||||
UpdateContent(null, null, null);
|
||||
ApplyShellChrome();
|
||||
}
|
||||
|
||||
public void UpdateContent(string? title, string? detail, string? badgeText)
|
||||
@@ -170,14 +188,32 @@ internal sealed class DesktopEditGhostView : Border
|
||||
_badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText);
|
||||
}
|
||||
|
||||
public void SetPreviewImage(IImage? image)
|
||||
{
|
||||
_previewImage.Source = image;
|
||||
_hasPreviewImage = image is not null;
|
||||
_previewImage.IsVisible = _hasPreviewImage;
|
||||
_previewOverlay.IsVisible = false;
|
||||
_fallbackCard.IsVisible = !_hasPreviewImage;
|
||||
ApplyShellChrome();
|
||||
}
|
||||
|
||||
public void UpdatePreviewMetrics(double width, double height)
|
||||
{
|
||||
var normalizedWidth = Math.Max(1, width);
|
||||
var normalizedHeight = Math.Max(1, height);
|
||||
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
|
||||
|
||||
CornerRadius = new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
|
||||
Padding = new Thickness(
|
||||
CornerRadius = _hasPreviewImage
|
||||
? new CornerRadius(Math.Clamp(minSide * 0.14, 14, 24))
|
||||
: new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
|
||||
Padding = _hasPreviewImage
|
||||
? new Thickness(
|
||||
Math.Clamp(minSide * 0.02, 1, 4),
|
||||
Math.Clamp(minSide * 0.02, 1, 4),
|
||||
Math.Clamp(minSide * 0.02, 1, 4),
|
||||
Math.Clamp(minSide * 0.02, 1, 4))
|
||||
: new Thickness(
|
||||
Math.Clamp(minSide * 0.10, 10, 18),
|
||||
Math.Clamp(minSide * 0.10, 10, 18),
|
||||
Math.Clamp(minSide * 0.10, 10, 18),
|
||||
@@ -200,30 +236,48 @@ internal sealed class DesktopEditGhostView : Border
|
||||
|
||||
public void SetInvalid(bool isInvalid)
|
||||
{
|
||||
_isInvalid = isInvalid;
|
||||
|
||||
if (isInvalid)
|
||||
{
|
||||
Background = _invalidBackgroundBrush;
|
||||
BorderBrush = _invalidBorderBrush;
|
||||
_accentDot.Background = _invalidAccentBrush;
|
||||
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
|
||||
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
|
||||
_titleTextBlock.Foreground = _invalidBorderBrush;
|
||||
_detailTextBlock.Foreground = _invalidBorderBrush;
|
||||
_badgeTextBlock.Foreground = _invalidBorderBrush;
|
||||
if (!_hasPreviewImage)
|
||||
{
|
||||
Background = _invalidBackgroundBrush;
|
||||
BorderBrush = _invalidBorderBrush;
|
||||
BorderThickness = new Thickness(1);
|
||||
Opacity = 0.9;
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyShellChrome();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Background = _normalBackgroundBrush;
|
||||
BorderBrush = _normalBorderBrush;
|
||||
_accentDot.Background = _normalAccentBrush;
|
||||
_badgeBorder.Background = _normalBadgeBackgroundBrush;
|
||||
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
|
||||
_titleTextBlock.Foreground = _normalTextBrush;
|
||||
_detailTextBlock.Foreground = _normalMutedTextBrush;
|
||||
_badgeTextBlock.Foreground = _normalTextBrush;
|
||||
if (!_hasPreviewImage)
|
||||
{
|
||||
Background = _normalBackgroundBrush;
|
||||
BorderBrush = _normalBorderBrush;
|
||||
BorderThickness = new Thickness(1);
|
||||
Opacity = 1.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyShellChrome();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetRestingScale(double scale)
|
||||
{
|
||||
@@ -238,4 +292,67 @@ internal sealed class DesktopEditGhostView : Border
|
||||
_scaleTransform.ScaleX = clampedScale;
|
||||
_scaleTransform.ScaleY = clampedScale;
|
||||
}
|
||||
|
||||
internal bool HasPreviewImage => _hasPreviewImage;
|
||||
|
||||
internal void SetScaleTransitionDuration(TimeSpan duration)
|
||||
{
|
||||
_scaleTransform.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, duration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, duration)
|
||||
};
|
||||
}
|
||||
|
||||
internal void SetOpacityTransitionDuration(TimeSpan duration)
|
||||
{
|
||||
Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(duration)
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyShellChrome()
|
||||
{
|
||||
if (_hasPreviewImage)
|
||||
{
|
||||
Background = Brushes.Transparent;
|
||||
BorderBrush = Brushes.Transparent;
|
||||
BorderThickness = new Thickness(0);
|
||||
BoxShadow = BoxShadows.Parse("0 14 32 #1A000000");
|
||||
Opacity = 1.0;
|
||||
return;
|
||||
}
|
||||
|
||||
BoxShadow = default;
|
||||
if (_isInvalid)
|
||||
{
|
||||
Background = _invalidBackgroundBrush;
|
||||
BorderBrush = _invalidBorderBrush;
|
||||
BorderThickness = new Thickness(1);
|
||||
Opacity = 0.9;
|
||||
return;
|
||||
}
|
||||
|
||||
Background = _normalBackgroundBrush;
|
||||
BorderBrush = _normalBorderBrush;
|
||||
BorderThickness = new Thickness(1);
|
||||
Opacity = 1.0;
|
||||
}
|
||||
|
||||
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Property = property,
|
||||
Duration = duration,
|
||||
Easing = StandardEasing
|
||||
};
|
||||
|
||||
private static DoubleTransition CreateOpacityTransition(TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Property = Visual.OpacityProperty,
|
||||
Duration = duration,
|
||||
Easing = StandardEasing
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ internal enum DesktopEditGhostVisualStyle
|
||||
internal sealed class DesktopEditOverlayPresenter
|
||||
{
|
||||
private static readonly TimeSpan FastDuration = FluttermotionToken.Fast;
|
||||
private static readonly TimeSpan PickupDuration = TimeSpan.FromMilliseconds(160);
|
||||
private static readonly TimeSpan CommitSettleDuration = TimeSpan.FromMilliseconds(160);
|
||||
private static readonly TimeSpan CancelSettleDuration = TimeSpan.FromMilliseconds(120);
|
||||
private static readonly Easing StandardEasing = new CubicEaseOut();
|
||||
|
||||
private readonly Canvas _root;
|
||||
@@ -31,10 +34,10 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
private bool _isVisible;
|
||||
private int _dismissVersion;
|
||||
|
||||
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF4F8EF7"));
|
||||
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF6B6B"));
|
||||
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#224F8EF7"));
|
||||
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#22FF6B6B"));
|
||||
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
|
||||
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF3B30"));
|
||||
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#140A84FF"));
|
||||
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30"));
|
||||
|
||||
public DesktopEditOverlayPresenter()
|
||||
{
|
||||
@@ -66,18 +69,8 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
};
|
||||
_candidateScale.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleXProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
},
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = ScaleTransform.ScaleYProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||
};
|
||||
|
||||
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
||||
@@ -98,12 +91,7 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
|
||||
_root.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = Visual.OpacityProperty,
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
CreateOpacityTransition(FastDuration)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +120,11 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
_ghostView.UpdateContent(title, detail, badge);
|
||||
}
|
||||
|
||||
public void SetPreviewImage(IImage? image)
|
||||
{
|
||||
_ghostView.SetPreviewImage(image);
|
||||
}
|
||||
|
||||
public void SetInvalid(bool isInvalid)
|
||||
{
|
||||
_isInvalid = isInvalid;
|
||||
@@ -146,12 +139,40 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
_root.IsVisible = true;
|
||||
_root.Opacity = 0;
|
||||
_ghostView.Opacity = 0;
|
||||
var initialGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.02 : 0.985;
|
||||
var targetGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.06 : 1;
|
||||
var imageMode = _ghostView.HasPreviewImage;
|
||||
var initialGhostScale = 0.985;
|
||||
var targetGhostScale = 1.0;
|
||||
|
||||
if (visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary)
|
||||
{
|
||||
initialGhostScale = 1.02;
|
||||
targetGhostScale = 1.06;
|
||||
}
|
||||
else if (imageMode)
|
||||
{
|
||||
initialGhostScale = 0.992;
|
||||
targetGhostScale = 1.03;
|
||||
}
|
||||
|
||||
_root.Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(PickupDuration)
|
||||
};
|
||||
_ghostView.SetOpacityTransitionDuration(PickupDuration);
|
||||
_ghostView.SetScaleTransitionDuration(PickupDuration);
|
||||
_candidateScale.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration)
|
||||
};
|
||||
_candidateOutline.Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(PickupDuration)
|
||||
};
|
||||
_ghostView.SetRestingScale(initialGhostScale);
|
||||
_candidateOutline.Opacity = 0;
|
||||
_candidateScale.ScaleX = 0.96;
|
||||
_candidateScale.ScaleY = 0.96;
|
||||
_candidateScale.ScaleX = 0.97;
|
||||
_candidateScale.ScaleY = 0.97;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
@@ -182,6 +203,7 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
_candidateScale.ScaleX = 0.96;
|
||||
_candidateScale.ScaleY = 0.96;
|
||||
_ghostView.SetRestingScale(0.96);
|
||||
_ghostView.SetPreviewImage(null);
|
||||
_root.IsVisible = false;
|
||||
}
|
||||
|
||||
@@ -204,11 +226,29 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
|
||||
var version = ++_dismissVersion;
|
||||
_isVisible = false;
|
||||
var settleDuration = isCancel ? CancelSettleDuration : CommitSettleDuration;
|
||||
_root.Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(settleDuration)
|
||||
};
|
||||
_ghostView.SetOpacityTransitionDuration(settleDuration);
|
||||
_ghostView.SetScaleTransitionDuration(settleDuration);
|
||||
_candidateScale.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration)
|
||||
};
|
||||
_candidateOutline.Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(settleDuration)
|
||||
};
|
||||
var targetScale = _ghostView.HasPreviewImage
|
||||
? 1.00
|
||||
: isCancel ? 0.96 : 1.04;
|
||||
|
||||
_candidateOutline.Opacity = 0;
|
||||
_ghostView.Opacity = 0;
|
||||
_root.Opacity = 0;
|
||||
|
||||
var targetScale = isCancel ? 0.96 : 1.04;
|
||||
_ghostView.AnimateToScale(targetScale);
|
||||
_candidateScale.ScaleX = targetScale;
|
||||
_candidateScale.ScaleY = targetScale;
|
||||
@@ -257,13 +297,13 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
Canvas.SetLeft(_candidateOutline, rect.X);
|
||||
Canvas.SetTop(_candidateOutline, rect.Y);
|
||||
|
||||
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.12, 14, 28);
|
||||
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26);
|
||||
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
|
||||
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||
_candidateOutline.Opacity = _isVisible ? 1 : 0;
|
||||
_candidateScale.ScaleX = _isVisible ? 1 : 0.96;
|
||||
_candidateScale.ScaleY = _isVisible ? 1 : 0.96;
|
||||
_candidateScale.ScaleX = _isVisible ? 1 : 0.97;
|
||||
_candidateScale.ScaleY = _isVisible ? 1 : 0.97;
|
||||
UpdateCandidateAppearance();
|
||||
}
|
||||
|
||||
@@ -284,4 +324,20 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
var height = Math.Max(1, rect.Height);
|
||||
return new Rect(rect.X, rect.Y, width, height);
|
||||
}
|
||||
|
||||
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Property = property,
|
||||
Duration = duration,
|
||||
Easing = StandardEasing
|
||||
};
|
||||
|
||||
private static DoubleTransition CreateOpacityTransition(TimeSpan duration) =>
|
||||
new()
|
||||
{
|
||||
Property = Visual.OpacityProperty,
|
||||
Duration = duration,
|
||||
Easing = StandardEasing
|
||||
};
|
||||
}
|
||||
|
||||
261
LanMountainDesktop/Services/ComponentPreviewImageService.cs
Normal file
261
LanMountainDesktop/Services/ComponentPreviewImageService.cs
Normal file
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class ComponentPreviewImageService : IComponentPreviewImageService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<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();
|
||||
}
|
||||
}
|
||||
281
LanMountainDesktop/Services/ComponentPreviewTypes.cs
Normal file
281
LanMountainDesktop/Services/ComponentPreviewTypes.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum ComponentPreviewKeyKind
|
||||
{
|
||||
ComponentType = 0,
|
||||
PlacementInstance = 1
|
||||
}
|
||||
|
||||
public readonly record struct ComponentPreviewKey
|
||||
{
|
||||
private ComponentPreviewKey(
|
||||
ComponentPreviewKeyKind kind,
|
||||
string componentTypeId,
|
||||
string? placementId,
|
||||
int widthCells,
|
||||
int heightCells)
|
||||
{
|
||||
Kind = kind;
|
||||
ComponentTypeId = NormalizeRequired(componentTypeId, nameof(componentTypeId));
|
||||
PlacementId = kind == ComponentPreviewKeyKind.PlacementInstance
|
||||
? NormalizeRequired(placementId, nameof(placementId))
|
||||
: null;
|
||||
WidthCells = NormalizeSpan(widthCells, nameof(widthCells));
|
||||
HeightCells = NormalizeSpan(heightCells, nameof(heightCells));
|
||||
}
|
||||
|
||||
public ComponentPreviewKeyKind Kind { get; }
|
||||
|
||||
public string ComponentTypeId { get; }
|
||||
|
||||
public string? PlacementId { get; }
|
||||
|
||||
public int WidthCells { get; }
|
||||
|
||||
public int HeightCells { get; }
|
||||
|
||||
public static ComponentPreviewKey ForComponentType(string componentTypeId, int widthCells, int heightCells)
|
||||
{
|
||||
return new ComponentPreviewKey(ComponentPreviewKeyKind.ComponentType, componentTypeId, null, widthCells, heightCells);
|
||||
}
|
||||
|
||||
public static ComponentPreviewKey ForPlacementInstance(string componentTypeId, string placementId, int widthCells, int heightCells)
|
||||
{
|
||||
return new ComponentPreviewKey(
|
||||
ComponentPreviewKeyKind.PlacementInstance,
|
||||
componentTypeId,
|
||||
placementId,
|
||||
widthCells,
|
||||
heightCells);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Kind == ComponentPreviewKeyKind.ComponentType
|
||||
? $"Type:{ComponentTypeId}[{WidthCells}x{HeightCells}]"
|
||||
: $"Placement:{ComponentTypeId}@{PlacementId}[{WidthCells}x{HeightCells}]";
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static int NormalizeSpan(int value, string paramName)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Span must be greater than zero.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ComponentPreviewImageState
|
||||
{
|
||||
Pending = 0,
|
||||
Ready = 1,
|
||||
Failed = 2
|
||||
}
|
||||
|
||||
public sealed class ComponentPreviewImageEntry : ObservableObject
|
||||
{
|
||||
private IImage? _bitmap;
|
||||
private ComponentPreviewImageState _state = ComponentPreviewImageState.Pending;
|
||||
private string _visualSignature = string.Empty;
|
||||
private string? _errorMessage;
|
||||
private long _revision;
|
||||
private DateTimeOffset _lastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
public ComponentPreviewImageEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
Key = key;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
}
|
||||
|
||||
public ComponentPreviewKey Key { get; }
|
||||
|
||||
public IImage? Bitmap
|
||||
{
|
||||
get => _bitmap;
|
||||
private set => SetProperty(ref _bitmap, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewImageState State
|
||||
{
|
||||
get => _state;
|
||||
private set => SetProperty(ref _state, value);
|
||||
}
|
||||
|
||||
public string VisualSignature
|
||||
{
|
||||
get => _visualSignature;
|
||||
private set => SetProperty(ref _visualSignature, value);
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
|
||||
public long Revision
|
||||
{
|
||||
get => _revision;
|
||||
private set => SetProperty(ref _revision, value);
|
||||
}
|
||||
|
||||
public DateTimeOffset LastUpdatedUtc
|
||||
{
|
||||
get => _lastUpdatedUtc;
|
||||
private set => SetProperty(ref _lastUpdatedUtc, value);
|
||||
}
|
||||
|
||||
internal long BeginGeneration(string visualSignature)
|
||||
{
|
||||
var normalizedVisualSignature = NormalizeSignature(visualSignature);
|
||||
var nextRevision = Revision + 1;
|
||||
Revision = nextRevision;
|
||||
VisualSignature = normalizedVisualSignature;
|
||||
State = ComponentPreviewImageState.Pending;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return nextRevision;
|
||||
}
|
||||
|
||||
internal bool TryApplyGeneratedBitmap(long expectedRevision, IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
if (Revision != expectedRevision)
|
||||
{
|
||||
DisposeIfNeeded(bitmap);
|
||||
return false;
|
||||
}
|
||||
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Ready;
|
||||
ReplaceBitmap(bitmap);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryApplyFailure(long expectedRevision, string visualSignature, string? errorMessage)
|
||||
{
|
||||
if (Revision != expectedRevision)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Failed;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void StoreBitmap(IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
Revision += 1;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Ready;
|
||||
ReplaceBitmap(bitmap);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void StoreFailure(string visualSignature, string? errorMessage)
|
||||
{
|
||||
Revision += 1;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Failed;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void Invalidate(string? visualSignature = null)
|
||||
{
|
||||
Revision += 1;
|
||||
if (visualSignature is not null)
|
||||
{
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
}
|
||||
|
||||
State = ComponentPreviewImageState.Pending;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void DisposeBitmap()
|
||||
{
|
||||
ReplaceBitmap(null);
|
||||
}
|
||||
|
||||
private void ReplaceBitmap(IImage? bitmap)
|
||||
{
|
||||
var previous = _bitmap;
|
||||
if (ReferenceEquals(previous, bitmap))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Bitmap = bitmap;
|
||||
DisposeIfNeeded(previous);
|
||||
}
|
||||
|
||||
private static void DisposeIfNeeded(IImage? bitmap)
|
||||
{
|
||||
if (bitmap is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeSignature(string? visualSignature)
|
||||
{
|
||||
return visualSignature?.Trim() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ComponentPreviewKeyComparer : IEqualityComparer<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();
|
||||
}
|
||||
}
|
||||
32
LanMountainDesktop/Services/IComponentPreviewImageService.cs
Normal file
32
LanMountainDesktop/Services/IComponentPreviewImageService.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IComponentPreviewImageService
|
||||
{
|
||||
ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null);
|
||||
|
||||
bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry);
|
||||
|
||||
IReadOnlyCollection<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);
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Controls;
|
||||
using System.ComponentModel;
|
||||
using LanMountainDesktop.Services;
|
||||
using FluentIcons.Common;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
|
||||
{
|
||||
public string Title { get; set; } = "Widgets";
|
||||
private string _title = "Widgets";
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set => SetProperty(ref _title, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
|
||||
|
||||
@@ -38,20 +46,134 @@ public sealed class ComponentLibraryCategoryViewModel
|
||||
}
|
||||
|
||||
public sealed class ComponentLibraryItemViewModel
|
||||
: ObservableObject
|
||||
{
|
||||
private readonly string _loadingPreviewText;
|
||||
private readonly string _previewUnavailableText;
|
||||
private string _displayName;
|
||||
private ComponentPreviewKey _previewKey;
|
||||
private ComponentPreviewImageEntry? _previewImageEntry;
|
||||
private ComponentPreviewImageState _previewState;
|
||||
private string? _previewErrorMessage;
|
||||
private string _previewStatusText;
|
||||
|
||||
public ComponentLibraryItemViewModel(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Control? previewControl)
|
||||
ComponentPreviewKey previewKey,
|
||||
string loadingPreviewText = "Loading preview...",
|
||||
string previewUnavailableText = "Preview unavailable",
|
||||
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||
{
|
||||
ComponentId = componentId;
|
||||
DisplayName = displayName;
|
||||
PreviewControl = previewControl;
|
||||
_displayName = displayName;
|
||||
_previewKey = previewKey;
|
||||
_loadingPreviewText = loadingPreviewText;
|
||||
_previewUnavailableText = previewUnavailableText;
|
||||
_previewStatusText = loadingPreviewText;
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
public string DisplayName
|
||||
{
|
||||
get => _displayName;
|
||||
set => SetProperty(ref _displayName, value);
|
||||
}
|
||||
|
||||
public Control? PreviewControl { get; }
|
||||
public ComponentPreviewKey PreviewKey
|
||||
{
|
||||
get => _previewKey;
|
||||
set => SetProperty(ref _previewKey, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry;
|
||||
|
||||
public object? PreviewBitmap => _previewImageEntry?.Bitmap;
|
||||
|
||||
public ComponentPreviewImageState PreviewState => _previewState;
|
||||
|
||||
public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending;
|
||||
|
||||
public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null;
|
||||
|
||||
public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed;
|
||||
|
||||
public string? PreviewErrorMessage => _previewErrorMessage;
|
||||
|
||||
public string PreviewStatusText => _previewStatusText;
|
||||
|
||||
public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry)
|
||||
{
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true);
|
||||
}
|
||||
|
||||
private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged)
|
||||
{
|
||||
if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_previewImageEntry is not null)
|
||||
{
|
||||
_previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged;
|
||||
}
|
||||
|
||||
_previewImageEntry = previewImageEntry;
|
||||
_previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||
_previewErrorMessage = previewImageEntry?.ErrorMessage;
|
||||
|
||||
_previewStatusText = _previewState switch
|
||||
{
|
||||
ComponentPreviewImageState.Ready => string.Empty,
|
||||
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||
? _previewUnavailableText
|
||||
: _previewErrorMessage!,
|
||||
_ => _loadingPreviewText
|
||||
};
|
||||
|
||||
if (_previewImageEntry is not null)
|
||||
{
|
||||
_previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged;
|
||||
}
|
||||
|
||||
RaisePreviewDependentProperties();
|
||||
}
|
||||
|
||||
private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (string.IsNullOrWhiteSpace(e.PropertyName) ||
|
||||
e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or
|
||||
nameof(ComponentPreviewImageEntry.State) or
|
||||
nameof(ComponentPreviewImageEntry.ErrorMessage))
|
||||
{
|
||||
_previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||
_previewErrorMessage = _previewImageEntry?.ErrorMessage;
|
||||
_previewStatusText = _previewState switch
|
||||
{
|
||||
ComponentPreviewImageState.Ready => string.Empty,
|
||||
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||
? _previewUnavailableText
|
||||
: _previewErrorMessage!,
|
||||
_ => _loadingPreviewText
|
||||
};
|
||||
|
||||
RaisePreviewDependentProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void RaisePreviewDependentProperties()
|
||||
{
|
||||
OnPropertyChanged(nameof(PreviewImageEntry));
|
||||
OnPropertyChanged(nameof(PreviewBitmap));
|
||||
OnPropertyChanged(nameof(PreviewState));
|
||||
OnPropertyChanged(nameof(IsPreviewPending));
|
||||
OnPropertyChanged(nameof(IsPreviewReady));
|
||||
OnPropertyChanged(nameof(IsPreviewFailed));
|
||||
OnPropertyChanged(nameof(PreviewErrorMessage));
|
||||
OnPropertyChanged(nameof(PreviewStatusText));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,48 @@
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
Padding="8">
|
||||
<ContentControl HorizontalAlignment="Center"
|
||||
<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"
|
||||
Content="{Binding PreviewControl}" />
|
||||
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>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
|
||||
@@ -14,6 +14,10 @@ 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()
|
||||
@@ -25,12 +29,20 @@ public partial class ComponentLibraryWindow : Window
|
||||
public ComponentLibraryWindow(
|
||||
IComponentLibraryService componentLibraryService,
|
||||
Func<double, ComponentLibraryCreateContext> createContextFactory,
|
||||
Func<string, string, string> localize)
|
||||
Func<string, string, string> localize,
|
||||
Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? previewKeyResolver = null,
|
||||
Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? previewEntryResolver = null,
|
||||
Action<ComponentPreviewKey>? warmPreviewRequested = null,
|
||||
Action<ComponentPreviewKey>? renderPreviewRequested = null)
|
||||
: this()
|
||||
{
|
||||
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
||||
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
||||
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
||||
_previewKeyResolver = previewKeyResolver;
|
||||
_previewEntryResolver = previewEntryResolver;
|
||||
_warmPreviewRequested = warmPreviewRequested;
|
||||
_renderPreviewRequested = renderPreviewRequested;
|
||||
Reload();
|
||||
}
|
||||
|
||||
@@ -38,9 +50,7 @@ public partial class ComponentLibraryWindow : Window
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
if (_componentLibraryService is null ||
|
||||
_createContextFactory is null ||
|
||||
_localize is null)
|
||||
if (_componentLibraryService is null || _localize is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -75,32 +85,26 @@ public partial class ComponentLibraryWindow : Window
|
||||
|
||||
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
|
||||
{
|
||||
if (_componentLibraryService is null ||
|
||||
_createContextFactory is null ||
|
||||
_localize is null)
|
||||
{
|
||||
return new ComponentLibraryItemViewModel(entry.ComponentId, entry.DisplayName, previewControl: null);
|
||||
}
|
||||
|
||||
Control? previewControl = null;
|
||||
_componentLibraryService.TryCreateControl(
|
||||
entry.ComponentId,
|
||||
_createContextFactory(42),
|
||||
out previewControl,
|
||||
out _);
|
||||
|
||||
if (previewControl is not null)
|
||||
{
|
||||
previewControl.IsHitTestVisible = false;
|
||||
previewControl.Focusable = false;
|
||||
}
|
||||
|
||||
return new ComponentLibraryItemViewModel(
|
||||
entry.ComponentId,
|
||||
string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
||||
var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
||||
? entry.DisplayName
|
||||
: _localize(entry.DisplayNameLocalizationKey, entry.DisplayName),
|
||||
previewControl);
|
||||
: _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName;
|
||||
var previewKey = ResolvePreviewKey(entry);
|
||||
var previewEntry = _previewEntryResolver?.Invoke(previewKey);
|
||||
var item = new ComponentLibraryItemViewModel(
|
||||
entry.ComponentId,
|
||||
displayName,
|
||||
previewKey,
|
||||
_localize?.Invoke("component_library.preview.loading", "Loading preview...") ?? "Loading preview...",
|
||||
_localize?.Invoke("component_library.preview.unavailable", "Preview unavailable") ?? "Preview unavailable",
|
||||
previewEntry);
|
||||
|
||||
if (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending)
|
||||
{
|
||||
_warmPreviewRequested?.Invoke(previewKey);
|
||||
_renderPreviewRequested?.Invoke(previewKey);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
@@ -118,6 +122,8 @@ public partial class ComponentLibraryWindow : Window
|
||||
{
|
||||
_viewModel.Components.Add(component);
|
||||
}
|
||||
|
||||
RequestPreviewWarmup(selectedCategory.Components);
|
||||
}
|
||||
|
||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||
@@ -140,6 +146,51 @@ public partial class ComponentLibraryWindow : Window
|
||||
Hide();
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry previewImageEntry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(previewImageEntry);
|
||||
|
||||
foreach (var category in _viewModel.Categories)
|
||||
{
|
||||
foreach (var component in category.Components)
|
||||
{
|
||||
if (component.PreviewKey.Equals(previewImageEntry.Key))
|
||||
{
|
||||
component.UpdatePreviewImageEntry(previewImageEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ComponentPreviewKey ResolvePreviewKey(ComponentLibraryComponentEntry entry)
|
||||
{
|
||||
if (_previewKeyResolver is not null)
|
||||
{
|
||||
return _previewKeyResolver(entry);
|
||||
}
|
||||
|
||||
return ComponentPreviewKey.ForComponentType(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
|
||||
}
|
||||
|
||||
private void RequestPreviewWarmup(IEnumerable<ComponentLibraryItemViewModel> components)
|
||||
{
|
||||
if (_warmPreviewRequested is null && _renderPreviewRequested is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var component in components)
|
||||
{
|
||||
if (!component.IsPreviewPending)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_warmPreviewRequested?.Invoke(component.PreviewKey);
|
||||
_renderPreviewRequested?.Invoke(component.PreviewKey);
|
||||
}
|
||||
}
|
||||
|
||||
private Symbol ResolveCategoryIcon(string categoryId)
|
||||
{
|
||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
600
LanMountainDesktop/Views/MainWindow.ComponentPreviewImages.cs
Normal file
600
LanMountainDesktop/Views/MainWindow.ComponentPreviewImages.cs
Normal file
@@ -0,0 +1,600 @@
|
||||
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;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
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 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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(componentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||
var cached = ResolvePreviewImageFromService(key);
|
||||
if (cached is not null)
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(key);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var entry = await QueuePreviewGenerationAsync(
|
||||
key,
|
||||
pageIndex: null,
|
||||
action: "ComponentTypePreview",
|
||||
forceRefresh: false);
|
||||
return entry.Bitmap;
|
||||
}
|
||||
|
||||
private async Task<IImage?> RefreshPlacementPreviewImageAsync(DesktopComponentPlacementSnapshot? placement, bool forceRefresh)
|
||||
{
|
||||
if (placement is null ||
|
||||
string.IsNullOrWhiteSpace(placement.ComponentId) ||
|
||||
string.IsNullOrWhiteSpace(placement.PlacementId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!IsPlacementPresent(placement.PlacementId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var snapshot = ClonePlacementSnapshot(placement);
|
||||
var key = CreatePlacementPreviewKey(
|
||||
snapshot.ComponentId,
|
||||
snapshot.PlacementId,
|
||||
snapshot.WidthCells,
|
||||
snapshot.HeightCells);
|
||||
if (!forceRefresh)
|
||||
{
|
||||
var cached = ResolvePreviewImageFromService(key);
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_componentPreviewImageService.RemovePlacementPreviews(snapshot.PlacementId);
|
||||
}
|
||||
|
||||
var entry = await QueuePreviewGenerationAsync(
|
||||
key,
|
||||
snapshot.PageIndex,
|
||||
action: "PlacementPreview",
|
||||
forceRefresh: false);
|
||||
if (!IsPlacementPresent(snapshot.PlacementId))
|
||||
{
|
||||
RemovePlacementPreviewImage(snapshot.PlacementId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Bitmap;
|
||||
}
|
||||
|
||||
private async Task<ComponentPreviewImageEntry> QueuePreviewGenerationAsync(
|
||||
ComponentPreviewKey key,
|
||||
int? pageIndex,
|
||||
string action,
|
||||
bool forceRefresh,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
|
||||
var visualSignature = BuildPreviewVisualSignature(key, renderCellSize);
|
||||
if (forceRefresh)
|
||||
{
|
||||
_componentPreviewImageService.Invalidate(key, visualSignature);
|
||||
}
|
||||
|
||||
var entry = await _componentPreviewImageService.QueueGenerationAsync(
|
||||
key,
|
||||
visualSignature,
|
||||
async ct =>
|
||||
{
|
||||
_ = ct;
|
||||
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
|
||||
!IsPlacementPresent(key.PlacementId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bitmap = await CapturePreviewImageAsync(
|
||||
key.ComponentTypeId,
|
||||
key.PlacementId,
|
||||
pageIndex,
|
||||
key.WidthCells,
|
||||
key.HeightCells,
|
||||
renderCellSize,
|
||||
action);
|
||||
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
|
||||
!IsPlacementPresent(key.PlacementId))
|
||||
{
|
||||
DisposeImageIfNeeded(bitmap);
|
||||
return null;
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
},
|
||||
cancellationToken);
|
||||
NotifyPreviewEntryUpdated(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async Task<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
|
||||
{
|
||||
Width = previewWidth,
|
||||
Height = previewHeight,
|
||||
Background = Brushes.Transparent,
|
||||
ClipToBounds = true,
|
||||
Child = previewControl
|
||||
};
|
||||
|
||||
Canvas.SetLeft(stage, -20000);
|
||||
Canvas.SetTop(stage, -20000);
|
||||
ComponentPreviewStagingHost.Children.Add(stage);
|
||||
|
||||
try
|
||||
{
|
||||
stage.Measure(new Size(previewWidth, previewHeight));
|
||||
stage.Arrange(new Rect(0, 0, previewWidth, previewHeight));
|
||||
stage.UpdateLayout();
|
||||
await WaitForPreviewRenderPassAsync();
|
||||
|
||||
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||
var pixelSize = new PixelSize(
|
||||
Math.Max(1, (int)Math.Ceiling(previewWidth * renderScale)),
|
||||
Math.Max(1, (int)Math.Ceiling(previewHeight * renderScale)));
|
||||
var bitmap = new RenderTargetBitmap(pixelSize, new Vector(96 * renderScale, 96 * renderScale));
|
||||
bitmap.Render(stage);
|
||||
return bitmap;
|
||||
}
|
||||
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentPreview",
|
||||
$"Action={action}; ComponentId={componentId}; PlacementId={placementId ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false",
|
||||
ex);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ComponentPreviewStagingHost.Children.Remove(stage);
|
||||
ClearTimeZoneServiceBindings(stage);
|
||||
if (previewControl is IDisposable disposableControl)
|
||||
{
|
||||
disposableControl.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForPreviewRenderPassAsync()
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Background);
|
||||
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private double ResolvePreviewRenderCellSize(int widthCells, int heightCells)
|
||||
{
|
||||
var baseCellSize = _currentDesktopCellSize > 0
|
||||
? _currentDesktopCellSize * 1.10
|
||||
: 74;
|
||||
var densityBoost = Math.Max(widthCells, heightCells) >= 4 ? 8 : 0;
|
||||
return Math.Clamp(baseCellSize + densityBoost, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
|
||||
}
|
||||
|
||||
private string BuildPreviewVisualSignature(ComponentPreviewKey key, double renderCellSize)
|
||||
{
|
||||
var appearance = _appearanceThemeService.GetCurrent();
|
||||
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.GlobalCornerRadiusScale:F3}|Accent={FormatSignatureColor(appearance.AccentColor)}");
|
||||
}
|
||||
|
||||
private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells)
|
||||
{
|
||||
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||
return ComponentPreviewKey.ForComponentType(componentId, span.WidthCells, span.HeightCells);
|
||||
}
|
||||
|
||||
private ComponentPreviewKey CreatePlacementPreviewKey(string componentId, string placementId, int widthCells, int heightCells)
|
||||
{
|
||||
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||
return ComponentPreviewKey.ForPlacementInstance(componentId, placementId, span.WidthCells, span.HeightCells);
|
||||
}
|
||||
|
||||
private bool IsPlacementPresent(string? placementId)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(placementId) &&
|
||||
_desktopComponentPlacements.Any(candidate =>
|
||||
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private string BuildCurrentVisualSignature(ComponentPreviewKey key)
|
||||
{
|
||||
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
|
||||
return BuildPreviewVisualSignature(key, renderCellSize);
|
||||
}
|
||||
|
||||
private bool TryGetReusablePreviewEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||
{
|
||||
if (!_componentPreviewImageService.TryGetEntry(key, out entry) ||
|
||||
entry is null ||
|
||||
entry.State != ComponentPreviewImageState.Ready ||
|
||||
entry.Bitmap is null)
|
||||
{
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = BuildCurrentVisualSignature(key);
|
||||
if (!string.Equals(entry.VisualSignature, expectedSignature, StringComparison.Ordinal))
|
||||
{
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private IImage? ResolvePreviewImageFromService(ComponentPreviewKey key)
|
||||
{
|
||||
if (!TryGetReusablePreviewEntry(key, out var entry) || entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Bitmap;
|
||||
}
|
||||
|
||||
private ComponentPreviewImageEntry? ResolvePreviewEntry(ComponentPreviewKey key)
|
||||
{
|
||||
if (!_componentPreviewImageService.TryGetEntry(key, out var entry) || entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.State != ComponentPreviewImageState.Ready)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
return TryGetReusablePreviewEntry(key, out var reusable) ? reusable : null;
|
||||
}
|
||||
|
||||
private IImage? ResolveComponentTypePreviewImage(string componentId, int widthCells, int heightCells)
|
||||
{
|
||||
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||
return ResolvePreviewImageFromService(key);
|
||||
}
|
||||
|
||||
private IImage? ResolveDesktopEditPreviewImage(string componentId, string? placementId, int widthCells, int heightCells)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(placementId))
|
||||
{
|
||||
var placementKey = CreatePlacementPreviewKey(componentId, placementId, widthCells, heightCells);
|
||||
var placementImage = ResolvePreviewImageFromService(placementKey);
|
||||
if (placementImage is not null)
|
||||
{
|
||||
return placementImage;
|
||||
}
|
||||
}
|
||||
|
||||
var componentTypeKey = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||
return ResolvePreviewImageFromService(componentTypeKey);
|
||||
}
|
||||
|
||||
private (int WidthCells, int HeightCells) ResolveOverlayPreviewSpan(
|
||||
string componentId,
|
||||
string? placementId,
|
||||
int? widthCells,
|
||||
int? heightCells)
|
||||
{
|
||||
if (widthCells is > 0 && heightCells is > 0)
|
||||
{
|
||||
return NormalizeComponentCellSpan(componentId, (widthCells.Value, heightCells.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||
TryGetDesktopPlacementById(placementId, out var placement))
|
||||
{
|
||||
return NormalizeComponentCellSpan(componentId, (placement.WidthCells, placement.HeightCells));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) &&
|
||||
string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase) &&
|
||||
_desktopEditSession.WidthCells > 0 &&
|
||||
_desktopEditSession.HeightCells > 0)
|
||||
{
|
||||
return NormalizeComponentCellSpan(componentId, (_desktopEditSession.WidthCells, _desktopEditSession.HeightCells));
|
||||
}
|
||||
|
||||
if (_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
|
||||
{
|
||||
return NormalizeComponentCellSpan(
|
||||
componentId,
|
||||
(descriptor.Definition.MinWidthCells, descriptor.Definition.MinHeightCells));
|
||||
}
|
||||
|
||||
return (1, 1);
|
||||
}
|
||||
|
||||
private void ApplyDesktopEditOverlayPreviewImage(
|
||||
string componentId,
|
||||
string? placementId,
|
||||
int? widthCells = null,
|
||||
int? heightCells = null)
|
||||
{
|
||||
var span = ResolveOverlayPreviewSpan(componentId, placementId, widthCells, heightCells);
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
_desktopEditOverlayPresenter?.SetPreviewImage(ResolveDesktopEditPreviewImage(componentId, placementId, span.WidthCells, span.HeightCells));
|
||||
}
|
||||
|
||||
private void PrimeDesktopEditPreviewImage(
|
||||
string componentId,
|
||||
string? placementId,
|
||||
int pageIndex,
|
||||
int widthCells,
|
||||
int heightCells)
|
||||
{
|
||||
_ = pageIndex;
|
||||
var normalized = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||
_ = EnsureComponentTypePreviewImageAsync(componentId, normalized.WidthCells, normalized.HeightCells);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||
TryGetDesktopPlacementById(placementId, out var placement))
|
||||
{
|
||||
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void QueuePlacementPreviewRefresh(DesktopComponentPlacementSnapshot? placement)
|
||||
{
|
||||
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: true);
|
||||
}
|
||||
|
||||
private void RemovePlacementPreviewImage(string? placementId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(placementId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_componentPreviewImageService.RemovePlacementPreviews(placementId);
|
||||
}
|
||||
|
||||
private void RemovePlacementPreviewImages(IEnumerable<DesktopComponentPlacementSnapshot> placements)
|
||||
{
|
||||
foreach (var placementId in placements
|
||||
.Select(placement => placement.PlacementId)
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
RemovePlacementPreviewImage(placementId);
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterComponentLibraryPreviewVisual(ComponentPreviewKey key, Image image, Control fallback)
|
||||
{
|
||||
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var visuals))
|
||||
{
|
||||
visuals = [];
|
||||
_componentLibraryPreviewVisualTargets[key] = visuals;
|
||||
}
|
||||
|
||||
visuals.Add(new ComponentLibraryPreviewVisualTarget(image, fallback));
|
||||
}
|
||||
|
||||
private void ClearComponentLibraryPreviewVisualTargets()
|
||||
{
|
||||
_componentLibraryPreviewVisualTargets.Clear();
|
||||
}
|
||||
|
||||
private void ApplyPreviewEntryToEmbeddedVisuals(ComponentPreviewKey key)
|
||||
{
|
||||
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var targets))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previewImage = ResolvePreviewImageFromService(key);
|
||||
foreach (var target in targets)
|
||||
{
|
||||
target.Image.Source = previewImage;
|
||||
target.Image.IsVisible = previewImage is not null;
|
||||
target.Fallback.IsVisible = previewImage is null;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyPreviewEntryUpdated(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(entry.Key);
|
||||
_detachedComponentLibraryWindow?.UpdatePreviewImage(entry);
|
||||
|
||||
if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||
{
|
||||
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, entry.Key.PlacementId);
|
||||
}
|
||||
else
|
||||
{
|
||||
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, placementId: null);
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static void DisposeImageIfNeeded(IImage? image)
|
||||
{
|
||||
if (image is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSignatureColor(Color color)
|
||||
{
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}");
|
||||
}
|
||||
|
||||
private void RefreshDesktopEditOverlayPreviewIfActive(string componentId, string? placementId)
|
||||
{
|
||||
if (_desktopEditOverlayPresenter is null ||
|
||||
(!_desktopEditSession.IsActive && !_isDesktopEditCommitPending) ||
|
||||
string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) ||
|
||||
!string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||
!string.Equals(_desktopEditSession.PlacementId, placementId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyDesktopEditOverlayPreviewImage(
|
||||
_desktopEditSession.ComponentId,
|
||||
_desktopEditSession.PlacementId,
|
||||
_desktopEditSession.WidthCells,
|
||||
_desktopEditSession.HeightCells);
|
||||
}
|
||||
|
||||
private ComponentPreviewKey ResolveDetachedLibraryPreviewKey(ComponentLibraryComponentEntry entry)
|
||||
{
|
||||
return CreateComponentTypePreviewKey(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
|
||||
}
|
||||
|
||||
private ComponentPreviewImageEntry? ResolveDetachedLibraryPreviewEntry(ComponentPreviewKey key)
|
||||
{
|
||||
return ResolvePreviewEntry(key);
|
||||
}
|
||||
|
||||
private void RequestDetachedLibraryPreviewWarm(ComponentPreviewKey key)
|
||||
{
|
||||
_ = QueuePreviewGenerationAsync(
|
||||
key,
|
||||
pageIndex: null,
|
||||
action: "DetachedLibraryWarm",
|
||||
forceRefresh: false);
|
||||
}
|
||||
|
||||
private void RequestDetachedLibraryPreviewRender(ComponentPreviewKey key)
|
||||
{
|
||||
_ = QueuePreviewGenerationAsync(
|
||||
key,
|
||||
pageIndex: null,
|
||||
action: "DetachedLibraryRender",
|
||||
forceRefresh: false);
|
||||
}
|
||||
}
|
||||
@@ -555,7 +555,11 @@ public partial class MainWindow
|
||||
_calculatorDataService,
|
||||
_settingsFacade);
|
||||
},
|
||||
L);
|
||||
L,
|
||||
previewKeyResolver: ResolveDetachedLibraryPreviewKey,
|
||||
previewEntryResolver: ResolveDetachedLibraryPreviewEntry,
|
||||
warmPreviewRequested: RequestDetachedLibraryPreviewWarm,
|
||||
renderPreviewRequested: RequestDetachedLibraryPreviewRender);
|
||||
window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested;
|
||||
window.Closed += OnDetachedComponentLibraryClosed;
|
||||
return window;
|
||||
@@ -867,6 +871,7 @@ public partial class MainWindow
|
||||
|
||||
_desktopComponentPlacements.Remove(placement);
|
||||
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
||||
RemovePlacementPreviewImage(placement.PlacementId);
|
||||
|
||||
ClearDesktopComponentSelection();
|
||||
|
||||
@@ -935,6 +940,7 @@ public partial class MainWindow
|
||||
{
|
||||
RestoreDesktopPageComponents(placement.PageIndex);
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
QueuePlacementPreviewRefresh(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -961,6 +967,8 @@ public partial class MainWindow
|
||||
{
|
||||
ApplySelectionStateToHost(host, true);
|
||||
}
|
||||
|
||||
QueuePlacementPreviewRefresh(placement);
|
||||
}
|
||||
|
||||
private static void DisposeComponentIfNeeded(Border host)
|
||||
@@ -1017,6 +1025,7 @@ public partial class MainWindow
|
||||
_desktopComponentPlacements.Remove(placement);
|
||||
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
||||
}
|
||||
RemovePlacementPreviewImages(placementsToRemove);
|
||||
|
||||
_desktopPageCount = Math.Clamp(_desktopPageCount - 1, MinDesktopPageCount, MaxDesktopPageCount);
|
||||
|
||||
@@ -1197,6 +1206,7 @@ public partial class MainWindow
|
||||
pageGrid.Children.Add(host);
|
||||
|
||||
_desktopComponentPlacements.Add(placement);
|
||||
QueuePlacementPreviewRefresh(placement);
|
||||
InvalidateDesktopPageAwareComponentContextCache();
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
PersistSettings();
|
||||
@@ -2063,6 +2073,13 @@ public partial class MainWindow
|
||||
SetDesktopEditSourceHost(sourceHost, 0.22);
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
UpdateDesktopEditOverlayMetadata(placement.ComponentId, widthCells, heightCells, L("component.move", "Move"));
|
||||
ApplyDesktopEditOverlayPreviewImage(placement.ComponentId, placement.PlacementId, widthCells, heightCells);
|
||||
PrimeDesktopEditPreviewImage(
|
||||
placement.ComponentId,
|
||||
placement.PlacementId,
|
||||
placement.PageIndex,
|
||||
widthCells,
|
||||
heightCells);
|
||||
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
||||
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||
@@ -2109,6 +2126,13 @@ public partial class MainWindow
|
||||
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
UpdateDesktopEditOverlayMetadata(componentId, widthCells, heightCells, L("component_library.drag_hint", "Drag to place"));
|
||||
ApplyDesktopEditOverlayPreviewImage(componentId, placementId: null, widthCells, heightCells);
|
||||
PrimeDesktopEditPreviewImage(
|
||||
componentId,
|
||||
placementId: null,
|
||||
_currentDesktopSurfaceIndex,
|
||||
widthCells,
|
||||
heightCells);
|
||||
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
||||
_desktopEditOverlayPresenter?.SetCandidateRect(null);
|
||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||
@@ -2216,6 +2240,13 @@ public partial class MainWindow
|
||||
SetDesktopEditSourceHost(sourceHost, 0.22);
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
UpdateDesktopEditOverlayMetadata(placement.ComponentId, startSpan.WidthCells, startSpan.HeightCells, L("component.resize", "Resize"));
|
||||
ApplyDesktopEditOverlayPreviewImage(placement.ComponentId, placement.PlacementId, startSpan.WidthCells, startSpan.HeightCells);
|
||||
PrimeDesktopEditPreviewImage(
|
||||
placement.ComponentId,
|
||||
placement.PlacementId,
|
||||
placement.PageIndex,
|
||||
startSpan.WidthCells,
|
||||
startSpan.HeightCells);
|
||||
_desktopEditOverlayPresenter?.SetPreviewRect(_desktopEditOriginalRect);
|
||||
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||
@@ -2484,6 +2515,8 @@ public partial class MainWindow
|
||||
{
|
||||
ComponentLibraryBackTextBlock.Text = L("common.back", "Back");
|
||||
}
|
||||
|
||||
EnsureComponentLibraryPreviewWarmup();
|
||||
}
|
||||
|
||||
private IReadOnlyList<ComponentLibraryCategory> GetComponentLibraryCategories()
|
||||
@@ -2659,6 +2692,7 @@ public partial class MainWindow
|
||||
var category = _componentLibraryCategories[_componentLibraryCategoryIndex];
|
||||
_componentLibraryActiveCategoryId = category.Id;
|
||||
_componentLibraryComponentIndex = 0;
|
||||
_ = WarmComponentLibraryCategoryPreviewsAsync(category);
|
||||
BuildComponentLibraryComponentPages(category);
|
||||
ShowComponentLibraryComponentsView();
|
||||
}
|
||||
@@ -2679,6 +2713,7 @@ public partial class MainWindow
|
||||
ComponentLibraryComponentPagesContainer.Children.Clear();
|
||||
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
||||
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
||||
ClearComponentLibraryPreviewVisualTargets();
|
||||
if (componentCount == 0)
|
||||
{
|
||||
_componentLibraryComponentIndex = 0;
|
||||
@@ -2752,37 +2787,49 @@ public partial class MainWindow
|
||||
|
||||
var previewWidth = previewSpan.WidthCells * previewCellSize;
|
||||
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
||||
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
|
||||
var previewKey = CreateComponentTypePreviewKey(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||
var cachedPreviewImage = ResolveComponentTypePreviewImage(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||
|
||||
var previewControl = CreateDesktopComponentControl(
|
||||
component.ComponentId,
|
||||
renderCellSize,
|
||||
placementId: null,
|
||||
pageIndex: null,
|
||||
action: "ComponentLibraryPreview");
|
||||
if (previewControl is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Component library previews must stay non-interactive so drag gesture is reliable.
|
||||
previewControl.IsHitTestVisible = false;
|
||||
previewControl.Focusable = false;
|
||||
|
||||
var previewSurface = new Border
|
||||
{
|
||||
Width = previewSpan.WidthCells * renderCellSize,
|
||||
Height = previewSpan.HeightCells * renderCellSize,
|
||||
Background = Brushes.Transparent,
|
||||
IsHitTestVisible = false,
|
||||
Child = previewControl
|
||||
};
|
||||
|
||||
var previewViewbox = new Viewbox
|
||||
var previewImage = new Image
|
||||
{
|
||||
Width = previewWidth,
|
||||
Height = previewHeight,
|
||||
Stretch = Stretch.Uniform,
|
||||
Child = previewSurface
|
||||
Source = cachedPreviewImage,
|
||||
IsVisible = cachedPreviewImage is not null,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
|
||||
var previewFallback = new Border
|
||||
{
|
||||
Width = previewWidth,
|
||||
Height = previewHeight,
|
||||
Background = GetThemeBrush("AdaptiveCardBackgroundBrush"),
|
||||
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(Math.Clamp(Math.Min(previewWidth, previewHeight) * 0.18, 12, 28)),
|
||||
IsVisible = cachedPreviewImage is null,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = L("component_library.preview_loading", "Preparing preview"),
|
||||
FontSize = 11,
|
||||
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
RegisterComponentLibraryPreviewVisual(previewKey, previewImage, previewFallback);
|
||||
|
||||
var previewSurface = new Grid
|
||||
{
|
||||
Width = previewWidth,
|
||||
Height = previewHeight,
|
||||
IsHitTestVisible = false,
|
||||
Children =
|
||||
{
|
||||
previewImage,
|
||||
previewFallback
|
||||
}
|
||||
};
|
||||
|
||||
var previewBorder = new Border
|
||||
@@ -2792,7 +2839,7 @@ public partial class MainWindow
|
||||
ClipToBounds = false,
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Child = previewViewbox,
|
||||
Child = previewSurface,
|
||||
Tag = component.ComponentId
|
||||
};
|
||||
previewBorder.PointerPressed += OnComponentLibraryComponentPreviewPointerPressed;
|
||||
@@ -2832,6 +2879,15 @@ public partial class MainWindow
|
||||
Grid.SetRow(page, 0);
|
||||
Grid.SetColumn(page, i);
|
||||
ComponentLibraryComponentPagesContainer.Children.Add(page);
|
||||
|
||||
if (cachedPreviewImage is null)
|
||||
{
|
||||
_ = EnsureComponentTypePreviewImageAsync(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(previewKey);
|
||||
}
|
||||
}
|
||||
|
||||
_componentLibraryComponentHostTransform = ComponentLibraryComponentPagesHost.RenderTransform as TranslateTransform;
|
||||
@@ -2856,6 +2912,7 @@ public partial class MainWindow
|
||||
ComponentLibraryComponentPagesContainer.Children.Clear();
|
||||
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
||||
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
||||
ClearComponentLibraryPreviewVisualTargets();
|
||||
}
|
||||
|
||||
private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component)
|
||||
|
||||
@@ -14,7 +14,8 @@ namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private static readonly TimeSpan DesktopEditOverlayAnimationDuration = FluttermotionToken.Fast;
|
||||
private static readonly TimeSpan DesktopEditCommitAnimationDuration = FluttermotionToken.Standard;
|
||||
private static readonly TimeSpan DesktopEditCancelAnimationDuration = FluttermotionToken.Fast;
|
||||
|
||||
private DesktopEditSession _desktopEditSession;
|
||||
private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter;
|
||||
@@ -328,7 +329,7 @@ public partial class MainWindow
|
||||
|
||||
ResetDesktopEditState();
|
||||
},
|
||||
DesktopEditOverlayAnimationDuration);
|
||||
DesktopEditCancelAnimationDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -369,7 +370,7 @@ public partial class MainWindow
|
||||
RestoreComponentLibraryAfterDesktopEdit();
|
||||
ResetDesktopEditState();
|
||||
},
|
||||
DesktopEditOverlayAnimationDuration);
|
||||
DesktopEditCommitAnimationDuration);
|
||||
}
|
||||
|
||||
private void UpdateDesktopEditSession(Point pointerInViewport)
|
||||
@@ -707,6 +708,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
QueuePlacementPreviewRefresh(placement);
|
||||
PersistSettings();
|
||||
TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
|
||||
}
|
||||
|
||||
@@ -243,6 +243,15 @@
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user