mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4d0c4cd8 | ||
|
|
eb066b53f1 | ||
|
|
5ea242af9a |
42
.trae/specs/localization-fix/checklist.md
Normal file
42
.trae/specs/localization-fix/checklist.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 本地化修复 Checklist
|
||||
|
||||
## MainWindow 修复
|
||||
- [ ] `TaskbarProfileDisplayNameTextBlock.Text` 在中文下显示"用户"(或保持动态)
|
||||
- [ ] `TaskbarProfileSettingsActionTextBlock.Text` 在中文下显示"设置"
|
||||
- [ ] `TaskbarProfileDesktopEditActionTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
- [ ] `TaskbarProfilePowerActionTextBlock.Text` 在中文下显示"电源"
|
||||
- [ ] `TaskbarPowerBackTextBlock.Text` 在中文下显示"返回"
|
||||
- [ ] `TaskbarPowerTitleTextBlock.Text` 在中文下显示"电源"
|
||||
- [ ] `PowerShutdownTextBlock.Text` 在中文下显示"关机"
|
||||
- [ ] `PowerRestartTextBlock.Text` 在中文下显示"重启"
|
||||
- [ ] `PowerLogoutTextBlock.Text` 在中文下显示"注销"
|
||||
- [ ] `PowerSleepTextBlock.Text` 在中文下显示"睡眠"
|
||||
- [ ] `PowerLockTextBlock.Text` 在中文下显示"锁定屏幕"
|
||||
- [ ] `ComponentLibraryTitleTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
- [ ] `ComponentLibraryEmptyTextBlock.Text` 在中文下显示"左右滑动选择类别,点击进入,然后拖动组件到桌面放置。"
|
||||
- [ ] `ComponentLibraryBackTextBlock.Text` 在中文下显示"返回"
|
||||
- [ ] `ComponentLibraryCollapsedChipTextBlock.Text` 在中文下显示"桌面编辑"
|
||||
|
||||
## Launcher 修复
|
||||
- [ ] `SplashWindow` 在中文下显示中文启动文本
|
||||
- [ ] `DataLocationPromptWindow` 在中文下全部显示中文
|
||||
- [ ] `ErrorWindow` 在中文下全部显示中文
|
||||
- [ ] `LoadingDetailsWindow` 在中文下全部显示中文
|
||||
- [ ] `UpdateWindow` 在中文下显示中文标题
|
||||
|
||||
## 组件修复
|
||||
- [ ] `BrowserWidget` 在中文下显示"浏览器运行时不可用"
|
||||
- [ ] `WhiteboardWidget` 工具提示在中文下显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
|
||||
- [ ] `HolidayCalendarWidget` 在中文下显示"节假日倒计时"、"天"
|
||||
- [ ] `BilibiliHotSearchWidget` 在中文下显示"热门话题"
|
||||
- [ ] `WallpaperSettingsPage` 自定义颜色 Tooltip 在中文下显示"自定义颜色"
|
||||
|
||||
## 资源文件
|
||||
- [ ] `zh-CN.json` 包含所有新增键值
|
||||
- [ ] `en-US.json` 包含所有新增键值
|
||||
- [ ] Launcher 本地化文件包含所有新增键值
|
||||
|
||||
## 构建与质量
|
||||
- [ ] `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过,无错误
|
||||
- [ ] 无新增警告
|
||||
- [ ] 无遗漏的硬编码英文(通过 `grep -r 'Text="[a-zA-Z]'` 等检查)
|
||||
85
.trae/specs/localization-fix/spec.md
Normal file
85
.trae/specs/localization-fix/spec.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 本地化修复 Spec
|
||||
|
||||
## Why
|
||||
|
||||
- 项目在中文设置下,多处 UI 仍显示英文。
|
||||
- 主要问题集中在:
|
||||
1. `MainWindow.axaml` 中任务栏头像弹窗、电源菜单、组件库等文本硬编码为英文,且未被 `ApplyLocalization()` 覆盖。
|
||||
2. `LanMountainDesktop.Launcher` 的所有视图完全没有接入本地化系统。
|
||||
3. 部分组件(BrowserWidget、WhiteboardWidget、HolidayCalendarWidget 等)存在未覆盖的硬编码英文。
|
||||
4. 少量设置页面 Tooltip 硬编码英文。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 1. MainWindow.axaml 硬编码修复
|
||||
将以下硬编码文本改为由 `ApplyLocalization()` 通过 `L()` 动态设置:
|
||||
- 任务栏头像弹窗:`User` → `power.user` / `Settings` → `settings.title` / `Edit Desktop` → `button.component_library` / `Power` → `power.title`
|
||||
- 电源菜单:`Back` → `common.back` / `Power` → `power.title` / `Shutdown` → `power.shutdown` / `Restart` → `power.restart` / `Log Out` → `power.logout` / `Sleep` → `power.sleep` / `Lock Screen` → `power.lock_screen`
|
||||
- 组件库:`Widgets` → `component_library.title` / `Back` → `common.back` / `No components.` → `component_library.empty`
|
||||
- 悬浮芯片:`Widgets` → `component_library.title`
|
||||
|
||||
### 2. Launcher 视图本地化
|
||||
为 `LanMountainDesktop.Launcher/Views/` 下的窗口引入独立本地化机制(复用 `LocalizationService` 或内嵌资源字典):
|
||||
- `SplashWindow.axaml`:`LanMountain Desktop`、`Initializing...`
|
||||
- `DataLocationPromptWindow.axaml`:全部文本
|
||||
- `ErrorWindow.axaml`:全部文本
|
||||
- `LoadingDetailsWindow.axaml`:全部文本
|
||||
- `UpdateWindow.axaml`:`Update`
|
||||
|
||||
### 3. 组件硬编码修复
|
||||
- `BrowserWidget.axaml`:`Browser runtime unavailable.` → 新增键 `browser.widget.unavailable`
|
||||
- `WhiteboardWidget.axaml`:`Pen` / `Eraser` / `Clear` / `Export SVG` → 新增键 `whiteboard.tool.pen` 等
|
||||
- `HolidayCalendarWidget.axaml`:`Holiday countdown` / `Days` → 新增键 `holiday.widget.title` / `holiday.widget.days`
|
||||
- `BilibiliHotSearchWidget.axaml`:`Trending Topic` → 新增键 `bilihot.widget.trending_topic`
|
||||
- `WallpaperSettingsPage.axaml`:`Custom color` Tooltip → 复用 `settings.wallpaper.custom_color_tooltip`
|
||||
|
||||
### 4. 本地化资源文件补充
|
||||
在 `zh-CN.json` 和 `en-US.json` 中补充上述新增键值。
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code:
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml`
|
||||
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
|
||||
- `LanMountainDesktop.Launcher/Views/*.axaml`(多个文件)
|
||||
- `LanMountainDesktop/Views/Components/BrowserWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/WhiteboardWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/HolidayCalendarWidget.axaml`
|
||||
- `LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml`
|
||||
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
|
||||
- `LanMountainDesktop/Localization/zh-CN.json`
|
||||
- `LanMountainDesktop/Localization/en-US.json`
|
||||
- Affected behavior:
|
||||
- 中文设置下上述位置将正确显示中文。
|
||||
- Launcher 各窗口将支持中英文切换。
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: MainWindow 任务栏弹窗与电源菜单本地化
|
||||
系统 SHALL 在 `ApplyLocalization()` 中覆盖任务栏头像弹窗和电源菜单的所有文本。
|
||||
|
||||
#### Scenario: 中文设置下打开任务栏弹窗
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** 弹窗中显示"设置"、"桌面编辑"、"电源"等中文文本
|
||||
- **AND THEN** 电源菜单中显示"返回"、"关机"、"重启"、"注销"、"睡眠"、"锁定屏幕"等中文文本
|
||||
|
||||
### Requirement: Launcher 窗口本地化
|
||||
系统 SHALL 让 Launcher 的所有窗口文本通过本地化服务获取。
|
||||
|
||||
#### Scenario: 中文设置下启动应用
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** SplashWindow 显示中文启动文本
|
||||
- **AND THEN** 数据位置选择、错误页、加载详情页等显示中文
|
||||
|
||||
### Requirement: 组件与设置页硬编码修复
|
||||
系统 SHALL 移除或覆盖所有组件和设置页中的英文硬编码文本。
|
||||
|
||||
#### Scenario: 中文设置下查看各组件
|
||||
- **WHEN** 语言设置为中文
|
||||
- **THEN** BrowserWidget 显示"浏览器运行时不可用"
|
||||
- **AND THEN** WhiteboardWidget 工具提示显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
|
||||
- **AND THEN** HolidayCalendarWidget 显示"节假日倒计时"、"天"
|
||||
- **AND THEN** BilibiliHotSearchWidget 显示"热门话题"
|
||||
- **AND THEN** 壁纸设置页自定义颜色 Tooltip 显示"自定义颜色"
|
||||
39
.trae/specs/localization-fix/tasks.md
Normal file
39
.trae/specs/localization-fix/tasks.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 本地化修复 Tasks
|
||||
|
||||
## Task 1: MainWindow.axaml 硬编码文本移除与代码覆盖
|
||||
- [ ] 1.1 在 `MainWindow.axaml` 中,将任务栏头像弹窗的 `User`、`Settings`、`Edit Desktop`、`Power` 的 `Text` 属性改为空或绑定(保留 x:Name)
|
||||
- [ ] 1.2 在 `MainWindow.axaml` 中,将电源菜单的 `Back`、`Power`、`Shutdown`、`Restart`、`Log Out`、`Sleep`、`Lock Screen` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.3 在 `MainWindow.axaml` 中,将组件库的 `Widgets`、`Back`、`No components.` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.4 在 `MainWindow.axaml` 中,将悬浮芯片的 `Widgets` 的 `Text` 属性改为空或绑定
|
||||
- [ ] 1.5 在 `MainWindow.SettingsHardCut.Stubs.cs` 的 `ApplyLocalization()` 中补充上述所有控件的 `L()` 赋值
|
||||
|
||||
## Task 2: Launcher 视图本地化
|
||||
- [ ] 2.1 在 `LanMountainDesktop.Launcher` 中引入 `LocalizationService`(或共享主应用服务)
|
||||
- [ ] 2.2 为 Launcher 创建独立的 `Localization/` 目录和 `zh-CN.json` / `en-US.json`
|
||||
- [ ] 2.3 修改 `SplashWindow.axaml`:将 `LanMountain Desktop`、`Initializing...` 改为动态绑定
|
||||
- [ ] 2.4 修改 `DataLocationPromptWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.5 修改 `ErrorWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.6 修改 `LoadingDetailsWindow.axaml`:将所有文本改为动态绑定
|
||||
- [ ] 2.7 修改 `UpdateWindow.axaml`:将 `Update` 改为动态绑定
|
||||
- [ ] 2.8 在 Launcher 启动流程中初始化语言设置
|
||||
|
||||
## Task 3: 组件硬编码修复
|
||||
- [ ] 3.1 `BrowserWidget.axaml`:将 `Browser runtime unavailable.` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.2 `WhiteboardWidget.axaml`:将 `Pen`、`Eraser`、`Clear`、`Export SVG` Tooltip 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.3 `HolidayCalendarWidget.axaml`:将 `Holiday countdown`、`Days` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.4 `BilibiliHotSearchWidget.axaml`:将 `Trending Topic` 改为绑定,并在代码后置中通过 `L()` 设置
|
||||
- [ ] 3.5 `WallpaperSettingsPage.axaml`:将 `Custom color` Tooltip 改为绑定到 `settings.wallpaper.custom_color_tooltip`
|
||||
|
||||
## Task 4: 本地化资源文件补充
|
||||
- [ ] 4.1 在 `zh-CN.json` 中补充以下键值:
|
||||
- `browser.widget.unavailable`
|
||||
- `whiteboard.tool.pen`、`whiteboard.tool.eraser`、`whiteboard.tool.clear`、`whiteboard.tool.export_svg`
|
||||
- `holiday.widget.title`、`holiday.widget.days`
|
||||
- `bilihot.widget.trending_topic`
|
||||
- `power.user`(或复用现有键)
|
||||
- [ ] 4.2 在 `en-US.json` 中补充上述键值的英文版本
|
||||
- [ ] 4.3 为 Launcher 创建独立的本地化 JSON 文件并填充中英文
|
||||
|
||||
## Task 5: 验证
|
||||
- [ ] 5.1 执行 `dotnet build LanMountainDesktop.slnx -c Debug` 确保编译通过
|
||||
- [ ] 5.2 检查是否有遗漏的硬编码英文(通过正则搜索)
|
||||
@@ -9,8 +9,10 @@ namespace LanMountainDesktop.Launcher.Services;
|
||||
/// </summary>
|
||||
internal sealed class PluginInstallerService
|
||||
{
|
||||
private const string ManifestFileName = "manifest.json";
|
||||
private const string PackageFileExtension = ".lmdp";
|
||||
private const string ManifestFileName = "plugin.json";
|
||||
private const string LegacyManifestFileName = "manifest.json";
|
||||
private const string PackageFileExtension = ".laapp";
|
||||
private const string LegacyPackageFileExtension = ".lmdp";
|
||||
private const string RuntimeDirectoryName = "runtime";
|
||||
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
@@ -114,14 +116,16 @@ internal sealed class PluginInstallerService
|
||||
public PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
var entries = FindManifestEntries(archive, ManifestFileName);
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
entries = FindManifestEntries(archive, LegacyManifestFileName);
|
||||
}
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
|
||||
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}' or '{LegacyManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
@@ -141,6 +145,13 @@ internal sealed class PluginInstallerService
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static ZipArchiveEntry[] FindManifestEntries(ZipArchive archive, string manifestFileName)
|
||||
{
|
||||
return archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, manifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
|
||||
@@ -148,8 +159,11 @@ internal sealed class PluginInstallerService
|
||||
Directory.CreateDirectory(pendingDeletionDir);
|
||||
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
|
||||
.EnumerateFiles(pluginsDirectory, "*", SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path =>
|
||||
path.EndsWith(PackageFileExtension, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(LegacyPackageFileExtension, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
Official `dotnet new` template package for LanMountainDesktop plugins.
|
||||
|
||||
## Baseline
|
||||
|
||||
- Target framework: `net10.0`
|
||||
- Plugin SDK: `LanMountainDesktop.PluginSdk` `5.0.0`
|
||||
- Manifest: `plugin.json`
|
||||
- Package: `.laapp`
|
||||
- Runtime mode: `in-proc`
|
||||
|
||||
## Install
|
||||
|
||||
```powershell
|
||||
@@ -15,3 +23,19 @@ dotnet new lmd-plugin -n YourPluginName
|
||||
```
|
||||
|
||||
The generated project references `LanMountainDesktop.PluginSdk` and produces a `.laapp` package automatically when built.
|
||||
|
||||
## Package contract
|
||||
|
||||
Every plugin package must contain:
|
||||
|
||||
- `plugin.json`
|
||||
- the entrance assembly declared by `entranceAssembly`
|
||||
- the `.deps.json` next to the entrance assembly
|
||||
|
||||
Optional package content:
|
||||
|
||||
- `Localization/*.json`
|
||||
- plugin assets and other managed dependencies
|
||||
- `airappmarket-entry.template.json` in the repository root for market publishing
|
||||
|
||||
Market publishing uses `market-manifest.json` with `schemaVersion`, `manifest`, `compatibility`, `repository`, `publication.packageSources`, and `capabilities`.
|
||||
|
||||
@@ -10,11 +10,10 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
public void CreateExpanded_InitializesExpandedStateAndHidesChip()
|
||||
{
|
||||
var margin = new Thickness(24, 24, 24, 100);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState);
|
||||
Assert.Equal(margin, state.ExpandedMargin);
|
||||
Assert.Equal(0.75, state.ExpandedOpacity, 3);
|
||||
Assert.False(state.IsChipVisible);
|
||||
}
|
||||
|
||||
@@ -22,7 +21,7 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions()
|
||||
{
|
||||
var margin = new Thickness(20, 18, 20, 96);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
|
||||
var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||
var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||
@@ -36,24 +35,19 @@ public sealed class ComponentLibraryCollapseStateTests
|
||||
Assert.Equal(margin, collapsed.ExpandedMargin);
|
||||
Assert.Equal(margin, restoring.ExpandedMargin);
|
||||
|
||||
Assert.Equal(1, collapsing.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, collapsed.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, restoring.ExpandedOpacity, 3);
|
||||
|
||||
Assert.True(collapsing.IsChipVisible);
|
||||
Assert.True(collapsed.IsChipVisible);
|
||||
Assert.False(restoring.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExpanded_ProducesRestorableSnapshotEvenWhenOriginalOpacityIsLow()
|
||||
public void CreateExpanded_DoesNotCaptureTransientOpacityAsRestorableState()
|
||||
{
|
||||
var margin = new Thickness(18, 22, 18, 88);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin);
|
||||
var restored = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||
|
||||
Assert.Equal(margin, restored.ExpandedMargin);
|
||||
Assert.Equal(0.15, restored.ExpandedOpacity, 3);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, restored.VisualState);
|
||||
Assert.False(restored.IsChipVisible);
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentPreviewImageServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var executionOrder = new List<string>();
|
||||
var activeCount = 0;
|
||||
var maxActiveCount = 0;
|
||||
|
||||
Task<ComponentPreviewImageEntry> Queue(string componentTypeId)
|
||||
{
|
||||
var key = ComponentPreviewKey.ForComponentType(componentTypeId, widthCells: 2, heightCells: 2);
|
||||
return service.QueueGenerationAsync(
|
||||
key,
|
||||
visualSignature: $"sig:{componentTypeId}",
|
||||
async _ =>
|
||||
{
|
||||
var activeNow = Interlocked.Increment(ref activeCount);
|
||||
maxActiveCount = Math.Max(maxActiveCount, activeNow);
|
||||
lock (executionOrder)
|
||||
{
|
||||
executionOrder.Add(componentTypeId);
|
||||
}
|
||||
|
||||
await Task.Delay(40);
|
||||
Interlocked.Decrement(ref activeCount);
|
||||
return CreateImage();
|
||||
});
|
||||
}
|
||||
|
||||
var first = Queue("Clock");
|
||||
var second = Queue("Weather");
|
||||
var third = Queue("Calendar");
|
||||
|
||||
await Task.WhenAll(first, second, third);
|
||||
|
||||
Assert.Equal(1, maxActiveCount);
|
||||
Assert.Equal(["Clock", "Weather", "Calendar"], executionOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_DeduplicatesConcurrentRequestsForSameKey()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var generationCount = 0;
|
||||
var bitmap = CreateImage();
|
||||
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task<IImage?> Generation(CancellationToken _)
|
||||
{
|
||||
Interlocked.Increment(ref generationCount);
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
var first = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||
var second = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||
|
||||
Assert.Same(first, second);
|
||||
|
||||
completion.SetResult(bitmap);
|
||||
var entry = await first;
|
||||
|
||||
Assert.Equal(1, generationCount);
|
||||
Assert.Equal(ComponentPreviewImageState.Ready, entry.State);
|
||||
Assert.Same(bitmap, entry.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_ResetsSingleKeyToPending()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
var stored = service.Store(key, image, "clock-sig");
|
||||
var previousRevision = stored.Revision;
|
||||
|
||||
var result = service.Invalidate(key);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, stored.State);
|
||||
Assert.Null(stored.Bitmap);
|
||||
Assert.True(image.IsDisposed);
|
||||
Assert.True(stored.Revision > previousRevision);
|
||||
Assert.Equal("clock-sig", stored.VisualSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovePlacementPreviews_RemovesOnlyMatchingPlacementEntries()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
|
||||
var removedClock = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2);
|
||||
var removedWeather = ComponentPreviewKey.ForPlacementInstance("Weather", "desk-1", widthCells: 4, heightCells: 2);
|
||||
var keptPlacement = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-2", widthCells: 2, heightCells: 2);
|
||||
var keptType = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var removedClockImage = CreateDisposableImage();
|
||||
var removedWeatherImage = CreateDisposableImage();
|
||||
var keptPlacementImage = CreateDisposableImage();
|
||||
var keptTypeImage = CreateDisposableImage();
|
||||
|
||||
service.Store(removedClock, removedClockImage, "sig-a");
|
||||
service.Store(removedWeather, removedWeatherImage, "sig-b");
|
||||
service.Store(keptPlacement, keptPlacementImage, "sig-c");
|
||||
service.Store(keptType, keptTypeImage, "sig-d");
|
||||
|
||||
var removedCount = service.RemovePlacementPreviews("desk-1");
|
||||
|
||||
Assert.Equal(2, removedCount);
|
||||
Assert.False(service.TryGetEntry(removedClock, out _));
|
||||
Assert.False(service.TryGetEntry(removedWeather, out _));
|
||||
Assert.True(service.TryGetEntry(keptPlacement, out _));
|
||||
Assert.True(service.TryGetEntry(keptType, out _));
|
||||
Assert.True(removedClockImage.IsDisposed);
|
||||
Assert.True(removedWeatherImage.IsDisposed);
|
||||
Assert.False(keptPlacementImage.IsDisposed);
|
||||
Assert.False(keptTypeImage.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateVisualSignature_InvalidatesEveryMatchingEntry()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
const string matchingSignature = "shared-sig";
|
||||
const string otherSignature = "other-sig";
|
||||
|
||||
var first = service.Store(
|
||||
ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2),
|
||||
CreateImage(),
|
||||
matchingSignature);
|
||||
var second = service.Store(
|
||||
ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2),
|
||||
CreateImage(),
|
||||
matchingSignature);
|
||||
var third = service.Store(
|
||||
ComponentPreviewKey.ForComponentType("Weather", widthCells: 2, heightCells: 1),
|
||||
CreateImage(),
|
||||
otherSignature);
|
||||
|
||||
var invalidatedCount = service.InvalidateVisualSignature(matchingSignature);
|
||||
|
||||
Assert.Equal(2, invalidatedCount);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, first.State);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, second.State);
|
||||
Assert.Null(first.Bitmap);
|
||||
Assert.Null(second.Bitmap);
|
||||
Assert.Equal(ComponentPreviewImageState.Ready, third.State);
|
||||
Assert.NotNull(third.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReplacingBitmap_DisposesPreviousBitmap_WhenInstanceChanges()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var first = CreateDisposableImage();
|
||||
var second = CreateDisposableImage();
|
||||
|
||||
service.Store(key, first, "sig-a");
|
||||
service.Store(key, second, "sig-b");
|
||||
|
||||
Assert.True(first.IsDisposed);
|
||||
Assert.False(second.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReplacingBitmap_DoesNotDispose_WhenSameInstanceReused()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
|
||||
service.Store(key, image, "sig-a");
|
||||
service.Store(key, image, "sig-b");
|
||||
|
||||
Assert.False(image.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoreFailure_DisposesExistingBitmap()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
|
||||
service.Store(key, image, "sig-a");
|
||||
var entry = service.StoreFailure(key, "sig-a", "failed");
|
||||
|
||||
Assert.True(image.IsDisposed);
|
||||
Assert.Equal(ComponentPreviewImageState.Failed, entry.State);
|
||||
Assert.Null(entry.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_DisposesStaleGeneratedBitmap_WhenEntryWasInvalidated()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var stale = CreateDisposableImage();
|
||||
|
||||
var generationTask = service.QueueGenerationAsync(key, "sig-a", _ => completion.Task);
|
||||
_ = service.Invalidate(key);
|
||||
completion.SetResult(stale);
|
||||
var entry = await generationTask;
|
||||
|
||||
Assert.True(stale.IsDisposed);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, entry.State);
|
||||
Assert.Null(entry.Bitmap);
|
||||
}
|
||||
|
||||
private static IImage CreateImage() => new TestImage();
|
||||
private static DisposableTestImage CreateDisposableImage() => new();
|
||||
|
||||
private sealed class TestImage : IImage
|
||||
{
|
||||
public Size Size => new(1, 1);
|
||||
|
||||
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||
{
|
||||
_ = context;
|
||||
_ = sourceRect;
|
||||
_ = destRect;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DisposableTestImage : IImage, IDisposable
|
||||
{
|
||||
public Size Size => new(1, 1);
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
IsDisposed = true;
|
||||
}
|
||||
|
||||
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||
{
|
||||
_ = context;
|
||||
_ = sourceRect;
|
||||
_ = destRect;
|
||||
}
|
||||
}
|
||||
}
|
||||
135
LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs
Normal file
135
LanMountainDesktop.Tests/DesktopComponentRenderModeTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopComponentRenderModeTests
|
||||
{
|
||||
private const string ComponentId = "RenderModeProbe";
|
||||
|
||||
[Fact]
|
||||
public void DescriptorCreateControl_DefaultsToLiveRenderMode()
|
||||
{
|
||||
var descriptor = CreateDescriptor();
|
||||
var control = (ProbeControl)descriptor.CreateControl(
|
||||
cellSize: 64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
placementId: "desktop-placement");
|
||||
|
||||
Assert.Equal(DesktopComponentRenderMode.Live, control.RuntimeContext?.RenderMode);
|
||||
Assert.Equal("desktop-placement", control.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescriptorCreateControl_CanCreateLibraryPreviewRenderModeWithoutPlacement()
|
||||
{
|
||||
var descriptor = CreateDescriptor();
|
||||
var control = (ProbeControl)descriptor.CreateControl(
|
||||
cellSize: 64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
placementId: null,
|
||||
renderMode: DesktopComponentRenderMode.LibraryPreview);
|
||||
|
||||
Assert.Equal(DesktopComponentRenderMode.LibraryPreview, control.RuntimeContext?.RenderMode);
|
||||
Assert.Null(control.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComponentLibraryService_CreatesLibraryPreviewRenderMode()
|
||||
{
|
||||
var service = new ComponentLibraryService(
|
||||
CreateComponentRegistry(),
|
||||
CreateRuntimeRegistry());
|
||||
|
||||
var created = service.TryCreateControl(
|
||||
ComponentId,
|
||||
new ComponentLibraryCreateContext(
|
||||
64,
|
||||
CreateTimeZoneService(),
|
||||
CreateWeatherInfoService(),
|
||||
new RecommendationDataService(),
|
||||
new CalculatorDataService(),
|
||||
CreateSettingsFacade(),
|
||||
PlacementId: null,
|
||||
RenderMode: DesktopComponentRenderMode.LibraryPreview),
|
||||
out var control,
|
||||
out var exception);
|
||||
|
||||
Assert.True(created, exception?.ToString());
|
||||
var probe = Assert.IsType<ProbeControl>(control);
|
||||
Assert.Equal(DesktopComponentRenderMode.LibraryPreview, probe.RuntimeContext?.RenderMode);
|
||||
Assert.Null(probe.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
private static DesktopComponentRuntimeDescriptor CreateDescriptor()
|
||||
{
|
||||
Assert.True(CreateRuntimeRegistry().TryGetDescriptor(ComponentId, out var descriptor));
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
private static DesktopComponentRuntimeRegistry CreateRuntimeRegistry()
|
||||
{
|
||||
return new DesktopComponentRuntimeRegistry(
|
||||
CreateComponentRegistry(),
|
||||
[
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
ComponentId,
|
||||
displayNameLocalizationKey: null,
|
||||
_ => new ProbeControl(),
|
||||
cornerRadiusResolver: (System.Func<double, double>?)null)
|
||||
]);
|
||||
}
|
||||
|
||||
private static ComponentRegistry CreateComponentRegistry()
|
||||
{
|
||||
return new ComponentRegistry(
|
||||
[
|
||||
new DesktopComponentDefinition(
|
||||
ComponentId,
|
||||
"Render Mode Probe",
|
||||
"Apps",
|
||||
"Test",
|
||||
MinWidthCells: 1,
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true)
|
||||
]);
|
||||
}
|
||||
|
||||
private static ISettingsFacadeService CreateSettingsFacade()
|
||||
{
|
||||
return HostSettingsFacadeProvider.GetOrCreate();
|
||||
}
|
||||
|
||||
private static TimeZoneService CreateTimeZoneService()
|
||||
{
|
||||
return CreateSettingsFacade().Region.GetTimeZoneService();
|
||||
}
|
||||
|
||||
private static IWeatherInfoService CreateWeatherInfoService()
|
||||
{
|
||||
return CreateSettingsFacade().Weather.GetWeatherInfoService();
|
||||
}
|
||||
|
||||
private sealed class ProbeControl : Control, IComponentRuntimeContextAware
|
||||
{
|
||||
public DesktopComponentRuntimeContext? RuntimeContext { get; private set; }
|
||||
|
||||
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
|
||||
{
|
||||
RuntimeContext = context;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using System.IO.Compression;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -26,6 +27,96 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Assert.Equal("plugin_elevation_required", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_InstallsLaappWithPluginJson_InsideUserScope()
|
||||
{
|
||||
var packagePath = Path.Combine(_tempRoot, "sample.laapp");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
Assert.Equal("plugin.install.sample", result.ManifestId);
|
||||
Assert.Equal("Sample Plugin", result.ManifestName);
|
||||
Assert.NotNull(result.InstalledPackagePath);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
Assert.EndsWith(".laapp", result.InstalledPackagePath, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
||||
{
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
var firstPackagePath = Path.Combine(_tempRoot, "sample-1.laapp");
|
||||
var secondPackagePath = Path.Combine(_tempRoot, "sample-2.laapp");
|
||||
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
||||
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.True(second.Success);
|
||||
Assert.Single(Directory.EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.TopDirectoryOnly));
|
||||
Assert.True(File.Exists(second.InstalledPackagePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_StillSupportsLegacyManifestJson()
|
||||
{
|
||||
var packagePath = Path.Combine(_tempRoot, "legacy.lmdp");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
}
|
||||
|
||||
private static void CreatePluginPackage(string packagePath, string manifestFileName, string pluginId, string pluginName)
|
||||
{
|
||||
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
|
||||
var entry = archive.CreateEntry(manifestFileName);
|
||||
using var stream = entry.Open();
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(
|
||||
$$"""
|
||||
{
|
||||
"id": "{{pluginId}}",
|
||||
"name": "{{pluginName}}",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static string CreateUserScopedPluginsDirectory()
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Tests",
|
||||
nameof(PluginInstallerServiceTests),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
|
||||
127
LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs
Normal file
127
LanMountainDesktop.Tests/PluginMarketIndexDocumentTests.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using LanMountainDesktop.Services.PluginMarket;
|
||||
using System.Net;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginMarketIndexDocumentTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_WithNestedV2Entry_MapsDisplayFieldsAndWorkspacePath()
|
||||
{
|
||||
var document = AirAppMarketIndexDocument.Load(CreateNestedIndexJson(), "test-index.json");
|
||||
var plugin = Assert.Single(document.Plugins);
|
||||
var source = Assert.Single(plugin.PackageSources);
|
||||
|
||||
Assert.Equal("LanMountainDesktop.SamplePlugin", plugin.Id);
|
||||
Assert.Equal("LanMountain Sample Plugin", plugin.Name);
|
||||
Assert.Equal("SDK v5 sample plugin.", plugin.Description);
|
||||
Assert.Equal("LanMountainDesktop", plugin.Author);
|
||||
Assert.Equal("0.4.0", plugin.Version);
|
||||
Assert.Equal("5.0.0", plugin.ApiVersion);
|
||||
Assert.Equal("0.0.1", plugin.MinHostVersion);
|
||||
Assert.Equal("https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/assets/sample-plugin.svg", plugin.IconUrl);
|
||||
Assert.Equal("https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop.SamplePlugin/main/README.md", plugin.ReadmeUrl);
|
||||
Assert.Equal("workspace://LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.0.4.0.laapp", source.Url);
|
||||
Assert.Equal(PluginPackageSourceKind.WorkspaceLocal, source.SourceKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichAsync_WhenRepositoryMetadataUnavailable_PreservesNestedDisplayFields()
|
||||
{
|
||||
var document = AirAppMarketIndexDocument.Load(
|
||||
CreateNestedIndexJson("LanMountainDesktop.MissingPlugin"),
|
||||
"test-index.json");
|
||||
using var httpClient = new HttpClient(new NotFoundHandler());
|
||||
using var resolver = new AirAppMarketMetadataResolverService(httpClient);
|
||||
|
||||
var enriched = await resolver.EnrichAsync(document);
|
||||
var plugin = Assert.Single(enriched.Plugins);
|
||||
|
||||
Assert.Equal("LanMountain Sample Plugin", plugin.Name);
|
||||
Assert.Equal("SDK v5 sample plugin.", plugin.Description);
|
||||
Assert.Equal("LanMountainDesktop", plugin.Author);
|
||||
Assert.Equal("0.4.0", plugin.Version);
|
||||
Assert.Equal("5.0.0", plugin.ApiVersion);
|
||||
Assert.Equal("0.0.1", plugin.MinHostVersion);
|
||||
Assert.Equal("v0.4.0", plugin.ReleaseTag);
|
||||
Assert.Equal("LanMountainDesktop.SamplePlugin.0.4.0.laapp", plugin.ReleaseAssetName);
|
||||
Assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", plugin.Sha256);
|
||||
Assert.Equal(1024, plugin.PackageSizeBytes);
|
||||
}
|
||||
|
||||
private static string CreateNestedIndexJson(string repositoryName = "LanMountainDesktop.SamplePlugin")
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"schemaVersion": "2.0.0",
|
||||
"sourceId": "official",
|
||||
"sourceName": "LanAirApp",
|
||||
"generatedAt": "2026-04-29T00:00:00Z",
|
||||
"contracts": [],
|
||||
"plugins": [
|
||||
{
|
||||
"manifest": {
|
||||
"id": "LanMountainDesktop.SamplePlugin",
|
||||
"name": "LanMountain Sample Plugin",
|
||||
"description": "SDK v5 sample plugin.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "0.4.0",
|
||||
"apiVersion": "5.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll",
|
||||
"sharedContracts": []
|
||||
},
|
||||
"compatibility": {
|
||||
"minHostVersion": "0.0.1",
|
||||
"apiVersion": "5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"projectUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/wwiinnddyy/{{repositoryName}}/main/README.md",
|
||||
"homepageUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"repositoryUrl": "https://github.com/wwiinnddyy/{{repositoryName}}",
|
||||
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/assets/sample-plugin.svg",
|
||||
"tags": [ "official", "sdk" ],
|
||||
"releaseNotes": "Reference plugin for SDK v5 validation."
|
||||
},
|
||||
"publication": {
|
||||
"releaseTag": "v0.4.0",
|
||||
"releaseAssetName": "LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"packageSizeBytes": 1024,
|
||||
"publishedAt": "2026-04-29T00:00:00Z",
|
||||
"updatedAt": "2026-04-29T00:00:00Z",
|
||||
"packageSources": [
|
||||
{
|
||||
"kind": "workspaceLocal",
|
||||
"path": "workspace://LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"assetName": "LanMountainDesktop.SamplePlugin.0.4.0.laapp",
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"sizeBytes": 1024,
|
||||
"releaseTag": "v0.4.0",
|
||||
"priority": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"desktopComponents": [ "LanMountainDesktop.SamplePlugin.StatusClock" ],
|
||||
"settingsSections": [ "status" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private sealed class NotFoundHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent("{}")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
internal static class ComponentPreviewRuntimeQuiescer
|
||||
{
|
||||
private static readonly BindingFlags TimerMemberFlags =
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
public static void Attach(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
control.IsHitTestVisible = false;
|
||||
control.Focusable = false;
|
||||
control.AttachedToVisualTree += (_, _) =>
|
||||
Dispatcher.UIThread.Post(() => Quiesce(control), DispatcherPriority.Background);
|
||||
control.DetachedFromVisualTree += (_, _) => Quiesce(control);
|
||||
Quiesce(control);
|
||||
}
|
||||
|
||||
public static void Detach(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
Quiesce(control);
|
||||
}
|
||||
|
||||
public static void Quiesce(Control control)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
foreach (var candidate in EnumerateControls(control))
|
||||
{
|
||||
StopDispatcherTimers(candidate);
|
||||
candidate.IsHitTestVisible = false;
|
||||
candidate.Focusable = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<Control> EnumerateControls(Control root)
|
||||
{
|
||||
yield return root;
|
||||
|
||||
foreach (var descendant in root.GetVisualDescendants().OfType<Control>())
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
private static void StopDispatcherTimers(object target)
|
||||
{
|
||||
var type = target.GetType();
|
||||
foreach (var field in type.GetFields(TimerMemberFlags))
|
||||
{
|
||||
if (typeof(DispatcherTimer).IsAssignableFrom(field.FieldType) &&
|
||||
field.GetValue(target) is DispatcherTimer timer)
|
||||
{
|
||||
timer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in type.GetProperties(TimerMemberFlags))
|
||||
{
|
||||
if (!property.CanRead ||
|
||||
property.GetIndexParameters().Length != 0 ||
|
||||
!typeof(DispatcherTimer).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (property.GetValue(target) is DispatcherTimer timer)
|
||||
{
|
||||
timer.Stop();
|
||||
}
|
||||
}
|
||||
catch (TargetInvocationException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public enum DesktopComponentRenderMode
|
||||
{
|
||||
Live = 0,
|
||||
LibraryPreview = 1
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public sealed record DesktopComponentRuntimeContext(
|
||||
IAppearanceThemeService AppearanceTheme,
|
||||
ComponentChromeContext Chrome,
|
||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);
|
||||
|
||||
@@ -12,7 +12,6 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
{
|
||||
private static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(150);
|
||||
private static readonly Easing TransitionEasing = new CubicEaseOut();
|
||||
private const double StableOpacityThreshold = 0.01;
|
||||
|
||||
private readonly Border _componentLibraryWindow;
|
||||
private readonly Border _collapsedChipHost;
|
||||
@@ -37,9 +36,7 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
_collapsedChipIcon = collapsedChipIcon;
|
||||
|
||||
EnsureTransforms();
|
||||
_state = ComponentLibraryCollapseState.CreateExpanded(
|
||||
_componentLibraryWindow.Margin,
|
||||
_componentLibraryWindow.Opacity <= 0 ? 1 : _componentLibraryWindow.Opacity);
|
||||
_state = ComponentLibraryCollapseState.CreateExpanded(_componentLibraryWindow.Margin);
|
||||
ApplyExpandedSnapshot();
|
||||
_collapsedChipHost.IsVisible = false;
|
||||
_collapsedChipHost.IsHitTestVisible = false;
|
||||
@@ -50,19 +47,16 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
|
||||
public ComponentLibraryCollapseVisualState VisualState => _state.VisualState;
|
||||
|
||||
public void SyncExpandedState(Thickness margin, double opacity)
|
||||
public void SyncExpandedState(Thickness margin)
|
||||
{
|
||||
var hasStableOpacity = IsStableExpandedOpacity(opacity);
|
||||
var nextExpandedOpacity = hasStableOpacity ? Math.Clamp(opacity, 0, 1) : _state.ExpandedOpacity;
|
||||
_state = _state with
|
||||
{
|
||||
ExpandedMargin = margin,
|
||||
ExpandedOpacity = nextExpandedOpacity
|
||||
ExpandedMargin = margin
|
||||
};
|
||||
|
||||
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded or ComponentLibraryCollapseVisualState.Restoring)
|
||||
{
|
||||
ApplyExpandedSnapshot(applyOpacity: hasStableOpacity);
|
||||
ApplyExpandedSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +116,7 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
_componentLibraryWindow.Opacity = 1;
|
||||
_windowTranslate.Y = 0;
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
@@ -190,14 +184,10 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyExpandedSnapshot(bool applyOpacity = true)
|
||||
private void ApplyExpandedSnapshot()
|
||||
{
|
||||
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||
if (applyOpacity)
|
||||
{
|
||||
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||
}
|
||||
|
||||
_componentLibraryWindow.Opacity = 1;
|
||||
_componentLibraryWindow.IsVisible = true;
|
||||
_componentLibraryWindow.IsHitTestVisible = true;
|
||||
_windowTranslate.Y = 0;
|
||||
@@ -270,11 +260,4 @@ internal sealed class ComponentLibraryCollapsePresenter
|
||||
_componentLibraryWindow.Opacity = 0;
|
||||
_windowTranslate.Y = 28;
|
||||
}
|
||||
|
||||
private static bool IsStableExpandedOpacity(double opacity)
|
||||
{
|
||||
return !double.IsNaN(opacity) &&
|
||||
!double.IsInfinity(opacity) &&
|
||||
opacity > StableOpacityThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,13 @@ internal enum ComponentLibraryCollapseVisualState
|
||||
internal readonly record struct ComponentLibraryCollapseState(
|
||||
ComponentLibraryCollapseVisualState VisualState,
|
||||
Thickness ExpandedMargin,
|
||||
double ExpandedOpacity,
|
||||
bool IsChipVisible)
|
||||
{
|
||||
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin, double expandedOpacity)
|
||||
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin)
|
||||
{
|
||||
return new(
|
||||
ComponentLibraryCollapseVisualState.Expanded,
|
||||
expandedMargin,
|
||||
expandedOpacity,
|
||||
IsChipVisible: false);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ internal sealed class ComponentLibraryService : IComponentLibraryService
|
||||
context.RecommendationInfoService,
|
||||
context.CalculatorDataService,
|
||||
context.SettingsFacade,
|
||||
context.PlacementId);
|
||||
context.PlacementId,
|
||||
context.RenderMode);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class ComponentPreviewImageService : IComponentPreviewImageService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<ComponentPreviewKey, ComponentPreviewImageEntry> _entries = new(ComponentPreviewKeyComparer.Instance);
|
||||
private readonly Dictionary<ComponentPreviewKey, Task<ComponentPreviewImageEntry>> _inFlightRequests = new(ComponentPreviewKeyComparer.Instance);
|
||||
private Task _queueTail = Task.CompletedTask;
|
||||
|
||||
public ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new ComponentPreviewImageEntry(key, visualSignature);
|
||||
_entries[key] = created;
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
entry = existing;
|
||||
return true;
|
||||
}
|
||||
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _entries.Values.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||
ComponentPreviewKey key,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(generationWork);
|
||||
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
|
||||
if (entry.State == ComponentPreviewImageState.Ready &&
|
||||
entry.Bitmap is not null &&
|
||||
StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||
{
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
if (_inFlightRequests.TryGetValue(key, out var inFlight))
|
||||
{
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
var expectedRevision = entry.BeginGeneration(normalizedSignature);
|
||||
var previousTask = _queueTail;
|
||||
var queuedTask = RunGenerationAsync(
|
||||
previousTask,
|
||||
key,
|
||||
entry,
|
||||
expectedRevision,
|
||||
normalizedSignature,
|
||||
generationWork,
|
||||
cancellationToken);
|
||||
|
||||
_inFlightRequests[key] = queuedTask;
|
||||
_queueTail = queuedTask.ContinueWith(
|
||||
static _ => { },
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
return queuedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
entry.StoreBitmap(bitmap, normalizedSignature);
|
||||
_inFlightRequests.Remove(key);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null)
|
||||
{
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entry = GetOrCreateEntryCore(key);
|
||||
entry.StoreFailure(normalizedSignature, errorMessage);
|
||||
_inFlightRequests.Remove(key);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Invalidate(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.Invalidate(visualSignature);
|
||||
_inFlightRequests.Remove(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int RemovePlacementPreviews(string placementId)
|
||||
{
|
||||
var normalizedPlacementId = NormalizeRequired(placementId, nameof(placementId));
|
||||
lock (_gate)
|
||||
{
|
||||
var entriesToRemove = _entries
|
||||
.Where(static pair => pair.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||
.Where(pair => StringComparer.OrdinalIgnoreCase.Equals(pair.Key.PlacementId, normalizedPlacementId))
|
||||
.ToArray();
|
||||
|
||||
foreach (var pair in entriesToRemove)
|
||||
{
|
||||
pair.Value.DisposeBitmap();
|
||||
_entries.Remove(pair.Key);
|
||||
_inFlightRequests.Remove(pair.Key);
|
||||
}
|
||||
|
||||
return entriesToRemove.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public int InvalidateVisualSignature(string visualSignature)
|
||||
{
|
||||
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||
lock (_gate)
|
||||
{
|
||||
var entriesToInvalidate = _entries.Values
|
||||
.Where(entry => StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||
.ToArray();
|
||||
|
||||
foreach (var entry in entriesToInvalidate)
|
||||
{
|
||||
entry.Invalidate(normalizedSignature);
|
||||
_inFlightRequests.Remove(entry.Key);
|
||||
}
|
||||
|
||||
return entriesToInvalidate.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ComponentPreviewImageEntry> RunGenerationAsync(
|
||||
Task previousTask,
|
||||
ComponentPreviewKey key,
|
||||
ComponentPreviewImageEntry entry,
|
||||
long expectedRevision,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await previousTask.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep serial queue processing even if previous work faulted.
|
||||
}
|
||||
|
||||
IImage? bitmap;
|
||||
try
|
||||
{
|
||||
bitmap = await generationWork(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
entry.TryApplyFailure(expectedRevision, visualSignature, ex.Message);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (bitmap is null)
|
||||
{
|
||||
entry.TryApplyFailure(expectedRevision, visualSignature, "Preview generation returned no bitmap.");
|
||||
}
|
||||
else
|
||||
{
|
||||
entry.TryApplyGeneratedBitmap(expectedRevision, bitmap, visualSignature);
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_inFlightRequests.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ComponentPreviewImageEntry GetOrCreateEntryCore(ComponentPreviewKey key)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new ComponentPreviewImageEntry(key);
|
||||
_entries[key] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum ComponentPreviewKeyKind
|
||||
{
|
||||
ComponentType = 0,
|
||||
PlacementInstance = 1
|
||||
}
|
||||
|
||||
public readonly record struct ComponentPreviewKey
|
||||
{
|
||||
private ComponentPreviewKey(
|
||||
ComponentPreviewKeyKind kind,
|
||||
string componentTypeId,
|
||||
string? placementId,
|
||||
int widthCells,
|
||||
int heightCells)
|
||||
{
|
||||
Kind = kind;
|
||||
ComponentTypeId = NormalizeRequired(componentTypeId, nameof(componentTypeId));
|
||||
PlacementId = kind == ComponentPreviewKeyKind.PlacementInstance
|
||||
? NormalizeRequired(placementId, nameof(placementId))
|
||||
: null;
|
||||
WidthCells = NormalizeSpan(widthCells, nameof(widthCells));
|
||||
HeightCells = NormalizeSpan(heightCells, nameof(heightCells));
|
||||
}
|
||||
|
||||
public ComponentPreviewKeyKind Kind { get; }
|
||||
|
||||
public string ComponentTypeId { get; }
|
||||
|
||||
public string? PlacementId { get; }
|
||||
|
||||
public int WidthCells { get; }
|
||||
|
||||
public int HeightCells { get; }
|
||||
|
||||
public static ComponentPreviewKey ForComponentType(string componentTypeId, int widthCells, int heightCells)
|
||||
{
|
||||
return new ComponentPreviewKey(ComponentPreviewKeyKind.ComponentType, componentTypeId, null, widthCells, heightCells);
|
||||
}
|
||||
|
||||
public static ComponentPreviewKey ForPlacementInstance(string componentTypeId, string placementId, int widthCells, int heightCells)
|
||||
{
|
||||
return new ComponentPreviewKey(
|
||||
ComponentPreviewKeyKind.PlacementInstance,
|
||||
componentTypeId,
|
||||
placementId,
|
||||
widthCells,
|
||||
heightCells);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Kind == ComponentPreviewKeyKind.ComponentType
|
||||
? $"Type:{ComponentTypeId}[{WidthCells}x{HeightCells}]"
|
||||
: $"Placement:{ComponentTypeId}@{PlacementId}[{WidthCells}x{HeightCells}]";
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static int NormalizeSpan(int value, string paramName)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(paramName, value, "Span must be greater than zero.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ComponentPreviewImageState
|
||||
{
|
||||
Pending = 0,
|
||||
Ready = 1,
|
||||
Failed = 2
|
||||
}
|
||||
|
||||
public sealed class ComponentPreviewImageEntry : ObservableObject
|
||||
{
|
||||
private IImage? _bitmap;
|
||||
private ComponentPreviewImageState _state = ComponentPreviewImageState.Pending;
|
||||
private string _visualSignature = string.Empty;
|
||||
private string? _errorMessage;
|
||||
private long _revision;
|
||||
private DateTimeOffset _lastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
public ComponentPreviewImageEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||
{
|
||||
Key = key;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
}
|
||||
|
||||
public ComponentPreviewKey Key { get; }
|
||||
|
||||
public IImage? Bitmap
|
||||
{
|
||||
get => _bitmap;
|
||||
private set => SetProperty(ref _bitmap, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewImageState State
|
||||
{
|
||||
get => _state;
|
||||
private set => SetProperty(ref _state, value);
|
||||
}
|
||||
|
||||
public string VisualSignature
|
||||
{
|
||||
get => _visualSignature;
|
||||
private set => SetProperty(ref _visualSignature, value);
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set => SetProperty(ref _errorMessage, value);
|
||||
}
|
||||
|
||||
public long Revision
|
||||
{
|
||||
get => _revision;
|
||||
private set => SetProperty(ref _revision, value);
|
||||
}
|
||||
|
||||
public DateTimeOffset LastUpdatedUtc
|
||||
{
|
||||
get => _lastUpdatedUtc;
|
||||
private set => SetProperty(ref _lastUpdatedUtc, value);
|
||||
}
|
||||
|
||||
internal long BeginGeneration(string visualSignature)
|
||||
{
|
||||
var normalizedVisualSignature = NormalizeSignature(visualSignature);
|
||||
var nextRevision = Revision + 1;
|
||||
Revision = nextRevision;
|
||||
VisualSignature = normalizedVisualSignature;
|
||||
State = ComponentPreviewImageState.Pending;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return nextRevision;
|
||||
}
|
||||
|
||||
internal bool TryApplyGeneratedBitmap(long expectedRevision, IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
if (Revision != expectedRevision)
|
||||
{
|
||||
DisposeIfNeeded(bitmap);
|
||||
return false;
|
||||
}
|
||||
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Ready;
|
||||
ReplaceBitmap(bitmap);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryApplyFailure(long expectedRevision, string visualSignature, string? errorMessage)
|
||||
{
|
||||
if (Revision != expectedRevision)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Failed;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void StoreBitmap(IImage bitmap, string visualSignature)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
Revision += 1;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Ready;
|
||||
ReplaceBitmap(bitmap);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void StoreFailure(string visualSignature, string? errorMessage)
|
||||
{
|
||||
Revision += 1;
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
State = ComponentPreviewImageState.Failed;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void Invalidate(string? visualSignature = null)
|
||||
{
|
||||
Revision += 1;
|
||||
if (visualSignature is not null)
|
||||
{
|
||||
VisualSignature = NormalizeSignature(visualSignature);
|
||||
}
|
||||
|
||||
State = ComponentPreviewImageState.Pending;
|
||||
ReplaceBitmap(null);
|
||||
ErrorMessage = null;
|
||||
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
internal void DisposeBitmap()
|
||||
{
|
||||
ReplaceBitmap(null);
|
||||
}
|
||||
|
||||
private void ReplaceBitmap(IImage? bitmap)
|
||||
{
|
||||
var previous = _bitmap;
|
||||
if (ReferenceEquals(previous, bitmap))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Bitmap = bitmap;
|
||||
DisposeIfNeeded(previous);
|
||||
}
|
||||
|
||||
private static void DisposeIfNeeded(IImage? bitmap)
|
||||
{
|
||||
if (bitmap is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeSignature(string? visualSignature)
|
||||
{
|
||||
return visualSignature?.Trim() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ComponentPreviewKeyComparer : IEqualityComparer<ComponentPreviewKey>
|
||||
{
|
||||
public static ComponentPreviewKeyComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(ComponentPreviewKey x, ComponentPreviewKey y)
|
||||
{
|
||||
return x.Kind == y.Kind &&
|
||||
StringComparer.OrdinalIgnoreCase.Equals(x.ComponentTypeId, y.ComponentTypeId) &&
|
||||
StringComparer.OrdinalIgnoreCase.Equals(x.PlacementId, y.PlacementId) &&
|
||||
x.WidthCells == y.WidthCells &&
|
||||
x.HeightCells == y.HeightCells;
|
||||
}
|
||||
|
||||
public int GetHashCode(ComponentPreviewKey obj)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(obj.Kind);
|
||||
hash.Add(obj.ComponentTypeId, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.PlacementId, StringComparer.OrdinalIgnoreCase);
|
||||
hash.Add(obj.WidthCells);
|
||||
hash.Add(obj.HeightCells);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ public sealed record ComponentLibraryCreateContext(
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
ICalculatorDataService CalculatorDataService,
|
||||
ISettingsFacadeService SettingsFacade,
|
||||
string? PlacementId = null);
|
||||
string? PlacementId = null,
|
||||
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);
|
||||
|
||||
public interface IComponentLibraryService
|
||||
{
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IComponentPreviewImageService
|
||||
{
|
||||
ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null);
|
||||
|
||||
bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry);
|
||||
|
||||
IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot();
|
||||
|
||||
Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||
ComponentPreviewKey key,
|
||||
string visualSignature,
|
||||
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature);
|
||||
|
||||
ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null);
|
||||
|
||||
bool Invalidate(ComponentPreviewKey key, string? visualSignature = null);
|
||||
|
||||
int RemovePlacementPreviews(string placementId);
|
||||
|
||||
int InvalidateVisualSignature(string visualSignature);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -111,7 +112,7 @@ internal sealed class LauncherClient
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
|
||||
Arguments = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
|
||||
$"plugin install --source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
|
||||
};
|
||||
|
||||
return Process.Start(startInfo);
|
||||
@@ -130,7 +131,17 @@ internal sealed class LauncherClient
|
||||
|
||||
private static string ResolveLauncherPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(baseDirectory, "Launcher", LauncherExecutableName),
|
||||
Path.Combine(baseDirectory, LauncherExecutableName),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "LanMountainDesktop.Launcher", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName))
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
|
||||
@@ -1236,7 +1236,31 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
|
||||
repository,
|
||||
publication,
|
||||
sources,
|
||||
[]);
|
||||
BuildCapabilities(entry));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginCapabilityInfo> BuildCapabilities(AirAppMarketPluginEntry entry)
|
||||
{
|
||||
if (entry.Capabilities is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var capabilities = new List<PluginCapabilityInfo>();
|
||||
capabilities.AddRange(entry.Capabilities.SharedContracts.Select(contract =>
|
||||
new PluginCapabilityInfo(contract.Id, contract.Version, contract.AssemblyName)));
|
||||
capabilities.AddRange(entry.Capabilities.DesktopComponents.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.SettingsSections.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.Exports.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
capabilities.AddRange(entry.Capabilities.MessageTypes.Select(id =>
|
||||
new PluginCapabilityInfo(id, null, null)));
|
||||
|
||||
return capabilities
|
||||
.DistinctBy(capability => $"{capability.Id}@{capability.Version}@{capability.AssemblyName}")
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using LanMountainDesktop.Services;
|
||||
using Avalonia.Controls;
|
||||
using FluentIcons.Common;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
@@ -55,33 +54,20 @@ public sealed class ComponentLibraryCategoryViewModel
|
||||
public sealed class ComponentLibraryItemViewModel
|
||||
: ObservableObject
|
||||
{
|
||||
private readonly string _loadingPreviewText;
|
||||
private readonly string _previewUnavailableText;
|
||||
private string _displayName;
|
||||
private string? _description;
|
||||
private ComponentPreviewKey _previewKey;
|
||||
private ComponentPreviewImageEntry? _previewImageEntry;
|
||||
private ComponentPreviewImageState _previewState;
|
||||
private string? _previewErrorMessage;
|
||||
private string _previewStatusText;
|
||||
private Control? _previewControl;
|
||||
|
||||
public ComponentLibraryItemViewModel(
|
||||
string componentId,
|
||||
string displayName,
|
||||
ComponentPreviewKey previewKey,
|
||||
string? description = null,
|
||||
string loadingPreviewText = "Loading preview...",
|
||||
string previewUnavailableText = "Preview unavailable",
|
||||
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||
Control? previewControl = null)
|
||||
{
|
||||
ComponentId = componentId;
|
||||
_displayName = displayName;
|
||||
_description = description;
|
||||
_previewKey = previewKey;
|
||||
_loadingPreviewText = loadingPreviewText;
|
||||
_previewUnavailableText = previewUnavailableText;
|
||||
_previewStatusText = loadingPreviewText;
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
|
||||
_previewControl = previewControl;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
@@ -98,98 +84,10 @@ public sealed class ComponentLibraryItemViewModel
|
||||
set => SetProperty(ref _description, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewKey PreviewKey
|
||||
public Control? PreviewControl
|
||||
{
|
||||
get => _previewKey;
|
||||
set => SetProperty(ref _previewKey, value);
|
||||
get => _previewControl;
|
||||
set => SetProperty(ref _previewControl, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry;
|
||||
|
||||
public object? PreviewBitmap => _previewImageEntry?.Bitmap;
|
||||
|
||||
public ComponentPreviewImageState PreviewState => _previewState;
|
||||
|
||||
public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending;
|
||||
|
||||
public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null;
|
||||
|
||||
public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed;
|
||||
|
||||
public string? PreviewErrorMessage => _previewErrorMessage;
|
||||
|
||||
public string PreviewStatusText => _previewStatusText;
|
||||
|
||||
public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry)
|
||||
{
|
||||
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true);
|
||||
}
|
||||
|
||||
private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged)
|
||||
{
|
||||
if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_previewImageEntry is not null)
|
||||
{
|
||||
_previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged;
|
||||
}
|
||||
|
||||
_previewImageEntry = previewImageEntry;
|
||||
_previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||
_previewErrorMessage = previewImageEntry?.ErrorMessage;
|
||||
|
||||
_previewStatusText = _previewState switch
|
||||
{
|
||||
ComponentPreviewImageState.Ready => string.Empty,
|
||||
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||
? _previewUnavailableText
|
||||
: _previewErrorMessage!,
|
||||
_ => _loadingPreviewText
|
||||
};
|
||||
|
||||
if (_previewImageEntry is not null)
|
||||
{
|
||||
_previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged;
|
||||
}
|
||||
|
||||
RaisePreviewDependentProperties();
|
||||
}
|
||||
|
||||
private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (string.IsNullOrWhiteSpace(e.PropertyName) ||
|
||||
e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or
|
||||
nameof(ComponentPreviewImageEntry.State) or
|
||||
nameof(ComponentPreviewImageEntry.ErrorMessage))
|
||||
{
|
||||
_previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||
_previewErrorMessage = _previewImageEntry?.ErrorMessage;
|
||||
_previewStatusText = _previewState switch
|
||||
{
|
||||
ComponentPreviewImageState.Ready => string.Empty,
|
||||
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||
? _previewUnavailableText
|
||||
: _previewErrorMessage!,
|
||||
_ => _loadingPreviewText
|
||||
};
|
||||
|
||||
RaisePreviewDependentProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private void RaisePreviewDependentProperties()
|
||||
{
|
||||
OnPropertyChanged(nameof(PreviewImageEntry));
|
||||
OnPropertyChanged(nameof(PreviewBitmap));
|
||||
OnPropertyChanged(nameof(PreviewState));
|
||||
OnPropertyChanged(nameof(IsPreviewPending));
|
||||
OnPropertyChanged(nameof(IsPreviewReady));
|
||||
OnPropertyChanged(nameof(IsPreviewFailed));
|
||||
OnPropertyChanged(nameof(PreviewErrorMessage));
|
||||
OnPropertyChanged(nameof(PreviewStatusText));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,48 +99,11 @@
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
Padding="8">
|
||||
<Grid>
|
||||
<Image Source="{Binding PreviewBitmap}"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
IsVisible="{Binding IsPreviewReady}" />
|
||||
|
||||
<Border IsVisible="{Binding IsPreviewPending}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<ProgressBar Width="96"
|
||||
IsIndeterminate="True" />
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding PreviewStatusText}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border IsVisible="{Binding IsPreviewFailed}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding PreviewStatusText}" />
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding PreviewErrorMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
<ContentControl Content="{Binding PreviewControl}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsHitTestVisible="False"
|
||||
Focusable="False" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
@@ -14,10 +15,6 @@ public partial class ComponentLibraryWindow : Window
|
||||
private IComponentLibraryService? _componentLibraryService;
|
||||
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
|
||||
private Func<string, string, string>? _localize;
|
||||
private Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? _previewKeyResolver;
|
||||
private Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? _previewEntryResolver;
|
||||
private Action<ComponentPreviewKey>? _warmPreviewRequested;
|
||||
private Action<ComponentPreviewKey>? _renderPreviewRequested;
|
||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||
|
||||
public ComponentLibraryWindow()
|
||||
@@ -29,20 +26,12 @@ public partial class ComponentLibraryWindow : Window
|
||||
public ComponentLibraryWindow(
|
||||
IComponentLibraryService componentLibraryService,
|
||||
Func<double, ComponentLibraryCreateContext> createContextFactory,
|
||||
Func<string, string, string> localize,
|
||||
Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? previewKeyResolver = null,
|
||||
Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? previewEntryResolver = null,
|
||||
Action<ComponentPreviewKey>? warmPreviewRequested = null,
|
||||
Action<ComponentPreviewKey>? renderPreviewRequested = null)
|
||||
Func<string, string, string> localize)
|
||||
: this()
|
||||
{
|
||||
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
||||
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
||||
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
||||
_previewKeyResolver = previewKeyResolver;
|
||||
_previewEntryResolver = previewEntryResolver;
|
||||
_warmPreviewRequested = warmPreviewRequested;
|
||||
_renderPreviewRequested = renderPreviewRequested;
|
||||
Reload();
|
||||
}
|
||||
|
||||
@@ -56,6 +45,7 @@ public partial class ComponentLibraryWindow : Window
|
||||
}
|
||||
|
||||
_viewModel.Title = _localize("component_library.title", "Widgets");
|
||||
DisposePreviewControls(_viewModel.Categories.SelectMany(static category => category.Components));
|
||||
_viewModel.Categories.Clear();
|
||||
_viewModel.Components.Clear();
|
||||
|
||||
@@ -88,24 +78,12 @@ public partial class ComponentLibraryWindow : Window
|
||||
var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
||||
? entry.DisplayName
|
||||
: _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName;
|
||||
var previewKey = ResolvePreviewKey(entry);
|
||||
var previewEntry = _previewEntryResolver?.Invoke(previewKey);
|
||||
var item = new ComponentLibraryItemViewModel(
|
||||
var previewControl = CreateStaticPreviewControl(entry);
|
||||
return new ComponentLibraryItemViewModel(
|
||||
entry.ComponentId,
|
||||
displayName,
|
||||
previewKey,
|
||||
description: null,
|
||||
_localize?.Invoke("component_library.preview.loading", "Loading preview...") ?? "Loading preview...",
|
||||
_localize?.Invoke("component_library.preview.unavailable", "Preview unavailable") ?? "Preview unavailable",
|
||||
previewEntry);
|
||||
|
||||
if (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending)
|
||||
{
|
||||
_warmPreviewRequested?.Invoke(previewKey);
|
||||
_renderPreviewRequested?.Invoke(previewKey);
|
||||
}
|
||||
|
||||
return item;
|
||||
previewControl);
|
||||
}
|
||||
|
||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
@@ -124,7 +102,7 @@ public partial class ComponentLibraryWindow : Window
|
||||
_viewModel.Components.Add(component);
|
||||
}
|
||||
|
||||
RequestPreviewWarmup(selectedCategory.Components);
|
||||
ComponentPreviewRuntimeQuiescer.Quiesce(this);
|
||||
}
|
||||
|
||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||
@@ -147,48 +125,54 @@ public partial class ComponentLibraryWindow : Window
|
||||
Hide();
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry previewImageEntry)
|
||||
private Control? CreateStaticPreviewControl(ComponentLibraryComponentEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(previewImageEntry);
|
||||
|
||||
foreach (var category in _viewModel.Categories)
|
||||
if (_componentLibraryService is null || _createContextFactory is null)
|
||||
{
|
||||
foreach (var component in category.Components)
|
||||
{
|
||||
if (component.PreviewKey.Equals(previewImageEntry.Key))
|
||||
{
|
||||
component.UpdatePreviewImageEntry(previewImageEntry);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var cellSize = ResolvePreviewCellSize(entry);
|
||||
var context = _createContextFactory(cellSize) with
|
||||
{
|
||||
PlacementId = null,
|
||||
RenderMode = DesktopComponentRenderMode.LibraryPreview
|
||||
};
|
||||
|
||||
if (!_componentLibraryService.TryCreateControl(entry.ComponentId, context, out var control, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (control is not null)
|
||||
{
|
||||
ComponentPreviewRuntimeQuiescer.Attach(control);
|
||||
}
|
||||
|
||||
return control;
|
||||
}
|
||||
|
||||
private ComponentPreviewKey ResolvePreviewKey(ComponentLibraryComponentEntry entry)
|
||||
private static double ResolvePreviewCellSize(ComponentLibraryComponentEntry entry)
|
||||
{
|
||||
if (_previewKeyResolver is not null)
|
||||
{
|
||||
return _previewKeyResolver(entry);
|
||||
}
|
||||
|
||||
return ComponentPreviewKey.ForComponentType(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
|
||||
var maxWidth = 180d;
|
||||
var maxHeight = 120d;
|
||||
return Math.Clamp(
|
||||
Math.Min(
|
||||
maxWidth / Math.Max(1, entry.MinWidthCells),
|
||||
maxHeight / Math.Max(1, entry.MinHeightCells)),
|
||||
24d,
|
||||
72d);
|
||||
}
|
||||
|
||||
private void RequestPreviewWarmup(IEnumerable<ComponentLibraryItemViewModel> components)
|
||||
private static void DisposePreviewControls(IEnumerable<ComponentLibraryItemViewModel> components)
|
||||
{
|
||||
if (_warmPreviewRequested is null && _renderPreviewRequested is null)
|
||||
foreach (var control in components.Select(static component => component.PreviewControl).OfType<Control>())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var component in components)
|
||||
{
|
||||
if (!component.IsPreviewPending)
|
||||
ComponentPreviewRuntimeQuiescer.Detach(control);
|
||||
if (control is IDisposable disposable)
|
||||
{
|
||||
continue;
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
_warmPreviewRequested?.Invoke(component.PreviewKey);
|
||||
_renderPreviewRequested?.Invoke(component.PreviewKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ public sealed record DesktopComponentControlFactoryContext(
|
||||
ISettingsService SettingsService,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||
string? PlacementId = null);
|
||||
string? PlacementId = null,
|
||||
DesktopComponentRenderMode RenderMode = DesktopComponentRenderMode.Live);
|
||||
|
||||
public sealed class DesktopComponentRuntimeRegistration
|
||||
{
|
||||
@@ -115,7 +116,8 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
IRecommendationInfoService recommendationInfoService,
|
||||
ICalculatorDataService calculatorDataService,
|
||||
ISettingsFacadeService settingsFacade,
|
||||
string? placementId = null)
|
||||
string? placementId = null,
|
||||
DesktopComponentRenderMode renderMode = DesktopComponentRenderMode.Live)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settingsFacade);
|
||||
|
||||
@@ -141,7 +143,8 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
settingsService,
|
||||
componentSettingsStore,
|
||||
componentAccessor,
|
||||
placementId));
|
||||
placementId,
|
||||
renderMode));
|
||||
var runtimeContext = new DesktopComponentRuntimeContext(
|
||||
Definition.Id,
|
||||
placementId,
|
||||
@@ -150,7 +153,8 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
appearanceTheme,
|
||||
chromeContext,
|
||||
componentAccessor,
|
||||
componentSettingsStore);
|
||||
componentSettingsStore,
|
||||
renderMode);
|
||||
|
||||
ApplySettingsDependencies(control, settingsService, componentSettingsStore);
|
||||
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:converters="using:Avalonia.Data.Converters"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
|
||||
x:DataType="vm:ComponentLibraryWindowViewModel">
|
||||
|
||||
<UserControl.Styles>
|
||||
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍褜鍓欓崯顖炲Φ閸曨厽鍠嗛柛鏇ㄥ幖椤ュ酣鎮?- 闂傚倷绶¢崜鐔奉焽瑜旈獮?Fluent NavigationView 濠碉紕鍋涢鍛偓娑掓櫊閹?-->
|
||||
<Style Selector="ListBoxItem.category-item">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Margin" Value="0,2"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00"/>
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
|
||||
@@ -26,18 +18,6 @@
|
||||
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绲绘禍婊堟煟閻斿搫顣肩紒鍌氱墦閺屸€愁吋閸涱喗鎮欓梺纭呮腹閸楀啿顕i鍕倞鐟滃繘骞?-->
|
||||
<Style Selector="ListBoxItem.category-item fi|FluentIcon.category-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:selected fi|FluentIcon.category-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBoxItem.category-item TextBlock.category-text">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</Style>
|
||||
@@ -47,14 +27,9 @@
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="0"
|
||||
Margin="0">
|
||||
<!-- 闁诲骸缍婂鑽ょ磽濮樿泛鐤鹃柛鎾茶閸嬫挻鎷呴崘顭戞闂佺硶鏅涢幊妯虹暦?- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍?+ 闂佸湱鍘ч悺銊ッ洪悢鐓庣??闂備礁鎼悮顐﹀磿閸欏鐝舵慨妞诲亾鐎殿喗鎸冲鍫曞箣椤撶啿鏌ょ紓鍌氬€风粈浣衡偓姘间簻閳? -->
|
||||
<Border Width="280"
|
||||
Background="Transparent">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Width="280" Background="Transparent">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
<!-- 闂備礁鎲$敮鎺懳涘☉姘仏妞ゆ劧绠戠粈鍡樹繆閵堝懎顏ラ柍?-->
|
||||
<ListBox x:Name="CategoryListBox"
|
||||
Grid.Row="0"
|
||||
Background="Transparent"
|
||||
@@ -64,13 +39,10 @@
|
||||
ItemsSource="{Binding Categories}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12"
|
||||
Margin="12,10">
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12" Margin="12,10">
|
||||
<fi:FluentIcon Icon="{Binding Icon}"
|
||||
IconVariant="Regular"
|
||||
FontSize="18"
|
||||
Classes="category-icon"/>
|
||||
FontSize="18"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
@@ -81,9 +53,7 @@
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- 闂佸湱鍘ч悺銊ッ洪悢鐓庣??闂備礁鎼悮顐﹀磿閸欏鐝舵慨妞诲亾鐎殿喗鎸冲鍫曞箣椤撶啿鏌ょ紓鍌氬€风粈浣衡偓姘间簻閳? - 闂備線娼荤拹鐔煎礉鐏炲墽鈻曢煫鍥ㄦ⒒閻熷湱鎲稿澶樻晪闂侇剙绉甸崵瀣亜韫囨挸顏╅柣蹇旂懇楠炴牜鈧稒蓱缁€瀣煕?-->
|
||||
<StackPanel Grid.Row="1"
|
||||
Margin="12,8,8,12">
|
||||
<StackPanel Grid.Row="1" Margin="12,8,8,12">
|
||||
<Border Height="1"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
Opacity="0.4"
|
||||
@@ -100,35 +70,26 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 闂備礁鎲¢悷銉╁储閺嶎厼鐤鹃柛顐f礀缁€鍐煕濞戝崬寮鹃柛鐔锋喘閺屾盯寮介浣碘偓鍐磼濡も偓閼活垶顢欒箛娑欐櫆闁圭瀛╅悵鐑芥⒑濮瑰洤濡奸悗姘煎墴瀹曡鎯旈妸锔规寗闂佸搫鍟崐绋库枔?-->
|
||||
<Border Grid.Column="1"
|
||||
Width="1"
|
||||
HorizontalAlignment="Left"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
Opacity="0.5"/>
|
||||
|
||||
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€閳藉懐鐭楅梺鍛婃处閸n喖顭囬弮鍫熺厱?(闂備礁鎲¢悷銉╁储閺嶎厼鐤? -->
|
||||
<ScrollViewer Grid.Column="1"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="16,8,12,8"
|
||||
Spacing="0">
|
||||
|
||||
<!-- 闂備礁鎼悧鍡浰囬悽绋跨劦妞ゆ巻鍋撴い锔诲櫍閹虫瑩骞嬮悩鐢碉紲闂佸憡娲︽禍婵嬵敃娴犲鐓涢柛鎰╁妼椤h櫕绻涢崼鐔风伌鐎殿喕鍗冲畷婊嗩槹濞?-->
|
||||
<StackPanel Margin="16,8,12,8">
|
||||
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
|
||||
|
||||
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€椤厽鍕冮梺鍝勬川婵増绂掑☉銏♀拻闁割偅绋戦悘顏呯節?- 闂備礁鎼悧鍡浰囨潏鈹惧亾濮樼厧骞樼紒顔规櫇閳ь剨缍嗛崢濂稿礈瑜版帗鐓涢柛婊€绀侀悘銉ヮ熆閻熷府韬柡浣哥Ф娴狅箓鎳栭埡鍐╁枦缂傚倷鐒﹂崝鏍€冮崨鑸汗婵炴垯鍨洪崵鍕倶閻愰潧浜鹃柣婵愬灣閹叉悂鎳滈鈧悘顏堟煕閵婏附鐨戝ù鐙呯畵瀹曟帒顭ㄩ崼銏犵闂備礁鎲$敮鎺懳涘☉銏犵柧?-->
|
||||
<Border Classes="surface-translucent-panel"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="16">
|
||||
<!-- 缂傚倸鍊风粈浣衡偓姘间簻閳诲酣濮€閵堝懎鍞ㄩ梺鎼炲労閸擄箓寮?-->
|
||||
<TextBlock FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding SelectedComponent.DisplayName}"/>
|
||||
|
||||
<!-- 闂備焦鎮堕崕閬嶅箹椤愶附鍋╅柣鎰靛墮缁剁偟鎲稿澶嬪剭妞ゆ帒瀚崕宥夋煕閺囥劌鐏遍柡鍡樻礋閹嘲鈻庤箛鏇烆暫閻庤娲熸禍鍫曞箖?-->
|
||||
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
@@ -136,59 +97,14 @@
|
||||
Width="420"
|
||||
Height="300"
|
||||
HorizontalAlignment="Center">
|
||||
<Grid Margin="16">
|
||||
<!-- 濠碘槅鍋呭妯尖偓姘煎灦閿濈偛顓兼径濠勫€為梺鍛婃寙閸愮偓姣?-->
|
||||
<Image Source="{Binding SelectedComponent.PreviewBitmap}"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
IsVisible="{Binding SelectedComponent.IsPreviewReady}"/>
|
||||
|
||||
<!-- 闂備礁鎲″缁樻叏閹灐褰掑炊閵娧€鏋栧銈嗘尵婵鐟ч梻?-->
|
||||
<Border IsVisible="{Binding SelectedComponent.IsPreviewPending}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<ProgressBar Width="120"
|
||||
IsIndeterminate="True"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding SelectedComponent.PreviewStatusText}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 濠电姰鍨洪崕鑲╁垝閸撗勫枂闁挎洖鍊归崑鎰版煠閸濄儺鏆柛?-->
|
||||
<Border IsVisible="{Binding SelectedComponent.IsPreviewFailed}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:FluentIcon Icon="ImageOff"
|
||||
IconVariant="Regular"
|
||||
FontSize="48"
|
||||
Opacity="0.5"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding SelectedComponent.PreviewStatusText}"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding SelectedComponent.PreviewErrorMessage}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
<ContentControl x:Name="SelectedComponentPreviewHost"
|
||||
Margin="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsHitTestVisible="False"
|
||||
Focusable="False"/>
|
||||
</Border>
|
||||
|
||||
<!-- "婵犵數鍎戠紞鈧い鏇嗗嫭鍙忛柣鎰悁閻掑﹪鐓崶銊︾闁活厼顑呴湁?闂備礁婀遍…鍫ニ囬悽绋跨?- 闂備線娼荤拹鐔煎礉閹存繍鐒藉ù鍏兼綑缁狙囨煕椤垵鏋涢柡浣哥埣閹﹢鎮欓崣澶婃闂佺厧鐏氶崹鍧楀极瀹ュ洣娌柣鎾崇岸閺嬪繘姊哄ú缁樺▏闁告柨顑囬埀顒勬涧閺堫剟鏁嶉幇顑╃喖宕崟顓犵暢闂佽崵濮撮鍛村疮閾忣偆鐝?-->
|
||||
<Button HorizontalAlignment="Center"
|
||||
Classes="accent"
|
||||
Padding="24,10"
|
||||
@@ -203,12 +119,12 @@
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- 缂傚倷绀侀惌浣糕枍閿濆棙鍙忛柟闂寸缁?-->
|
||||
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
MinHeight="400">
|
||||
<StackPanel Spacing="16" HorizontalAlignment="Center"
|
||||
<StackPanel Spacing="16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<fi:FluentIcon Icon="Apps"
|
||||
IconVariant="Regular"
|
||||
|
||||
@@ -11,7 +11,6 @@ using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
@@ -19,18 +18,19 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
{
|
||||
public event EventHandler<string>? AddComponentRequested;
|
||||
|
||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||
private List<DesktopComponentDefinition> _allDefinitions = new();
|
||||
private static readonly LocalizationService LocalizationService = new();
|
||||
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
private static readonly LocalizationService _localizationService = new();
|
||||
private List<DesktopComponentDefinition> _allDefinitions = new();
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private Control? _selectedPreviewControl;
|
||||
|
||||
public FusedDesktopComponentLibraryControl()
|
||||
{
|
||||
@@ -43,10 +43,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
LoadRegistry();
|
||||
LoadCategories();
|
||||
|
||||
// 为 ListBoxItem 添加 category-item 样式类
|
||||
CategoryListBox.ContainerPrepared += OnCategoryListBoxContainerPrepared;
|
||||
|
||||
// 默认选择第一个分类
|
||||
if (_viewModel.Categories.Count > 0)
|
||||
{
|
||||
CategoryListBox.SelectedIndex = 0;
|
||||
@@ -55,6 +52,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
|
||||
private void OnCategoryListBoxContainerPrepared(object? sender, ContainerPreparedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (e.Container is ListBoxItem listBoxItem)
|
||||
{
|
||||
listBoxItem.Classes.Add("category-item");
|
||||
@@ -71,7 +69,7 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
_settingsFacade);
|
||||
|
||||
_allDefinitions = _componentRegistry.GetAll()
|
||||
.Where(d => d.AllowDesktopPlacement)
|
||||
.Where(static definition => definition.AllowDesktopPlacement)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -80,8 +78,6 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
_viewModel.Categories.Clear();
|
||||
|
||||
var languageCode = _settingsFacade.Region.Get().LanguageCode;
|
||||
|
||||
// 添加"全部组件"分类
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
"all",
|
||||
L(languageCode, "component_category.all", "All"),
|
||||
@@ -89,32 +85,26 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
Array.Empty<ComponentLibraryItemViewModel>()));
|
||||
|
||||
var usedCategories = _allDefinitions
|
||||
.Select(d => d.Category)
|
||||
.Distinct()
|
||||
.Where(c => !string.IsNullOrEmpty(c));
|
||||
.Select(static definition => definition.Category)
|
||||
.Where(static category => !string.IsNullOrWhiteSpace(category))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var cat in usedCategories)
|
||||
foreach (var category in usedCategories)
|
||||
{
|
||||
var icon = ResolveCategoryIcon(cat);
|
||||
var title = GetLocalizedCategoryTitle(languageCode, cat);
|
||||
|
||||
var categoryComponents = _allDefinitions
|
||||
.Where(d => string.Equals(d.Category, cat, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(d => d.DisplayName)
|
||||
.Select(d => CreateComponentItem(d))
|
||||
.Where(definition => string.Equals(definition.Category, category, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(CreateComponentItem)
|
||||
.ToArray();
|
||||
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
cat,
|
||||
title,
|
||||
icon,
|
||||
category,
|
||||
GetLocalizedCategoryTitle(languageCode, category),
|
||||
ResolveCategoryIcon(category),
|
||||
categoryComponents));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类图标映射 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
|
||||
/// </summary>
|
||||
private static Symbol ResolveCategoryIcon(string categoryId)
|
||||
{
|
||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock;
|
||||
@@ -129,9 +119,6 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分类本地化标题 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
|
||||
/// </summary>
|
||||
private string GetLocalizedCategoryTitle(string languageCode, string categoryId)
|
||||
{
|
||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock");
|
||||
@@ -148,101 +135,123 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
|
||||
private string L(string languageCode, string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(languageCode, key, fallback);
|
||||
return LocalizationService.GetString(languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
|
||||
private static ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
|
||||
{
|
||||
var previewKey = ComponentPreviewKey.ForComponentType(
|
||||
definition.Id,
|
||||
definition.MinWidthCells,
|
||||
definition.MinHeightCells);
|
||||
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
ComponentPreviewImageEntry? previewEntry = null;
|
||||
|
||||
if (mainWindow is not null)
|
||||
{
|
||||
previewEntry = mainWindow.GetPreviewEntry(previewKey);
|
||||
}
|
||||
|
||||
var item = new ComponentLibraryItemViewModel(
|
||||
definition.Id,
|
||||
definition.DisplayName,
|
||||
previewKey,
|
||||
description: null,
|
||||
"正在加载预览...",
|
||||
"预览不可用",
|
||||
previewEntry);
|
||||
|
||||
if (mainWindow is not null && (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending))
|
||||
{
|
||||
mainWindow.RequestDetachedLibraryPreview(previewKey);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
foreach (var category in _viewModel.Categories)
|
||||
{
|
||||
foreach (var component in category.Components)
|
||||
{
|
||||
if (component.PreviewKey.Equals(entry.Key))
|
||||
{
|
||||
component.UpdatePreviewImageEntry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ComponentLibraryItemViewModel(definition.Id, definition.DisplayName);
|
||||
}
|
||||
|
||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
UpdateSelectedComponent();
|
||||
}
|
||||
|
||||
private void UpdateSelectedComponent()
|
||||
{
|
||||
var selectedCategory = CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel;
|
||||
if (selectedCategory is null)
|
||||
if (CategoryListBox.SelectedItem is not ComponentLibraryCategoryViewModel selectedCategory)
|
||||
{
|
||||
_viewModel.SelectedComponent = null;
|
||||
SetSelectedPreviewControl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取该分类下的组件列表
|
||||
IEnumerable<DesktopComponentDefinition> filtered;
|
||||
if (selectedCategory.Id == "all")
|
||||
{
|
||||
filtered = _allDefinitions.OrderBy(d => d.DisplayName);
|
||||
}
|
||||
else
|
||||
{
|
||||
filtered = _allDefinitions
|
||||
.Where(d => string.Equals(d.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(d => d.DisplayName);
|
||||
}
|
||||
var filtered = selectedCategory.Id == "all"
|
||||
? _allDefinitions.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
: _allDefinitions
|
||||
.Where(definition => string.Equals(definition.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 选择该分类下的第一个组件作为默认选中
|
||||
var firstComponent = filtered.FirstOrDefault();
|
||||
if (firstComponent is not null)
|
||||
{
|
||||
// 查找或创建对应的 ViewModel
|
||||
var existingComponent = selectedCategory.Components.FirstOrDefault(c => c.ComponentId == firstComponent.Id);
|
||||
if (existingComponent is not null)
|
||||
{
|
||||
_viewModel.SelectedComponent = existingComponent;
|
||||
}
|
||||
else
|
||||
{
|
||||
_viewModel.SelectedComponent = CreateComponentItem(firstComponent);
|
||||
}
|
||||
}
|
||||
else
|
||||
if (firstComponent is null)
|
||||
{
|
||||
_viewModel.SelectedComponent = null;
|
||||
SetSelectedPreviewControl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
_viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id)
|
||||
?? CreateComponentItem(firstComponent);
|
||||
SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent));
|
||||
}
|
||||
|
||||
private Control? CreateStaticPreviewControl(DesktopComponentDefinition definition)
|
||||
{
|
||||
if (_componentRuntimeRegistry is null ||
|
||||
!_componentRuntimeRegistry.TryGetDescriptor(definition.Id, out var descriptor))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var control = descriptor.CreateControl(
|
||||
ResolvePreviewCellSize(definition),
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placementId: null,
|
||||
renderMode: DesktopComponentRenderMode.LibraryPreview);
|
||||
ComponentPreviewRuntimeQuiescer.Attach(control);
|
||||
return control;
|
||||
}
|
||||
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentLibrary",
|
||||
$"Failed to create static fused preview for component '{definition.Id}'.",
|
||||
ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static double ResolvePreviewCellSize(DesktopComponentDefinition definition)
|
||||
{
|
||||
const double maxWidth = 360d;
|
||||
const double maxHeight = 240d;
|
||||
return Math.Clamp(
|
||||
Math.Min(
|
||||
maxWidth / Math.Max(1, definition.MinWidthCells),
|
||||
maxHeight / Math.Max(1, definition.MinHeightCells)),
|
||||
32d,
|
||||
96d);
|
||||
}
|
||||
|
||||
private void SetSelectedPreviewControl(Control? control)
|
||||
{
|
||||
DisposeSelectedPreviewControl();
|
||||
_selectedPreviewControl = control;
|
||||
if (SelectedComponentPreviewHost is not null)
|
||||
{
|
||||
SelectedComponentPreviewHost.Content = control;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeSelectedPreviewControl()
|
||||
{
|
||||
if (_selectedPreviewControl is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ComponentPreviewRuntimeQuiescer.Detach(_selectedPreviewControl);
|
||||
if (_selectedPreviewControl is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
_selectedPreviewControl = null;
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
DisposeSelectedPreviewControl();
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||
@@ -255,15 +264,11 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
|
||||
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// 打开设置窗口并导航到插件目录页面
|
||||
if (Application.Current is App app)
|
||||
{
|
||||
app.OpenIndependentSettingsModule("FusedDesktopComponentLibrary", "plugin-catalog");
|
||||
}
|
||||
|
||||
// 关闭所在窗口
|
||||
var window = this.FindAncestorOfType<Window>();
|
||||
var componentLibraryWindow = this.FindAncestorOfType<Window>();
|
||||
componentLibraryWindow?.Close();
|
||||
this.FindAncestorOfType<Window>()?.Close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +118,4 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.UnregisterFusedLibraryWindow(this);
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
LibraryControl.UpdatePreviewImage(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -18,412 +11,117 @@ namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private const double PreviewRenderCellSizeMin = 42;
|
||||
private const double PreviewRenderCellSizeMax = 112;
|
||||
|
||||
private readonly IComponentPreviewImageService _componentPreviewImageService = new ComponentPreviewImageService();
|
||||
private readonly Dictionary<ComponentPreviewKey, List<ComponentLibraryPreviewVisualTarget>> _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance);
|
||||
private bool _componentLibraryPreviewWarmupStarted;
|
||||
private FusedDesktopComponentLibraryWindow? _fusedLibraryWindow;
|
||||
|
||||
private sealed record ComponentLibraryPreviewVisualTarget(Image Image, Control Fallback);
|
||||
|
||||
private void EnsureComponentLibraryPreviewWarmup()
|
||||
{
|
||||
if (_componentLibraryCategories.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var activeCategoryId = _componentLibraryActiveCategoryId ??
|
||||
_componentLibraryCategories[Math.Clamp(_componentLibraryCategoryIndex, 0, _componentLibraryCategories.Count - 1)].Id;
|
||||
if (!_componentLibraryPreviewWarmupStarted)
|
||||
{
|
||||
_componentLibraryPreviewWarmupStarted = true;
|
||||
_ = WarmComponentLibraryPreviewsSeriallyAsync(activeCategoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
var activeCategory = _componentLibraryCategories.FirstOrDefault(category =>
|
||||
string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase));
|
||||
if (activeCategory is not null)
|
||||
{
|
||||
_ = WarmComponentLibraryCategoryPreviewsAsync(activeCategory);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WarmComponentLibraryPreviewsSeriallyAsync(string activeCategoryId)
|
||||
{
|
||||
var prioritized = _componentLibraryCategories
|
||||
.OrderBy(category => string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||
.ToList();
|
||||
|
||||
foreach (var category in prioritized)
|
||||
{
|
||||
await WarmComponentLibraryCategoryPreviewsAsync(category);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WarmComponentLibraryCategoryPreviewsAsync(ComponentLibraryCategory category)
|
||||
{
|
||||
foreach (var component in category.Components)
|
||||
{
|
||||
var span = NormalizeComponentCellSpan(
|
||||
component.ComponentId,
|
||||
(component.MinWidthCells, component.MinHeightCells));
|
||||
await EnsureComponentTypePreviewImageAsync(component.ComponentId, span.WidthCells, span.HeightCells);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IImage?> EnsureComponentTypePreviewImageAsync(string componentId, int widthCells, int heightCells)
|
||||
private Control CreateStaticComponentLibraryPreview(
|
||||
string componentId,
|
||||
double cellSize,
|
||||
double previewWidth,
|
||||
double previewHeight)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(componentId))
|
||||
{
|
||||
return null;
|
||||
return CreateStaticComponentPreviewFallback(previewWidth, previewHeight);
|
||||
}
|
||||
|
||||
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||
var cached = ResolvePreviewImageFromService(key);
|
||||
if (cached is not null)
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(key);
|
||||
return cached;
|
||||
}
|
||||
var context = new ComponentLibraryCreateContext(
|
||||
cellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
PlacementId: null,
|
||||
RenderMode: DesktopComponentRenderMode.LibraryPreview);
|
||||
|
||||
var entry = await QueuePreviewGenerationAsync(
|
||||
key,
|
||||
pageIndex: null,
|
||||
action: "ComponentTypePreview",
|
||||
forceRefresh: false);
|
||||
return entry.Bitmap;
|
||||
}
|
||||
|
||||
private async Task<IImage?> RefreshPlacementPreviewImageAsync(DesktopComponentPlacementSnapshot? placement, bool forceRefresh)
|
||||
{
|
||||
if (placement is null ||
|
||||
string.IsNullOrWhiteSpace(placement.ComponentId) ||
|
||||
string.IsNullOrWhiteSpace(placement.PlacementId))
|
||||
if (!_componentLibraryService.TryCreateControl(componentId, context, out var control, out var exception) ||
|
||||
control is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!IsPlacementPresent(placement.PlacementId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var snapshot = ClonePlacementSnapshot(placement);
|
||||
var key = CreatePlacementPreviewKey(
|
||||
snapshot.ComponentId,
|
||||
snapshot.PlacementId,
|
||||
snapshot.WidthCells,
|
||||
snapshot.HeightCells);
|
||||
if (!forceRefresh)
|
||||
{
|
||||
var cached = ResolvePreviewImageFromService(key);
|
||||
if (cached is not null)
|
||||
if (exception is not null)
|
||||
{
|
||||
return cached;
|
||||
AppLogger.Warn(
|
||||
"ComponentLibrary",
|
||||
$"Failed to create static preview for component '{componentId}'.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_componentPreviewImageService.RemovePlacementPreviews(snapshot.PlacementId);
|
||||
|
||||
return CreateStaticComponentPreviewFallback(previewWidth, previewHeight);
|
||||
}
|
||||
|
||||
var entry = await QueuePreviewGenerationAsync(
|
||||
key,
|
||||
snapshot.PageIndex,
|
||||
action: "PlacementPreview",
|
||||
forceRefresh: false);
|
||||
if (!IsPlacementPresent(snapshot.PlacementId))
|
||||
{
|
||||
RemovePlacementPreviewImage(snapshot.PlacementId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Bitmap;
|
||||
control.Width = previewWidth;
|
||||
control.Height = previewHeight;
|
||||
ComponentPreviewRuntimeQuiescer.Attach(control);
|
||||
return control;
|
||||
}
|
||||
|
||||
private async Task<ComponentPreviewImageEntry> QueuePreviewGenerationAsync(
|
||||
ComponentPreviewKey key,
|
||||
int? pageIndex,
|
||||
string action,
|
||||
bool forceRefresh,
|
||||
CancellationToken cancellationToken = default)
|
||||
private Control CreateStaticComponentPreviewFallback(double previewWidth, double previewHeight)
|
||||
{
|
||||
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
|
||||
var visualSignature = BuildPreviewVisualSignature(key, renderCellSize);
|
||||
if (forceRefresh)
|
||||
{
|
||||
_componentPreviewImageService.Invalidate(key, visualSignature);
|
||||
}
|
||||
|
||||
var entry = await _componentPreviewImageService.QueueGenerationAsync(
|
||||
key,
|
||||
visualSignature,
|
||||
async ct =>
|
||||
{
|
||||
_ = ct;
|
||||
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
|
||||
!IsPlacementPresent(key.PlacementId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bitmap = await CapturePreviewImageAsync(
|
||||
key.ComponentTypeId,
|
||||
key.PlacementId,
|
||||
pageIndex,
|
||||
key.WidthCells,
|
||||
key.HeightCells,
|
||||
renderCellSize,
|
||||
action);
|
||||
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
|
||||
!IsPlacementPresent(key.PlacementId))
|
||||
{
|
||||
DisposeImageIfNeeded(bitmap);
|
||||
return null;
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
},
|
||||
cancellationToken);
|
||||
NotifyPreviewEntryUpdated(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async Task<IImage?> CapturePreviewImageAsync(
|
||||
string componentId,
|
||||
string? placementId,
|
||||
int? pageIndex,
|
||||
int widthCells,
|
||||
int heightCells,
|
||||
double renderCellSize,
|
||||
string action)
|
||||
{
|
||||
if (ComponentPreviewStagingHost is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var safeWidthCells = Math.Max(1, widthCells);
|
||||
var safeHeightCells = Math.Max(1, heightCells);
|
||||
var safeCellSize = Math.Clamp(renderCellSize, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
|
||||
var previewWidth = safeWidthCells * safeCellSize;
|
||||
var previewHeight = safeHeightCells * safeCellSize;
|
||||
|
||||
var previewControl = CreateDesktopComponentControl(
|
||||
componentId,
|
||||
safeCellSize,
|
||||
placementId,
|
||||
pageIndex,
|
||||
action);
|
||||
if (previewControl is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
previewControl.IsHitTestVisible = false;
|
||||
previewControl.Focusable = false;
|
||||
|
||||
var stage = new Border
|
||||
return new Border
|
||||
{
|
||||
Width = previewWidth,
|
||||
Height = previewHeight,
|
||||
Background = Brushes.Transparent,
|
||||
ClipToBounds = true,
|
||||
Child = previewControl
|
||||
Background = GetThemeBrush("AdaptiveCardBackgroundBrush"),
|
||||
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
|
||||
BorderThickness = new Avalonia.Thickness(1),
|
||||
CornerRadius = new Avalonia.CornerRadius(Math.Clamp(Math.Min(previewWidth, previewHeight) * 0.18, 12, 28)),
|
||||
IsHitTestVisible = false,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = L("component_library.preview_unavailable", "Preview unavailable"),
|
||||
FontSize = 11,
|
||||
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush"),
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Canvas.SetLeft(stage, -20000);
|
||||
Canvas.SetTop(stage, -20000);
|
||||
ComponentPreviewStagingHost.Children.Add(stage);
|
||||
|
||||
try
|
||||
private static void DisposeStaticComponentLibraryPreviews(IEnumerable<Control> roots)
|
||||
{
|
||||
foreach (var control in roots.SelectMany(EnumerateControls))
|
||||
{
|
||||
stage.Measure(new Size(previewWidth, previewHeight));
|
||||
stage.Arrange(new Rect(0, 0, previewWidth, previewHeight));
|
||||
stage.UpdateLayout();
|
||||
await WaitForPreviewRenderPassAsync();
|
||||
|
||||
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||
var pixelSize = new PixelSize(
|
||||
Math.Max(1, (int)Math.Ceiling(previewWidth * renderScale)),
|
||||
Math.Max(1, (int)Math.Ceiling(previewHeight * renderScale)));
|
||||
var bitmap = new RenderTargetBitmap(pixelSize, new Vector(96 * renderScale, 96 * renderScale));
|
||||
bitmap.Render(stage);
|
||||
return bitmap;
|
||||
}
|
||||
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentPreview",
|
||||
$"Action={action}; ComponentId={componentId}; PlacementId={placementId ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false",
|
||||
ex);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ComponentPreviewStagingHost.Children.Remove(stage);
|
||||
ClearTimeZoneServiceBindings(stage);
|
||||
if (previewControl is IDisposable disposableControl)
|
||||
ComponentPreviewRuntimeQuiescer.Detach(control);
|
||||
if (control is IDisposable disposable)
|
||||
{
|
||||
disposableControl.Dispose();
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForPreviewRenderPassAsync()
|
||||
private static IEnumerable<Control> EnumerateControls(Control root)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Background);
|
||||
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Render);
|
||||
}
|
||||
yield return root;
|
||||
|
||||
private double ResolvePreviewRenderCellSize(int widthCells, int heightCells)
|
||||
{
|
||||
var baseCellSize = _currentDesktopCellSize > 0
|
||||
? _currentDesktopCellSize * 1.10
|
||||
: 74;
|
||||
var densityBoost = Math.Max(widthCells, heightCells) >= 4 ? 8 : 0;
|
||||
return Math.Clamp(baseCellSize + densityBoost, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
|
||||
}
|
||||
|
||||
private string BuildPreviewVisualSignature(ComponentPreviewKey key, double renderCellSize)
|
||||
{
|
||||
var appearance = _appearanceThemeService.GetCurrent();
|
||||
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.CornerRadiusStyle}|Accent={FormatSignatureColor(appearance.AccentColor)}");
|
||||
}
|
||||
|
||||
private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells)
|
||||
{
|
||||
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||
return ComponentPreviewKey.ForComponentType(componentId, span.WidthCells, span.HeightCells);
|
||||
}
|
||||
|
||||
private ComponentPreviewKey CreatePlacementPreviewKey(string componentId, string placementId, int widthCells, int heightCells)
|
||||
{
|
||||
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||
return ComponentPreviewKey.ForPlacementInstance(componentId, placementId, span.WidthCells, span.HeightCells);
|
||||
}
|
||||
|
||||
private bool IsPlacementPresent(string? placementId)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(placementId) &&
|
||||
_desktopComponentPlacements.Any(candidate =>
|
||||
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private string BuildCurrentVisualSignature(ComponentPreviewKey key)
|
||||
{
|
||||
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
|
||||
return BuildPreviewVisualSignature(key, renderCellSize);
|
||||
}
|
||||
|
||||
private bool TryGetReusablePreviewEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||
{
|
||||
if (!_componentPreviewImageService.TryGetEntry(key, out entry) ||
|
||||
entry is null ||
|
||||
entry.State != ComponentPreviewImageState.Ready ||
|
||||
entry.Bitmap is null)
|
||||
if (root is Panel panel)
|
||||
{
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = BuildCurrentVisualSignature(key);
|
||||
if (!string.Equals(entry.VisualSignature, expectedSignature, StringComparison.Ordinal))
|
||||
{
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private IImage? ResolvePreviewImageFromService(ComponentPreviewKey key)
|
||||
{
|
||||
if (!TryGetReusablePreviewEntry(key, out var entry) || entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Bitmap;
|
||||
}
|
||||
|
||||
private ComponentPreviewImageEntry? ResolvePreviewEntry(ComponentPreviewKey key)
|
||||
{
|
||||
if (!_componentPreviewImageService.TryGetEntry(key, out var entry) || entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.State != ComponentPreviewImageState.Ready)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
return TryGetReusablePreviewEntry(key, out var reusable) ? reusable : null;
|
||||
}
|
||||
|
||||
private IImage? ResolveComponentTypePreviewImage(string componentId, int widthCells, int heightCells)
|
||||
{
|
||||
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||
return ResolvePreviewImageFromService(key);
|
||||
}
|
||||
|
||||
private IImage? ResolveDesktopEditPreviewImage(string componentId, string? placementId, int widthCells, int heightCells)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(placementId))
|
||||
{
|
||||
var placementKey = CreatePlacementPreviewKey(componentId, placementId, widthCells, heightCells);
|
||||
var placementImage = ResolvePreviewImageFromService(placementKey);
|
||||
if (placementImage is not null)
|
||||
foreach (var child in panel.Children.OfType<Control>())
|
||||
{
|
||||
return placementImage;
|
||||
foreach (var descendant in EnumerateControls(child))
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var componentTypeKey = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||
return ResolvePreviewImageFromService(componentTypeKey);
|
||||
}
|
||||
|
||||
private (int WidthCells, int HeightCells) ResolveOverlayPreviewSpan(
|
||||
string componentId,
|
||||
string? placementId,
|
||||
int? widthCells,
|
||||
int? heightCells)
|
||||
{
|
||||
if (widthCells is > 0 && heightCells is > 0)
|
||||
if (root is ContentControl { Content: Control content })
|
||||
{
|
||||
return NormalizeComponentCellSpan(componentId, (widthCells.Value, heightCells.Value));
|
||||
foreach (var descendant in EnumerateControls(content))
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||
TryGetDesktopPlacementById(placementId, out var placement))
|
||||
if (root is Decorator { Child: Control decoratorChild })
|
||||
{
|
||||
return NormalizeComponentCellSpan(componentId, (placement.WidthCells, placement.HeightCells));
|
||||
foreach (var descendant in EnumerateControls(decoratorChild))
|
||||
{
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) &&
|
||||
string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase) &&
|
||||
_desktopEditSession.WidthCells > 0 &&
|
||||
_desktopEditSession.HeightCells > 0)
|
||||
{
|
||||
return NormalizeComponentCellSpan(componentId, (_desktopEditSession.WidthCells, _desktopEditSession.HeightCells));
|
||||
}
|
||||
|
||||
if (_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
|
||||
{
|
||||
return NormalizeComponentCellSpan(
|
||||
componentId,
|
||||
(descriptor.Definition.MinWidthCells, descriptor.Definition.MinHeightCells));
|
||||
}
|
||||
|
||||
return (1, 1);
|
||||
}
|
||||
|
||||
private void ApplyDesktopEditOverlayPreviewImage(
|
||||
@@ -432,9 +130,12 @@ public partial class MainWindow : Window
|
||||
int? widthCells = null,
|
||||
int? heightCells = null)
|
||||
{
|
||||
var span = ResolveOverlayPreviewSpan(componentId, placementId, widthCells, heightCells);
|
||||
_ = componentId;
|
||||
_ = placementId;
|
||||
_ = widthCells;
|
||||
_ = heightCells;
|
||||
EnsureDesktopEditOverlayPresenter();
|
||||
_desktopEditOverlayPresenter?.SetPreviewImage(ResolveDesktopEditPreviewImage(componentId, placementId, span.WidthCells, span.HeightCells));
|
||||
_desktopEditOverlayPresenter?.SetPreviewImage(null);
|
||||
}
|
||||
|
||||
private void PrimeDesktopEditPreviewImage(
|
||||
@@ -444,164 +145,28 @@ public partial class MainWindow : Window
|
||||
int widthCells,
|
||||
int heightCells)
|
||||
{
|
||||
_ = componentId;
|
||||
_ = placementId;
|
||||
_ = pageIndex;
|
||||
var normalized = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||
_ = EnsureComponentTypePreviewImageAsync(componentId, normalized.WidthCells, normalized.HeightCells);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||
TryGetDesktopPlacementById(placementId, out var placement))
|
||||
{
|
||||
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: false);
|
||||
}
|
||||
_ = widthCells;
|
||||
_ = heightCells;
|
||||
}
|
||||
|
||||
private void QueuePlacementPreviewRefresh(DesktopComponentPlacementSnapshot? placement)
|
||||
{
|
||||
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: true);
|
||||
_ = placement;
|
||||
}
|
||||
|
||||
private void RemovePlacementPreviewImage(string? placementId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(placementId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_componentPreviewImageService.RemovePlacementPreviews(placementId);
|
||||
_ = placementId;
|
||||
}
|
||||
|
||||
private void RemovePlacementPreviewImages(IEnumerable<DesktopComponentPlacementSnapshot> placements)
|
||||
{
|
||||
foreach (var placementId in placements
|
||||
.Select(placement => placement.PlacementId)
|
||||
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
RemovePlacementPreviewImage(placementId);
|
||||
}
|
||||
_ = placements;
|
||||
}
|
||||
|
||||
private void RegisterComponentLibraryPreviewVisual(ComponentPreviewKey key, Image image, Control fallback)
|
||||
{
|
||||
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var visuals))
|
||||
{
|
||||
visuals = [];
|
||||
_componentLibraryPreviewVisualTargets[key] = visuals;
|
||||
}
|
||||
|
||||
visuals.Add(new ComponentLibraryPreviewVisualTarget(image, fallback));
|
||||
}
|
||||
|
||||
private void ClearComponentLibraryPreviewVisualTargets()
|
||||
{
|
||||
_componentLibraryPreviewVisualTargets.Clear();
|
||||
}
|
||||
|
||||
private void ApplyPreviewEntryToEmbeddedVisuals(ComponentPreviewKey key)
|
||||
{
|
||||
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var targets))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previewImage = ResolvePreviewImageFromService(key);
|
||||
foreach (var target in targets)
|
||||
{
|
||||
target.Image.Source = previewImage;
|
||||
target.Image.IsVisible = previewImage is not null;
|
||||
target.Fallback.IsVisible = previewImage is null;
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyPreviewEntryUpdated(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(entry.Key);
|
||||
_detachedComponentLibraryWindow?.UpdatePreviewImage(entry);
|
||||
_fusedLibraryWindow?.UpdatePreviewImage(entry);
|
||||
|
||||
if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||
{
|
||||
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, entry.Key.PlacementId);
|
||||
}
|
||||
else
|
||||
{
|
||||
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, placementId: null);
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static void DisposeImageIfNeeded(IImage? image)
|
||||
{
|
||||
if (image is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSignatureColor(Color color)
|
||||
{
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}");
|
||||
}
|
||||
|
||||
private void RefreshDesktopEditOverlayPreviewIfActive(string componentId, string? placementId)
|
||||
{
|
||||
if (_desktopEditOverlayPresenter is null ||
|
||||
(!_desktopEditSession.IsActive && !_isDesktopEditCommitPending) ||
|
||||
string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) ||
|
||||
!string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||
!string.Equals(_desktopEditSession.PlacementId, placementId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyDesktopEditOverlayPreviewImage(
|
||||
_desktopEditSession.ComponentId,
|
||||
_desktopEditSession.PlacementId,
|
||||
_desktopEditSession.WidthCells,
|
||||
_desktopEditSession.HeightCells);
|
||||
}
|
||||
|
||||
private ComponentPreviewKey ResolveDetachedLibraryPreviewKey(ComponentLibraryComponentEntry entry)
|
||||
{
|
||||
return CreateComponentTypePreviewKey(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
|
||||
}
|
||||
|
||||
private ComponentPreviewImageEntry? ResolveDetachedLibraryPreviewEntry(ComponentPreviewKey key)
|
||||
{
|
||||
return ResolvePreviewEntry(key);
|
||||
}
|
||||
|
||||
private void RequestDetachedLibraryPreviewWarm(ComponentPreviewKey key)
|
||||
{
|
||||
_ = QueuePreviewGenerationAsync(
|
||||
key,
|
||||
pageIndex: null,
|
||||
action: "DetachedLibraryWarm",
|
||||
forceRefresh: false);
|
||||
}
|
||||
|
||||
private void RequestDetachedLibraryPreviewRender(ComponentPreviewKey key)
|
||||
{
|
||||
_ = QueuePreviewGenerationAsync(
|
||||
key,
|
||||
pageIndex: null,
|
||||
action: "DetachedLibraryRender",
|
||||
forceRefresh: false);
|
||||
}
|
||||
|
||||
// FusedDesktop 支持
|
||||
|
||||
public void RegisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
|
||||
{
|
||||
_fusedLibraryWindow = window;
|
||||
@@ -614,15 +179,4 @@ public partial class MainWindow : Window
|
||||
_fusedLibraryWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry? GetPreviewEntry(ComponentPreviewKey key)
|
||||
{
|
||||
return ResolvePreviewEntry(key);
|
||||
}
|
||||
|
||||
public void RequestDetachedLibraryPreview(ComponentPreviewKey key)
|
||||
{
|
||||
RequestDetachedLibraryPreviewWarm(key);
|
||||
RequestDetachedLibraryPreviewRender(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,13 +1480,11 @@ public partial class MainWindow : Window
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade);
|
||||
_settingsFacade,
|
||||
PlacementId: null,
|
||||
RenderMode: DesktopComponentRenderMode.LibraryPreview);
|
||||
},
|
||||
L,
|
||||
previewKeyResolver: ResolveDetachedLibraryPreviewKey,
|
||||
previewEntryResolver: ResolveDetachedLibraryPreviewEntry,
|
||||
warmPreviewRequested: RequestDetachedLibraryPreviewWarm,
|
||||
renderPreviewRequested: RequestDetachedLibraryPreviewRender);
|
||||
L);
|
||||
window.AddComponentRequested += OnDetachedComponentLibraryAddComponentRequested;
|
||||
window.Closed += OnDetachedComponentLibraryClosed;
|
||||
return window;
|
||||
@@ -3620,7 +3618,6 @@ public partial class MainWindow : Window
|
||||
var category = _componentLibraryCategories[_componentLibraryCategoryIndex];
|
||||
_componentLibraryActiveCategoryId = category.Id;
|
||||
_componentLibraryComponentIndex = 0;
|
||||
_ = WarmComponentLibraryCategoryPreviewsAsync(category);
|
||||
BuildComponentLibraryComponentPages(category);
|
||||
ShowComponentLibraryComponentsView();
|
||||
}
|
||||
@@ -3638,10 +3635,10 @@ public partial class MainWindow : Window
|
||||
var componentCount = _componentLibraryActiveComponents.Count;
|
||||
|
||||
ClearTimeZoneServiceBindings(ComponentLibraryComponentPagesContainer.Children.OfType<Control>().ToList());
|
||||
DisposeStaticComponentLibraryPreviews(ComponentLibraryComponentPagesContainer.Children.OfType<Control>());
|
||||
ComponentLibraryComponentPagesContainer.Children.Clear();
|
||||
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
||||
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
||||
ClearComponentLibraryPreviewVisualTargets();
|
||||
if (componentCount == 0)
|
||||
{
|
||||
_componentLibraryComponentIndex = 0;
|
||||
@@ -3715,51 +3712,22 @@ public partial class MainWindow : Window
|
||||
|
||||
var previewWidth = previewSpan.WidthCells * previewCellSize;
|
||||
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
||||
var previewKey = CreateComponentTypePreviewKey(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||
var cachedPreviewImage = ResolveComponentTypePreviewImage(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||
var previewControl = CreateStaticComponentLibraryPreview(
|
||||
component.ComponentId,
|
||||
previewCellSize,
|
||||
previewWidth,
|
||||
previewHeight);
|
||||
|
||||
var previewImage = new Image
|
||||
var previewSurface = new Border
|
||||
{
|
||||
Width = previewWidth,
|
||||
Height = previewHeight,
|
||||
Stretch = Stretch.Uniform,
|
||||
Source = cachedPreviewImage,
|
||||
IsVisible = cachedPreviewImage is not null,
|
||||
Background = Brushes.Transparent,
|
||||
ClipToBounds = false,
|
||||
Child = previewControl,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
|
||||
var previewFallback = new Border
|
||||
{
|
||||
Width = previewWidth,
|
||||
Height = previewHeight,
|
||||
Background = GetThemeBrush("AdaptiveCardBackgroundBrush"),
|
||||
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(Math.Clamp(Math.Min(previewWidth, previewHeight) * 0.18, 12, 28)),
|
||||
IsVisible = cachedPreviewImage is null,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = L("component_library.preview_loading", "Preparing preview"),
|
||||
FontSize = 11,
|
||||
Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
RegisterComponentLibraryPreviewVisual(previewKey, previewImage, previewFallback);
|
||||
|
||||
var previewSurface = new Grid
|
||||
{
|
||||
Width = previewWidth,
|
||||
Height = previewHeight,
|
||||
IsHitTestVisible = false,
|
||||
Children =
|
||||
{
|
||||
previewImage,
|
||||
previewFallback
|
||||
}
|
||||
};
|
||||
|
||||
var previewBorder = new Border
|
||||
{
|
||||
Width = previewWidth,
|
||||
@@ -3807,15 +3775,6 @@ public partial class MainWindow : Window
|
||||
Grid.SetRow(page, 0);
|
||||
Grid.SetColumn(page, i);
|
||||
ComponentLibraryComponentPagesContainer.Children.Add(page);
|
||||
|
||||
if (cachedPreviewImage is null)
|
||||
{
|
||||
_ = EnsureComponentTypePreviewImageAsync(component.ComponentId, previewSpan.WidthCells, previewSpan.HeightCells);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(previewKey);
|
||||
}
|
||||
}
|
||||
|
||||
_componentLibraryComponentHostTransform = ComponentLibraryComponentPagesHost.RenderTransform as TranslateTransform;
|
||||
@@ -3837,10 +3796,10 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
ClearTimeZoneServiceBindings(ComponentLibraryComponentPagesContainer.Children.OfType<Control>().ToList());
|
||||
DisposeStaticComponentLibraryPreviews(ComponentLibraryComponentPagesContainer.Children.OfType<Control>());
|
||||
ComponentLibraryComponentPagesContainer.Children.Clear();
|
||||
ComponentLibraryComponentPagesContainer.RowDefinitions.Clear();
|
||||
ComponentLibraryComponentPagesContainer.ColumnDefinitions.Clear();
|
||||
ClearComponentLibraryPreviewVisualTargets();
|
||||
}
|
||||
|
||||
private string GetLocalizedComponentDisplayName(ComponentLibraryComponentEntry component)
|
||||
|
||||
@@ -123,7 +123,7 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
_componentLibraryCollapsePresenter.SyncExpandedState(ComponentLibraryWindow.Margin, ComponentLibraryWindow.Opacity);
|
||||
_componentLibraryCollapsePresenter.SyncExpandedState(ComponentLibraryWindow.Margin);
|
||||
}
|
||||
|
||||
private void CollapseComponentLibraryForDesktopEdit(string? title)
|
||||
|
||||
@@ -68,6 +68,7 @@ public partial class MainWindow : Window
|
||||
private long _desktopSwipeLastTimestamp;
|
||||
private double _desktopSwipeVelocityX;
|
||||
private double _desktopSwipeBaseOffset;
|
||||
private int? _desktopSwipePointerId;
|
||||
private bool _desktopPageContextInitialized;
|
||||
private bool _desktopPageContextEditMode;
|
||||
private int _desktopPageContextActiveMask;
|
||||
@@ -515,6 +516,7 @@ public partial class MainWindow : Window
|
||||
|
||||
if (isThreeFinger || isRightDrag)
|
||||
{
|
||||
ClearDesktopPageContextSettle(refreshContext: false);
|
||||
// 婵犵數鍋為崹鍫曞箰閹间絸鍥箥椤旂懓浜?闂傚倷绀侀幉锟犳偡閿旂晫绠惧┑鐘叉搐閺嬩焦銇勯幘鍗炵仼缂佺媭鍨堕弻鈥崇暤椤旂厧鏁俊銈勬缁诲棙銇勯弽銊d粶闁稿鎸搁悾鐑藉炊閳哄﹥鏁ら梻鍌欑劍鐎笛呯矙閹烘挾鈹嶆繛宸簼閸婂鏌ㄩ弮鍥撳ù婧垮€濋弻娑㈠Ψ閿濆懎顬堝銈忕稻閻擄繝寮婚敓鐘查唶婵犲灚鍔栨缂傚倷绶¢崰鏍矓閻㈢數鐭夐柟鐑橆殔鐎氬鏌涢…鎴濅簻闁衡偓椤撶喓绠鹃悗娑欘焽閻鎮介娑辨疁閽樼喖鏌涘☉娆愮稇闁藉啰鍠栭弻鏇熷緞濡櫣浠紓浣插亾濠㈣埖鍔栭悡鐔兼煃鏉炴媽鍏岄柟鐣屽█閹粙顢涘☉娆戠▏濡炪倖娲╃紞渚€宕洪埀顒併亜閹哄秶鍔嶉柛娆忕箻閹鏁愭惔鈥茬敖闂佽鐏氶崝鎴﹀蓟? ClearDesktopPageContextSettle(refreshContext: false);
|
||||
_isThreeFingerOrRightDragSwipeActive = true;
|
||||
_isDesktopSwipeActive = true;
|
||||
@@ -525,6 +527,8 @@ public partial class MainWindow : Window
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = Stopwatch.GetTimestamp();
|
||||
_desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
|
||||
_desktopSwipePointerId = pointerId;
|
||||
e.Handled = true;
|
||||
|
||||
// 闂傚倷绀侀幖顐ょ矓閺夋嚚娲煛閸滀焦鏅╅梺鎼炲劘閸斿酣銆呴弻銉﹀€甸柨婵嗗€瑰▍鍡樸亜閹邦喗娅曢柍褜鍓涢幊鎾诲箟闄囬妵鎰板礃椤斻垹娲崺锟犲川椤旈棿鍝楅梻浣虹《濡插懘宕㈤崜褏鐭嗗鑸靛姈閳锋帡鏌涢幇鈺佸缂佺嫏鍕╀簻闁圭儤鎸鹃妴鎺旂磼鏉堛劌娴€规洜鍠栭、鏃堝椽娴i晲缂撻梻鍌欑閹诧紕鎹㈤崒婊呯煋閻庡灚鐡曟慨? e.Handled = true;
|
||||
return;
|
||||
@@ -532,6 +536,7 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
// 闂傚倷绀侀幉锟犫€﹂崶顒€绐楅柟閭﹀墾閼板灝銆掑锝呬壕閻庤娲╃换婵嗩嚕閹绢喗鍋勫瀣閳诲本绻濋悽闈浶㈤柨鏇樺劦瀹曞綊宕归锝呭伎闂佸啿鎼幊蹇涙倿婵犳碍鐓涢柛鏇ㄥ亞缁犳娊鎮? if (IsInteractivePointerSource(e.Source))
|
||||
if (IsInteractivePointerSource(e.Source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -555,6 +560,13 @@ public partial class MainWindow : Window
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = Stopwatch.GetTimestamp();
|
||||
_desktopSwipeBaseOffset = -_currentDesktopSurfaceIndex * _desktopSurfacePageWidth;
|
||||
_desktopSwipePointerId = pointerId;
|
||||
}
|
||||
|
||||
private bool IsDesktopSwipePointer(IPointer? pointer)
|
||||
{
|
||||
return !_desktopSwipePointerId.HasValue ||
|
||||
pointer is not null && pointer.Id == _desktopSwipePointerId.Value;
|
||||
}
|
||||
|
||||
private static bool IsInteractivePointerSource(object? source)
|
||||
@@ -736,6 +748,11 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnDesktopPagesPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (_isDesktopSwipeActive && !IsDesktopSwipePointer(e.Pointer))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isDesktopSwipeActive || !TryGetPointerPositionInDesktopViewport(e, out var pointerInViewport))
|
||||
{
|
||||
return;
|
||||
@@ -797,6 +814,11 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
if (_isDesktopSwipeActive && !IsDesktopSwipePointer(e.Pointer))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (EndDesktopSwipeInteraction(e.Pointer))
|
||||
{
|
||||
@@ -808,7 +830,17 @@ public partial class MainWindow : Window
|
||||
{
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
|
||||
if (!_isDesktopSwipeActive || !IsDesktopSwipePointer(e.Pointer))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Pointer?.Captured == DesktopPagesViewport)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EndDesktopSwipeInteraction(e.Pointer);
|
||||
}
|
||||
|
||||
@@ -829,6 +861,7 @@ public partial class MainWindow : Window
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
_desktopSwipePointerId = null;
|
||||
_desktopSwipeVelocityX = 0;
|
||||
_desktopSwipeLastTimestamp = 0;
|
||||
if (wasDirectionLocked)
|
||||
@@ -851,6 +884,7 @@ public partial class MainWindow : Window
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
_desktopSwipePointerId = null;
|
||||
|
||||
if (pointer?.Captured == DesktopPagesViewport)
|
||||
{
|
||||
|
||||
@@ -225,14 +225,6 @@
|
||||
<Canvas x:Name="DesktopEditDragLayer"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Canvas x:Name="ComponentPreviewStagingHost"
|
||||
Width="1"
|
||||
Height="1"
|
||||
Opacity="0"
|
||||
ClipToBounds="True"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
IsHitTestVisible="False" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -627,7 +619,7 @@
|
||||
<Border x:Name="ComponentLibraryWindow"
|
||||
IsVisible="False"
|
||||
Opacity="0"
|
||||
Classes="surface-translucent-strong"
|
||||
Background="Transparent"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
Width="620"
|
||||
@@ -636,8 +628,6 @@
|
||||
Height="320"
|
||||
MinHeight="260"
|
||||
Margin="24,24,24,100"
|
||||
CornerRadius="36"
|
||||
Padding="14"
|
||||
PointerPressed="OnComponentLibraryWindowPointerPressed"
|
||||
PointerMoved="OnComponentLibraryWindowPointerMoved"
|
||||
PointerReleased="OnComponentLibraryWindowPointerReleased">
|
||||
@@ -647,142 +637,146 @@
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
|
||||
<Grid RowDefinitions="Auto,*"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="ComponentLibraryTitleTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="Widgets" />
|
||||
<Button x:Name="CloseComponentLibraryButton"
|
||||
Grid.Column="1"
|
||||
Padding="8"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Click="OnCloseComponentLibraryClick">
|
||||
<fi:SymbolIcon Classes="icon-s"
|
||||
Symbol="Dismiss"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Classes="surface-translucent-panel"
|
||||
CornerRadius="12"
|
||||
Padding="14">
|
||||
<Grid>
|
||||
<!-- Category picker (outer) -->
|
||||
<Grid x:Name="ComponentLibraryCategoriesView">
|
||||
<Grid RowDefinitions="*">
|
||||
<Border x:Name="ComponentLibraryCategoryViewport"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<Grid x:Name="ComponentLibraryCategoryPagesHost"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Top">
|
||||
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="No components." />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- Component picker (inner) -->
|
||||
<Grid x:Name="ComponentLibraryComponentsView"
|
||||
IsVisible="False"
|
||||
RowDefinitions="Auto,*"
|
||||
RowSpacing="10">
|
||||
<Button x:Name="ComponentLibraryBackButton"
|
||||
Grid.Row="0"
|
||||
HorizontalAlignment="Left"
|
||||
Padding="8,6"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Click="OnComponentLibraryBackClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Classes="icon-s" Symbol="ArrowLeft" IconVariant="Regular" />
|
||||
<TextBlock x:Name="ComponentLibraryBackTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Back" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="ComponentLibraryPrevComponentButton"
|
||||
Grid.Column="0"
|
||||
Width="36"
|
||||
Height="36"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="18"
|
||||
Click="OnComponentLibraryPrevComponentClick"
|
||||
IsVisible="False">
|
||||
<fi:SymbolIcon Symbol="ChevronLeft"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
|
||||
<Border x:Name="ComponentLibraryComponentViewport"
|
||||
Grid.Column="1"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
|
||||
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
|
||||
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
|
||||
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
|
||||
<Grid>
|
||||
<Grid x:Name="ComponentLibraryComponentPagesHost"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
</Grid.RenderTransform>
|
||||
|
||||
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Button x:Name="ComponentLibraryNextComponentButton"
|
||||
Grid.Column="2"
|
||||
Width="36"
|
||||
Height="36"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="18"
|
||||
Click="OnComponentLibraryNextComponentClick"
|
||||
IsVisible="False">
|
||||
<fi:SymbolIcon Symbol="ChevronRight"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Border Classes="surface-translucent-strong"
|
||||
CornerRadius="36"
|
||||
Padding="14">
|
||||
<Grid RowDefinitions="Auto,*"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="ComponentLibraryTitleTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="Widgets" />
|
||||
<Button x:Name="CloseComponentLibraryButton"
|
||||
Grid.Column="1"
|
||||
Padding="8"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Click="OnCloseComponentLibraryClick">
|
||||
<fi:SymbolIcon Classes="icon-s"
|
||||
Symbol="Dismiss"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Classes="surface-translucent-panel"
|
||||
CornerRadius="12"
|
||||
Padding="14">
|
||||
<Grid>
|
||||
<!-- Category picker (outer) -->
|
||||
<Grid x:Name="ComponentLibraryCategoriesView">
|
||||
<Grid RowDefinitions="*">
|
||||
<Border x:Name="ComponentLibraryCategoryViewport"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<Grid x:Name="ComponentLibraryCategoryPagesHost"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Top">
|
||||
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="No components." />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- Component picker (inner) -->
|
||||
<Grid x:Name="ComponentLibraryComponentsView"
|
||||
IsVisible="False"
|
||||
RowDefinitions="Auto,*"
|
||||
RowSpacing="10">
|
||||
<Button x:Name="ComponentLibraryBackButton"
|
||||
Grid.Row="0"
|
||||
HorizontalAlignment="Left"
|
||||
Padding="8,6"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Click="OnComponentLibraryBackClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Classes="icon-s" Symbol="ArrowLeft" IconVariant="Regular" />
|
||||
<TextBlock x:Name="ComponentLibraryBackTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Back" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="8">
|
||||
<Button x:Name="ComponentLibraryPrevComponentButton"
|
||||
Grid.Column="0"
|
||||
Width="36"
|
||||
Height="36"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="18"
|
||||
Click="OnComponentLibraryPrevComponentClick"
|
||||
IsVisible="False">
|
||||
<fi:SymbolIcon Symbol="ChevronLeft"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
|
||||
<Border x:Name="ComponentLibraryComponentViewport"
|
||||
Grid.Column="1"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True"
|
||||
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
|
||||
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
|
||||
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
|
||||
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
|
||||
<Grid>
|
||||
<Grid x:Name="ComponentLibraryComponentPagesHost"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
</Grid.RenderTransform>
|
||||
|
||||
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Button x:Name="ComponentLibraryNextComponentButton"
|
||||
Grid.Column="2"
|
||||
Width="36"
|
||||
Height="36"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="18"
|
||||
Click="OnComponentLibraryNextComponentClick"
|
||||
IsVisible="False">
|
||||
<fi:SymbolIcon Symbol="ChevronRight"
|
||||
IconVariant="Regular" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="ComponentLibraryCollapsedChipHost"
|
||||
|
||||
@@ -34,6 +34,7 @@ public partial class TransparentOverlayWindow : Window
|
||||
private Point _swipeLastPoint;
|
||||
private double _swipeVelocityX;
|
||||
private long _swipeLastTimestamp;
|
||||
private int? _swipePointerId;
|
||||
|
||||
// 三指/右键拖动状态
|
||||
private bool _isThreeFingerOrRightDragSwipeActive;
|
||||
@@ -624,6 +625,7 @@ public partial class TransparentOverlayWindow : Window
|
||||
_swipeLastPoint = pointerPos;
|
||||
_swipeVelocityX = 0;
|
||||
_swipeLastTimestamp = Stopwatch.GetTimestamp();
|
||||
_swipePointerId = pointerId;
|
||||
e.Handled = true;
|
||||
}
|
||||
else
|
||||
@@ -634,6 +636,12 @@ public partial class TransparentOverlayWindow : Window
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
if (_isSwipeActive && !IsSwipePointer(e.Pointer))
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isSwipeActive)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
@@ -686,6 +694,12 @@ public partial class TransparentOverlayWindow : Window
|
||||
{
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
if (_isSwipeActive && !IsSwipePointer(e.Pointer))
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isSwipeActive)
|
||||
{
|
||||
@@ -703,7 +717,19 @@ public partial class TransparentOverlayWindow : Window
|
||||
{
|
||||
var pointerId = e.Pointer?.Id ?? 0;
|
||||
_activePointerIds.Remove(pointerId);
|
||||
|
||||
|
||||
if (_isSwipeActive && !IsSwipePointer(e.Pointer))
|
||||
{
|
||||
base.OnPointerCaptureLost(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isSwipeActive && e.Pointer?.Captured == this)
|
||||
{
|
||||
base.OnPointerCaptureLost(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isSwipeActive)
|
||||
{
|
||||
EndSwipeInteraction(e.Pointer);
|
||||
@@ -725,6 +751,12 @@ public partial class TransparentOverlayWindow : Window
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSwipePointer(IPointer? pointer)
|
||||
{
|
||||
return !_swipePointerId.HasValue ||
|
||||
pointer is not null && pointer.Id == _swipePointerId.Value;
|
||||
}
|
||||
|
||||
private void UpdateSwipeVelocity(Point currentPoint)
|
||||
{
|
||||
@@ -754,6 +786,7 @@ public partial class TransparentOverlayWindow : Window
|
||||
_isSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
_swipePointerId = null;
|
||||
_swipeVelocityX = 0;
|
||||
_swipeLastTimestamp = 0;
|
||||
}
|
||||
@@ -769,6 +802,7 @@ public partial class TransparentOverlayWindow : Window
|
||||
_isSwipeDirectionLocked = false;
|
||||
_isThreeFingerOrRightDragSwipeActive = false;
|
||||
_activePointerIds.Clear();
|
||||
_swipePointerId = null;
|
||||
|
||||
if (pointer?.Captured == this)
|
||||
{
|
||||
|
||||
@@ -112,6 +112,9 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
? entry.PackageSources
|
||||
: entry.Publication?.PackageSources ?? [];
|
||||
var firstPackageSourceUrl = resolvedPackageSources.FirstOrDefault()?.Url ?? entry.DownloadUrl;
|
||||
var existingManifest = entry.Manifest;
|
||||
var existingCompatibility = entry.Compatibility;
|
||||
var existingPublication = entry.Publication;
|
||||
|
||||
return new AirAppMarketPluginEntry
|
||||
{
|
||||
@@ -142,9 +145,13 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
{
|
||||
MinHostVersion = FirstNonEmpty(
|
||||
template?.MinHostVersion,
|
||||
existingCompatibility?.MinHostVersion,
|
||||
entry.MinHostVersion),
|
||||
PluginApiVersion = FirstNonEmpty(
|
||||
resolvedManifest?.ApiVersion,
|
||||
existingCompatibility?.PluginApiVersion,
|
||||
existingCompatibility?.ApiVersion,
|
||||
existingManifest?.ApiVersion,
|
||||
entry.ApiVersion)
|
||||
?? string.Empty
|
||||
}
|
||||
@@ -162,19 +169,24 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
},
|
||||
Publication = entry.Publication,
|
||||
Capabilities = entry.Capabilities,
|
||||
Id = FirstNonEmpty(resolvedManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
|
||||
Name = FirstNonEmpty(resolvedManifest?.Name, entry.Name) ?? string.Empty,
|
||||
Description = FirstNonEmpty(resolvedManifest?.Description, entry.Description) ?? string.Empty,
|
||||
Author = FirstNonEmpty(resolvedManifest?.Author, entry.Author) ?? string.Empty,
|
||||
Version = FirstNonEmpty(resolvedManifest?.Version, entry.Version) ?? string.Empty,
|
||||
ApiVersion = FirstNonEmpty(resolvedManifest?.ApiVersion, entry.ApiVersion) ?? string.Empty,
|
||||
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
|
||||
Id = FirstNonEmpty(resolvedManifest?.Id, existingManifest?.Id, entry.Id, entry.PluginId) ?? entry.PluginId,
|
||||
Name = FirstNonEmpty(resolvedManifest?.Name, existingManifest?.Name, entry.Name) ?? string.Empty,
|
||||
Description = FirstNonEmpty(resolvedManifest?.Description, existingManifest?.Description, entry.Description) ?? string.Empty,
|
||||
Author = FirstNonEmpty(resolvedManifest?.Author, existingManifest?.Author, entry.Author) ?? string.Empty,
|
||||
Version = FirstNonEmpty(resolvedManifest?.Version, existingManifest?.Version, entry.Version) ?? string.Empty,
|
||||
ApiVersion = FirstNonEmpty(
|
||||
resolvedManifest?.ApiVersion,
|
||||
existingCompatibility?.PluginApiVersion,
|
||||
existingCompatibility?.ApiVersion,
|
||||
existingManifest?.ApiVersion,
|
||||
entry.ApiVersion) ?? string.Empty,
|
||||
MinHostVersion = FirstNonEmpty(template?.MinHostVersion, existingCompatibility?.MinHostVersion, entry.MinHostVersion) ?? string.Empty,
|
||||
DownloadUrl = FirstNonEmpty(firstPackageSourceUrl, entry.DownloadUrl) ?? string.Empty,
|
||||
Sha256 = entry.Sha256,
|
||||
PackageSizeBytes = entry.PackageSizeBytes,
|
||||
Sha256 = FirstNonEmpty(existingPublication?.Sha256, entry.Sha256) ?? string.Empty,
|
||||
PackageSizeBytes = existingPublication?.PackageSizeBytes > 0 ? existingPublication.PackageSizeBytes : entry.PackageSizeBytes,
|
||||
IconUrl = FirstNonEmpty(template?.IconUrl, repository.IconUrl, entry.IconUrl) ?? string.Empty,
|
||||
ReleaseTag = entry.ReleaseTag,
|
||||
ReleaseAssetName = entry.ReleaseAssetName,
|
||||
ReleaseTag = FirstNonEmpty(existingPublication?.ReleaseTag, entry.ReleaseTag) ?? string.Empty,
|
||||
ReleaseAssetName = FirstNonEmpty(existingPublication?.ReleaseAssetName, entry.ReleaseAssetName) ?? string.Empty,
|
||||
ProjectUrl = FirstNonEmpty(template?.ProjectUrl, repository.ProjectUrl, entry.ProjectUrl) ?? string.Empty,
|
||||
ReadmeUrl = FirstNonEmpty(template?.ReadmeUrl, repository.ReadmeUrl, entry.ReadmeUrl) ?? string.Empty,
|
||||
HomepageUrl = FirstNonEmpty(template?.HomepageUrl, repository.HomepageUrl, entry.HomepageUrl) ?? string.Empty,
|
||||
@@ -191,9 +203,9 @@ internal sealed class AirAppMarketMetadataResolverService : IDisposable
|
||||
.ToList()
|
||||
?? entry.SharedContracts,
|
||||
PackageSources = resolvedPackageSources,
|
||||
Md5 = entry.Md5,
|
||||
PublishedAt = entry.PublishedAt,
|
||||
UpdatedAt = entry.UpdatedAt,
|
||||
Md5 = FirstNonEmpty(existingPublication?.Md5, entry.Md5) ?? string.Empty,
|
||||
PublishedAt = existingPublication?.PublishedAt ?? entry.PublishedAt,
|
||||
UpdatedAt = existingPublication?.UpdatedAt ?? entry.UpdatedAt,
|
||||
ReleaseNotes = FirstNonEmpty(template?.ReleaseNotes, repository.ReleaseNotes, entry.ReleaseNotes) ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
@@ -366,7 +366,17 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
|
||||
private static string ResolveLauncherPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(baseDirectory, "Launcher", LauncherExecutableName),
|
||||
Path.Combine(baseDirectory, LauncherExecutableName),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "LanMountainDesktop.Launcher", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Debug", "net10.0", LauncherExecutableName)),
|
||||
Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", "LanMountainDesktop.Launcher", "bin", "Release", "net10.0", LauncherExecutableName))
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
|
||||
@@ -646,6 +646,8 @@ internal sealed class AirAppMarketPluginCompatibilityEntry
|
||||
{
|
||||
public string MinHostVersion { get; init; } = string.Empty;
|
||||
|
||||
public string ApiVersion { get; init; } = string.Empty;
|
||||
|
||||
public string PluginApiVersion { get; init; } = string.Empty;
|
||||
|
||||
public AirAppMarketPluginCompatibilityEntry ValidateAndNormalize(string sourceName)
|
||||
@@ -656,9 +658,13 @@ internal sealed class AirAppMarketPluginCompatibilityEntry
|
||||
MinHostVersion,
|
||||
nameof(MinHostVersion),
|
||||
sourceName),
|
||||
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
AirAppMarketIndexDocument.NormalizeValue(PluginApiVersion) ?? ApiVersion,
|
||||
nameof(ApiVersion),
|
||||
sourceName),
|
||||
PluginApiVersion = AirAppMarketIndexDocument.NormalizeVersion(
|
||||
PluginApiVersion,
|
||||
nameof(PluginApiVersion),
|
||||
AirAppMarketIndexDocument.NormalizeValue(PluginApiVersion) ?? ApiVersion,
|
||||
nameof(ApiVersion),
|
||||
sourceName)
|
||||
};
|
||||
}
|
||||
@@ -742,6 +748,8 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
public PluginPackageSourceKind SourceKind { get; init; } = PluginPackageSourceKind.ReleaseAsset;
|
||||
|
||||
public AirAppMarketPluginPackageSourceEntry ValidateAndNormalize(string sourceName, string pluginId)
|
||||
@@ -755,9 +763,11 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
|
||||
$"Market index '{sourceName}' declares invalid package source kind '{normalizedKind}' for plugin '{pluginId}'.");
|
||||
}
|
||||
|
||||
var normalizedPath = AirAppMarketIndexDocument.NormalizeValue(Path);
|
||||
var normalizedUrl = AirAppMarketIndexDocument.NormalizeValue(Url)
|
||||
?? normalizedPath
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing package source url for plugin '{pluginId}'.");
|
||||
$"Market index '{sourceName}' is missing package source url/path for plugin '{pluginId}'.");
|
||||
EnsurePackageSourceUrl(normalizedUrl, sourceName, pluginId);
|
||||
|
||||
return new AirAppMarketPluginPackageSourceEntry
|
||||
@@ -770,6 +780,7 @@ internal sealed class AirAppMarketPluginPackageSourceEntry
|
||||
_ => normalizedKind
|
||||
},
|
||||
Url = normalizedUrl,
|
||||
Path = normalizedPath ?? string.Empty,
|
||||
SourceKind = sourceKind
|
||||
};
|
||||
}
|
||||
@@ -1240,6 +1251,7 @@ internal sealed class AirAppMarketPluginEntry
|
||||
{
|
||||
return compatibility is not null &&
|
||||
(!string.IsNullOrWhiteSpace(compatibility.MinHostVersion) ||
|
||||
!string.IsNullOrWhiteSpace(compatibility.ApiVersion) ||
|
||||
!string.IsNullOrWhiteSpace(compatibility.PluginApiVersion));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user