mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-28 21:34:28 +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 TimeSpan FastDuration = TimeSpan.FromMilliseconds(120);
|
||||||
private static readonly Easing StandardEasing = new CubicEaseOut();
|
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 Border _accentDot;
|
||||||
private readonly TextBlock _titleTextBlock;
|
private readonly TextBlock _titleTextBlock;
|
||||||
private readonly TextBlock _detailTextBlock;
|
private readonly TextBlock _detailTextBlock;
|
||||||
@@ -33,6 +36,9 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
|
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
|
||||||
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
|
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
|
||||||
|
|
||||||
|
private bool _hasPreviewImage;
|
||||||
|
private bool _isInvalid;
|
||||||
|
|
||||||
public DesktopEditGhostView()
|
public DesktopEditGhostView()
|
||||||
{
|
{
|
||||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||||
@@ -47,27 +53,12 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
RenderTransform = _scaleTransform;
|
RenderTransform = _scaleTransform;
|
||||||
Transitions = new Transitions
|
Transitions = new Transitions
|
||||||
{
|
{
|
||||||
new DoubleTransition
|
CreateOpacityTransition(FastDuration)
|
||||||
{
|
|
||||||
Property = Visual.OpacityProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
_scaleTransform.Transitions = new Transitions
|
_scaleTransform.Transitions = new Transitions
|
||||||
{
|
{
|
||||||
new DoubleTransition
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||||
{
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||||
Property = ScaleTransform.ScaleXProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
},
|
|
||||||
new DoubleTransition
|
|
||||||
{
|
|
||||||
Property = ScaleTransform.ScaleYProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_accentDot = new Border
|
_accentDot = new Border
|
||||||
@@ -119,6 +110,18 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
Child = _badgeTextBlock
|
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
|
var headerPanel = new StackPanel
|
||||||
{
|
{
|
||||||
Orientation = Orientation.Horizontal,
|
Orientation = Orientation.Horizontal,
|
||||||
@@ -140,7 +143,7 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var rootGrid = new Grid
|
var fallbackGrid = new Grid
|
||||||
{
|
{
|
||||||
RowDefinitions = new RowDefinitions
|
RowDefinitions = new RowDefinitions
|
||||||
{
|
{
|
||||||
@@ -149,16 +152,31 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
},
|
},
|
||||||
RowSpacing = 8
|
RowSpacing = 8
|
||||||
};
|
};
|
||||||
rootGrid.Children.Add(contentPanel);
|
fallbackGrid.Children.Add(contentPanel);
|
||||||
rootGrid.Children.Add(_badgeBorder);
|
fallbackGrid.Children.Add(_badgeBorder);
|
||||||
Grid.SetRow(contentPanel, 0);
|
Grid.SetRow(contentPanel, 0);
|
||||||
Grid.SetRow(_badgeBorder, 1);
|
Grid.SetRow(_badgeBorder, 1);
|
||||||
_badgeBorder.Margin = new Thickness(0, 2, 0, 0);
|
_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);
|
UpdatePreviewMetrics(180, 120);
|
||||||
UpdateContent(null, null, null);
|
UpdateContent(null, null, null);
|
||||||
|
ApplyShellChrome();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateContent(string? title, string? detail, string? badgeText)
|
public void UpdateContent(string? title, string? detail, string? badgeText)
|
||||||
@@ -170,18 +188,36 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
_badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText);
|
_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)
|
public void UpdatePreviewMetrics(double width, double height)
|
||||||
{
|
{
|
||||||
var normalizedWidth = Math.Max(1, width);
|
var normalizedWidth = Math.Max(1, width);
|
||||||
var normalizedHeight = Math.Max(1, height);
|
var normalizedHeight = Math.Max(1, height);
|
||||||
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
|
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
|
||||||
|
|
||||||
CornerRadius = new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
|
CornerRadius = _hasPreviewImage
|
||||||
Padding = new Thickness(
|
? new CornerRadius(Math.Clamp(minSide * 0.14, 14, 24))
|
||||||
Math.Clamp(minSide * 0.10, 10, 18),
|
: new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
|
||||||
Math.Clamp(minSide * 0.10, 10, 18),
|
Padding = _hasPreviewImage
|
||||||
Math.Clamp(minSide * 0.10, 10, 18),
|
? new Thickness(
|
||||||
Math.Clamp(minSide * 0.09, 10, 16));
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4))
|
||||||
|
: new Thickness(
|
||||||
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
|
Math.Clamp(minSide * 0.09, 10, 16));
|
||||||
|
|
||||||
var titleFontSize = Math.Clamp(minSide * 0.12, 12, 18);
|
var titleFontSize = Math.Clamp(minSide * 0.12, 12, 18);
|
||||||
var detailFontSize = Math.Clamp(minSide * 0.085, 10, 13);
|
var detailFontSize = Math.Clamp(minSide * 0.085, 10, 13);
|
||||||
@@ -200,29 +236,47 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
|
|
||||||
public void SetInvalid(bool isInvalid)
|
public void SetInvalid(bool isInvalid)
|
||||||
{
|
{
|
||||||
|
_isInvalid = isInvalid;
|
||||||
|
|
||||||
if (isInvalid)
|
if (isInvalid)
|
||||||
{
|
{
|
||||||
Background = _invalidBackgroundBrush;
|
|
||||||
BorderBrush = _invalidBorderBrush;
|
|
||||||
_accentDot.Background = _invalidAccentBrush;
|
_accentDot.Background = _invalidAccentBrush;
|
||||||
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
|
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
|
||||||
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
|
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
|
||||||
_titleTextBlock.Foreground = _invalidBorderBrush;
|
_titleTextBlock.Foreground = _invalidBorderBrush;
|
||||||
_detailTextBlock.Foreground = _invalidBorderBrush;
|
_detailTextBlock.Foreground = _invalidBorderBrush;
|
||||||
_badgeTextBlock.Foreground = _invalidBorderBrush;
|
_badgeTextBlock.Foreground = _invalidBorderBrush;
|
||||||
Opacity = 0.9;
|
if (!_hasPreviewImage)
|
||||||
|
{
|
||||||
|
Background = _invalidBackgroundBrush;
|
||||||
|
BorderBrush = _invalidBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
Opacity = 0.9;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Background = _normalBackgroundBrush;
|
|
||||||
BorderBrush = _normalBorderBrush;
|
|
||||||
_accentDot.Background = _normalAccentBrush;
|
_accentDot.Background = _normalAccentBrush;
|
||||||
_badgeBorder.Background = _normalBadgeBackgroundBrush;
|
_badgeBorder.Background = _normalBadgeBackgroundBrush;
|
||||||
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
|
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
|
||||||
_titleTextBlock.Foreground = _normalTextBrush;
|
_titleTextBlock.Foreground = _normalTextBrush;
|
||||||
_detailTextBlock.Foreground = _normalMutedTextBrush;
|
_detailTextBlock.Foreground = _normalMutedTextBrush;
|
||||||
_badgeTextBlock.Foreground = _normalTextBrush;
|
_badgeTextBlock.Foreground = _normalTextBrush;
|
||||||
Opacity = 1.0;
|
if (!_hasPreviewImage)
|
||||||
|
{
|
||||||
|
Background = _normalBackgroundBrush;
|
||||||
|
BorderBrush = _normalBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
Opacity = 1.0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetRestingScale(double scale)
|
public void SetRestingScale(double scale)
|
||||||
@@ -238,4 +292,67 @@ internal sealed class DesktopEditGhostView : Border
|
|||||||
_scaleTransform.ScaleX = clampedScale;
|
_scaleTransform.ScaleX = clampedScale;
|
||||||
_scaleTransform.ScaleY = 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
|
internal sealed class DesktopEditOverlayPresenter
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan FastDuration = FluttermotionToken.Fast;
|
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 static readonly Easing StandardEasing = new CubicEaseOut();
|
||||||
|
|
||||||
private readonly Canvas _root;
|
private readonly Canvas _root;
|
||||||
@@ -31,10 +34,10 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
private bool _isVisible;
|
private bool _isVisible;
|
||||||
private int _dismissVersion;
|
private int _dismissVersion;
|
||||||
|
|
||||||
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF4F8EF7"));
|
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
|
||||||
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF6B6B"));
|
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF3B30"));
|
||||||
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#224F8EF7"));
|
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#140A84FF"));
|
||||||
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#22FF6B6B"));
|
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30"));
|
||||||
|
|
||||||
public DesktopEditOverlayPresenter()
|
public DesktopEditOverlayPresenter()
|
||||||
{
|
{
|
||||||
@@ -66,18 +69,8 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
};
|
};
|
||||||
_candidateScale.Transitions = new Transitions
|
_candidateScale.Transitions = new Transitions
|
||||||
{
|
{
|
||||||
new DoubleTransition
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||||
{
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||||
Property = ScaleTransform.ScaleXProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
},
|
|
||||||
new DoubleTransition
|
|
||||||
{
|
|
||||||
Property = ScaleTransform.ScaleYProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
||||||
@@ -98,12 +91,7 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
|
|
||||||
_root.Transitions = new Transitions
|
_root.Transitions = new Transitions
|
||||||
{
|
{
|
||||||
new DoubleTransition
|
CreateOpacityTransition(FastDuration)
|
||||||
{
|
|
||||||
Property = Visual.OpacityProperty,
|
|
||||||
Duration = FastDuration,
|
|
||||||
Easing = StandardEasing
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +120,11 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
_ghostView.UpdateContent(title, detail, badge);
|
_ghostView.UpdateContent(title, detail, badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetPreviewImage(IImage? image)
|
||||||
|
{
|
||||||
|
_ghostView.SetPreviewImage(image);
|
||||||
|
}
|
||||||
|
|
||||||
public void SetInvalid(bool isInvalid)
|
public void SetInvalid(bool isInvalid)
|
||||||
{
|
{
|
||||||
_isInvalid = isInvalid;
|
_isInvalid = isInvalid;
|
||||||
@@ -146,12 +139,40 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
_root.IsVisible = true;
|
_root.IsVisible = true;
|
||||||
_root.Opacity = 0;
|
_root.Opacity = 0;
|
||||||
_ghostView.Opacity = 0;
|
_ghostView.Opacity = 0;
|
||||||
var initialGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.02 : 0.985;
|
var imageMode = _ghostView.HasPreviewImage;
|
||||||
var targetGhostScale = visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary ? 1.06 : 1;
|
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);
|
_ghostView.SetRestingScale(initialGhostScale);
|
||||||
_candidateOutline.Opacity = 0;
|
_candidateOutline.Opacity = 0;
|
||||||
_candidateScale.ScaleX = 0.96;
|
_candidateScale.ScaleX = 0.97;
|
||||||
_candidateScale.ScaleY = 0.96;
|
_candidateScale.ScaleY = 0.97;
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
@@ -182,6 +203,7 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
_candidateScale.ScaleX = 0.96;
|
_candidateScale.ScaleX = 0.96;
|
||||||
_candidateScale.ScaleY = 0.96;
|
_candidateScale.ScaleY = 0.96;
|
||||||
_ghostView.SetRestingScale(0.96);
|
_ghostView.SetRestingScale(0.96);
|
||||||
|
_ghostView.SetPreviewImage(null);
|
||||||
_root.IsVisible = false;
|
_root.IsVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,11 +226,29 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
|
|
||||||
var version = ++_dismissVersion;
|
var version = ++_dismissVersion;
|
||||||
_isVisible = false;
|
_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;
|
_candidateOutline.Opacity = 0;
|
||||||
_ghostView.Opacity = 0;
|
_ghostView.Opacity = 0;
|
||||||
_root.Opacity = 0;
|
_root.Opacity = 0;
|
||||||
|
|
||||||
var targetScale = isCancel ? 0.96 : 1.04;
|
|
||||||
_ghostView.AnimateToScale(targetScale);
|
_ghostView.AnimateToScale(targetScale);
|
||||||
_candidateScale.ScaleX = targetScale;
|
_candidateScale.ScaleX = targetScale;
|
||||||
_candidateScale.ScaleY = targetScale;
|
_candidateScale.ScaleY = targetScale;
|
||||||
@@ -257,13 +297,13 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
Canvas.SetLeft(_candidateOutline, rect.X);
|
Canvas.SetLeft(_candidateOutline, rect.X);
|
||||||
Canvas.SetTop(_candidateOutline, rect.Y);
|
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.CornerRadius = new CornerRadius(cornerRadius);
|
||||||
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||||
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||||
_candidateOutline.Opacity = _isVisible ? 1 : 0;
|
_candidateOutline.Opacity = _isVisible ? 1 : 0;
|
||||||
_candidateScale.ScaleX = _isVisible ? 1 : 0.96;
|
_candidateScale.ScaleX = _isVisible ? 1 : 0.97;
|
||||||
_candidateScale.ScaleY = _isVisible ? 1 : 0.96;
|
_candidateScale.ScaleY = _isVisible ? 1 : 0.97;
|
||||||
UpdateCandidateAppearance();
|
UpdateCandidateAppearance();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,4 +324,20 @@ internal sealed class DesktopEditOverlayPresenter
|
|||||||
var height = Math.Max(1, rect.Height);
|
var height = Math.Max(1, rect.Height);
|
||||||
return new Rect(rect.X, rect.Y, width, 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.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using Avalonia.Controls;
|
using System.ComponentModel;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
using FluentIcons.Common;
|
using FluentIcons.Common;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace LanMountainDesktop.ViewModels;
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
|
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; } = [];
|
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
|
||||||
|
|
||||||
@@ -38,20 +46,134 @@ public sealed class ComponentLibraryCategoryViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ComponentLibraryItemViewModel
|
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(
|
public ComponentLibraryItemViewModel(
|
||||||
string componentId,
|
string componentId,
|
||||||
string displayName,
|
string displayName,
|
||||||
Control? previewControl)
|
ComponentPreviewKey previewKey,
|
||||||
|
string loadingPreviewText = "Loading preview...",
|
||||||
|
string previewUnavailableText = "Preview unavailable",
|
||||||
|
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||||
{
|
{
|
||||||
ComponentId = componentId;
|
ComponentId = componentId;
|
||||||
DisplayName = displayName;
|
_displayName = displayName;
|
||||||
PreviewControl = previewControl;
|
_previewKey = previewKey;
|
||||||
|
_loadingPreviewText = loadingPreviewText;
|
||||||
|
_previewUnavailableText = previewUnavailableText;
|
||||||
|
_previewStatusText = loadingPreviewText;
|
||||||
|
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ComponentId { get; }
|
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"
|
BorderThickness="1"
|
||||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
Padding="8">
|
Padding="8">
|
||||||
<ContentControl HorizontalAlignment="Center"
|
<Grid>
|
||||||
VerticalAlignment="Center"
|
<Image Source="{Binding PreviewBitmap}"
|
||||||
Content="{Binding PreviewControl}" />
|
Stretch="Uniform"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||||
|
IsVisible="{Binding IsPreviewReady}" />
|
||||||
|
|
||||||
|
<Border IsVisible="{Binding IsPreviewPending}"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8">
|
||||||
|
<ProgressBar Width="96"
|
||||||
|
IsIndeterminate="True" />
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="{Binding PreviewStatusText}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border IsVisible="{Binding IsPreviewFailed}"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8">
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
Text="{Binding PreviewStatusText}" />
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="{Binding PreviewErrorMessage}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
private IComponentLibraryService? _componentLibraryService;
|
private IComponentLibraryService? _componentLibraryService;
|
||||||
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
|
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
|
||||||
private Func<string, string, string>? _localize;
|
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();
|
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||||
|
|
||||||
public ComponentLibraryWindow()
|
public ComponentLibraryWindow()
|
||||||
@@ -25,12 +29,20 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
public ComponentLibraryWindow(
|
public ComponentLibraryWindow(
|
||||||
IComponentLibraryService componentLibraryService,
|
IComponentLibraryService componentLibraryService,
|
||||||
Func<double, ComponentLibraryCreateContext> createContextFactory,
|
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()
|
: this()
|
||||||
{
|
{
|
||||||
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
||||||
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
||||||
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
||||||
|
_previewKeyResolver = previewKeyResolver;
|
||||||
|
_previewEntryResolver = previewEntryResolver;
|
||||||
|
_warmPreviewRequested = warmPreviewRequested;
|
||||||
|
_renderPreviewRequested = renderPreviewRequested;
|
||||||
Reload();
|
Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +50,7 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
|
|
||||||
public void Reload()
|
public void Reload()
|
||||||
{
|
{
|
||||||
if (_componentLibraryService is null ||
|
if (_componentLibraryService is null || _localize is null)
|
||||||
_createContextFactory is null ||
|
|
||||||
_localize is null)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,32 +85,26 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
|
|
||||||
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
|
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
|
||||||
{
|
{
|
||||||
if (_componentLibraryService is null ||
|
var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
||||||
_createContextFactory is null ||
|
? entry.DisplayName
|
||||||
_localize is null)
|
: _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName;
|
||||||
|
var previewKey = ResolvePreviewKey(entry);
|
||||||
|
var previewEntry = _previewEntryResolver?.Invoke(previewKey);
|
||||||
|
var item = new ComponentLibraryItemViewModel(
|
||||||
|
entry.ComponentId,
|
||||||
|
displayName,
|
||||||
|
previewKey,
|
||||||
|
_localize?.Invoke("component_library.preview.loading", "Loading preview...") ?? "Loading preview...",
|
||||||
|
_localize?.Invoke("component_library.preview.unavailable", "Preview unavailable") ?? "Preview unavailable",
|
||||||
|
previewEntry);
|
||||||
|
|
||||||
|
if (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending)
|
||||||
{
|
{
|
||||||
return new ComponentLibraryItemViewModel(entry.ComponentId, entry.DisplayName, previewControl: null);
|
_warmPreviewRequested?.Invoke(previewKey);
|
||||||
|
_renderPreviewRequested?.Invoke(previewKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
Control? previewControl = null;
|
return item;
|
||||||
_componentLibraryService.TryCreateControl(
|
|
||||||
entry.ComponentId,
|
|
||||||
_createContextFactory(42),
|
|
||||||
out previewControl,
|
|
||||||
out _);
|
|
||||||
|
|
||||||
if (previewControl is not null)
|
|
||||||
{
|
|
||||||
previewControl.IsHitTestVisible = false;
|
|
||||||
previewControl.Focusable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ComponentLibraryItemViewModel(
|
|
||||||
entry.ComponentId,
|
|
||||||
string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
|
||||||
? entry.DisplayName
|
|
||||||
: _localize(entry.DisplayNameLocalizationKey, entry.DisplayName),
|
|
||||||
previewControl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
@@ -118,6 +122,8 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
{
|
{
|
||||||
_viewModel.Components.Add(component);
|
_viewModel.Components.Add(component);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RequestPreviewWarmup(selectedCategory.Components);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -140,6 +146,51 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
Hide();
|
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)
|
private Symbol ResolveCategoryIcon(string categoryId)
|
||||||
{
|
{
|
||||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
||||||
<Border x:Name="RootBorder"
|
<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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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,
|
_calculatorDataService,
|
||||||
_settingsFacade);
|
_settingsFacade);
|
||||||
},
|
},
|
||||||
L);
|
L,
|
||||||
|
previewKeyResolver: ResolveDetachedLibraryPreviewKey,
|
||||||
|
previewEntryResolver: ResolveDetachedLibraryPreviewEntry,
|
||||||
|
warmPreviewRequested: RequestDetachedLibraryPreviewWarm,
|
||||||
|
renderPreviewRequested: RequestDetachedLibraryPreviewRender);
|
||||||
window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested;
|
window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested;
|
||||||
window.Closed += OnDetachedComponentLibraryClosed;
|
window.Closed += OnDetachedComponentLibraryClosed;
|
||||||
return window;
|
return window;
|
||||||
@@ -867,6 +871,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
_desktopComponentPlacements.Remove(placement);
|
_desktopComponentPlacements.Remove(placement);
|
||||||
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
||||||
|
RemovePlacementPreviewImage(placement.PlacementId);
|
||||||
|
|
||||||
ClearDesktopComponentSelection();
|
ClearDesktopComponentSelection();
|
||||||
|
|
||||||
@@ -935,6 +940,7 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
RestoreDesktopPageComponents(placement.PageIndex);
|
RestoreDesktopPageComponents(placement.PageIndex);
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -961,6 +967,8 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
ApplySelectionStateToHost(host, true);
|
ApplySelectionStateToHost(host, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void DisposeComponentIfNeeded(Border host)
|
private static void DisposeComponentIfNeeded(Border host)
|
||||||
@@ -1017,6 +1025,7 @@ public partial class MainWindow
|
|||||||
_desktopComponentPlacements.Remove(placement);
|
_desktopComponentPlacements.Remove(placement);
|
||||||
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
_componentSettingsStore.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
||||||
}
|
}
|
||||||
|
RemovePlacementPreviewImages(placementsToRemove);
|
||||||
|
|
||||||
_desktopPageCount = Math.Clamp(_desktopPageCount - 1, MinDesktopPageCount, MaxDesktopPageCount);
|
_desktopPageCount = Math.Clamp(_desktopPageCount - 1, MinDesktopPageCount, MaxDesktopPageCount);
|
||||||
|
|
||||||
@@ -1197,6 +1206,7 @@ public partial class MainWindow
|
|||||||
pageGrid.Children.Add(host);
|
pageGrid.Children.Add(host);
|
||||||
|
|
||||||
_desktopComponentPlacements.Add(placement);
|
_desktopComponentPlacements.Add(placement);
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
InvalidateDesktopPageAwareComponentContextCache();
|
InvalidateDesktopPageAwareComponentContextCache();
|
||||||
UpdateDesktopPageAwareComponentContext();
|
UpdateDesktopPageAwareComponentContext();
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
@@ -2063,6 +2073,13 @@ public partial class MainWindow
|
|||||||
SetDesktopEditSourceHost(sourceHost, 0.22);
|
SetDesktopEditSourceHost(sourceHost, 0.22);
|
||||||
EnsureDesktopEditOverlayPresenter();
|
EnsureDesktopEditOverlayPresenter();
|
||||||
UpdateDesktopEditOverlayMetadata(placement.ComponentId, widthCells, heightCells, L("component.move", "Move"));
|
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?.SetPreviewRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
@@ -2109,6 +2126,13 @@ public partial class MainWindow
|
|||||||
|
|
||||||
EnsureDesktopEditOverlayPresenter();
|
EnsureDesktopEditOverlayPresenter();
|
||||||
UpdateDesktopEditOverlayMetadata(componentId, widthCells, heightCells, L("component_library.drag_hint", "Drag to place"));
|
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?.SetPreviewRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetCandidateRect(null);
|
_desktopEditOverlayPresenter?.SetCandidateRect(null);
|
||||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
@@ -2216,6 +2240,13 @@ public partial class MainWindow
|
|||||||
SetDesktopEditSourceHost(sourceHost, 0.22);
|
SetDesktopEditSourceHost(sourceHost, 0.22);
|
||||||
EnsureDesktopEditOverlayPresenter();
|
EnsureDesktopEditOverlayPresenter();
|
||||||
UpdateDesktopEditOverlayMetadata(placement.ComponentId, startSpan.WidthCells, startSpan.HeightCells, L("component.resize", "Resize"));
|
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?.SetPreviewRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
_desktopEditOverlayPresenter?.SetCandidateRect(_desktopEditOriginalRect);
|
||||||
_desktopEditOverlayPresenter?.SetInvalid(false);
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
@@ -2484,6 +2515,8 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
ComponentLibraryBackTextBlock.Text = L("common.back", "Back");
|
ComponentLibraryBackTextBlock.Text = L("common.back", "Back");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnsureComponentLibraryPreviewWarmup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<ComponentLibraryCategory> GetComponentLibraryCategories()
|
private IReadOnlyList<ComponentLibraryCategory> GetComponentLibraryCategories()
|
||||||
@@ -2659,6 +2692,7 @@ public partial class MainWindow
|
|||||||
var category = _componentLibraryCategories[_componentLibraryCategoryIndex];
|
var category = _componentLibraryCategories[_componentLibraryCategoryIndex];
|
||||||
_componentLibraryActiveCategoryId = category.Id;
|
_componentLibraryActiveCategoryId = category.Id;
|
||||||
_componentLibraryComponentIndex = 0;
|
_componentLibraryComponentIndex = 0;
|
||||||
|
_ = WarmComponentLibraryCategoryPreviewsAsync(category);
|
||||||
BuildComponentLibraryComponentPages(category);
|
BuildComponentLibraryComponentPages(category);
|
||||||
ShowComponentLibraryComponentsView();
|
ShowComponentLibraryComponentsView();
|
||||||
}
|
}
|
||||||
@@ -2679,6 +2713,7 @@ public partial class MainWindow
|
|||||||
ComponentLibraryComponentPagesContainer.Children.Clear();
|
ComponentLibraryComponentPagesContainer.Children.Clear();
|
||||||
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
||||||
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
||||||
|
ClearComponentLibraryPreviewVisualTargets();
|
||||||
if (componentCount == 0)
|
if (componentCount == 0)
|
||||||
{
|
{
|
||||||
_componentLibraryComponentIndex = 0;
|
_componentLibraryComponentIndex = 0;
|
||||||
@@ -2752,37 +2787,49 @@ public partial class MainWindow
|
|||||||
|
|
||||||
var previewWidth = previewSpan.WidthCells * previewCellSize;
|
var previewWidth = previewSpan.WidthCells * previewCellSize;
|
||||||
var previewHeight = previewSpan.HeightCells * 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(
|
var previewImage = new Image
|
||||||
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
|
|
||||||
{
|
{
|
||||||
Width = previewWidth,
|
Width = previewWidth,
|
||||||
Height = previewHeight,
|
Height = previewHeight,
|
||||||
Stretch = Stretch.Uniform,
|
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
|
var previewBorder = new Border
|
||||||
@@ -2792,7 +2839,7 @@ public partial class MainWindow
|
|||||||
ClipToBounds = false,
|
ClipToBounds = false,
|
||||||
Background = Brushes.Transparent,
|
Background = Brushes.Transparent,
|
||||||
BorderThickness = new Thickness(0),
|
BorderThickness = new Thickness(0),
|
||||||
Child = previewViewbox,
|
Child = previewSurface,
|
||||||
Tag = component.ComponentId
|
Tag = component.ComponentId
|
||||||
};
|
};
|
||||||
previewBorder.PointerPressed += OnComponentLibraryComponentPreviewPointerPressed;
|
previewBorder.PointerPressed += OnComponentLibraryComponentPreviewPointerPressed;
|
||||||
@@ -2832,6 +2879,15 @@ public partial class MainWindow
|
|||||||
Grid.SetRow(page, 0);
|
Grid.SetRow(page, 0);
|
||||||
Grid.SetColumn(page, i);
|
Grid.SetColumn(page, i);
|
||||||
ComponentLibraryComponentPagesContainer.Children.Add(page);
|
ComponentLibraryComponentPagesContainer.Children.Add(page);
|
||||||
|
|
||||||
|
if (cachedPreviewImage is null)
|
||||||
|
{
|
||||||
|
_ = EnsureComponentTypePreviewImageAsync(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyPreviewEntryToEmbeddedVisuals(previewKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_componentLibraryComponentHostTransform = ComponentLibraryComponentPagesHost.RenderTransform as TranslateTransform;
|
_componentLibraryComponentHostTransform = ComponentLibraryComponentPagesHost.RenderTransform as TranslateTransform;
|
||||||
@@ -2856,6 +2912,7 @@ public partial class MainWindow
|
|||||||
ComponentLibraryComponentPagesContainer.Children.Clear();
|
ComponentLibraryComponentPagesContainer.Children.Clear();
|
||||||
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
||||||
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
||||||
|
ClearComponentLibraryPreviewVisualTargets();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component)
|
private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component)
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ namespace LanMountainDesktop.Views;
|
|||||||
|
|
||||||
public partial class MainWindow
|
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 DesktopEditSession _desktopEditSession;
|
||||||
private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter;
|
private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter;
|
||||||
@@ -328,7 +329,7 @@ public partial class MainWindow
|
|||||||
|
|
||||||
ResetDesktopEditState();
|
ResetDesktopEditState();
|
||||||
},
|
},
|
||||||
DesktopEditOverlayAnimationDuration);
|
DesktopEditCancelAnimationDuration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +370,7 @@ public partial class MainWindow
|
|||||||
RestoreComponentLibraryAfterDesktopEdit();
|
RestoreComponentLibraryAfterDesktopEdit();
|
||||||
ResetDesktopEditState();
|
ResetDesktopEditState();
|
||||||
},
|
},
|
||||||
DesktopEditOverlayAnimationDuration);
|
DesktopEditCommitAnimationDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateDesktopEditSession(Point pointerInViewport)
|
private void UpdateDesktopEditSession(Point pointerInViewport)
|
||||||
@@ -707,6 +708,7 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
|
TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,15 @@
|
|||||||
|
|
||||||
<Canvas x:Name="DesktopEditDragLayer"
|
<Canvas x:Name="DesktopEditDragLayer"
|
||||||
IsHitTestVisible="False" />
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
|
<Canvas x:Name="ComponentPreviewStagingHost"
|
||||||
|
Width="1"
|
||||||
|
Height="1"
|
||||||
|
Opacity="0"
|
||||||
|
ClipToBounds="True"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user