mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
372b5b7adc | ||
|
|
74703582e7 | ||
|
|
26ff11b16b | ||
|
|
b83cfb47b0 | ||
|
|
a0bb83c743 | ||
|
|
af2e7b4f2f | ||
|
|
798124e500 | ||
|
|
95ecb06668 | ||
|
|
ac7e8db516 | ||
|
|
8ded721f46 | ||
|
|
a559325f5a | ||
|
|
b60368527f | ||
|
|
c8c3f51bff | ||
|
|
685323e057 | ||
|
|
def21c79b1 |
91
AGENTS.md
Normal file
91
AGENTS.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# LanMountainDesktop AI Guide
|
||||||
|
|
||||||
|
本文件是 AI 助手进入本仓库时的第一入口。面向 Codex、Cursor、Trae 等工具,目标是减少重复探索,快速定位权威文档、关键目录和执行约束。
|
||||||
|
|
||||||
|
## 1. 项目目标与仓库边界
|
||||||
|
|
||||||
|
- 本仓库是阑山桌面桌面宿主、宿主侧插件运行时、Plugin SDK、共享契约与基础外观/设置能力的权威来源。
|
||||||
|
- 不要把插件市场元数据、开发者门户或官方示例插件实现当作本仓库内容维护。
|
||||||
|
- 市场和生态材料属于兄弟仓库 `LanAirApp`。
|
||||||
|
- 官方示例插件属于独立仓库 `LanMountainDesktop.SamplePlugin`。
|
||||||
|
|
||||||
|
边界详情看:
|
||||||
|
|
||||||
|
- `docs/ECOSYSTEM_BOUNDARIES.md`
|
||||||
|
- `docs/ARCHITECTURE.md`
|
||||||
|
|
||||||
|
## 2. 关键目录地图
|
||||||
|
|
||||||
|
- `LanMountainDesktop/`: 主宿主应用,包含 UI、服务、组件系统、主题与插件运行时接入
|
||||||
|
- `LanMountainDesktop/ComponentSystem/`: 内置组件定义、注册、扩展加载
|
||||||
|
- `LanMountainDesktop/plugins/`: 宿主侧插件运行时、安装与 market 集成
|
||||||
|
- `LanMountainDesktop/Views/` and `ViewModels/`: UI 页面、窗口与视图模型
|
||||||
|
- `LanMountainDesktop/Services/`: 设置、遥测、启动、持久化、业务服务
|
||||||
|
- `LanMountainDesktop.PluginSdk/`: 插件 SDK 公共接口和默认打包行为
|
||||||
|
- `LanMountainDesktop.Shared.Contracts/`: 宿主/插件共享契约
|
||||||
|
- `LanMountainDesktop.Tests/`: 宿主与 SDK 测试
|
||||||
|
- `.trae/specs/`: feature 级规格、任务拆解和验收清单
|
||||||
|
|
||||||
|
更详细映射看 `docs/ai/CODEBASE_MAP.md`。
|
||||||
|
|
||||||
|
## 3. 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet restore
|
||||||
|
dotnet build LanMountainDesktop.slnx -c Debug
|
||||||
|
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||||
|
dotnet test LanMountainDesktop.slnx -c Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
插件本地包生成:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/Pack-PluginPackages.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 改动前后必做检查
|
||||||
|
|
||||||
|
改动前:
|
||||||
|
|
||||||
|
- 先确认需求是否已经在 `.trae/specs/` 中存在
|
||||||
|
- 先确认产品、架构、专题规范分别以哪份文档为准
|
||||||
|
- 避免沿用旧根目录产品文档中的过时事实
|
||||||
|
|
||||||
|
改动后:
|
||||||
|
|
||||||
|
- 至少检查构建和与改动相关的测试
|
||||||
|
- 如果行为、流程、边界或命令变化,更新对应文档
|
||||||
|
- 如果是新功能或行为调整,补齐或更新 `.trae/specs/<feature>/`
|
||||||
|
|
||||||
|
## 5. 高频区域注意事项
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md` 与 `docs/CORNER_RADIUS_SPEC.md`
|
||||||
|
- 设置页相关改动通常同时落在 `Views/`、`ViewModels/`、`Services/` 和 `.trae/specs/`
|
||||||
|
- UI 启动与窗口生命周期主线在 `Program.cs` 和 `App.axaml.cs`
|
||||||
|
|
||||||
|
### 插件
|
||||||
|
|
||||||
|
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
|
||||||
|
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
|
||||||
|
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
|
||||||
|
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||||
|
|
||||||
|
### 设置与主题
|
||||||
|
|
||||||
|
- 设置持久化和 scope 变化优先检查 `LanMountainDesktop.Settings.Core/`
|
||||||
|
- 外观、圆角、主题资源优先检查 `LanMountainDesktop.Appearance/` 与专题规范
|
||||||
|
|
||||||
|
## 6. 权威来源
|
||||||
|
|
||||||
|
- 产品定位:`docs/PRODUCT.md`
|
||||||
|
- 架构与模块职责:`docs/ARCHITECTURE.md`
|
||||||
|
- 运行、构建、测试、打包:`docs/DEVELOPMENT.md`
|
||||||
|
- feature 规格:`.trae/specs/`
|
||||||
|
- 视觉规范:`docs/VISUAL_SPEC.md`
|
||||||
|
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
|
||||||
|
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
|
||||||
|
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||||
|
|
||||||
|
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。
|
||||||
@@ -6,7 +6,9 @@ public static class SettingsCategories
|
|||||||
public const string Appearance = "Appearance";
|
public const string Appearance = "Appearance";
|
||||||
public const string Components = "Components";
|
public const string Components = "Components";
|
||||||
public const string Plugins = "Plugins";
|
public const string Plugins = "Plugins";
|
||||||
public const string PluginMarket = "PluginMarket";
|
public const string PluginCatalog = "PluginCatalog";
|
||||||
|
[Obsolete("Use PluginCatalog instead.")]
|
||||||
|
public const string PluginMarket = PluginCatalog;
|
||||||
public const string Update = "Update";
|
public const string Update = "Update";
|
||||||
public const string About = "About";
|
public const string About = "About";
|
||||||
public const string Advanced = "Advanced";
|
public const string Advanced = "Advanced";
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public enum SettingsPageCategory
|
|||||||
Appearance = 10,
|
Appearance = 10,
|
||||||
Components = 20,
|
Components = 20,
|
||||||
Plugins = 30,
|
Plugins = 30,
|
||||||
|
PluginCatalog = 35,
|
||||||
|
[Obsolete("Use PluginCatalog instead.")]
|
||||||
PluginMarket = 35,
|
PluginMarket = 35,
|
||||||
About = 40
|
About = 40
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
<RepositoryType>git</RepositoryType>
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using LanMountainDesktop.DesktopEditing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ComponentLibraryCollapseStateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateExpanded_InitializesExpandedStateAndHidesChip()
|
||||||
|
{
|
||||||
|
var margin = new Thickness(24, 24, 24, 100);
|
||||||
|
var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75);
|
||||||
|
|
||||||
|
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState);
|
||||||
|
Assert.Equal(margin, state.ExpandedMargin);
|
||||||
|
Assert.Equal(0.75, state.ExpandedOpacity, 3);
|
||||||
|
Assert.False(state.IsChipVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions()
|
||||||
|
{
|
||||||
|
var margin = new Thickness(20, 18, 20, 96);
|
||||||
|
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1);
|
||||||
|
|
||||||
|
var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||||
|
var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||||
|
var restoring = collapsed.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
|
||||||
|
|
||||||
|
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsing, collapsing.VisualState);
|
||||||
|
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsed, collapsed.VisualState);
|
||||||
|
Assert.Equal(ComponentLibraryCollapseVisualState.Restoring, restoring.VisualState);
|
||||||
|
|
||||||
|
Assert.Equal(margin, collapsing.ExpandedMargin);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var margin = new Thickness(18, 22, 18, 88);
|
||||||
|
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs
Normal file
257
LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ComponentPreviewImageServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var executionOrder = new List<string>();
|
||||||
|
var activeCount = 0;
|
||||||
|
var maxActiveCount = 0;
|
||||||
|
|
||||||
|
Task<ComponentPreviewImageEntry> Queue(string componentTypeId)
|
||||||
|
{
|
||||||
|
var key = ComponentPreviewKey.ForComponentType(componentTypeId, widthCells: 2, heightCells: 2);
|
||||||
|
return service.QueueGenerationAsync(
|
||||||
|
key,
|
||||||
|
visualSignature: $"sig:{componentTypeId}",
|
||||||
|
async _ =>
|
||||||
|
{
|
||||||
|
var activeNow = Interlocked.Increment(ref activeCount);
|
||||||
|
maxActiveCount = Math.Max(maxActiveCount, activeNow);
|
||||||
|
lock (executionOrder)
|
||||||
|
{
|
||||||
|
executionOrder.Add(componentTypeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(40);
|
||||||
|
Interlocked.Decrement(ref activeCount);
|
||||||
|
return CreateImage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = Queue("Clock");
|
||||||
|
var second = Queue("Weather");
|
||||||
|
var third = Queue("Calendar");
|
||||||
|
|
||||||
|
await Task.WhenAll(first, second, third);
|
||||||
|
|
||||||
|
Assert.Equal(1, maxActiveCount);
|
||||||
|
Assert.Equal(["Clock", "Weather", "Calendar"], executionOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueGenerationAsync_DeduplicatesConcurrentRequestsForSameKey()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var generationCount = 0;
|
||||||
|
var bitmap = CreateImage();
|
||||||
|
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
Task<IImage?> Generation(CancellationToken _)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref generationCount);
|
||||||
|
return completion.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||||
|
var second = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
|
||||||
|
completion.SetResult(bitmap);
|
||||||
|
var entry = await first;
|
||||||
|
|
||||||
|
Assert.Equal(1, generationCount);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Ready, entry.State);
|
||||||
|
Assert.Same(bitmap, entry.Bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Invalidate_ResetsSingleKeyToPending()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var image = CreateDisposableImage();
|
||||||
|
var stored = service.Store(key, image, "clock-sig");
|
||||||
|
var previousRevision = stored.Revision;
|
||||||
|
|
||||||
|
var result = service.Invalidate(key);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Pending, stored.State);
|
||||||
|
Assert.Null(stored.Bitmap);
|
||||||
|
Assert.True(image.IsDisposed);
|
||||||
|
Assert.True(stored.Revision > previousRevision);
|
||||||
|
Assert.Equal("clock-sig", stored.VisualSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemovePlacementPreviews_RemovesOnlyMatchingPlacementEntries()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
|
||||||
|
var removedClock = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2);
|
||||||
|
var removedWeather = ComponentPreviewKey.ForPlacementInstance("Weather", "desk-1", widthCells: 4, heightCells: 2);
|
||||||
|
var keptPlacement = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-2", widthCells: 2, heightCells: 2);
|
||||||
|
var keptType = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var removedClockImage = CreateDisposableImage();
|
||||||
|
var removedWeatherImage = CreateDisposableImage();
|
||||||
|
var keptPlacementImage = CreateDisposableImage();
|
||||||
|
var keptTypeImage = CreateDisposableImage();
|
||||||
|
|
||||||
|
service.Store(removedClock, removedClockImage, "sig-a");
|
||||||
|
service.Store(removedWeather, removedWeatherImage, "sig-b");
|
||||||
|
service.Store(keptPlacement, keptPlacementImage, "sig-c");
|
||||||
|
service.Store(keptType, keptTypeImage, "sig-d");
|
||||||
|
|
||||||
|
var removedCount = service.RemovePlacementPreviews("desk-1");
|
||||||
|
|
||||||
|
Assert.Equal(2, removedCount);
|
||||||
|
Assert.False(service.TryGetEntry(removedClock, out _));
|
||||||
|
Assert.False(service.TryGetEntry(removedWeather, out _));
|
||||||
|
Assert.True(service.TryGetEntry(keptPlacement, out _));
|
||||||
|
Assert.True(service.TryGetEntry(keptType, out _));
|
||||||
|
Assert.True(removedClockImage.IsDisposed);
|
||||||
|
Assert.True(removedWeatherImage.IsDisposed);
|
||||||
|
Assert.False(keptPlacementImage.IsDisposed);
|
||||||
|
Assert.False(keptTypeImage.IsDisposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidateVisualSignature_InvalidatesEveryMatchingEntry()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
const string matchingSignature = "shared-sig";
|
||||||
|
const string otherSignature = "other-sig";
|
||||||
|
|
||||||
|
var first = service.Store(
|
||||||
|
ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2),
|
||||||
|
CreateImage(),
|
||||||
|
matchingSignature);
|
||||||
|
var second = service.Store(
|
||||||
|
ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2),
|
||||||
|
CreateImage(),
|
||||||
|
matchingSignature);
|
||||||
|
var third = service.Store(
|
||||||
|
ComponentPreviewKey.ForComponentType("Weather", widthCells: 2, heightCells: 1),
|
||||||
|
CreateImage(),
|
||||||
|
otherSignature);
|
||||||
|
|
||||||
|
var invalidatedCount = service.InvalidateVisualSignature(matchingSignature);
|
||||||
|
|
||||||
|
Assert.Equal(2, invalidatedCount);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Pending, first.State);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Pending, second.State);
|
||||||
|
Assert.Null(first.Bitmap);
|
||||||
|
Assert.Null(second.Bitmap);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Ready, third.State);
|
||||||
|
Assert.NotNull(third.Bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Store_ReplacingBitmap_DisposesPreviousBitmap_WhenInstanceChanges()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var first = CreateDisposableImage();
|
||||||
|
var second = CreateDisposableImage();
|
||||||
|
|
||||||
|
service.Store(key, first, "sig-a");
|
||||||
|
service.Store(key, second, "sig-b");
|
||||||
|
|
||||||
|
Assert.True(first.IsDisposed);
|
||||||
|
Assert.False(second.IsDisposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Store_ReplacingBitmap_DoesNotDispose_WhenSameInstanceReused()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var image = CreateDisposableImage();
|
||||||
|
|
||||||
|
service.Store(key, image, "sig-a");
|
||||||
|
service.Store(key, image, "sig-b");
|
||||||
|
|
||||||
|
Assert.False(image.IsDisposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StoreFailure_DisposesExistingBitmap()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var image = CreateDisposableImage();
|
||||||
|
|
||||||
|
service.Store(key, image, "sig-a");
|
||||||
|
var entry = service.StoreFailure(key, "sig-a", "failed");
|
||||||
|
|
||||||
|
Assert.True(image.IsDisposed);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Failed, entry.State);
|
||||||
|
Assert.Null(entry.Bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueGenerationAsync_DisposesStaleGeneratedBitmap_WhenEntryWasInvalidated()
|
||||||
|
{
|
||||||
|
var service = new ComponentPreviewImageService();
|
||||||
|
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||||
|
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var stale = CreateDisposableImage();
|
||||||
|
|
||||||
|
var generationTask = service.QueueGenerationAsync(key, "sig-a", _ => completion.Task);
|
||||||
|
_ = service.Invalidate(key);
|
||||||
|
completion.SetResult(stale);
|
||||||
|
var entry = await generationTask;
|
||||||
|
|
||||||
|
Assert.True(stale.IsDisposed);
|
||||||
|
Assert.Equal(ComponentPreviewImageState.Pending, entry.State);
|
||||||
|
Assert.Null(entry.Bitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IImage CreateImage() => new TestImage();
|
||||||
|
private static DisposableTestImage CreateDisposableImage() => new();
|
||||||
|
|
||||||
|
private sealed class TestImage : IImage
|
||||||
|
{
|
||||||
|
public Size Size => new(1, 1);
|
||||||
|
|
||||||
|
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||||
|
{
|
||||||
|
_ = context;
|
||||||
|
_ = sourceRect;
|
||||||
|
_ = destRect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DisposableTestImage : IImage, IDisposable
|
||||||
|
{
|
||||||
|
public Size Size => new(1, 1);
|
||||||
|
|
||||||
|
public bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IsDisposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||||
|
{
|
||||||
|
_ = context;
|
||||||
|
_ = sourceRect;
|
||||||
|
_ = destRect;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs
Normal file
15
LanMountainDesktop.Tests/DesktopEditCommitMathTests.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using LanMountainDesktop.DesktopEditing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class DesktopEditCommitMathTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IsPendingCommitValid_ReturnsTrueOnlyForMatchingActiveVersion()
|
||||||
|
{
|
||||||
|
Assert.True(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 4));
|
||||||
|
Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: false, scheduledVersion: 4, currentVersion: 4));
|
||||||
|
Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
173
LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
Normal file
173
LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using LanMountainDesktop.DesktopEditing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class DesktopPlacementMathTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ComputeDragStartThreshold_UsesFloorAndCellScale()
|
||||||
|
{
|
||||||
|
Assert.Equal(10d, DesktopPlacementMath.ComputeDragStartThreshold(24));
|
||||||
|
Assert.Equal(14.4d, DesktopPlacementMath.ComputeDragStartThreshold(80), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasExceededThreshold_OnlyReturnsTrueAfterEnoughMovement()
|
||||||
|
{
|
||||||
|
var start = new Point(20, 20);
|
||||||
|
|
||||||
|
Assert.False(DesktopPlacementMath.HasExceededThreshold(start, new Point(27, 25), 10));
|
||||||
|
Assert.True(DesktopPlacementMath.HasExceededThreshold(start, new Point(31, 20), 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OcclusionHelpers_DetectPointAndRectOverlap()
|
||||||
|
{
|
||||||
|
var libraryBounds = new Rect(100, 100, 200, 160);
|
||||||
|
|
||||||
|
Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(120, 150), libraryBounds));
|
||||||
|
Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(80, 90), libraryBounds));
|
||||||
|
Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(250, 120, 120, 80), libraryBounds));
|
||||||
|
Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(10, 10, 40, 40), libraryBounds));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetSnappedCell_ClampsInsideGridBounds()
|
||||||
|
{
|
||||||
|
var grid = new DesktopGridGeometry(
|
||||||
|
Origin: default,
|
||||||
|
CellSize: 80,
|
||||||
|
CellGap: 8,
|
||||||
|
ColumnCount: 4,
|
||||||
|
RowCount: 5);
|
||||||
|
|
||||||
|
var result = DesktopPlacementMath.TryGetSnappedCell(
|
||||||
|
grid,
|
||||||
|
pointerInViewport: new Point(490, 520),
|
||||||
|
pointerOffset: new Point(10, 10),
|
||||||
|
widthCells: 2,
|
||||||
|
heightCells: 3,
|
||||||
|
out var column,
|
||||||
|
out var row);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal(2, column);
|
||||||
|
Assert.Equal(2, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCellRect_MapsCellsToPixelRect()
|
||||||
|
{
|
||||||
|
var grid = new DesktopGridGeometry(
|
||||||
|
Origin: new Point(12, 24),
|
||||||
|
CellSize: 80,
|
||||||
|
CellGap: 8,
|
||||||
|
ColumnCount: 6,
|
||||||
|
RowCount: 8);
|
||||||
|
|
||||||
|
var rect = DesktopPlacementMath.GetCellRect(grid, column: 2, row: 3, widthCells: 2, heightCells: 3);
|
||||||
|
|
||||||
|
Assert.Equal(188, rect.X, 3);
|
||||||
|
Assert.Equal(288, rect.Y, 3);
|
||||||
|
Assert.Equal(168, rect.Width, 3);
|
||||||
|
Assert.Equal(256, rect.Height, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Session_DoesNotCommitWhilePointerIsStillInsideLibrary()
|
||||||
|
{
|
||||||
|
var session = DesktopEditSession.CreatePendingNew(
|
||||||
|
componentId: "demo",
|
||||||
|
pageIndex: 0,
|
||||||
|
widthCells: 2,
|
||||||
|
heightCells: 2,
|
||||||
|
startPointerInViewport: new Point(80, 80),
|
||||||
|
pointerOffsetInViewport: new Point(60, 60),
|
||||||
|
componentLibraryBounds: new Rect(0, 0, 220, 300));
|
||||||
|
|
||||||
|
session = session.WithCurrentPointer(new Point(130, 150));
|
||||||
|
|
||||||
|
Assert.True(session.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80)));
|
||||||
|
Assert.True(session.IsPointerInsideComponentLibrary());
|
||||||
|
Assert.False(session.CanCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Session_ResizePreviewStillBlocksWhenPointerRemainsInsideLibrary()
|
||||||
|
{
|
||||||
|
var session = DesktopEditSession.CreateResizingExisting(
|
||||||
|
componentId: "demo",
|
||||||
|
placementId: "placement-1",
|
||||||
|
pageIndex: 0,
|
||||||
|
widthCells: 2,
|
||||||
|
heightCells: 2,
|
||||||
|
startPointerInViewport: new Point(80, 80),
|
||||||
|
componentLibraryBounds: new Rect(0, 0, 220, 300))
|
||||||
|
.WithCurrentPointer(new Point(130, 150));
|
||||||
|
|
||||||
|
Assert.True(session.IsPointerInsideComponentLibrary());
|
||||||
|
Assert.False(session.CanCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasCellPositionChanged_DetectsNoOpAndRealMoves()
|
||||||
|
{
|
||||||
|
Assert.False(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 3));
|
||||||
|
Assert.True(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HasCellSpanChanged_DetectsNoOpAndRealResizes()
|
||||||
|
{
|
||||||
|
Assert.False(DesktopPlacementMath.HasCellSpanChanged(2, 3, 2, 3));
|
||||||
|
Assert.True(DesktopPlacementMath.HasCellSpanChanged(2, 3, 3, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanCommitPlacement_BlocksWhenPlacementIsOccludedByLibrary()
|
||||||
|
{
|
||||||
|
var placementRect = new Rect(160, 110, 180, 140);
|
||||||
|
var occludingLibraryBounds = new Rect(120, 80, 240, 220);
|
||||||
|
var distantLibraryBounds = new Rect(420, 420, 80, 80);
|
||||||
|
|
||||||
|
Assert.False(DesktopPlacementMath.CanCommitPlacement(placementRect, occludingLibraryBounds));
|
||||||
|
Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, distantLibraryBounds));
|
||||||
|
Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, componentLibraryBounds: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Session_AllowsCommitWhenComponentLibraryBoundsAreCleared()
|
||||||
|
{
|
||||||
|
var pendingSession = DesktopEditSession.CreatePendingNew(
|
||||||
|
componentId: "demo",
|
||||||
|
pageIndex: 0,
|
||||||
|
widthCells: 2,
|
||||||
|
heightCells: 2,
|
||||||
|
startPointerInViewport: new Point(80, 80),
|
||||||
|
pointerOffsetInViewport: new Point(60, 60),
|
||||||
|
componentLibraryBounds: null)
|
||||||
|
.WithCurrentPointer(new Point(200, 180));
|
||||||
|
|
||||||
|
Assert.True(pendingSession.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80)));
|
||||||
|
Assert.False(pendingSession.IsPointerInsideComponentLibrary());
|
||||||
|
Assert.False(pendingSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
|
||||||
|
Assert.False(pendingSession.CanCommit);
|
||||||
|
|
||||||
|
var resizeSession = DesktopEditSession.CreateResizingExisting(
|
||||||
|
componentId: "demo",
|
||||||
|
placementId: "placement-1",
|
||||||
|
pageIndex: 0,
|
||||||
|
widthCells: 2,
|
||||||
|
heightCells: 2,
|
||||||
|
startPointerInViewport: new Point(80, 80),
|
||||||
|
componentLibraryBounds: null)
|
||||||
|
.WithCurrentPointer(new Point(200, 180))
|
||||||
|
.WithTargetCell(row: 2, column: 3);
|
||||||
|
|
||||||
|
Assert.False(resizeSession.IsPointerInsideComponentLibrary());
|
||||||
|
Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
|
||||||
|
Assert.True(resizeSession.CanCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<FontFamily x:Key="AppFontFamily">avares://LanMountainDesktop/Assets/Fonts#MiSans</FontFamily>
|
<FontFamily x:Key="AppFontFamily">avares://LanMountainDesktop/Assets/Fonts#MiSans</FontFamily>
|
||||||
|
<FontFamily x:Key="AppFontFamilyJP">avares://LanMountainDesktop/Assets/Fonts#MiSans JP</FontFamily>
|
||||||
|
<FontFamily x:Key="AppFontFamilyKR">avares://LanMountainDesktop/Assets/Fonts#MiSans KR</FontFamily>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/NavigationStyles.axaml" />
|
||||||
|
|
||||||
<Style Selector="Window">
|
<Style Selector="Window">
|
||||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public partial class App : Application
|
|||||||
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
||||||
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
private readonly FontFamilyService _fontFamilyService = new();
|
||||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||||
@@ -448,6 +449,21 @@ public partial class App : Application
|
|||||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||||
Thread.CurrentThread.CurrentCulture = culture;
|
Thread.CurrentThread.CurrentCulture = culture;
|
||||||
Thread.CurrentThread.CurrentUICulture = culture;
|
Thread.CurrentThread.CurrentUICulture = culture;
|
||||||
|
|
||||||
|
ApplyLanguageSpecificFont(languageCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLanguageSpecificFont(string languageCode)
|
||||||
|
{
|
||||||
|
var fontFamily = _fontFamilyService.GetFontFamilyForLanguage(languageCode);
|
||||||
|
if (Resources.TryGetValue("AppFontFamily", out var currentFont) &&
|
||||||
|
currentFont is FontFamily currentFontFamily &&
|
||||||
|
currentFontFamily.Name == fontFamily.Name)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Resources["AppFontFamily"] = fontFamily;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ActivateMainWindow()
|
private void ActivateMainWindow()
|
||||||
|
|||||||
@@ -1,54 +1,229 @@
|
|||||||
# 隐私与遥测说明
|
# LanMountainDesktop 遥测隐私政策
|
||||||
|
|
||||||
LanMountainDesktop 提供两类可选遥测能力:
|
**生效日期**:2026年3月22日\
|
||||||
|
**最后更新**:2026年3月22日
|
||||||
|
|
||||||
- 崩溃数据上传
|
***
|
||||||
- 行为数据分析
|
|
||||||
|
|
||||||
这两个开关默认关闭。即使两项都关闭,应用仍会在首次启动时向 PostHog 发送一次最小化的启动基线事件,用于统计用户量。
|
## 引言
|
||||||
|
|
||||||
## 默认行为
|
LanMountainDesktop(以下简称"本应用")由 灵方软件Lincube(以下简称"我们")开发和维护。我们深知用户隐私的重要性,并致力于保护您的个人信息安全。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
|
||||||
|
|
||||||
当“崩溃数据上传”和“行为数据分析”都关闭时:
|
使用本应用即表示您同意本隐私政策的条款。如果您不同意本政策的任何部分,请停止使用本应用。
|
||||||
|
|
||||||
- 仅首次启动会发送一次 `app_first_launch` 事件
|
***
|
||||||
- 该事件只用于统计用户量
|
|
||||||
- 事件时间由 PostHog 接入侧记录的请求时间和启动时间决定
|
|
||||||
- 不会主动上传设备型号、操作系统细节、组件操作轨迹等详细信息
|
|
||||||
|
|
||||||
## 崩溃数据上传
|
## 1. 数据收集范围
|
||||||
|
|
||||||
当开启“崩溃数据上传”时,应用会把崩溃与未处理异常发送到 Sentry,用于分析稳定性问题。
|
### 1.1 我们收集的数据
|
||||||
|
|
||||||
上报内容可能包括:
|
本应用提供两类可选的数据收集功能:
|
||||||
|
|
||||||
- 异常堆栈和错误上下文
|
| 数据类型 | 收集方式 | 默认状态 | 用途 |
|
||||||
- 应用版本与运行环境
|
| ------ | ---- | ---- | -------- |
|
||||||
- 操作系统信息
|
| 启动基线事件 | 自动收集 | 开启 | 统计用户量 |
|
||||||
- 设备基础信息
|
| 崩溃数据 | 用户授权 | 关闭 | 分析稳定性问题 |
|
||||||
- 最近的日志尾部内容
|
| 行为数据 | 用户授权 | 关闭 | 分析功能使用情况 |
|
||||||
|
|
||||||
应用退出或崩溃时,会尽量补充最后一次会话和日志信息,方便定位问题。
|
### 1.2 启动基线事件
|
||||||
|
|
||||||
## 行为数据分析
|
无论您是否开启其他遥测选项,本应用会在首次启动时发送一次最小化的启动基线事件(`app_first_launch`),用于统计活跃用户量。该事件仅包含:
|
||||||
|
|
||||||
当开启“行为数据分析”时,应用会把关键行为事件发送到 PostHog,用于分析功能使用情况和会话路径。
|
- 匿名安装标识符(Install ID)
|
||||||
|
- 应用版本号
|
||||||
|
- 启动时间戳
|
||||||
|
|
||||||
上报内容可能包括:
|
### 1.3 崩溃数据
|
||||||
|
|
||||||
- 应用启动和退出时间
|
当您开启"崩溃数据上传"功能时,我们可能收集以下信息:
|
||||||
- 会话开始与结束时间
|
|
||||||
- 设置页打开、关闭和导航
|
|
||||||
- 抽屉打开和关闭
|
|
||||||
- 桌面组件的放置、移动、缩放、删除和编辑入口
|
|
||||||
|
|
||||||
这些事件会被转换成 PostHog 可以直接接收和分析的事件格式,方便在 PostHog 中按事件流查看用户行为。桌面端的“回放”能力通过事件时间线重建,而不是浏览器式 Session Replay。
|
- **异常信息**:异常类型、错误消息、堆栈跟踪
|
||||||
|
- **应用信息**:应用版本、构建号、运行时环境
|
||||||
|
- **系统信息**:操作系统版本、系统架构、可用内存
|
||||||
|
- **设备信息**:设备型号、屏幕分辨率
|
||||||
|
- **日志信息**:应用崩溃前的最近日志记录(可能包含您在使用过程中产生的操作记录)
|
||||||
|
|
||||||
## 身份与隐私控制
|
### 1.4 行为数据
|
||||||
|
|
||||||
应用会使用随机生成的匿名 install ID 和可刷新 telemetry ID 来区分安装与运行会话。
|
当您开启"行为数据分析"功能时,我们可能收集以下信息:
|
||||||
|
|
||||||
- 刷新 telemetry ID 只会影响后续详细遥测
|
- **会话信息**:应用启动/退出时间、会话持续时间
|
||||||
- 关闭开关后,不会继续发送对应类别的详细遥测
|
- **功能使用**:设置页面访问、抽屉操作、组件库操作
|
||||||
- IP 只会通过 Sentry / PostHog 的服务端接入侧自然记录,不会作为自定义字段重复上报
|
- **组件操作**:桌面组件的放置、移动、调整大小、删除操作
|
||||||
|
- **界面交互**:页面切换、编辑模式进入/退出
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 2. 数据使用目的
|
||||||
|
|
||||||
|
我们收集的数据将用于以下目的:
|
||||||
|
|
||||||
|
### 2.1 启动基线事件
|
||||||
|
|
||||||
|
- 统计应用的用户数量和活跃度
|
||||||
|
- 了解应用的安装分布情况
|
||||||
|
|
||||||
|
### 2.2 崩溃数据
|
||||||
|
|
||||||
|
- 诊断和修复应用崩溃问题
|
||||||
|
- 提高应用的稳定性和可靠性
|
||||||
|
- 识别和解决性能瓶颈
|
||||||
|
|
||||||
|
### 2.3 行为数据
|
||||||
|
|
||||||
|
- 了解用户如何使用本应用的功能
|
||||||
|
- 改进用户体验和界面设计
|
||||||
|
- 指导功能开发和优先级决策
|
||||||
|
- 分析用户行为模式和趋势
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 3. 数据存储与传输
|
||||||
|
|
||||||
|
### 3.1 数据传输
|
||||||
|
|
||||||
|
您的数据将通过加密连接传输至以下第三方服务:
|
||||||
|
|
||||||
|
| 服务提供商 | 服务类型 | 数据内容 | 隐私政策 |
|
||||||
|
| ------- | ---- | --------- | ----------------------------- |
|
||||||
|
| PostHog | 产品分析 | 启动事件、行为数据 | <https://posthog.com/privacy> |
|
||||||
|
| Sentry | 错误监控 | 崩溃数据、异常信息 | <https://sentry.io/privacy/> |
|
||||||
|
|
||||||
|
### 3.2 数据存储位置
|
||||||
|
|
||||||
|
数据存储于上述第三方服务的服务器,这些服务器可能位于中国境外。我们已与这些服务提供商签订数据处理协议,确保您的数据得到适当保护。
|
||||||
|
|
||||||
|
### 3.3 数据保留期限
|
||||||
|
|
||||||
|
- **启动基线事件**:保留期限由 PostHog 服务配置决定,通常为 13 个月
|
||||||
|
- **崩溃数据**:保留期限由 Sentry 服务配置决定,通常为 90 天
|
||||||
|
- **行为数据**:保留期限由 PostHog 服务配置决定,通常为 13 个月
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 4. 用户权利与控制
|
||||||
|
|
||||||
|
### 4.1 您的权利
|
||||||
|
|
||||||
|
根据适用的数据保护法律,您享有以下权利:
|
||||||
|
|
||||||
|
- **知情权**:了解我们收集哪些数据及其用途
|
||||||
|
- **访问权**:请求获取我们持有的您的个人数据副本
|
||||||
|
- **更正权**:请求更正不准确或不完整的个人数据
|
||||||
|
- **删除权**:请求删除您的个人数据
|
||||||
|
- **撤回同意权**:随时撤回您对数据收集的同意
|
||||||
|
- **数据可携带权**:以结构化格式接收您的个人数据
|
||||||
|
|
||||||
|
### 4.2 如何行使您的权利
|
||||||
|
|
||||||
|
您可以通过以下方式行使上述权利:
|
||||||
|
|
||||||
|
1. **关闭遥测功能**:在应用设置 > 隐私设置中关闭相应开关
|
||||||
|
2. **刷新遥测标识**:在应用设置 > 隐私设置中点击"刷新遥测 ID"
|
||||||
|
3. **联系我们**:通过 GitHub Issues 提交数据相关请求
|
||||||
|
|
||||||
|
### 4.3 功能控制
|
||||||
|
|
||||||
|
| 功能 | 控制方式 | 效果 |
|
||||||
|
| ------- | ---- | ----------- |
|
||||||
|
| 崩溃数据上传 | 设置开关 | 关闭后停止发送崩溃数据 |
|
||||||
|
| 行为数据分析 | 设置开关 | 关闭后停止发送行为数据 |
|
||||||
|
| 刷新遥测 ID | 手动触发 | 生成新的匿名标识符 |
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 5. 身份标识
|
||||||
|
|
||||||
|
### 5.1 匿名标识符
|
||||||
|
|
||||||
|
我们使用以下匿名标识符来区分用户和会话:
|
||||||
|
|
||||||
|
- **Install ID**:在应用首次安装时随机生成的唯一标识符,用于区分不同的安装实例
|
||||||
|
- **Telemetry ID**:匿名标识符,用于关联遥测数据
|
||||||
|
|
||||||
|
### 5.2 标识符特性
|
||||||
|
|
||||||
|
- 这些标识符不包含您的真实身份信息
|
||||||
|
- 标识符与您的个人身份(如姓名、邮箱、电话)无关联
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 6. 数据安全
|
||||||
|
|
||||||
|
### 6.1 安全措施
|
||||||
|
|
||||||
|
我们采取以下安全措施保护您的数据:
|
||||||
|
|
||||||
|
- **传输加密**:所有数据传输均使用 TLS/HTTPS 加密
|
||||||
|
- **访问控制**:限制对数据的访问权限,仅授权人员可访问
|
||||||
|
- **匿名化处理**:使用匿名标识符而非个人身份信息
|
||||||
|
|
||||||
|
### 6.2 数据泄露响应
|
||||||
|
|
||||||
|
如发生数据泄露事件,我们将:
|
||||||
|
|
||||||
|
1. 及时评估泄露的影响范围和严重程度
|
||||||
|
2. 采取必要措施阻止进一步泄露
|
||||||
|
3. 根据法律要求通知相关监管机构和受影响用户
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 7. 第三方服务
|
||||||
|
|
||||||
|
### 7.1 PostHog
|
||||||
|
|
||||||
|
PostHog 是我们使用的产品分析平台,用于收集和分析用户行为数据。PostHog 的隐私政策请参阅:<https://posthog.com/privacy>
|
||||||
|
|
||||||
|
### 7.2 Sentry
|
||||||
|
|
||||||
|
Sentry 是我们使用的错误监控平台,用于收集和分析崩溃数据。Sentry 的隐私政策请参阅:<https://sentry.io/privacy/>
|
||||||
|
|
||||||
|
### 7.3 第三方责任
|
||||||
|
|
||||||
|
我们仅将上述第三方服务用于本政策所述目的。我们不对这些第三方的隐私实践负责,建议您阅读其隐私政策。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 8. 儿童隐私
|
||||||
|
|
||||||
|
本应用不面向 14 周岁以下的儿童。我们不会故意收集儿童的个人信息。如果您是 14 周岁以下儿童的监护人,且发现您的孩子向我们提供了个人信息,请联系我们,我们将采取措施删除相关信息。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 9. 隐私政策更新
|
||||||
|
|
||||||
|
我们可能会不时更新本隐私政策。更新后的政策将在本应用内发布,并在政策顶部注明"最后更新"日期。重大变更时,我们将在应用内通过显著方式通知您。
|
||||||
|
|
||||||
|
建议您定期查阅本政策,以了解我们如何保护您的信息。继续使用本应用即表示您接受更新后的隐私政策。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 10. 适用法律
|
||||||
|
|
||||||
|
本隐私政策的解释和执行适用中华人民共和国法律法规,包括但不限于:
|
||||||
|
|
||||||
|
- 《中华人民共和国个人信息保护法》
|
||||||
|
- 《中华人民共和国数据安全法》
|
||||||
|
- 《中华人民共和国网络安全法》
|
||||||
|
- 《信息安全技术 个人信息安全规范》(GB/T 35273)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 11. 联系我们
|
||||||
|
|
||||||
|
如果您对本隐私政策有任何疑问、意见或建议,请通过以下方式联系我们:
|
||||||
|
|
||||||
|
- **GitHub 仓库**:<https://github.com/wwiinnddyy/LanMountainDesktop>
|
||||||
|
- **问题反馈**:<https://github.com/wwiinnddyy/LanMountainDesktop/issues>
|
||||||
|
|
||||||
|
我们将在收到您的请求后 30 日内予以答复。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 12. 条款可分割性
|
||||||
|
|
||||||
|
如果本隐私政策的任何条款被有管辖权的法院或监管机构认定为无效或不可执行,该条款应在最小必要范围内进行修改以使其有效和可执行,或如果无法修改,则予以删除。本政策的其余条款将继续有效。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
**本隐私政策最终解释权归灵方软件Lincube所有。**
|
||||||
|
|||||||
1
LanMountainDesktop/Assets/bilibili.svg
Normal file
1
LanMountainDesktop/Assets/bilibili.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
LanMountainDesktop/Assets/juya_avatar.jpg
Normal file
BIN
LanMountainDesktop/Assets/juya_avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
1
LanMountainDesktop/Assets/wechat.svg
Normal file
1
LanMountainDesktop/Assets/wechat.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
|
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
|
||||||
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
||||||
public const string DesktopIfengNews = "DesktopIfengNews";
|
public const string DesktopIfengNews = "DesktopIfengNews";
|
||||||
|
public const string DesktopJuyaNews = "DesktopJuyaNews";
|
||||||
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
|
||||||
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
|
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
|
||||||
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
|
||||||
|
|||||||
@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 4,
|
MinHeightCells: 4,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopJuyaNews,
|
||||||
|
"橘鸦早报",
|
||||||
|
"News",
|
||||||
|
"Info",
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 4,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||||
"Bilibili Hot Search",
|
"Bilibili Hot Search",
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Animation;
|
||||||
|
using Avalonia.Animation.Easings;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopEditing;
|
||||||
|
|
||||||
|
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;
|
||||||
|
private readonly TextBlock _collapsedChipTextBlock;
|
||||||
|
private readonly Control? _collapsedChipIcon;
|
||||||
|
private readonly TranslateTransform _windowTranslate = new();
|
||||||
|
private readonly TranslateTransform _chipTranslate = new();
|
||||||
|
private readonly ScaleTransform _chipScale = new(1, 1);
|
||||||
|
|
||||||
|
private ComponentLibraryCollapseState _state;
|
||||||
|
private int _transitionVersion;
|
||||||
|
|
||||||
|
public ComponentLibraryCollapsePresenter(
|
||||||
|
Border componentLibraryWindow,
|
||||||
|
Border collapsedChipHost,
|
||||||
|
TextBlock collapsedChipTextBlock,
|
||||||
|
Control? collapsedChipIcon = null)
|
||||||
|
{
|
||||||
|
_componentLibraryWindow = componentLibraryWindow ?? throw new ArgumentNullException(nameof(componentLibraryWindow));
|
||||||
|
_collapsedChipHost = collapsedChipHost ?? throw new ArgumentNullException(nameof(collapsedChipHost));
|
||||||
|
_collapsedChipTextBlock = collapsedChipTextBlock ?? throw new ArgumentNullException(nameof(collapsedChipTextBlock));
|
||||||
|
_collapsedChipIcon = collapsedChipIcon;
|
||||||
|
|
||||||
|
EnsureTransforms();
|
||||||
|
_state = ComponentLibraryCollapseState.CreateExpanded(
|
||||||
|
_componentLibraryWindow.Margin,
|
||||||
|
_componentLibraryWindow.Opacity <= 0 ? 1 : _componentLibraryWindow.Opacity);
|
||||||
|
ApplyExpandedSnapshot();
|
||||||
|
_collapsedChipHost.IsVisible = false;
|
||||||
|
_collapsedChipHost.IsHitTestVisible = false;
|
||||||
|
_collapsedChipHost.Opacity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsCollapsed => _state.VisualState is ComponentLibraryCollapseVisualState.Collapsing or ComponentLibraryCollapseVisualState.Collapsed;
|
||||||
|
|
||||||
|
public ComponentLibraryCollapseVisualState VisualState => _state.VisualState;
|
||||||
|
|
||||||
|
public void SyncExpandedState(Thickness margin, double opacity)
|
||||||
|
{
|
||||||
|
var hasStableOpacity = IsStableExpandedOpacity(opacity);
|
||||||
|
var nextExpandedOpacity = hasStableOpacity ? Math.Clamp(opacity, 0, 1) : _state.ExpandedOpacity;
|
||||||
|
_state = _state with
|
||||||
|
{
|
||||||
|
ExpandedMargin = margin,
|
||||||
|
ExpandedOpacity = nextExpandedOpacity
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded or ComponentLibraryCollapseVisualState.Restoring)
|
||||||
|
{
|
||||||
|
ApplyExpandedSnapshot(applyOpacity: hasStableOpacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Collapse(string title)
|
||||||
|
{
|
||||||
|
_collapsedChipTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Widgets" : title;
|
||||||
|
|
||||||
|
if (_state.VisualState is ComponentLibraryCollapseVisualState.Collapsing or ComponentLibraryCollapseVisualState.Collapsed)
|
||||||
|
{
|
||||||
|
ShowCollapsedChip(_transitionVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = ++_transitionVersion;
|
||||||
|
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||||
|
|
||||||
|
ApplyExpandedSnapshot();
|
||||||
|
ShowCollapsedChip(version);
|
||||||
|
SetCollapsedWindowTargets();
|
||||||
|
|
||||||
|
DispatcherTimer.RunOnce(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (version != _transitionVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||||
|
_componentLibraryWindow.IsVisible = false;
|
||||||
|
_componentLibraryWindow.IsHitTestVisible = false;
|
||||||
|
},
|
||||||
|
TransitionDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Restore()
|
||||||
|
{
|
||||||
|
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded)
|
||||||
|
{
|
||||||
|
ApplyExpandedSnapshot();
|
||||||
|
_collapsedChipHost.IsVisible = false;
|
||||||
|
_collapsedChipHost.IsHitTestVisible = false;
|
||||||
|
_collapsedChipHost.Opacity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = ++_transitionVersion;
|
||||||
|
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
|
||||||
|
|
||||||
|
PrepareRestoringWindow();
|
||||||
|
HideCollapsedChip(version);
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (version != _transitionVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||||
|
_windowTranslate.Y = 0;
|
||||||
|
},
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
|
||||||
|
DispatcherTimer.RunOnce(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (version != _transitionVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||||
|
_componentLibraryWindow.IsVisible = true;
|
||||||
|
_componentLibraryWindow.IsHitTestVisible = true;
|
||||||
|
},
|
||||||
|
TransitionDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureTransforms()
|
||||||
|
{
|
||||||
|
_componentLibraryWindow.RenderTransform = _windowTranslate;
|
||||||
|
_windowTranslate.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
new DoubleTransition
|
||||||
|
{
|
||||||
|
Property = TranslateTransform.YProperty,
|
||||||
|
Duration = TransitionDuration,
|
||||||
|
Easing = TransitionEasing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_collapsedChipHost.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
|
||||||
|
_collapsedChipHost.RenderTransform = new TransformGroup
|
||||||
|
{
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_chipTranslate,
|
||||||
|
_chipScale
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_chipTranslate.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
new DoubleTransition
|
||||||
|
{
|
||||||
|
Property = TranslateTransform.YProperty,
|
||||||
|
Duration = TransitionDuration,
|
||||||
|
Easing = TransitionEasing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_chipScale.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
new DoubleTransition
|
||||||
|
{
|
||||||
|
Property = ScaleTransform.ScaleXProperty,
|
||||||
|
Duration = TransitionDuration,
|
||||||
|
Easing = TransitionEasing
|
||||||
|
},
|
||||||
|
new DoubleTransition
|
||||||
|
{
|
||||||
|
Property = ScaleTransform.ScaleYProperty,
|
||||||
|
Duration = TransitionDuration,
|
||||||
|
Easing = TransitionEasing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyExpandedSnapshot(bool applyOpacity = true)
|
||||||
|
{
|
||||||
|
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||||
|
if (applyOpacity)
|
||||||
|
{
|
||||||
|
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentLibraryWindow.IsVisible = true;
|
||||||
|
_componentLibraryWindow.IsHitTestVisible = true;
|
||||||
|
_windowTranslate.Y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetCollapsedWindowTargets()
|
||||||
|
{
|
||||||
|
_componentLibraryWindow.Opacity = 0;
|
||||||
|
_windowTranslate.Y = 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowCollapsedChip(int version)
|
||||||
|
{
|
||||||
|
_collapsedChipHost.IsVisible = true;
|
||||||
|
_collapsedChipHost.IsHitTestVisible = false;
|
||||||
|
_collapsedChipTextBlock.IsVisible = true;
|
||||||
|
if (_collapsedChipIcon is not null)
|
||||||
|
{
|
||||||
|
_collapsedChipIcon.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_collapsedChipHost.Opacity = 0;
|
||||||
|
_chipTranslate.Y = 8;
|
||||||
|
_chipScale.ScaleX = 0.96;
|
||||||
|
_chipScale.ScaleY = 0.96;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (version != _transitionVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_collapsedChipHost.Opacity = 1;
|
||||||
|
_chipTranslate.Y = 0;
|
||||||
|
_chipScale.ScaleX = 1;
|
||||||
|
_chipScale.ScaleY = 1;
|
||||||
|
},
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HideCollapsedChip(int version)
|
||||||
|
{
|
||||||
|
_collapsedChipHost.IsVisible = true;
|
||||||
|
_collapsedChipHost.IsHitTestVisible = false;
|
||||||
|
_collapsedChipHost.Opacity = 0;
|
||||||
|
_chipTranslate.Y = 8;
|
||||||
|
_chipScale.ScaleX = 0.96;
|
||||||
|
_chipScale.ScaleY = 0.96;
|
||||||
|
|
||||||
|
DispatcherTimer.RunOnce(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (version != _transitionVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_collapsedChipHost.IsVisible = false;
|
||||||
|
},
|
||||||
|
TransitionDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrepareRestoringWindow()
|
||||||
|
{
|
||||||
|
_componentLibraryWindow.IsVisible = true;
|
||||||
|
_componentLibraryWindow.IsHitTestVisible = true;
|
||||||
|
_componentLibraryWindow.Margin = _state.ExpandedMargin;
|
||||||
|
_componentLibraryWindow.Opacity = 0;
|
||||||
|
_windowTranslate.Y = 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsStableExpandedOpacity(double opacity)
|
||||||
|
{
|
||||||
|
return !double.IsNaN(opacity) &&
|
||||||
|
!double.IsInfinity(opacity) &&
|
||||||
|
opacity > StableOpacityThreshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopEditing;
|
||||||
|
|
||||||
|
internal enum ComponentLibraryCollapseVisualState
|
||||||
|
{
|
||||||
|
Expanded,
|
||||||
|
Collapsing,
|
||||||
|
Collapsed,
|
||||||
|
Restoring
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct ComponentLibraryCollapseState(
|
||||||
|
ComponentLibraryCollapseVisualState VisualState,
|
||||||
|
Thickness ExpandedMargin,
|
||||||
|
double ExpandedOpacity,
|
||||||
|
bool IsChipVisible)
|
||||||
|
{
|
||||||
|
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin, double expandedOpacity)
|
||||||
|
{
|
||||||
|
return new(
|
||||||
|
ComponentLibraryCollapseVisualState.Expanded,
|
||||||
|
expandedMargin,
|
||||||
|
expandedOpacity,
|
||||||
|
IsChipVisible: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentLibraryCollapseState WithVisualState(ComponentLibraryCollapseVisualState visualState, bool isChipVisible)
|
||||||
|
{
|
||||||
|
return this with
|
||||||
|
{
|
||||||
|
VisualState = visualState,
|
||||||
|
IsChipVisible = isChipVisible
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LanMountainDesktop.DesktopEditing;
|
||||||
|
|
||||||
|
internal static class DesktopEditCommitMath
|
||||||
|
{
|
||||||
|
public static bool IsPendingCommitValid(bool isPending, int scheduledVersion, int currentVersion)
|
||||||
|
{
|
||||||
|
return isPending && scheduledVersion == currentVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
358
LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs
Normal file
358
LanMountainDesktop/DesktopEditing/DesktopEditGhostView.cs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Animation;
|
||||||
|
using Avalonia.Animation.Easings;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopEditing;
|
||||||
|
|
||||||
|
internal sealed class DesktopEditGhostView : Border
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120);
|
||||||
|
private static readonly Easing StandardEasing = new CubicEaseOut();
|
||||||
|
|
||||||
|
private readonly Image _previewImage;
|
||||||
|
private readonly Border _previewOverlay;
|
||||||
|
private readonly Border _fallbackCard;
|
||||||
|
private readonly Border _accentDot;
|
||||||
|
private readonly TextBlock _titleTextBlock;
|
||||||
|
private readonly TextBlock _detailTextBlock;
|
||||||
|
private readonly Border _badgeBorder;
|
||||||
|
private readonly TextBlock _badgeTextBlock;
|
||||||
|
private readonly ScaleTransform _scaleTransform = new(1, 1);
|
||||||
|
|
||||||
|
private readonly SolidColorBrush _normalBackgroundBrush = new(Color.Parse("#F11B2430"));
|
||||||
|
private readonly SolidColorBrush _normalBorderBrush = new(Color.Parse("#4D8AA3C1"));
|
||||||
|
private readonly SolidColorBrush _normalAccentBrush = new(Color.Parse("#FF4F8EF7"));
|
||||||
|
private readonly SolidColorBrush _normalTextBrush = new(Color.Parse("#FFF5F7FA"));
|
||||||
|
private readonly SolidColorBrush _normalMutedTextBrush = new(Color.Parse("#BDE2E8F0"));
|
||||||
|
private readonly SolidColorBrush _normalBadgeBackgroundBrush = new(Color.Parse("#245E86D6"));
|
||||||
|
private readonly SolidColorBrush _normalBadgeBorderBrush = new(Color.Parse("#557EA7E6"));
|
||||||
|
private readonly SolidColorBrush _invalidBackgroundBrush = new(Color.Parse("#F01B1022"));
|
||||||
|
private readonly SolidColorBrush _invalidBorderBrush = new(Color.Parse("#FFE25555"));
|
||||||
|
private readonly SolidColorBrush _invalidAccentBrush = new(Color.Parse("#FFFF6B6B"));
|
||||||
|
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
|
||||||
|
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
|
||||||
|
|
||||||
|
private bool _hasPreviewImage;
|
||||||
|
private bool _isInvalid;
|
||||||
|
|
||||||
|
public DesktopEditGhostView()
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||||
|
VerticalAlignment = VerticalAlignment.Stretch;
|
||||||
|
Padding = new Thickness(14);
|
||||||
|
Background = _normalBackgroundBrush;
|
||||||
|
BorderBrush = _normalBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
CornerRadius = new CornerRadius(22);
|
||||||
|
ClipToBounds = true;
|
||||||
|
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
|
||||||
|
RenderTransform = _scaleTransform;
|
||||||
|
Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(FastDuration)
|
||||||
|
};
|
||||||
|
_scaleTransform.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||||
|
};
|
||||||
|
|
||||||
|
_accentDot = new Border
|
||||||
|
{
|
||||||
|
Width = 10,
|
||||||
|
Height = 10,
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Background = _normalAccentBrush,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
_titleTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Foreground = _normalTextBrush,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
TextWrapping = TextWrapping.NoWrap,
|
||||||
|
MaxLines = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
_detailTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Foreground = _normalMutedTextBrush,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
TextWrapping = TextWrapping.NoWrap,
|
||||||
|
MaxLines = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
_badgeTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Foreground = _normalTextBrush,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
TextWrapping = TextWrapping.NoWrap,
|
||||||
|
MaxLines = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
_badgeBorder = new Border
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Padding = new Thickness(9, 4),
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Background = _normalBadgeBackgroundBrush,
|
||||||
|
BorderBrush = _normalBadgeBorderBrush,
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
Child = _badgeTextBlock
|
||||||
|
};
|
||||||
|
|
||||||
|
_previewImage = new Image
|
||||||
|
{
|
||||||
|
Stretch = Stretch.UniformToFill,
|
||||||
|
IsVisible = false
|
||||||
|
};
|
||||||
|
|
||||||
|
_previewOverlay = new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#1A000000")),
|
||||||
|
IsVisible = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var headerPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Orientation.Horizontal,
|
||||||
|
Spacing = 8,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_accentDot,
|
||||||
|
_titleTextBlock
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var contentPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 6,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
headerPanel,
|
||||||
|
_detailTextBlock
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var fallbackGrid = new Grid
|
||||||
|
{
|
||||||
|
RowDefinitions = new RowDefinitions
|
||||||
|
{
|
||||||
|
new RowDefinition(GridLength.Auto),
|
||||||
|
new RowDefinition(GridLength.Auto)
|
||||||
|
},
|
||||||
|
RowSpacing = 8
|
||||||
|
};
|
||||||
|
fallbackGrid.Children.Add(contentPanel);
|
||||||
|
fallbackGrid.Children.Add(_badgeBorder);
|
||||||
|
Grid.SetRow(contentPanel, 0);
|
||||||
|
Grid.SetRow(_badgeBorder, 1);
|
||||||
|
_badgeBorder.Margin = new Thickness(0, 2, 0, 0);
|
||||||
|
|
||||||
|
_fallbackCard = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
Child = fallbackGrid
|
||||||
|
};
|
||||||
|
|
||||||
|
Child = new Grid
|
||||||
|
{
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_previewImage,
|
||||||
|
_previewOverlay,
|
||||||
|
_fallbackCard
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdatePreviewMetrics(180, 120);
|
||||||
|
UpdateContent(null, null, null);
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateContent(string? title, string? detail, string? badgeText)
|
||||||
|
{
|
||||||
|
_titleTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Component" : title;
|
||||||
|
_detailTextBlock.Text = string.IsNullOrWhiteSpace(detail) ? string.Empty : detail;
|
||||||
|
_detailTextBlock.IsVisible = !string.IsNullOrWhiteSpace(detail);
|
||||||
|
_badgeTextBlock.Text = string.IsNullOrWhiteSpace(badgeText) ? string.Empty : badgeText;
|
||||||
|
_badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPreviewImage(IImage? image)
|
||||||
|
{
|
||||||
|
_previewImage.Source = image;
|
||||||
|
_hasPreviewImage = image is not null;
|
||||||
|
_previewImage.IsVisible = _hasPreviewImage;
|
||||||
|
_previewOverlay.IsVisible = false;
|
||||||
|
_fallbackCard.IsVisible = !_hasPreviewImage;
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdatePreviewMetrics(double width, double height)
|
||||||
|
{
|
||||||
|
var normalizedWidth = Math.Max(1, width);
|
||||||
|
var normalizedHeight = Math.Max(1, height);
|
||||||
|
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
|
||||||
|
|
||||||
|
CornerRadius = _hasPreviewImage
|
||||||
|
? new CornerRadius(Math.Clamp(minSide * 0.14, 14, 24))
|
||||||
|
: new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
|
||||||
|
Padding = _hasPreviewImage
|
||||||
|
? new Thickness(
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4),
|
||||||
|
Math.Clamp(minSide * 0.02, 1, 4))
|
||||||
|
: new Thickness(
|
||||||
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
|
Math.Clamp(minSide * 0.10, 10, 18),
|
||||||
|
Math.Clamp(minSide * 0.09, 10, 16));
|
||||||
|
|
||||||
|
var titleFontSize = Math.Clamp(minSide * 0.12, 12, 18);
|
||||||
|
var detailFontSize = Math.Clamp(minSide * 0.085, 10, 13);
|
||||||
|
var badgeFontSize = Math.Clamp(minSide * 0.08, 9, 12);
|
||||||
|
var dotSize = Math.Clamp(minSide * 0.07, 8, 12);
|
||||||
|
var badgeHorizontalPadding = Math.Clamp(minSide * 0.07, 8, 14);
|
||||||
|
var badgeVerticalPadding = Math.Clamp(minSide * 0.035, 3, 6);
|
||||||
|
|
||||||
|
_accentDot.Width = dotSize;
|
||||||
|
_accentDot.Height = dotSize;
|
||||||
|
_titleTextBlock.FontSize = titleFontSize;
|
||||||
|
_detailTextBlock.FontSize = detailFontSize;
|
||||||
|
_badgeTextBlock.FontSize = badgeFontSize;
|
||||||
|
_badgeBorder.Padding = new Thickness(badgeHorizontalPadding, badgeVerticalPadding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInvalid(bool isInvalid)
|
||||||
|
{
|
||||||
|
_isInvalid = isInvalid;
|
||||||
|
|
||||||
|
if (isInvalid)
|
||||||
|
{
|
||||||
|
_accentDot.Background = _invalidAccentBrush;
|
||||||
|
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
|
||||||
|
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
|
||||||
|
_titleTextBlock.Foreground = _invalidBorderBrush;
|
||||||
|
_detailTextBlock.Foreground = _invalidBorderBrush;
|
||||||
|
_badgeTextBlock.Foreground = _invalidBorderBrush;
|
||||||
|
if (!_hasPreviewImage)
|
||||||
|
{
|
||||||
|
Background = _invalidBackgroundBrush;
|
||||||
|
BorderBrush = _invalidBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
Opacity = 0.9;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_accentDot.Background = _normalAccentBrush;
|
||||||
|
_badgeBorder.Background = _normalBadgeBackgroundBrush;
|
||||||
|
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
|
||||||
|
_titleTextBlock.Foreground = _normalTextBrush;
|
||||||
|
_detailTextBlock.Foreground = _normalMutedTextBrush;
|
||||||
|
_badgeTextBlock.Foreground = _normalTextBrush;
|
||||||
|
if (!_hasPreviewImage)
|
||||||
|
{
|
||||||
|
Background = _normalBackgroundBrush;
|
||||||
|
BorderBrush = _normalBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
Opacity = 1.0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ApplyShellChrome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetRestingScale(double scale)
|
||||||
|
{
|
||||||
|
var clampedScale = Math.Clamp(scale, 0.85, 1.12);
|
||||||
|
_scaleTransform.ScaleX = clampedScale;
|
||||||
|
_scaleTransform.ScaleY = clampedScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AnimateToScale(double scale)
|
||||||
|
{
|
||||||
|
var clampedScale = Math.Clamp(scale, 0.85, 1.12);
|
||||||
|
_scaleTransform.ScaleX = clampedScale;
|
||||||
|
_scaleTransform.ScaleY = clampedScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool HasPreviewImage => _hasPreviewImage;
|
||||||
|
|
||||||
|
internal void SetScaleTransitionDuration(TimeSpan duration)
|
||||||
|
{
|
||||||
|
_scaleTransform.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, duration),
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, duration)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void SetOpacityTransitionDuration(TimeSpan duration)
|
||||||
|
{
|
||||||
|
Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(duration)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyShellChrome()
|
||||||
|
{
|
||||||
|
if (_hasPreviewImage)
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent;
|
||||||
|
BorderBrush = Brushes.Transparent;
|
||||||
|
BorderThickness = new Thickness(0);
|
||||||
|
BoxShadow = BoxShadows.Parse("0 14 32 #1A000000");
|
||||||
|
Opacity = 1.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxShadow = default;
|
||||||
|
if (_isInvalid)
|
||||||
|
{
|
||||||
|
Background = _invalidBackgroundBrush;
|
||||||
|
BorderBrush = _invalidBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
Opacity = 0.9;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Background = _normalBackgroundBrush;
|
||||||
|
BorderBrush = _normalBorderBrush;
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
Opacity = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Property = property,
|
||||||
|
Duration = duration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DoubleTransition CreateOpacityTransition(TimeSpan duration) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Property = Visual.OpacityProperty,
|
||||||
|
Duration = duration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
};
|
||||||
|
}
|
||||||
343
LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs
Normal file
343
LanMountainDesktop/DesktopEditing/DesktopEditOverlayPresenter.cs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Animation;
|
||||||
|
using Avalonia.Animation.Easings;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Theme;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopEditing;
|
||||||
|
|
||||||
|
internal enum DesktopEditGhostVisualStyle
|
||||||
|
{
|
||||||
|
StandardLift = 0,
|
||||||
|
ElevatedFromLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DesktopEditOverlayPresenter
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan FastDuration = FluttermotionToken.Fast;
|
||||||
|
private static readonly TimeSpan PickupDuration = TimeSpan.FromMilliseconds(160);
|
||||||
|
private static readonly TimeSpan CommitSettleDuration = TimeSpan.FromMilliseconds(160);
|
||||||
|
private static readonly TimeSpan CancelSettleDuration = TimeSpan.FromMilliseconds(120);
|
||||||
|
private static readonly Easing StandardEasing = new CubicEaseOut();
|
||||||
|
|
||||||
|
private readonly Canvas _root;
|
||||||
|
private readonly DesktopEditGhostView _ghostView;
|
||||||
|
private readonly Border _candidateOutline;
|
||||||
|
private readonly ScaleTransform _candidateScale = new(1, 1);
|
||||||
|
|
||||||
|
private Rect? _previewRect;
|
||||||
|
private Rect? _candidateRect;
|
||||||
|
private bool _isInvalid;
|
||||||
|
private bool _isVisible;
|
||||||
|
private int _dismissVersion;
|
||||||
|
|
||||||
|
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
|
||||||
|
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF3B30"));
|
||||||
|
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#140A84FF"));
|
||||||
|
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30"));
|
||||||
|
|
||||||
|
public DesktopEditOverlayPresenter()
|
||||||
|
{
|
||||||
|
_ghostView = new DesktopEditGhostView
|
||||||
|
{
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Opacity = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
_candidateOutline = new Border
|
||||||
|
{
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Background = _candidateFillBrush,
|
||||||
|
BorderBrush = _candidateBrush,
|
||||||
|
BorderThickness = new Thickness(2),
|
||||||
|
CornerRadius = new CornerRadius(22),
|
||||||
|
Opacity = 0,
|
||||||
|
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative),
|
||||||
|
RenderTransform = _candidateScale,
|
||||||
|
Transitions = new Transitions
|
||||||
|
{
|
||||||
|
new DoubleTransition
|
||||||
|
{
|
||||||
|
Property = Visual.OpacityProperty,
|
||||||
|
Duration = FastDuration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_candidateScale.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||||
|
};
|
||||||
|
|
||||||
|
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
||||||
|
_ghostView.SetValue(Panel.ZIndexProperty, 1);
|
||||||
|
|
||||||
|
_root = new Canvas
|
||||||
|
{
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
ClipToBounds = false,
|
||||||
|
Opacity = 0,
|
||||||
|
IsVisible = false,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_candidateOutline,
|
||||||
|
_ghostView
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_root.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(FastDuration)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public Control Root => _root;
|
||||||
|
|
||||||
|
public void SetViewportSize(Size size)
|
||||||
|
{
|
||||||
|
_root.Width = Math.Max(1, size.Width);
|
||||||
|
_root.Height = Math.Max(1, size.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPreviewRect(Rect rect)
|
||||||
|
{
|
||||||
|
_previewRect = Normalize(rect);
|
||||||
|
ApplyPreviewRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetCandidateRect(Rect? rect)
|
||||||
|
{
|
||||||
|
_candidateRect = rect is null ? null : Normalize(rect.Value);
|
||||||
|
ApplyCandidateRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateGhostContent(string? title, string? detail = null, string? badge = null)
|
||||||
|
{
|
||||||
|
_ghostView.UpdateContent(title, detail, badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPreviewImage(IImage? image)
|
||||||
|
{
|
||||||
|
_ghostView.SetPreviewImage(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInvalid(bool isInvalid)
|
||||||
|
{
|
||||||
|
_isInvalid = isInvalid;
|
||||||
|
_ghostView.SetInvalid(isInvalid);
|
||||||
|
UpdateCandidateAppearance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Show(DesktopEditGhostVisualStyle visualStyle = DesktopEditGhostVisualStyle.StandardLift)
|
||||||
|
{
|
||||||
|
_dismissVersion++;
|
||||||
|
_isVisible = true;
|
||||||
|
_root.IsVisible = true;
|
||||||
|
_root.Opacity = 0;
|
||||||
|
_ghostView.Opacity = 0;
|
||||||
|
var imageMode = _ghostView.HasPreviewImage;
|
||||||
|
var initialGhostScale = 0.985;
|
||||||
|
var targetGhostScale = 1.0;
|
||||||
|
|
||||||
|
if (visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary)
|
||||||
|
{
|
||||||
|
initialGhostScale = 1.02;
|
||||||
|
targetGhostScale = 1.06;
|
||||||
|
}
|
||||||
|
else if (imageMode)
|
||||||
|
{
|
||||||
|
initialGhostScale = 0.992;
|
||||||
|
targetGhostScale = 1.03;
|
||||||
|
}
|
||||||
|
|
||||||
|
_root.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(PickupDuration)
|
||||||
|
};
|
||||||
|
_ghostView.SetOpacityTransitionDuration(PickupDuration);
|
||||||
|
_ghostView.SetScaleTransitionDuration(PickupDuration);
|
||||||
|
_candidateScale.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration),
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration)
|
||||||
|
};
|
||||||
|
_candidateOutline.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(PickupDuration)
|
||||||
|
};
|
||||||
|
_ghostView.SetRestingScale(initialGhostScale);
|
||||||
|
_candidateOutline.Opacity = 0;
|
||||||
|
_candidateScale.ScaleX = 0.97;
|
||||||
|
_candidateScale.ScaleY = 0.97;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (!_isVisible)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_root.Opacity = 1;
|
||||||
|
_ghostView.Opacity = 1;
|
||||||
|
_ghostView.SetRestingScale(targetGhostScale);
|
||||||
|
if (_candidateRect.HasValue)
|
||||||
|
{
|
||||||
|
_candidateOutline.Opacity = 1;
|
||||||
|
_candidateScale.ScaleX = 1;
|
||||||
|
_candidateScale.ScaleY = 1;
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Hide()
|
||||||
|
{
|
||||||
|
_dismissVersion++;
|
||||||
|
_isVisible = false;
|
||||||
|
_root.Opacity = 0;
|
||||||
|
_ghostView.Opacity = 0;
|
||||||
|
_candidateOutline.Opacity = 0;
|
||||||
|
_candidateScale.ScaleX = 0.96;
|
||||||
|
_candidateScale.ScaleY = 0.96;
|
||||||
|
_ghostView.SetRestingScale(0.96);
|
||||||
|
_ghostView.SetPreviewImage(null);
|
||||||
|
_root.IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Commit()
|
||||||
|
{
|
||||||
|
BeginDismiss(isCancel: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
BeginDismiss(isCancel: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BeginDismiss(bool isCancel)
|
||||||
|
{
|
||||||
|
if (!_isVisible)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = ++_dismissVersion;
|
||||||
|
_isVisible = false;
|
||||||
|
var settleDuration = isCancel ? CancelSettleDuration : CommitSettleDuration;
|
||||||
|
_root.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(settleDuration)
|
||||||
|
};
|
||||||
|
_ghostView.SetOpacityTransitionDuration(settleDuration);
|
||||||
|
_ghostView.SetScaleTransitionDuration(settleDuration);
|
||||||
|
_candidateScale.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration),
|
||||||
|
CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration)
|
||||||
|
};
|
||||||
|
_candidateOutline.Transitions = new Transitions
|
||||||
|
{
|
||||||
|
CreateOpacityTransition(settleDuration)
|
||||||
|
};
|
||||||
|
var targetScale = _ghostView.HasPreviewImage
|
||||||
|
? 1.00
|
||||||
|
: isCancel ? 0.96 : 1.04;
|
||||||
|
|
||||||
|
_candidateOutline.Opacity = 0;
|
||||||
|
_ghostView.Opacity = 0;
|
||||||
|
_root.Opacity = 0;
|
||||||
|
_ghostView.AnimateToScale(targetScale);
|
||||||
|
_candidateScale.ScaleX = targetScale;
|
||||||
|
_candidateScale.ScaleY = targetScale;
|
||||||
|
|
||||||
|
DispatcherTimer.RunOnce(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (version != _dismissVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_root.IsVisible = false;
|
||||||
|
},
|
||||||
|
FastDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPreviewRect()
|
||||||
|
{
|
||||||
|
if (!_previewRect.HasValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rect = _previewRect.Value;
|
||||||
|
_ghostView.Width = Math.Max(1, rect.Width);
|
||||||
|
_ghostView.Height = Math.Max(1, rect.Height);
|
||||||
|
Canvas.SetLeft(_ghostView, rect.X);
|
||||||
|
Canvas.SetTop(_ghostView, rect.Y);
|
||||||
|
_ghostView.UpdatePreviewMetrics(rect.Width, rect.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCandidateRect()
|
||||||
|
{
|
||||||
|
if (!_candidateRect.HasValue)
|
||||||
|
{
|
||||||
|
_candidateOutline.IsVisible = false;
|
||||||
|
_candidateOutline.Opacity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rect = _candidateRect.Value;
|
||||||
|
_candidateOutline.IsVisible = true;
|
||||||
|
_candidateOutline.Width = Math.Max(1, rect.Width);
|
||||||
|
_candidateOutline.Height = Math.Max(1, rect.Height);
|
||||||
|
Canvas.SetLeft(_candidateOutline, rect.X);
|
||||||
|
Canvas.SetTop(_candidateOutline, rect.Y);
|
||||||
|
|
||||||
|
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26);
|
||||||
|
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
|
||||||
|
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||||
|
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||||
|
_candidateOutline.Opacity = _isVisible ? 1 : 0;
|
||||||
|
_candidateScale.ScaleX = _isVisible ? 1 : 0.97;
|
||||||
|
_candidateScale.ScaleY = _isVisible ? 1 : 0.97;
|
||||||
|
UpdateCandidateAppearance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCandidateAppearance()
|
||||||
|
{
|
||||||
|
if (!_candidateRect.HasValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
|
||||||
|
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rect Normalize(Rect rect)
|
||||||
|
{
|
||||||
|
var width = Math.Max(1, rect.Width);
|
||||||
|
var height = Math.Max(1, rect.Height);
|
||||||
|
return new Rect(rect.X, rect.Y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Property = property,
|
||||||
|
Duration = duration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DoubleTransition CreateOpacityTransition(TimeSpan duration) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Property = Visual.OpacityProperty,
|
||||||
|
Duration = duration,
|
||||||
|
Easing = StandardEasing
|
||||||
|
};
|
||||||
|
}
|
||||||
205
LanMountainDesktop/DesktopEditing/DesktopEditSession.cs
Normal file
205
LanMountainDesktop/DesktopEditing/DesktopEditSession.cs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopEditing;
|
||||||
|
|
||||||
|
internal enum DesktopEditSessionMode
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
PendingNew,
|
||||||
|
DraggingNew,
|
||||||
|
DraggingExisting,
|
||||||
|
ResizingExisting
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct DesktopEditSession
|
||||||
|
{
|
||||||
|
public DesktopEditSessionMode Mode { get; init; }
|
||||||
|
public string? ComponentId { get; init; }
|
||||||
|
public string? PlacementId { get; init; }
|
||||||
|
public int PageIndex { get; init; }
|
||||||
|
public int WidthCells { get; init; }
|
||||||
|
public int HeightCells { get; init; }
|
||||||
|
public Point StartPointerInViewport { get; init; }
|
||||||
|
public Point CurrentPointerInViewport { get; init; }
|
||||||
|
public Point PointerOffsetInViewport { get; init; }
|
||||||
|
public Rect? ComponentLibraryBounds { get; init; }
|
||||||
|
public int TargetRow { get; init; }
|
||||||
|
public int TargetColumn { get; init; }
|
||||||
|
|
||||||
|
public bool IsActive => Mode != DesktopEditSessionMode.None;
|
||||||
|
public bool IsPendingNew => Mode == DesktopEditSessionMode.PendingNew;
|
||||||
|
public bool IsDraggingNew => Mode == DesktopEditSessionMode.DraggingNew;
|
||||||
|
public bool IsDraggingExisting => Mode == DesktopEditSessionMode.DraggingExisting;
|
||||||
|
public bool IsResizingExisting => Mode == DesktopEditSessionMode.ResizingExisting;
|
||||||
|
public bool HasTargetCell => TargetRow >= 0 && TargetColumn >= 0;
|
||||||
|
|
||||||
|
public double PointerTravelDistance => DesktopPlacementMath.Distance(StartPointerInViewport, CurrentPointerInViewport);
|
||||||
|
|
||||||
|
public bool HasExceededThreshold(double threshold)
|
||||||
|
{
|
||||||
|
return DesktopPlacementMath.HasExceededThreshold(StartPointerInViewport, CurrentPointerInViewport, threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPointerInsideComponentLibrary()
|
||||||
|
{
|
||||||
|
return DesktopPlacementMath.IsOccludedByComponentLibrary(CurrentPointerInViewport, ComponentLibraryBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPreviewOccludedByComponentLibrary(Rect previewRect)
|
||||||
|
{
|
||||||
|
return DesktopPlacementMath.IsOccludedByComponentLibrary(previewRect, ComponentLibraryBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanCommit => IsActive && HasTargetCell;
|
||||||
|
|
||||||
|
public Rect GetPreviewRect(DesktopGridGeometry grid)
|
||||||
|
{
|
||||||
|
if (HasTargetCell)
|
||||||
|
{
|
||||||
|
return DesktopPlacementMath.GetCellRect(
|
||||||
|
grid,
|
||||||
|
TargetColumn,
|
||||||
|
TargetRow,
|
||||||
|
Math.Max(1, WidthCells),
|
||||||
|
Math.Max(1, HeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
var freePreviewOrigin = DesktopPlacementMath.Subtract(CurrentPointerInViewport, PointerOffsetInViewport);
|
||||||
|
return new Rect(
|
||||||
|
freePreviewOrigin,
|
||||||
|
new Size(
|
||||||
|
Math.Max(1, WidthCells) * grid.CellSize + Math.Max(0, Math.Max(1, WidthCells) - 1) * grid.CellGap,
|
||||||
|
Math.Max(1, HeightCells) * grid.CellSize + Math.Max(0, Math.Max(1, HeightCells) - 1) * grid.CellGap));
|
||||||
|
}
|
||||||
|
|
||||||
|
public DesktopEditSession WithCurrentPointer(Point pointerInViewport)
|
||||||
|
{
|
||||||
|
return this with { CurrentPointerInViewport = pointerInViewport };
|
||||||
|
}
|
||||||
|
|
||||||
|
public DesktopEditSession WithComponentLibraryBounds(Rect? componentLibraryBounds)
|
||||||
|
{
|
||||||
|
return this with { ComponentLibraryBounds = componentLibraryBounds };
|
||||||
|
}
|
||||||
|
|
||||||
|
public DesktopEditSession WithTargetCell(int row, int column)
|
||||||
|
{
|
||||||
|
return this with { TargetRow = row, TargetColumn = column };
|
||||||
|
}
|
||||||
|
|
||||||
|
public DesktopEditSession PromoteToDraggingNew()
|
||||||
|
{
|
||||||
|
return this with { Mode = DesktopEditSessionMode.DraggingNew };
|
||||||
|
}
|
||||||
|
|
||||||
|
public DesktopEditSession PromoteToDraggingExisting()
|
||||||
|
{
|
||||||
|
return this with { Mode = DesktopEditSessionMode.DraggingExisting };
|
||||||
|
}
|
||||||
|
|
||||||
|
public DesktopEditSession PromoteToResizingExisting()
|
||||||
|
{
|
||||||
|
return this with { Mode = DesktopEditSessionMode.ResizingExisting };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DesktopEditSession CreatePendingNew(
|
||||||
|
string componentId,
|
||||||
|
int pageIndex,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells,
|
||||||
|
Point startPointerInViewport,
|
||||||
|
Point pointerOffsetInViewport,
|
||||||
|
Rect? componentLibraryBounds)
|
||||||
|
{
|
||||||
|
return new DesktopEditSession
|
||||||
|
{
|
||||||
|
Mode = DesktopEditSessionMode.PendingNew,
|
||||||
|
ComponentId = componentId,
|
||||||
|
PageIndex = pageIndex,
|
||||||
|
WidthCells = Math.Max(1, widthCells),
|
||||||
|
HeightCells = Math.Max(1, heightCells),
|
||||||
|
StartPointerInViewport = startPointerInViewport,
|
||||||
|
CurrentPointerInViewport = startPointerInViewport,
|
||||||
|
PointerOffsetInViewport = pointerOffsetInViewport,
|
||||||
|
ComponentLibraryBounds = componentLibraryBounds,
|
||||||
|
TargetRow = -1,
|
||||||
|
TargetColumn = -1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DesktopEditSession CreateDraggingNew(
|
||||||
|
string componentId,
|
||||||
|
int pageIndex,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells,
|
||||||
|
Point startPointerInViewport,
|
||||||
|
Point pointerOffsetInViewport,
|
||||||
|
Rect? componentLibraryBounds)
|
||||||
|
{
|
||||||
|
return CreatePendingNew(
|
||||||
|
componentId,
|
||||||
|
pageIndex,
|
||||||
|
widthCells,
|
||||||
|
heightCells,
|
||||||
|
startPointerInViewport,
|
||||||
|
pointerOffsetInViewport,
|
||||||
|
componentLibraryBounds) with
|
||||||
|
{
|
||||||
|
Mode = DesktopEditSessionMode.DraggingNew
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DesktopEditSession CreateDraggingExisting(
|
||||||
|
string componentId,
|
||||||
|
string placementId,
|
||||||
|
int pageIndex,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells,
|
||||||
|
Point startPointerInViewport,
|
||||||
|
Point pointerOffsetInViewport,
|
||||||
|
Rect? componentLibraryBounds)
|
||||||
|
{
|
||||||
|
return new DesktopEditSession
|
||||||
|
{
|
||||||
|
Mode = DesktopEditSessionMode.DraggingExisting,
|
||||||
|
ComponentId = componentId,
|
||||||
|
PlacementId = placementId,
|
||||||
|
PageIndex = pageIndex,
|
||||||
|
WidthCells = Math.Max(1, widthCells),
|
||||||
|
HeightCells = Math.Max(1, heightCells),
|
||||||
|
StartPointerInViewport = startPointerInViewport,
|
||||||
|
CurrentPointerInViewport = startPointerInViewport,
|
||||||
|
PointerOffsetInViewport = pointerOffsetInViewport,
|
||||||
|
ComponentLibraryBounds = componentLibraryBounds,
|
||||||
|
TargetRow = -1,
|
||||||
|
TargetColumn = -1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DesktopEditSession CreateResizingExisting(
|
||||||
|
string componentId,
|
||||||
|
string placementId,
|
||||||
|
int pageIndex,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells,
|
||||||
|
Point startPointerInViewport,
|
||||||
|
Rect? componentLibraryBounds)
|
||||||
|
{
|
||||||
|
return new DesktopEditSession
|
||||||
|
{
|
||||||
|
Mode = DesktopEditSessionMode.ResizingExisting,
|
||||||
|
ComponentId = componentId,
|
||||||
|
PlacementId = placementId,
|
||||||
|
PageIndex = pageIndex,
|
||||||
|
WidthCells = Math.Max(1, widthCells),
|
||||||
|
HeightCells = Math.Max(1, heightCells),
|
||||||
|
StartPointerInViewport = startPointerInViewport,
|
||||||
|
CurrentPointerInViewport = startPointerInViewport,
|
||||||
|
PointerOffsetInViewport = default,
|
||||||
|
ComponentLibraryBounds = componentLibraryBounds,
|
||||||
|
TargetRow = -1,
|
||||||
|
TargetColumn = -1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
176
LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs
Normal file
176
LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.DesktopEditing;
|
||||||
|
|
||||||
|
internal readonly record struct DesktopGridGeometry(
|
||||||
|
Point Origin,
|
||||||
|
double CellSize,
|
||||||
|
double CellGap,
|
||||||
|
int ColumnCount,
|
||||||
|
int RowCount)
|
||||||
|
{
|
||||||
|
public double Pitch => CellSize + CellGap;
|
||||||
|
|
||||||
|
public bool IsValid =>
|
||||||
|
CellSize > 0 &&
|
||||||
|
ColumnCount > 0 &&
|
||||||
|
RowCount > 0 &&
|
||||||
|
Pitch > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class DesktopPlacementMath
|
||||||
|
{
|
||||||
|
public static double ComputeDragStartThreshold(double cellSize)
|
||||||
|
{
|
||||||
|
return Math.Max(10d, Math.Max(0d, cellSize) * 0.18d);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double Distance(Point start, Point end)
|
||||||
|
{
|
||||||
|
return Math.Sqrt(DistanceSquared(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double DistanceSquared(Point start, Point end)
|
||||||
|
{
|
||||||
|
var deltaX = end.X - start.X;
|
||||||
|
var deltaY = end.Y - start.Y;
|
||||||
|
return deltaX * deltaX + deltaY * deltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasExceededThreshold(Point start, Point end, double threshold)
|
||||||
|
{
|
||||||
|
if (threshold <= 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DistanceSquared(start, end) >= threshold * threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Point Add(Point left, Point right)
|
||||||
|
{
|
||||||
|
return new Point(left.X + right.X, left.Y + right.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Point Subtract(Point left, Point right)
|
||||||
|
{
|
||||||
|
return new Point(left.X - right.X, left.Y - right.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ContainsPoint(Rect rect, Point point)
|
||||||
|
{
|
||||||
|
return rect.Contains(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Intersects(Rect left, Rect right)
|
||||||
|
{
|
||||||
|
return left.Intersects(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasCellPositionChanged(int originalRow, int originalColumn, int targetRow, int targetColumn)
|
||||||
|
{
|
||||||
|
return originalRow != targetRow || originalColumn != targetColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasCellSpanChanged(int originalWidthCells, int originalHeightCells, int targetWidthCells, int targetHeightCells)
|
||||||
|
{
|
||||||
|
return originalWidthCells != targetWidthCells || originalHeightCells != targetHeightCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsOccludedByComponentLibrary(Point point, Rect? componentLibraryBounds)
|
||||||
|
{
|
||||||
|
return componentLibraryBounds.HasValue && ContainsPoint(componentLibraryBounds.Value, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsOccludedByComponentLibrary(Rect previewRect, Rect? componentLibraryBounds)
|
||||||
|
{
|
||||||
|
return componentLibraryBounds.HasValue && Intersects(previewRect, componentLibraryBounds.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanCommitPlacement(Rect placementRect, Rect? componentLibraryBounds)
|
||||||
|
{
|
||||||
|
return !IsOccludedByComponentLibrary(placementRect, componentLibraryBounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Rect GetGridBounds(DesktopGridGeometry grid)
|
||||||
|
{
|
||||||
|
if (!grid.IsValid)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
var width = grid.ColumnCount * grid.CellSize + Math.Max(0, grid.ColumnCount - 1) * grid.CellGap;
|
||||||
|
var height = grid.RowCount * grid.CellSize + Math.Max(0, grid.RowCount - 1) * grid.CellGap;
|
||||||
|
return new Rect(grid.Origin, new Size(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Rect GetCellRect(
|
||||||
|
DesktopGridGeometry grid,
|
||||||
|
int column,
|
||||||
|
int row,
|
||||||
|
int widthCells = 1,
|
||||||
|
int heightCells = 1)
|
||||||
|
{
|
||||||
|
var safeWidthCells = Math.Max(1, widthCells);
|
||||||
|
var safeHeightCells = Math.Max(1, heightCells);
|
||||||
|
var safeColumn = Math.Max(0, column);
|
||||||
|
var safeRow = Math.Max(0, row);
|
||||||
|
var pitch = grid.Pitch;
|
||||||
|
var x = grid.Origin.X + safeColumn * pitch;
|
||||||
|
var y = grid.Origin.Y + safeRow * pitch;
|
||||||
|
var width = safeWidthCells * grid.CellSize + Math.Max(0, safeWidthCells - 1) * grid.CellGap;
|
||||||
|
var height = safeHeightCells * grid.CellSize + Math.Max(0, safeHeightCells - 1) * grid.CellGap;
|
||||||
|
return new Rect(x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Rect GetSnappedCellRect(
|
||||||
|
DesktopGridGeometry grid,
|
||||||
|
Point pointerInViewport,
|
||||||
|
Point pointerOffset,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells)
|
||||||
|
{
|
||||||
|
return TryGetSnappedCell(grid, pointerInViewport, pointerOffset, widthCells, heightCells, out var column, out var row)
|
||||||
|
? GetCellRect(grid, column, row, widthCells, heightCells)
|
||||||
|
: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetSnappedCell(
|
||||||
|
DesktopGridGeometry grid,
|
||||||
|
Point pointerInViewport,
|
||||||
|
Point pointerOffset,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells,
|
||||||
|
out int column,
|
||||||
|
out int row)
|
||||||
|
{
|
||||||
|
column = 0;
|
||||||
|
row = 0;
|
||||||
|
|
||||||
|
if (!grid.IsValid)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeWidthCells = Math.Max(1, widthCells);
|
||||||
|
var safeHeightCells = Math.Max(1, heightCells);
|
||||||
|
var maxColumn = Math.Max(0, grid.ColumnCount - safeWidthCells);
|
||||||
|
var maxRow = Math.Max(0, grid.RowCount - safeHeightCells);
|
||||||
|
var pitch = grid.Pitch;
|
||||||
|
if (pitch <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewOrigin = Subtract(pointerInViewport, pointerOffset);
|
||||||
|
var relativeX = previewOrigin.X - grid.Origin.X;
|
||||||
|
var relativeY = previewOrigin.Y - grid.Origin.Y;
|
||||||
|
|
||||||
|
column = (int)Math.Floor(relativeX / pitch);
|
||||||
|
row = (int)Math.Floor(relativeY / pitch);
|
||||||
|
column = Math.Clamp(column, 0, maxColumn);
|
||||||
|
row = Math.Clamp(row, 0, maxRow);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using Markdown.Avalonia;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Helpers;
|
namespace LanMountainDesktop.Helpers;
|
||||||
|
|
||||||
public static class PluginMarketMarkdownHelper
|
public static class PluginCatalogMarkdownHelper
|
||||||
{
|
{
|
||||||
private static Markdown.Avalonia.Markdown? _engine;
|
private static Markdown.Avalonia.Markdown? _engine;
|
||||||
|
|
||||||
@@ -276,6 +276,7 @@
|
|||||||
"settings.region.language_label": "Language",
|
"settings.region.language_label": "Language",
|
||||||
"settings.region.language_zh": "Chinese",
|
"settings.region.language_zh": "Chinese",
|
||||||
"settings.region.language_en": "English",
|
"settings.region.language_en": "English",
|
||||||
|
"settings.region.language_ja": "Japanese",
|
||||||
"settings.region.timezone_header": "Time Zone",
|
"settings.region.timezone_header": "Time Zone",
|
||||||
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
|
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
|
||||||
"settings.region.applied_format": "Language switched to: {0}",
|
"settings.region.applied_format": "Language switched to: {0}",
|
||||||
@@ -417,6 +418,11 @@
|
|||||||
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
|
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
|
||||||
"settings.update.download_threads_label": "Download Threads",
|
"settings.update.download_threads_label": "Download Threads",
|
||||||
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
|
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
|
||||||
|
"settings.update.force_check_label": "Force Check Update",
|
||||||
|
"settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.",
|
||||||
|
"settings.update.status_force_checking": "Force checking GitHub releases...",
|
||||||
|
"settings.update.status_force_no_asset": "Release found but no compatible installer available.",
|
||||||
|
"settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.",
|
||||||
"settings.update.install_now_button": "Install Now",
|
"settings.update.install_now_button": "Install Now",
|
||||||
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
|
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
|
||||||
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
|
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
|
||||||
@@ -524,10 +530,10 @@
|
|||||||
"settings.plugins.source_manifest": "Loose manifest",
|
"settings.plugins.source_manifest": "Loose manifest",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||||
"settings.nav.plugin_market": "Plugin Market",
|
"settings.nav.plugin_catalog": "Plugin Catalog",
|
||||||
"settings.plugin_market.title": "Plugin Market",
|
"settings.plugin_catalog.title": "Plugin Catalog",
|
||||||
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
"settings.plugin_catalog.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||||
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
|
"settings.plugin_catalog.unavailable": "Plugin runtime is not available, so the official catalog cannot be opened right now.",
|
||||||
"settings.update.status_idle": "No update check has been performed yet.",
|
"settings.update.status_idle": "No update check has been performed yet.",
|
||||||
"settings.update.status_preferences_saved": "Update preferences saved.",
|
"settings.update.status_preferences_saved": "Update preferences saved.",
|
||||||
"settings.update.status_check_failed": "Failed to check for updates.",
|
"settings.update.status_check_failed": "Failed to check for updates.",
|
||||||
@@ -958,6 +964,10 @@
|
|||||||
"study.interrupt_density.unavailable": "--",
|
"study.interrupt_density.unavailable": "--",
|
||||||
"desktop.add_page": "Add page",
|
"desktop.add_page": "Add page",
|
||||||
"desktop.delete_page": "Delete page",
|
"desktop.delete_page": "Delete page",
|
||||||
|
"desktop.delete_page_confirm.title": "Confirm Delete Page",
|
||||||
|
"desktop.delete_page_confirm.message": "Are you sure you want to delete the current page?\n\nThis will remove all components on this page and cannot be undone.",
|
||||||
|
"desktop.delete_page_confirm.primary": "Delete",
|
||||||
|
"desktop.delete_page_confirm.close": "Cancel",
|
||||||
"placement.fill": "Fill",
|
"placement.fill": "Fill",
|
||||||
"placement.fit": "Fit",
|
"placement.fit": "Fit",
|
||||||
"placement.stretch": "Stretch",
|
"placement.stretch": "Stretch",
|
||||||
|
|||||||
978
LanMountainDesktop/Localization/ja-JP.json
Normal file
978
LanMountainDesktop/Localization/ja-JP.json
Normal file
@@ -0,0 +1,978 @@
|
|||||||
|
{
|
||||||
|
"app.title": "LanMountainDesktop",
|
||||||
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
|
"tray.menu.show_desktop": "デスクトップを開く",
|
||||||
|
"tray.menu.settings": "設定",
|
||||||
|
"tray.menu.component_library": "ウィジェットライブラリ",
|
||||||
|
"tray.menu.restart": "アプリを再起動",
|
||||||
|
"tray.menu.exit": "アプリを終了",
|
||||||
|
"button.back_to_windows": "Windowsに戻る",
|
||||||
|
"button.back_to_platform": "{0}に戻る",
|
||||||
|
"tooltip.back_to_windows": "Windowsに戻る",
|
||||||
|
"tooltip.back_to_platform": "{0}に戻る",
|
||||||
|
"platform.windows": "Windows",
|
||||||
|
"platform.linux": "Linux",
|
||||||
|
"platform.macos": "macOS",
|
||||||
|
"tooltip.open_settings": "設定",
|
||||||
|
"settings.title": "設定",
|
||||||
|
"settings.shell.title": "設定",
|
||||||
|
"settings.shell.subtitle": "LanMountainDesktop 独立設定モジュール",
|
||||||
|
"settings.shell.sidebar_hint": "カテゴリを選択して、アプリの動作、デスクトップレイアウト、外観を調整します。",
|
||||||
|
"settings.shell.footer_hint": "トレイから開く設定は、この独立設定モジュールで管理されます。",
|
||||||
|
"settings.back_to_desktop": "デスクトップに戻る",
|
||||||
|
"settings.nav_header": "設定",
|
||||||
|
"settings.nav.group_desktop": "デスクトップ",
|
||||||
|
"settings.nav.group_system": "システム",
|
||||||
|
"settings.nav.group_extensions": "拡張機能",
|
||||||
|
"settings.nav.wallpaper": "壁紙",
|
||||||
|
"settings.nav.grid": "コンポーネント",
|
||||||
|
"settings.nav.color": "カラー",
|
||||||
|
"settings.nav.status_bar": "ステータスバー",
|
||||||
|
"settings.nav.weather": "天気",
|
||||||
|
"settings.nav.region": "地域",
|
||||||
|
"settings.nav.update": "アップデート",
|
||||||
|
"settings.nav.privacy": "プライバシー",
|
||||||
|
"settings.nav.launcher": "アプリランチャー",
|
||||||
|
"settings.nav.plugins": "プラグイン",
|
||||||
|
"settings.nav.about": "について",
|
||||||
|
"settings.wallpaper.title": "壁紙",
|
||||||
|
"settings.wallpaper.description": "画像または動画を選択して、アプリウィンドウの壁紙としてすぐに適用します。",
|
||||||
|
"settings.wallpaper.current_label": "現在の壁紙",
|
||||||
|
"settings.wallpaper.type_label": "壁紙タイプ",
|
||||||
|
"settings.wallpaper.type.image": "画像",
|
||||||
|
"settings.wallpaper.type.solid_color": "単色",
|
||||||
|
"settings.wallpaper.type.system": "システム壁紙",
|
||||||
|
"settings.wallpaper.system.label": "システム壁紙",
|
||||||
|
"settings.wallpaper.system.unavailable": "システム壁紙を読み込めません",
|
||||||
|
"settings.wallpaper.refresh_interval": "更新間隔",
|
||||||
|
"settings.wallpaper.refresh_now": "今すぐ更新",
|
||||||
|
"settings.wallpaper.refresh.30s": "30秒",
|
||||||
|
"settings.wallpaper.refresh.1m": "1分",
|
||||||
|
"settings.wallpaper.refresh.5m": "5分",
|
||||||
|
"settings.wallpaper.refresh.10m": "10分",
|
||||||
|
"settings.wallpaper.refresh.15m": "15分",
|
||||||
|
"settings.wallpaper.refresh.30m": "30分",
|
||||||
|
"settings.wallpaper.refresh.1h": "1時間",
|
||||||
|
"settings.wallpaper.refresh.2h": "2時間",
|
||||||
|
"settings.wallpaper.refresh.4h": "4時間",
|
||||||
|
"settings.wallpaper.refresh.8h": "8時間",
|
||||||
|
"settings.wallpaper.refresh.12h": "12時間",
|
||||||
|
"settings.wallpaper.refresh.24h": "24時間",
|
||||||
|
"settings.wallpaper.color_label": "壁紙の色",
|
||||||
|
"settings.wallpaper.placement_label": "配置",
|
||||||
|
"settings.wallpaper.placement_desc": "画像がデスクトップにどのように表示されるかを調整します。",
|
||||||
|
"settings.wallpaper.pick_button": "ファイルを参照",
|
||||||
|
"settings.wallpaper.clear_button": "単色にリセット",
|
||||||
|
"settings.wallpaper.no_selection": "壁紙が選択されていません。",
|
||||||
|
"settings.wallpaper.storage_unavailable": "ストレージプロバイダが利用できません。",
|
||||||
|
"settings.wallpaper.import_failed": "壁紙ファイルのインポートに失敗しました。",
|
||||||
|
"settings.wallpaper.image_applied": "画像の壁紙が適用されました。",
|
||||||
|
"settings.wallpaper.video_applied": "動画の壁紙が適用されました。",
|
||||||
|
"settings.wallpaper.unsupported_file": "選択されたファイルタイプはサポートされていません。",
|
||||||
|
"settings.wallpaper.apply_failed_format": "壁紙の適用に失敗しました: {0}",
|
||||||
|
"settings.wallpaper.mode_format": "壁紙モード: {0}。",
|
||||||
|
"settings.wallpaper.video_mode": "動画の壁紙は自動フィルモードを使用します。",
|
||||||
|
"settings.wallpaper.cleared": "背景が単色にリセットされました。",
|
||||||
|
"settings.wallpaper.default_status": "現在の背景は単色を使用しています。",
|
||||||
|
"settings.wallpaper.saved_not_found": "保存された壁紙ファイルが見つかりません。単色の背景を使用しています。",
|
||||||
|
"settings.wallpaper.restored": "保存された設定から壁紙が復元されました。",
|
||||||
|
"settings.wallpaper.video_restored": "保存された設定から動画の壁紙が復元されました。",
|
||||||
|
"settings.wallpaper.restore_failed": "保存された壁紙の復元に失敗しました。単色の背景を使用しています。",
|
||||||
|
"settings.wallpaper.video_not_found": "動画の壁紙ファイルが見つかりません。",
|
||||||
|
"settings.wallpaper.video_player_unavailable": "動画プレーヤーが利用できません。",
|
||||||
|
"settings.wallpaper.video_play_failed_format": "動画の壁紙の再生に失敗しました: {0}",
|
||||||
|
"settings.grid.title": "グリッドレイアウト",
|
||||||
|
"settings.grid.description": "すべてのコンポーネントは少なくとも1つのセルを占有する必要があります(最小1x1)。",
|
||||||
|
"settings.grid.short_side_label": "短辺のセル数",
|
||||||
|
"settings.grid.spacing_label": "グリッドの間隔",
|
||||||
|
"settings.grid.spacing_relaxed": "ゆとりあり(iOS)",
|
||||||
|
"settings.grid.spacing_compact": "コンパクト(Android)",
|
||||||
|
"settings.grid.edge_inset_label": "画面の余白",
|
||||||
|
"settings.grid.edge_inset_px_format": "≈ {0:F1}px",
|
||||||
|
"settings.grid.apply_button": "適用",
|
||||||
|
"settings.grid.info_format": "グリッド: {0}列 x {1}行 | セル {2:F1}px (1:1)",
|
||||||
|
"settings.color.title": "カラー",
|
||||||
|
"settings.color.description": "昼夜モードを切り替え、アプリのアクセントカラーを選択します。",
|
||||||
|
"settings.color.day_night_label": "昼夜モード",
|
||||||
|
"settings.color.day_night_on": "夜",
|
||||||
|
"settings.color.day_night_off": "昼",
|
||||||
|
"settings.color.recommended_label": "おすすめの色",
|
||||||
|
"settings.color.system_monet_label": "システムMonetカラー",
|
||||||
|
"settings.color.refresh_button": "更新",
|
||||||
|
"settings.color.mode_night": "夜モードが有効",
|
||||||
|
"settings.color.mode_day": "昼モードが有効",
|
||||||
|
"settings.color.mode_status_format": "テーマモード: {0}。",
|
||||||
|
"settings.color.monet_refreshed": "Monetカラーが更新されました。",
|
||||||
|
"settings.color.theme_ready_format": "テーマカラーの準備完了: {0}。",
|
||||||
|
"settings.color.theme_applied_format": "{0}カラーが適用されました: {1}。",
|
||||||
|
"settings.color.theme_updated_wallpaper": "壁紙が更新されました。Monetカラーが更新されました。",
|
||||||
|
"settings.color.theme_updated_video": "動画の壁紙が更新されました。テーマカラーが更新されました。",
|
||||||
|
"settings.color.theme_cleared_wallpaper": "壁紙がクリアされました。Monetカラーが更新されました。",
|
||||||
|
"settings.status_bar.title": "ステータスバー",
|
||||||
|
"settings.status_bar.description": "上部のステータスバーに表示するコンポーネントを選択します。",
|
||||||
|
"settings.status_bar.clock_header": "時計コンポーネント",
|
||||||
|
"settings.status_bar.clock_description": "上部のステータスバーに時計を表示します。",
|
||||||
|
"settings.status_bar.clock_transparent_background_label": "透明な背景",
|
||||||
|
"settings.status_bar.clock_transparent_background_desc": "カプセルの背景を削除し、時計のテキストのみを保持します。",
|
||||||
|
"settings.status_bar.spacing_header": "コンポーネントの間隔",
|
||||||
|
"settings.status_bar.spacing_desc": "ステータスバーコンポーネント間の間隔を調整します。",
|
||||||
|
"settings.status_bar.spacing_mode_compact": "コンパクト",
|
||||||
|
"settings.status_bar.spacing_mode_relaxed": "ゆとりあり",
|
||||||
|
"settings.status_bar.spacing_mode_custom": "カスタム",
|
||||||
|
"settings.status_bar.spacing_custom_label": "カスタム間隔(%)",
|
||||||
|
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||||
|
"settings.privacy.title": "プライバシー",
|
||||||
|
"settings.privacy.description": "アプリの改善に役立つオプションの匿名アップロードを管理します。",
|
||||||
|
"settings.privacy.crash_upload_title": "匿名クラッシュデータのアップロード",
|
||||||
|
"settings.privacy.crash_upload_description": "アプリケーションの安定性向上にご協力ください。",
|
||||||
|
"settings.privacy.usage_upload_title": "匿名使用データのアップロード",
|
||||||
|
"settings.privacy.usage_upload_description": "アプリケーション機能の改善にご協力ください。",
|
||||||
|
"settings.privacy.device_id_title": "デバイスID",
|
||||||
|
"settings.privacy.device_id_description": "このデバイスの一意識別子。更新をクリックして再生成します。",
|
||||||
|
"settings.privacy.refresh_device_id": "更新",
|
||||||
|
"settings.privacy.policy_hint_prefix": "詳細については、",
|
||||||
|
"settings.privacy.view_policy": "プライバシーポリシーをご覧ください",
|
||||||
|
"settings.weather.title": "天気",
|
||||||
|
"settings.weather.description": "天気の場所、Xiaomi天気プレビュー、起動時の位置情報取得動作を設定します。",
|
||||||
|
"settings.weather.location_source_header": "位置情報ソース",
|
||||||
|
"settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。",
|
||||||
|
"settings.weather.mode_city_search": "都市検索",
|
||||||
|
"settings.weather.mode_coordinates": "座標",
|
||||||
|
"settings.weather.auto_refresh": "起動時に位置情報を自動更新",
|
||||||
|
"settings.weather.city_search_header": "都市検索",
|
||||||
|
"settings.weather.city_search_desc": "都市を検索し、天気の場所を適用します。",
|
||||||
|
"settings.weather.search_placeholder": "例: 東京",
|
||||||
|
"settings.weather.search_button": "検索",
|
||||||
|
"settings.weather.apply_city_button": "都市を適用",
|
||||||
|
"settings.weather.search_hint": "都市名で検索し、場所を適用します。",
|
||||||
|
"settings.weather.search_required": "都市のキーワードを入力してください。",
|
||||||
|
"settings.weather.search_no_results": "場所が見つかりませんでした。",
|
||||||
|
"settings.weather.search_failed_format": "検索に失敗しました: {0}",
|
||||||
|
"settings.weather.search_result_count_format": "{0}件の場所が見つかりました。",
|
||||||
|
"settings.weather.search_select_required": "検索結果から場所を1つ選択してください。",
|
||||||
|
"settings.weather.search_applied_format": "場所が適用されました: {0}",
|
||||||
|
"settings.weather.coordinates_header": "座標",
|
||||||
|
"settings.weather.coordinates_desc": "緯度/経度とオプションのキー/名前を設定します。",
|
||||||
|
"settings.weather.latitude_label": "緯度",
|
||||||
|
"settings.weather.longitude_label": "経度",
|
||||||
|
"settings.weather.location_key_placeholder": "場所キー(オプション)",
|
||||||
|
"settings.weather.location_name_placeholder": "表示名(オプション)",
|
||||||
|
"settings.weather.apply_coordinates_button": "座標を適用",
|
||||||
|
"settings.weather.coordinates_saved_format": "座標が保存されました: {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.coordinates_default_name_format": "座標 {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.location_services_header": "位置情報サービス",
|
||||||
|
"settings.weather.location_services_desc": "現在のWindowsの場所を使用し、起動時に自動的に更新するかどうかを決定します。",
|
||||||
|
"settings.weather.use_current_location": "現在地を使用",
|
||||||
|
"settings.weather.location_unsupported": "現在のプラットフォームは現在地の取得をサポートしていません。",
|
||||||
|
"settings.weather.location_ready": "現在のWindowsの場所を使用できます。",
|
||||||
|
"settings.weather.location_refreshing": "現在地を取得中...",
|
||||||
|
"settings.weather.location_refresh_success_format": "現在地が適用されました: {0}",
|
||||||
|
"settings.weather.location_refresh_failed_format": "現在地の取得に失敗しました: {0}",
|
||||||
|
"settings.weather.preview_header": "接続テスト",
|
||||||
|
"settings.weather.preview_desc": "テストリクエストを送信して現在の設定を確認します。",
|
||||||
|
"settings.weather.preview_button": "テスト取得",
|
||||||
|
"settings.weather.preview_section": "天気プレビュー",
|
||||||
|
"settings.weather.settings_section": "設定",
|
||||||
|
"settings.weather.preview_panel_header": "天気プレビュー",
|
||||||
|
"settings.weather.preview_panel_desc": "現在の天気サービスの状態を更新して確認します。",
|
||||||
|
"settings.weather.refresh_button": "更新",
|
||||||
|
"settings.weather.preview_updated_format": "{0}に更新",
|
||||||
|
"settings.weather.preview_hint": "テスト取得を使用して天気の設定を確認します。",
|
||||||
|
"settings.weather.preview_missing_location": "テストする前に天気の場所を適用してください。",
|
||||||
|
"settings.weather.preview_success_format": "テスト成功: {0} · {1} · {2}",
|
||||||
|
"settings.weather.preview_failed_format": "テスト取得に失敗しました: {0}",
|
||||||
|
"settings.weather.preview_unknown": "不明",
|
||||||
|
"settings.weather.alert_filter_header": "除外するアラート",
|
||||||
|
"settings.weather.alert_filter_desc": "これらの単語を含むアラートは表示されません。1行に1つのルール。",
|
||||||
|
"settings.weather.alert_filter_placeholder": "1行に1つのキーワード",
|
||||||
|
"settings.weather.icon_style_header": "天気アイコンスタイル",
|
||||||
|
"settings.weather.icon_style_desc": "天気シンボルのFluentアイコンスタイルを選択します。",
|
||||||
|
"settings.weather.icon_style_fluent_regular": "Fluent Regular",
|
||||||
|
"settings.weather.icon_style_fluent_filled": "Fluent Filled",
|
||||||
|
"settings.weather.no_tls_header": "TLSなしの天気リクエスト",
|
||||||
|
"settings.weather.no_tls_desc": "推奨されません。互換性のないネットワーク環境でのみ有効にしてください。",
|
||||||
|
"settings.weather.status_city_empty": "都市の場所が設定されていません。",
|
||||||
|
"settings.weather.status_city_format": "モード: {0} | {1} | キー: {2}",
|
||||||
|
"settings.weather.status_coordinates_format": "モード: {0} | 緯度 {1:F4}, 経度 {2:F4} | キー: {3}",
|
||||||
|
"settings.weather.city_selection_label": "都市選択",
|
||||||
|
"settings.weather.coordinates_selection_label": "座標の場所",
|
||||||
|
"settings.weather.location_city_summary_desc": "天気の照会に使用される現在の都市を選択します。",
|
||||||
|
"settings.weather.location_coordinates_summary_desc": "天気の照会に使用される緯度/経度とオプションの場所名を設定します。",
|
||||||
|
"settings.weather.location_not_selected": "場所が選択されていません",
|
||||||
|
"settings.weather.alert_list_label": "除外リスト",
|
||||||
|
"settings.weather.alert_list_desc": "1行に1つの除外ルール。",
|
||||||
|
"settings.weather.no_tls_toggle": "非TLSリクエストのフォールバックを許可",
|
||||||
|
"settings.weather.footer_hint": "デスクトップ天気ウィジェットは、ここで設定された場所とアラート除外設定を再利用します。",
|
||||||
|
"settings.weather.location_header": "天気の場所",
|
||||||
|
"settings.weather.location_desc": "天気ウィジェットで使用する場所を設定します。",
|
||||||
|
"settings.weather.location_placeholder": "例: 東京",
|
||||||
|
"settings.weather.location_apply": "保存",
|
||||||
|
"settings.weather.location_empty": "天気の場所が設定されていません。",
|
||||||
|
"settings.weather.location_required": "天気の場所は空にできません。",
|
||||||
|
"settings.weather.location_current_format": "現在の天気の場所: {0}",
|
||||||
|
"settings.weather.location_saved_format": "天気の場所が保存されました: {0}",
|
||||||
|
"weather.widget.location_not_configured": "天気の場所が設定されていません",
|
||||||
|
"weather.widget.configure_hint": "設定 > 天気を開いて設定",
|
||||||
|
"weather.widget.loading": "読み込み中...",
|
||||||
|
"weather.widget.fetch_failed": "天気の取得に失敗しました",
|
||||||
|
"weather.widget.retrying": "自動的に再試行中",
|
||||||
|
"weather.widget.location_unknown": "不明な場所",
|
||||||
|
"weather.widget.condition_clear": "晴れ",
|
||||||
|
"weather.widget.condition_cloudy": "曇り",
|
||||||
|
"weather.widget.condition_rain": "雨",
|
||||||
|
"weather.widget.condition_storm": "雷雨",
|
||||||
|
"weather.widget.condition_snow": "雪",
|
||||||
|
"weather.widget.condition_fog": "霧",
|
||||||
|
"weather.widget.condition_unknown": "不明",
|
||||||
|
"weather.widget.range_unknown": "-- / --",
|
||||||
|
"weather.widget.range_format": "{0} / {1}",
|
||||||
|
"schedule.widget.no_source": "ClassIslandのスケジュールデータが見つかりません",
|
||||||
|
"schedule.widget.no_class_today": "今日の授業はありません",
|
||||||
|
"schedule.widget.layout_missing": "スケジュールの時間レイアウトがありません",
|
||||||
|
"schedule.widget.subject_fallback": "無題の授業",
|
||||||
|
"schedule.widget.detail_fallback": "詳細なし",
|
||||||
|
"schedule.settings.title": "スケジュールのインポート",
|
||||||
|
"schedule.settings.desc": "ClassIsland CSESスケジュールをインポートし、有効にするものを選択します。",
|
||||||
|
"schedule.settings.add": "スケジュールを追加",
|
||||||
|
"schedule.settings.empty": "インポートされたスケジュールはありません",
|
||||||
|
"schedule.settings.unnamed": "無題のスケジュール",
|
||||||
|
"schedule.settings.delete": "削除",
|
||||||
|
"schedule.settings.picker_title": "ClassIslandスケジュールファイルを選択",
|
||||||
|
"schedule.settings.picker_file_type.all": "ClassIslandスケジュールファイル",
|
||||||
|
"schedule.settings.picker_file_type.json": "ClassIslandプロファイル(JSON)",
|
||||||
|
"schedule.settings.picker_file_type.cses": "CSESスケジュール(YAML)",
|
||||||
|
"schedule.settings.semester.title": "学期設定",
|
||||||
|
"schedule.settings.semester.start_date": "学期開始日",
|
||||||
|
"schedule.settings.semester.week_cycle": "週サイクル",
|
||||||
|
"schedule.settings.semester.week_cycle_desc": "複数週スケジュールの週ローテーションサイクルを設定します(例: 奇数週/偶数週の場合は2)。",
|
||||||
|
"schedule.settings.semester.week_cycle_format": "{0}週ローテーション",
|
||||||
|
"worldclock.settings.title": "世界時計の設定",
|
||||||
|
"worldclock.settings.desc": "4つの時計それぞれのタイムゾーンを選択します。",
|
||||||
|
"worldclock.settings.clock_1": "時計 1",
|
||||||
|
"worldclock.settings.clock_2": "時計 2",
|
||||||
|
"worldclock.settings.clock_3": "時計 3",
|
||||||
|
"worldclock.settings.clock_4": "時計 4",
|
||||||
|
"worldclock.settings.second_mode_label": "秒針",
|
||||||
|
"worldclock.widget.today": "今日",
|
||||||
|
"worldclock.widget.yesterday": "昨日",
|
||||||
|
"worldclock.widget.tomorrow": "明日",
|
||||||
|
"worldclock.widget.offset_same": "0時間",
|
||||||
|
"worldclock.widget.offset_ahead_hours": "{0}時間進む",
|
||||||
|
"worldclock.widget.offset_behind_hours": "{0}時間遅れる",
|
||||||
|
"worldclock.widget.offset_ahead_hm": "{0}時間{1}分進む",
|
||||||
|
"worldclock.widget.offset_behind_hm": "{0}時間{1}分遅れる",
|
||||||
|
"weather.widget.aqi_unknown": "AQI --",
|
||||||
|
"weather.widget.aqi_format": "AQI {0}",
|
||||||
|
"weather.widget.updated_format": "{0:HH:mm}に更新",
|
||||||
|
"weather.hourly.now": "現在",
|
||||||
|
"weather.hourly.sunset": "日没",
|
||||||
|
"weather.multiday.today": "今日",
|
||||||
|
"weather.multiday.tomorrow": "明日",
|
||||||
|
"weather.multiday.aqi_format": "空気質 {0}",
|
||||||
|
"weather.multiday.aqi_unknown": "空気質 --",
|
||||||
|
"settings.region.title": "地域",
|
||||||
|
"settings.region.description": "言語を選択し、設定と主要なUIにすぐに適用します。",
|
||||||
|
"settings.region.language_header": "言語",
|
||||||
|
"settings.region.language_label": "言語",
|
||||||
|
"settings.region.language_zh": "中国語",
|
||||||
|
"settings.region.language_en": "英語",
|
||||||
|
"settings.region.language_ja": "日本語",
|
||||||
|
"settings.region.timezone_header": "タイムゾーン",
|
||||||
|
"settings.region.timezone_desc": "タイムゾーンを選択します。時計とカレンダーウィジェットはこのゾーンに従います。",
|
||||||
|
"settings.region.applied_format": "言語が切り替わりました: {0}",
|
||||||
|
"settings.region.follow_system": "システムの既定に従う",
|
||||||
|
"settings.general.title": "一般",
|
||||||
|
"settings.general.description": "言語、タイムゾーン、ランタイムの動作を調整します。",
|
||||||
|
"settings.general.basic_header": "基本設定",
|
||||||
|
"settings.general.runtime_header": "ランタイム",
|
||||||
|
"settings.general.preview_header": "日時プレビュー",
|
||||||
|
"settings.general.preview_time_label": "時刻",
|
||||||
|
"settings.general.preview_date_label": "日付",
|
||||||
|
"settings.general.render_mode_restart_message": "レンダリングモードの変更にはアプリの再起動が必要です。",
|
||||||
|
"settings.appearance.title": "外観",
|
||||||
|
"settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。",
|
||||||
|
"settings.appearance.theme_header": "テーマ",
|
||||||
|
"settings.color.enable_night_mode_toggle": "夜モードを有効にする",
|
||||||
|
"settings.color.use_system_chrome_toggle": "システムのウィンドウクロームを使用",
|
||||||
|
"settings.color.theme_color_label": "テーマのアクセントカラー",
|
||||||
|
"settings.appearance.theme_color_mode_label": "テーマカラーソース",
|
||||||
|
"settings.appearance.theme_color_mode.neutral": "デフォルトニュートラル",
|
||||||
|
"settings.appearance.theme_color_mode.user": "ユーザーテーマカラーMonet",
|
||||||
|
"settings.appearance.theme_color_mode.wallpaper": "壁紙Monet",
|
||||||
|
"settings.appearance.theme_color_mode_desc.neutral": "ライトモードとダークモードにデフォルトの白と黒のニュートラルサーフェスを使用します。",
|
||||||
|
"settings.appearance.theme_color_mode_desc.user": "選択したテーマカラーをシェル全体のMonetシードとして使用します。",
|
||||||
|
"settings.appearance.theme_color_mode_desc.wallpaper": "壁紙の色を使用します。アプリの壁紙が優先され、次にシステムの壁紙が使用されます。",
|
||||||
|
"settings.appearance.theme_color_preview.app": "現在、アプリの壁紙から抽出された色をプレビューしています。",
|
||||||
|
"settings.appearance.theme_color_preview.system": "現在、システムの壁紙から抽出された色をプレビューしています。",
|
||||||
|
"settings.appearance.theme_color_preview.fallback": "使用可能な壁紙が見つかりませんでした。アプリはフォールバックのアクセントを使用しています。",
|
||||||
|
"component.color_scheme.follow_system": "システムのカラースキームに従う",
|
||||||
|
"component.color_scheme.native": "コンポーネントのカスタムカラースキームを使用",
|
||||||
|
"settings.appearance.system_material.none": "なし",
|
||||||
|
"settings.appearance.system_material.mica": "Mica",
|
||||||
|
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||||
|
"settings.appearance.system_material_desc.switchable": "選択したマテリアルをウィンドウ、Dock、ステータスバー、コンポーネントホストに適用します。",
|
||||||
|
"settings.appearance.system_material_desc.fixed": "現在のシステムは、ここにリストされているマテリアルモードのみを公開しています。",
|
||||||
|
"settings.appearance.restart_message": "テーマソースとシステムマテリアルの変更にはアプリの再起動が必要です。",
|
||||||
|
"settings.appearance.preview.primary": "プライマリ",
|
||||||
|
"settings.appearance.preview.secondary": "セカンダリ",
|
||||||
|
"settings.appearance.preview.tertiary": "ターシャリ",
|
||||||
|
"settings.appearance.preview.neutral": "ニュートラル",
|
||||||
|
"settings.appearance.preview.seed": "シード",
|
||||||
|
"settings.appearance.preview.neutral_light": "白",
|
||||||
|
"settings.appearance.preview.neutral_dark": "黒",
|
||||||
|
"settings.appearance.preview.apply_seed": "適用",
|
||||||
|
"settings.appearance.preview.wallpaper_candidates": "壁紙シード候補",
|
||||||
|
"settings.appearance.preview.wallpaper_current": "現在",
|
||||||
|
"settings.wallpaper.placement.fill": "フィル",
|
||||||
|
"settings.wallpaper.placement.fit": "フィット",
|
||||||
|
"settings.wallpaper.placement.stretch": "ストレッチ",
|
||||||
|
"settings.wallpaper.placement.center": "中央",
|
||||||
|
"settings.wallpaper.placement.tile": "タイル",
|
||||||
|
"settings.status_bar.clock_format_label": "時計の形式",
|
||||||
|
"settings.status_bar.clock_format.hm": "時:分",
|
||||||
|
"settings.status_bar.clock_format.hms": "時:分:秒",
|
||||||
|
"settings.components.title": "コンポーネント",
|
||||||
|
"settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。",
|
||||||
|
"settings.components.grid_header": "グリッド設定",
|
||||||
|
"settings.components.header": "グリッド設定",
|
||||||
|
"settings.components.short_side_label": "短辺のセル数",
|
||||||
|
"settings.components.edge_inset_label": "画面の余白",
|
||||||
|
"settings.components.spacing_label": "コンポーネントの間隔",
|
||||||
|
"settings.components.spacing_compact": "コンパクト",
|
||||||
|
"settings.components.spacing_relaxed": "ゆとりあり",
|
||||||
|
"settings.components.corner_radius.header": "コーナーデザイン",
|
||||||
|
"settings.components.corner_radius.label": "コンポーネントのコーナー半径",
|
||||||
|
"settings.components.corner_radius.description": "角張った端からカプセルのような形まで、共通のコーナー半径を調整し、内部のセーフエリアを拡張します。",
|
||||||
|
"settings.update.title": "アップデート",
|
||||||
|
"settings.update.current_version_label": "現在のバージョン",
|
||||||
|
"settings.update.latest_version_label": "最新リリース",
|
||||||
|
"settings.update.published_at_label": "公開日",
|
||||||
|
"settings.update.options_header": "アップデートオプション",
|
||||||
|
"settings.update.options_desc": "アップデートチェックとリリースチャンネルを設定します。",
|
||||||
|
"settings.update.auto_check_toggle": "起動時に自動的にアップデートを確認",
|
||||||
|
"settings.update.include_prerelease_toggle": "プレリリース版を含める",
|
||||||
|
"settings.update.channel_label": "アップデートチャンネル",
|
||||||
|
"settings.update.channel_stable": "安定版",
|
||||||
|
"settings.update.channel_preview": "プレビュー",
|
||||||
|
"settings.update.actions_header": "アップデートアクション",
|
||||||
|
"settings.update.actions_desc": "リリースを確認し、インストーラーをダウンロードし、アップデートを開始します。",
|
||||||
|
"settings.update.check_button": "アップデートを確認",
|
||||||
|
"settings.update.download_install_button": "ダウンロードしてインストール",
|
||||||
|
"settings.update.download_progress_idle": "ダウンロード進捗: -",
|
||||||
|
"settings.update.download_progress_format": "ダウンロード進捗: {0:F0}%",
|
||||||
|
"settings.update.status_ready": "アップデートを確認する準備ができました。",
|
||||||
|
"settings.update.status_channel_changed": "アップデートチャンネルが変更されました。再度確認してください。",
|
||||||
|
"settings.update.status_channel_changed_format": "アップデートチャンネルが{0}に切り替わりました。再度確認してください。",
|
||||||
|
"settings.update.status_windows_only": "自動インストーラーアップデートは現在Windowsでのみ利用可能です。",
|
||||||
|
"settings.update.status_checking": "GitHubリリースを確認中...",
|
||||||
|
"settings.update.status_check_failed_format": "アップデートの確認に失敗しました: {0}",
|
||||||
|
"settings.update.status_up_to_date": "最新バージョンを使用しています。",
|
||||||
|
"settings.update.status_asset_missing": "新しいリリースが利用可能ですが、互換性のあるインストーラーが見つかりませんでした。",
|
||||||
|
"settings.update.status_available_format": "新しいバージョン{0}が利用可能です。ダウンロードしてインストールをクリックしてください。",
|
||||||
|
"settings.update.status_downloading": "インストーラーをダウンロード中...",
|
||||||
|
"settings.update.status_download_failed_format": "ダウンロードに失敗しました: {0}",
|
||||||
|
"settings.update.status_launching_installer": "ダウンロード完了。インストーラーを起動中...",
|
||||||
|
"settings.update.status_installer_missing": "ダウンロード後にインストーラーファイルが見つかりませんでした。",
|
||||||
|
"settings.update.status_installer_started": "インストーラーが開始されました。アプリはアップデートのために終了します。",
|
||||||
|
"settings.update.status_elevation_cancelled": "管理者権限が付与されませんでした。アップデートはキャンセルされました。",
|
||||||
|
"settings.update.status_launch_failed_format": "インストーラーの起動に失敗しました: {0}",
|
||||||
|
"settings.about.title": "について",
|
||||||
|
"settings.about.version_format": "バージョン: {0}",
|
||||||
|
"settings.about.codename_format": "コードネーム: {0}",
|
||||||
|
"settings.about.font_format": "フォント: {0}",
|
||||||
|
"settings.about.startup_header": "Windowsのスタートアップ",
|
||||||
|
"settings.about.startup_desc": "Windowsへのサインイン時にアプリを自動的に起動します。",
|
||||||
|
"settings.about.startup_toggle": "Windowsサインイン時に起動",
|
||||||
|
"settings.about.render_mode_header": "アプリのレンダリングモード",
|
||||||
|
"settings.about.render_mode_desc": "レンダリングバックエンドを選択します。このオプションを変更した後、アプリを再起動します。サポートされていないモードはソフトウェアにフォールバックします。",
|
||||||
|
"settings.about.render_mode.default": "デフォルト",
|
||||||
|
"settings.about.render_mode.software": "ソフトウェア",
|
||||||
|
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||||
|
"settings.about.render_mode.wgl": "WGL",
|
||||||
|
"settings.about.render_mode.vulkan": "Vulkan",
|
||||||
|
"settings.about.render_mode.unknown": "不明",
|
||||||
|
"settings.about.render_mode.current_label": "現在の実際のバックエンド",
|
||||||
|
"settings.about.render_mode.current_format": "現在のバックエンド: {0}",
|
||||||
|
"settings.about.render_mode.impl_format": "ランタイム実装: {0}",
|
||||||
|
"settings.about.render_mode.impl_unavailable": "ランタイム実装の詳細は利用できません。",
|
||||||
|
"settings.about.description": "アプリケーションの詳細。",
|
||||||
|
"settings.update.description": "リリースを確認し、アップデートチャンネルとダウンロードソースを選択し、アップデートのインストール方法を制御します。",
|
||||||
|
"settings.update.status_card_title": "アップデートステータス",
|
||||||
|
"settings.update.status_card_description": "アップデートを確認し、リリースの詳細を確認し、新しいバージョンが利用可能な場合はダウンロードまたはインストールを続行します。",
|
||||||
|
"settings.update.preferences_header": "アップデート設定",
|
||||||
|
"settings.update.preferences_description": "リリースチャンネル、インストーラーのダウンロードソース、インストール動作、ダウンロードの並列度を選択します。",
|
||||||
|
"settings.update.last_checked_label": "最終確認日時",
|
||||||
|
"settings.update.source_label": "ダウンロードソース",
|
||||||
|
"settings.update.source_github": "GitHub",
|
||||||
|
"settings.update.source_ghproxy": "gh-proxy",
|
||||||
|
"settings.update.source_github_desc": "GitHubからリリースアセットを直接ダウンロードします。",
|
||||||
|
"settings.update.source_ghproxy_desc": "GitHubリリースアセットをダウンロードする際にgh-proxyミラーを使用します。",
|
||||||
|
"settings.update.mode_label": "アップデートモード",
|
||||||
|
"settings.update.mode_manual": "手動アップデート",
|
||||||
|
"settings.update.mode_download_then_confirm": "サイレントダウンロード",
|
||||||
|
"settings.update.mode_silent_on_exit": "サイレントインストール",
|
||||||
|
"settings.update.mode_manual_desc": "アップデートの確認のみ。ダウンロードとインストールのタイミングを決定します。",
|
||||||
|
"settings.update.mode_download_then_confirm_desc": "バックグラウンドでアップデートをダウンロードし、インストール前に確認を求めます。",
|
||||||
|
"settings.update.mode_silent_on_exit_desc": "バックグラウンドでアップデートをダウンロードし、アプリの終了時にインストールします。",
|
||||||
|
"settings.update.channel_stable_desc": "安定ビルドは信頼性を重視し、ほとんどのユーザーにおすすめです。",
|
||||||
|
"settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。",
|
||||||
|
"settings.update.download_threads_label": "ダウンロードスレッド",
|
||||||
|
"settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。",
|
||||||
|
"settings.update.force_check_label": "強制アップデート確認",
|
||||||
|
"settings.update.force_check_desc": "GitHubから強制的に最新バージョンを取得し、バージョン比較を無視します。",
|
||||||
|
"settings.update.status_force_checking": "GitHubリリースを強制確認中...",
|
||||||
|
"settings.update.status_force_no_asset": "リリースは見つかりましたが、互換性のあるインストーラーがありません。",
|
||||||
|
"settings.update.status_force_available_format": "リリース {0} が利用可能です。「ダウンロードしてインストール」をクリックしてください。",
|
||||||
|
"settings.update.install_now_button": "今すぐインストール",
|
||||||
|
"settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。",
|
||||||
|
"settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。",
|
||||||
|
"settings.about.app_info_header": "アプリケーション情報",
|
||||||
|
"settings.about.update_header": "アップデート",
|
||||||
|
"settings.about.version_label": "バージョン",
|
||||||
|
"settings.about.codename_label": "コードネーム",
|
||||||
|
"settings.about.render_backend_label": "レンダーバックエンド",
|
||||||
|
"settings.about.render_backend_format": "レンダーバックエンド: {0}",
|
||||||
|
"settings.restart_dialog.title": "再起動が必要",
|
||||||
|
"settings.restart_dialog.render_mode_message": "レンダリングモードを「{0}」から「{1}」に切り替えるには、アプリを再起動します。今すぐ再起動しますか?",
|
||||||
|
"settings.restart_dialog.restart": "今すぐ再起動",
|
||||||
|
"settings.restart_dialog.later": "後で",
|
||||||
|
"settings.restart_dialog.cancel": "キャンセル",
|
||||||
|
"settings.restart_dock.title": "再起動が必要",
|
||||||
|
"settings.restart_dock.description": "一部の変更はアプリの再起動後に有効になります。",
|
||||||
|
"settings.restart_dock.button": "アプリを再起動",
|
||||||
|
"settings.footer": "LanMountainDesktop 設定",
|
||||||
|
"filepicker.title": "壁紙を選択",
|
||||||
|
"filepicker.image_files": "画像ファイル",
|
||||||
|
"filepicker.video_files": "動画ファイル",
|
||||||
|
"common.day": "昼",
|
||||||
|
"common.night": "夜",
|
||||||
|
"common.back": "戻る",
|
||||||
|
"common.close": "閉じる",
|
||||||
|
"common.unknown": "不明なエラー",
|
||||||
|
"common.recommended": "おすすめ",
|
||||||
|
"common.monet": "Monet",
|
||||||
|
"desktop.page_index_format": "デスクトップ {0}",
|
||||||
|
"launcher.title": "アプリランチャー",
|
||||||
|
"launcher.folder": "フォルダ",
|
||||||
|
"launcher.subtitle": "Windowsスタートメニューからのアプリとフォルダ",
|
||||||
|
"launcher.subtitle_linux": "Linuxデスクトップエントリから発見されたインストール済みアプリ",
|
||||||
|
"launcher.empty": "スタートメニューのエントリが見つかりません。",
|
||||||
|
"launcher.empty_linux": "Linuxデスクトップエントリが見つかりませんでした。",
|
||||||
|
"launcher.empty_folder": "このフォルダは空です。",
|
||||||
|
"launcher.folder_items_format": "{0}個のアプリ",
|
||||||
|
"launcher.context.hide_icon": "アイコンを非表示",
|
||||||
|
"launcher.action.hide": "非表示",
|
||||||
|
"settings.launcher.title": "アプリランチャー",
|
||||||
|
"settings.launcher.description": "アプリランチャーの非表示アプリとフォルダを管理します。",
|
||||||
|
"settings.launcher.hidden_header": "非表示アイテム",
|
||||||
|
"settings.launcher.hidden_desc": "非表示のランチャーエントリを確認し、再度表示します。",
|
||||||
|
"settings.launcher.hidden_hint": "デスクトップ編集モードで、ランチャーアイコンを選択して非表示をクリックします。非表示のエントリはここに表示されます。",
|
||||||
|
"settings.launcher.hidden_empty": "非表示アイテムはありません。",
|
||||||
|
"settings.launcher.hidden_summary_format": "{0}個の非表示アイテム",
|
||||||
|
"settings.launcher.hidden_type_folder": "フォルダ",
|
||||||
|
"settings.launcher.hidden_type_shortcut": "アプリ",
|
||||||
|
"settings.launcher.restore_button": "再表示",
|
||||||
|
"settings.plugins.title": "プラグイン",
|
||||||
|
"settings.plugins.runtime_header": "プラグインランタイム",
|
||||||
|
"settings.plugins.runtime_desc": "プラグインランタイムの状態とロード結果を確認します。",
|
||||||
|
"settings.plugins.runtime_hint": "このページには、インストールされたプラグインの発見ステータス、ロード結果、ランタイム診断が表示されます。",
|
||||||
|
"settings.plugins.runtime_status": "プラグインの発見が完了すると、プラグインランタイムのステータスがここに表示されます。",
|
||||||
|
"settings.plugins.description": "インストールされたプラグインを管理し、ランタイムの状態を確認します。",
|
||||||
|
"settings.plugins.initial_status": "プラグインの状態を更新して、最新のインストール済みプラグインを確認してください。",
|
||||||
|
"settings.plugins.refresh_button": "プラグインを更新",
|
||||||
|
"settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。",
|
||||||
|
"settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。",
|
||||||
|
"settings.plugins.refresh_failed": "プラグインカタログインデックスのロードに失敗しました。",
|
||||||
|
"settings.plugins.marketplace_header": "マーケットプレイス",
|
||||||
|
"settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。",
|
||||||
|
"settings.plugins.delete_button_short": "削除",
|
||||||
|
"settings.plugins.install_button_short": "インストール",
|
||||||
|
"settings.plugins.restart_required": "プラグインの変更は再起動後に有効になります。",
|
||||||
|
"settings.plugins.toggle_unchanged_format": "プラグイン「{0}」は変更されませんでした。",
|
||||||
|
"settings.plugins.delete_failed_name_format": "プラグイン「{0}」の削除に失敗しました。",
|
||||||
|
"settings.plugins.install_failed_name_format": "「{0}」のインストールに失敗しました。",
|
||||||
|
"settings.plugins.installed_header": "インストール済みプラグイン",
|
||||||
|
"settings.plugins.installed_desc": "インストール済みプラグインを確認し、ここで削除します。",
|
||||||
|
"settings.plugins.import_header": "パッケージからインストール",
|
||||||
|
"settings.plugins.import_desc": ".laappパッケージを開き、ローカルプラグインディレクトリにステージングします。",
|
||||||
|
"settings.plugins.restart_hint": "プラグインのインストールと削除の変更は、アプリの再起動後に有効になります。",
|
||||||
|
"settings.plugins.empty": "プラグインが見つかりません。",
|
||||||
|
"settings.plugins.runtime_unavailable": "プラグインランタイムは利用できません。",
|
||||||
|
"settings.plugins.summary_format": "{0}個のプラグインを検出; 有効 {1}; ロード済み {2}; 設定ページ {3}; ウィジェット {4}; 失敗 {5}。",
|
||||||
|
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||||
|
"settings.plugins.state.enabled": "有効",
|
||||||
|
"settings.plugins.state.enabled_failed": "有効 / ロード失敗",
|
||||||
|
"settings.plugins.state.disabled": "無効",
|
||||||
|
"settings.plugins.state.loaded": "ロード済み",
|
||||||
|
"settings.plugins.state.load_failed": "ロード失敗",
|
||||||
|
"settings.plugins.toggle_on": "有効",
|
||||||
|
"settings.plugins.toggle_off": "無効",
|
||||||
|
"settings.plugins.toggle_result_format": "プラグイン「{0}」は次回起動時に{1}になりました。ページとウィジェットの変更を適用するには、アプリを再起動してください。",
|
||||||
|
"settings.plugins.toggle_state_enabled": "有効",
|
||||||
|
"settings.plugins.toggle_state_disabled": "無効",
|
||||||
|
"settings.plugins.toggle_failed_detail_format": "プラグイン「{0}」の更新に失敗しました: {1}",
|
||||||
|
"settings.plugins.install_button": ".laappパッケージを開く",
|
||||||
|
"settings.plugins.install_unavailable": "プラグインランタイムが利用できないため、.laappパッケージをインストールできません。",
|
||||||
|
"settings.plugins.install_hint_format": ".laappパッケージを開いて次にインストールします: {0}",
|
||||||
|
"settings.plugins.install_picker_title": "プラグインパッケージを選択",
|
||||||
|
"settings.plugins.install_file_type": ".laappプラグインパッケージ",
|
||||||
|
"settings.plugins.install_picker_unavailable": "ストレージプロバイダが利用できません。",
|
||||||
|
"settings.plugins.install_copy_failed": "選択した.laappパッケージのコピーに失敗しました。",
|
||||||
|
"settings.plugins.install_success_format": "プラグイン「{0}」がインストールされました。新しく追加された設定ページとウィジェットを適用するには、アプリを再起動してください。",
|
||||||
|
"settings.plugins.install_failed_format": "プラグインパッケージのインストールに失敗しました: {0}",
|
||||||
|
"settings.plugins.delete_button": "プラグインを削除",
|
||||||
|
"settings.plugins.delete_success_format": "プラグイン「{0}」は削除のためにステージングされました。削除を完了するには、アプリを再起動してください。",
|
||||||
|
"settings.plugins.delete_failed_format": "プラグインの削除に失敗しました: {0}",
|
||||||
|
"settings.plugins.delete_failed_detail_format": "プラグイン「{0}」の削除に失敗しました: {1}",
|
||||||
|
"settings.plugins.publisher_format": "パブリッシャー: {0}",
|
||||||
|
"settings.plugins.publisher_unknown": "不明なパブリッシャー",
|
||||||
|
"settings.plugins.source_package": ".laappパッケージ",
|
||||||
|
"settings.plugins.source_manifest": "ルーズマニフェスト",
|
||||||
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
|
"settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}",
|
||||||
|
"settings.nav.plugin_catalog": "プラグインカタログ",
|
||||||
|
"settings.plugin_catalog.title": "プラグインカタログ",
|
||||||
|
"settings.plugin_catalog.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
|
||||||
|
"settings.plugin_catalog.unavailable": "プラグインランタイムが利用できないため、公式カタログを開けません。",
|
||||||
|
"settings.update.status_idle": "アップデートの確認はまだ実行されていません。",
|
||||||
|
"settings.update.status_preferences_saved": "アップデート設定が保存されました。",
|
||||||
|
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
|
||||||
|
"settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1})",
|
||||||
|
"settings.update.status_up_to_date_format": "最新版です({0})。",
|
||||||
|
"settings.window.drawer_default": "詳細",
|
||||||
|
"market.toolbar.search_placeholder": "プラグインを検索",
|
||||||
|
"market.toolbar.refresh": "更新",
|
||||||
|
"market.status.loading": "公式プラグインカタログをロード中...",
|
||||||
|
"market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。",
|
||||||
|
"market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}",
|
||||||
|
"market.status.load_failed_format": "プラグインカタログのロードに失敗しました: {0}",
|
||||||
|
"market.status.installing_format": "プラグイン「{0}」をダウンロードしてステージング中...",
|
||||||
|
"market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。",
|
||||||
|
"market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}",
|
||||||
|
"market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。",
|
||||||
|
"market.list.empty": "プラグインカタログはまだロードされていません。",
|
||||||
|
"market.list.no_results": "現在の検索に一致するプラグインはありません。",
|
||||||
|
"market.card.subtitle_format": "{0} | v{1}",
|
||||||
|
"market.card.loaded": "ロード済み",
|
||||||
|
"market.card.pending_restart": "再起動が必要",
|
||||||
|
"market.detail.placeholder": "左側のプラグインを選択して詳細を確認します。",
|
||||||
|
"market.detail.author": "パブリッシャー",
|
||||||
|
"market.detail.version": "バージョン",
|
||||||
|
"market.detail.api_version": "APIバージョン",
|
||||||
|
"market.detail.min_host_version": "最小ホストバージョン",
|
||||||
|
"market.detail.installed_version": "インストール済みバージョン",
|
||||||
|
"market.detail.not_installed": "未インストール",
|
||||||
|
"market.detail.readme": "README",
|
||||||
|
"market.detail.plugin_information": "プラグイン情報",
|
||||||
|
"market.detail.author_subtitle_format": "{0}作成",
|
||||||
|
"market.detail.package_size": "パッケージサイズ",
|
||||||
|
"market.detail.published_at": "公開日",
|
||||||
|
"market.detail.updated_at": "更新日",
|
||||||
|
"market.detail.tags": "タグ",
|
||||||
|
"market.detail.project": "プロジェクト",
|
||||||
|
"market.detail.state": "インストール状態",
|
||||||
|
"market.detail.market_source": "マーケットソース",
|
||||||
|
"market.detail.homepage": "ホームページ",
|
||||||
|
"market.detail.repository": "リポジトリ",
|
||||||
|
"market.detail.release_notes": "リリースノート",
|
||||||
|
"market.detail.dependencies": "依存関係",
|
||||||
|
"market.detail.dependencies_empty": "このプラグインは共有コントラクトの依存関係を宣言していません。",
|
||||||
|
"market.detail.readme_loading": "READMEをロード中...",
|
||||||
|
"market.detail.readme_empty": "READMEは空です。",
|
||||||
|
"market.detail.readme_error_format": "READMEをロードできませんでした: {0}",
|
||||||
|
"market.detail.state.not_installed": "未インストール",
|
||||||
|
"market.detail.state.update_available": "アップデートあり",
|
||||||
|
"market.detail.state.installed": "インストール済み",
|
||||||
|
"market.detail.unknown": "不明",
|
||||||
|
"market.button.install": "インストール",
|
||||||
|
"market.button.update": "アップデート",
|
||||||
|
"market.button.installed": "インストール済み",
|
||||||
|
"market.button.installing": "インストール中...",
|
||||||
|
"market.button.restart": "再起動して適用",
|
||||||
|
"button.component_library": "デスクトップを編集",
|
||||||
|
"tooltip.component_library": "デスクトップを編集",
|
||||||
|
"component_library.title": "ウィジェット",
|
||||||
|
"component_library.empty": "スワイプしてカテゴリを選択し、タップして開き、ウィジェットをデスクトップにドラッグします。",
|
||||||
|
"component_library.drag_hint": "ドラッグして配置",
|
||||||
|
"component.delete": "削除",
|
||||||
|
"component.edit": "編集",
|
||||||
|
"component.editor.instance_scope": "変更はこのコンポーネントインスタンスにのみ適用されます。",
|
||||||
|
"component.editor.info_header": "コンポーネント情報",
|
||||||
|
"component.editor.id_label": "コンポーネントID",
|
||||||
|
"component.editor.placement_label": "配置ID",
|
||||||
|
"component.editor.scope_label": "スコープ",
|
||||||
|
"component.editor.scope_instance": "インスタンススコープのエディタ",
|
||||||
|
"component_category.clock": "時計",
|
||||||
|
"component_category.date": "カレンダー",
|
||||||
|
"component_category.weather": "天気",
|
||||||
|
"component_category.board": "ボード",
|
||||||
|
"component_category.media": "メディア",
|
||||||
|
"component_category.info": "情報",
|
||||||
|
"component_category.calculator": "計算機",
|
||||||
|
"component_category.study": "学習",
|
||||||
|
"component_category.file": "ファイル",
|
||||||
|
"component.date": "カレンダー",
|
||||||
|
"component.month_calendar": "月間カレンダー",
|
||||||
|
"component.lunar_calendar": "旧暦カレンダー",
|
||||||
|
"component.desktop_clock": "時計",
|
||||||
|
"component.weather_clock": "天気時計",
|
||||||
|
"component.world_clock": "世界時計",
|
||||||
|
"component.desktop_timer": "タイマー",
|
||||||
|
"component.desktop_weather": "天気",
|
||||||
|
"component.hourly_weather": "時間別天気",
|
||||||
|
"component.multiday_weather": "数日間天気",
|
||||||
|
"component.extended_weather": "拡張天気",
|
||||||
|
"component.class_schedule": "時間割",
|
||||||
|
"component.music_control": "音楽コントロール",
|
||||||
|
"component.audio_recorder": "レコーダー",
|
||||||
|
"component.daily_poetry": "今日の詩",
|
||||||
|
"component.daily_artwork": "今日のアート",
|
||||||
|
"component.daily_word": "今日の言葉",
|
||||||
|
"component.daily_word_2x2": "今日の言葉 2x2",
|
||||||
|
"component.cnr_daily_news": "CNRヘッドライン",
|
||||||
|
"component.ifeng_news": "iFengニュース",
|
||||||
|
"component.bilibili_hot_search": "Bilibiliトレンド",
|
||||||
|
"component.baidu_hot_search": "Baiduトレンド",
|
||||||
|
"component.stcn24_forum": "STCN 24",
|
||||||
|
"component.exchange_rate_converter": "為替レート変換",
|
||||||
|
"component.whiteboard": "黒板(縦向き)",
|
||||||
|
"component.blackboard_landscape": "黒板(横向き)",
|
||||||
|
"component.browser": "ブラウザ",
|
||||||
|
"component.office_recent_documents": "最近のドキュメント",
|
||||||
|
"whiteboard.settings.desc": "各黒板は独自のノート履歴を保持し、独立して保存します。",
|
||||||
|
"whiteboard.settings.retention.title": "ノートの保持期間",
|
||||||
|
"whiteboard.settings.retention.desc": "この黒板が保存されたノートを保持する期間を選択します。期限切れのデータは自動的に削除されます。",
|
||||||
|
"whiteboard.settings.retention.option": "{0}日",
|
||||||
|
"whiteboard.settings.instance_scope": "この保持設定は黒板コンポーネントインスタンスごとに保存されます。",
|
||||||
|
"office_recent_documents.settings.desc": "このウィジェットが最近のドキュメントをスキャンするWindowsとOfficeのソースを選択します。",
|
||||||
|
"office_recent_documents.settings.sources_title": "最近のドキュメントソース",
|
||||||
|
"office_recent_documents.settings.sources_desc": "複数のソースを組み合わせることができます。レジストリ選択は、Office相互運用MRUフォールバックも利用可能にします。",
|
||||||
|
"office_recent_documents.settings.source.registry": "OfficeレジストリMRU",
|
||||||
|
"office_recent_documents.settings.source.recent_folders": "Windowsの最近使ったフォルダ",
|
||||||
|
"office_recent_documents.settings.source.jump_lists": "Windowsジャンプリスト",
|
||||||
|
"office_recent_documents.settings.hint": "すべてのソースを無効にすると、少なくとも1つのソースが再度有効になるまで、このウィジェットは空のままになります。",
|
||||||
|
"component.removable_storage": "リムーバブルストレージ",
|
||||||
|
"component.holiday_calendar": "祝日カレンダー",
|
||||||
|
"component.study_environment": "環境",
|
||||||
|
"component.study_session_control": "学習セッション制御",
|
||||||
|
"component.study_session_history": "セッション履歴",
|
||||||
|
"component.study_noise_curve": "ノイズカーブ",
|
||||||
|
"component.study_noise_distribution": "ノイズ分布",
|
||||||
|
"component.study_score_overview": "学習スコア概要",
|
||||||
|
"component.study_deduction_reasons": "減点理由",
|
||||||
|
"component.study_interrupt_density": "中断密度",
|
||||||
|
"desktop_clock.settings.title": "時計の設定",
|
||||||
|
"desktop_clock.settings.desc": "単一時計のタイムゾーンを選択します。",
|
||||||
|
"desktop_clock.settings.timezone_label": "タイムゾーン",
|
||||||
|
"desktop_clock.settings.second_mode_label": "秒針",
|
||||||
|
"clock.second_mode.tick": "ティック",
|
||||||
|
"clock.second_mode.sweep": "スイープ",
|
||||||
|
"poetry.widget.loading_content": "詩を読み込み中...",
|
||||||
|
"poetry.widget.loading_author": "読み込み中...",
|
||||||
|
"poetry.widget.fetch_failed": "詩の取得に失敗しました",
|
||||||
|
"poetry.widget.fallback_content": "今日の詩は一時的に利用できません。",
|
||||||
|
"poetry.widget.fallback_author": "後でもう一度お試しください",
|
||||||
|
"poetry.widget.unknown_author": "不明",
|
||||||
|
"artwork.widget.loading": "読み込み中...",
|
||||||
|
"artwork.widget.loading_title": "今日のアート",
|
||||||
|
"artwork.widget.loading_subtitle": "今日の傑作を取得中",
|
||||||
|
"artwork.widget.fetch_failed": "アートの取得に失敗しました",
|
||||||
|
"artwork.widget.fallback_title": "今日のアート",
|
||||||
|
"artwork.widget.fallback_artist": "おすすめサービスは利用できません",
|
||||||
|
"artwork.widget.fallback_year": "後でもう一度お試しください",
|
||||||
|
"artwork.widget.unknown_artist": "不明なアーティスト",
|
||||||
|
"dailyword.widget.loading": "読み込み中...",
|
||||||
|
"dailyword.widget.loading_word": "今日の言葉",
|
||||||
|
"dailyword.widget.loading_pronunciation": "発音を取得中...",
|
||||||
|
"dailyword.widget.loading_meaning": "意味を取得中...",
|
||||||
|
"dailyword.widget.loading_example": "例文を取得中...",
|
||||||
|
"dailyword.widget.loading_example_translation": "読み込み中...",
|
||||||
|
"dailyword.widget.fetch_failed": "今日の言葉の取得に失敗しました",
|
||||||
|
"dailyword.widget.fallback_word": "今日の言葉",
|
||||||
|
"dailyword.widget.fallback_pronunciation": "発音は利用できません",
|
||||||
|
"dailyword.widget.fallback_meaning": "Youdao辞書は一時的に利用できません。",
|
||||||
|
"dailyword.widget.fallback_example": "更新ボタンをタップして再試行してください。",
|
||||||
|
"dailyword.widget.fallback_example_translation": "ネットワークが回復すると再試行します。",
|
||||||
|
"dailyword2x2.widget.tap_to_show": "タップして意味を表示",
|
||||||
|
"cnrnews.widget.loading": "読み込み中...",
|
||||||
|
"cnrnews.widget.loading_title": "CNRヘッドラインを取得中",
|
||||||
|
"cnrnews.widget.loading_subtitle": "お待ちください",
|
||||||
|
"cnrnews.widget.fetch_failed": "ニュースの取得に失敗しました",
|
||||||
|
"cnrnews.widget.fallback_title": "CNRニュースは一時的に利用できません",
|
||||||
|
"cnrnews.widget.fallback_subtitle": "更新をタップして再試行してください",
|
||||||
|
"cnrnews.widget.hot_label": "ホット",
|
||||||
|
"bilihot.widget.brand": "Bilibiliトレンド",
|
||||||
|
"bilihot.widget.top_right_label": "Bilibiliトレンド",
|
||||||
|
"bilihot.widget.search_entry": "検索",
|
||||||
|
"bilihot.widget.search_placeholder": "トレンドトピックを検索",
|
||||||
|
"bilihot.widget.loading": "読み込み中...",
|
||||||
|
"bilihot.widget.loading_item": "読み込み中...",
|
||||||
|
"bilihot.widget.fetch_failed": "トレンドの取得に失敗しました",
|
||||||
|
"bilihot.widget.fallback_item": "トレンドデータなし",
|
||||||
|
"bilihot.widget.more_hot": "もっとトレンドを見る",
|
||||||
|
"baiduhot.widget.brand": "Baiduトレンド",
|
||||||
|
"baiduhot.widget.loading": "読み込み中...",
|
||||||
|
"baiduhot.widget.loading_item": "読み込み中...",
|
||||||
|
"baiduhot.widget.fetch_failed": "トレンドの取得に失敗しました",
|
||||||
|
"baiduhot.widget.fallback_item": "トレンドデータなし",
|
||||||
|
"baiduhot.widget.refresh_tooltip": "更新",
|
||||||
|
"ifeng.widget.brand": "iFengニュース",
|
||||||
|
"ifeng.widget.loading": "読み込み中...",
|
||||||
|
"ifeng.widget.loading_item": "読み込み中...",
|
||||||
|
"ifeng.widget.fetch_failed": "ニュースの取得に失敗しました",
|
||||||
|
"ifeng.widget.fallback_item": "ニュースデータなし",
|
||||||
|
"ifeng.widget.refresh_tooltip": "更新",
|
||||||
|
"dailyword.settings.title": "今日の言葉の設定",
|
||||||
|
"dailyword.settings.desc": "自動更新と更新間隔を設定します。",
|
||||||
|
"dailyword.settings.auto_refresh_label": "自動更新",
|
||||||
|
"dailyword.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||||
|
"dailyword.settings.frequency_label": "更新間隔",
|
||||||
|
"bilihot.settings.title": "Bilibiliトレンドの設定",
|
||||||
|
"bilihot.settings.desc": "自動更新と更新間隔を設定します。",
|
||||||
|
"bilihot.settings.auto_refresh_label": "自動更新",
|
||||||
|
"bilihot.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||||
|
"bilihot.settings.frequency_label": "更新間隔",
|
||||||
|
"baiduhot.settings.title": "Baiduトレンドの設定",
|
||||||
|
"baiduhot.settings.desc": "ソース、自動更新、更新間隔を設定します。",
|
||||||
|
"baiduhot.settings.source_label": "データソース",
|
||||||
|
"baiduhot.settings.source_official": "公式ソース",
|
||||||
|
"baiduhot.settings.source_rss": "サードパーティRSS",
|
||||||
|
"baiduhot.settings.auto_refresh_label": "自動更新",
|
||||||
|
"baiduhot.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||||
|
"baiduhot.settings.frequency_label": "更新間隔",
|
||||||
|
"ifeng.settings.title": "iFengニュースの設定",
|
||||||
|
"ifeng.settings.desc": "チャンネル、自動更新、更新間隔を設定します。",
|
||||||
|
"ifeng.settings.channel_label": "ニュースチャンネル",
|
||||||
|
"ifeng.settings.channel_comprehensive": "総合",
|
||||||
|
"ifeng.settings.channel_mainland": "中国本土",
|
||||||
|
"ifeng.settings.channel_taiwan": "台湾",
|
||||||
|
"ifeng.settings.auto_refresh_label": "自動更新",
|
||||||
|
"ifeng.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||||
|
"ifeng.settings.frequency_label": "更新間隔",
|
||||||
|
"refresh.frequency.5m": "5分",
|
||||||
|
"refresh.frequency.10m": "10分",
|
||||||
|
"refresh.frequency.12m": "12分",
|
||||||
|
"refresh.frequency.15m": "15分",
|
||||||
|
"refresh.frequency.20m": "20分",
|
||||||
|
"refresh.frequency.30m": "30分",
|
||||||
|
"refresh.frequency.40m": "40分",
|
||||||
|
"refresh.frequency.1h": "1時間",
|
||||||
|
"refresh.frequency.3h": "3時間",
|
||||||
|
"refresh.frequency.6h": "6時間",
|
||||||
|
"refresh.frequency.12h": "12時間",
|
||||||
|
"refresh.frequency.24h": "24時間",
|
||||||
|
"weather.widget.settings.title": "天気ウィジェットの設定",
|
||||||
|
"weather.widget.settings.desc": "すべての天気ウィジェットの自動更新と更新間隔を設定します。",
|
||||||
|
"weather.widget.settings.auto_refresh_label": "自動更新",
|
||||||
|
"weather.widget.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||||
|
"weather.widget.settings.frequency_label": "更新間隔",
|
||||||
|
"weather.widget.settings.frequency_10m": "10分",
|
||||||
|
"weather.widget.settings.frequency_12m": "12分",
|
||||||
|
"weather.widget.settings.frequency_15m": "15分",
|
||||||
|
"weather.widget.settings.frequency_30m": "30分",
|
||||||
|
"weather.widget.settings.frequency_1h": "1時間",
|
||||||
|
"weather.widget.settings.frequency_3h": "3時間",
|
||||||
|
"stcn24.widget.loading": "読み込み中...",
|
||||||
|
"stcn24.widget.loading_item": "読み込み中...",
|
||||||
|
"stcn24.widget.fetch_failed": "フォーラム投稿の取得に失敗しました",
|
||||||
|
"stcn24.widget.fallback_item": "投稿なし",
|
||||||
|
"stcn24.settings.title": "STCN 24の設定",
|
||||||
|
"stcn24.settings.desc": "情報ソース、自動更新、更新間隔を設定します。",
|
||||||
|
"stcn24.settings.source_label": "情報ソース",
|
||||||
|
"stcn24.settings.source_latest_created": "最新の投稿",
|
||||||
|
"stcn24.settings.source_latest_activity": "最新のアクティビティ",
|
||||||
|
"stcn24.settings.source_most_replies": "返信数順",
|
||||||
|
"stcn24.settings.source_earliest_created": "最古の投稿",
|
||||||
|
"stcn24.settings.source_earliest_activity": "最古のアクティビティ",
|
||||||
|
"stcn24.settings.source_least_replies": "返信が少ない順",
|
||||||
|
"stcn24.settings.source_frontpage_latest": "フロントページ最新",
|
||||||
|
"stcn24.settings.source_frontpage_earliest": "フロントページ最古",
|
||||||
|
"stcn24.settings.auto_refresh_label": "自動更新",
|
||||||
|
"stcn24.settings.auto_refresh_enabled": "自動更新を有効にする",
|
||||||
|
"stcn24.settings.frequency_label": "更新間隔",
|
||||||
|
"stcn24.settings.frequency_5m": "5分",
|
||||||
|
"stcn24.settings.frequency_10m": "10分",
|
||||||
|
"stcn24.settings.frequency_20m": "20分",
|
||||||
|
"stcn24.settings.frequency_30m": "30分",
|
||||||
|
"stcn24.settings.frequency_1h": "1時間",
|
||||||
|
"stcn24.settings.frequency_3h": "3時間",
|
||||||
|
"exchange.widget.loading": "為替レートを読み込み中...",
|
||||||
|
"exchange.widget.fetch_failed": "為替レートの取得に失敗しました",
|
||||||
|
"cnrnews.settings.title": "CNRの設定",
|
||||||
|
"cnrnews.settings.desc": "自動ローテーションと更新間隔を設定します。",
|
||||||
|
"cnrnews.settings.auto_rotate_label": "自動ローテーション",
|
||||||
|
"cnrnews.settings.auto_rotate_enabled": "自動ローテーションを有効にする",
|
||||||
|
"cnrnews.settings.frequency_label": "ローテーション間隔",
|
||||||
|
"cnrnews.settings.frequency_5m": "5分",
|
||||||
|
"cnrnews.settings.frequency_10m": "10分",
|
||||||
|
"cnrnews.settings.frequency_40m": "40分",
|
||||||
|
"cnrnews.settings.frequency_1h": "1時間",
|
||||||
|
"cnrnews.settings.frequency_12h": "12時間",
|
||||||
|
"cnrnews.settings.frequency_24h": "24時間",
|
||||||
|
"artwork.settings.title": "今日のアートの設定",
|
||||||
|
"artwork.settings.desc": "今日のアートで使用されるデータソースを切り替えます。",
|
||||||
|
"artwork.settings.source_label": "ミラーソース",
|
||||||
|
"artwork.settings.source_domestic": "国内ミラー",
|
||||||
|
"artwork.settings.source_overseas": "海外ミラー",
|
||||||
|
"artwork.settings.source_status_domestic": "現在のソース: 国内ミラー(中国ネットワーク向けに最適化)",
|
||||||
|
"artwork.settings.source_status_overseas": "現在のソース: 海外ミラー(美術館のおすすめ)",
|
||||||
|
"music.widget.unsupported": "このプラットフォームでは音楽コントロールはサポートされていません",
|
||||||
|
"music.widget.unsupported_hint": "このウィジェットにはWindows SMTCが必要です",
|
||||||
|
"music.widget.no_session": "音楽ソースなし",
|
||||||
|
"music.widget.no_session_hint": "アプリストアからQQ音楽/酷狗/網易雲音楽をインストールしてください",
|
||||||
|
"music.widget.open_player": "プレーヤーを開く",
|
||||||
|
"music.widget.unknown_title": "不明なタイトル",
|
||||||
|
"music.widget.unknown_artist": "不明なアーティスト",
|
||||||
|
"music.widget.status.opened": "開かれました",
|
||||||
|
"music.widget.status.changing": "変更中",
|
||||||
|
"music.widget.status.stopped": "停止",
|
||||||
|
"music.widget.status.playing": "再生中",
|
||||||
|
"music.widget.status.paused": "一時停止",
|
||||||
|
"recording.widget.title": "レコーダー",
|
||||||
|
"recording.widget.hint.ready": "赤いボタンをタップして録音",
|
||||||
|
"recording.widget.hint.recording": "録音中",
|
||||||
|
"recording.widget.hint.paused": "一時停止",
|
||||||
|
"recording.widget.hint.unsupported": "マイクが利用できません",
|
||||||
|
"recording.widget.hint.error": "録音に失敗しました",
|
||||||
|
"recording.widget.hint.saved_format": "保存しました {0}",
|
||||||
|
"recording.widget.save_picker_title": "録音ファイルを保存",
|
||||||
|
"recording.widget.save_picker_type": "WAVオーディオ",
|
||||||
|
"study.environment.status_label": "環境",
|
||||||
|
"study.environment.status.initializing": "初期化中",
|
||||||
|
"study.environment.status.ready": "準備完了",
|
||||||
|
"study.environment.status.quiet": "静か",
|
||||||
|
"study.environment.status.noisy": "うるさい",
|
||||||
|
"study.environment.status.paused": "一時停止",
|
||||||
|
"study.environment.status.error": "エラー",
|
||||||
|
"study.environment.status.unsupported": "未対応",
|
||||||
|
"study.environment.value.unavailable": "--",
|
||||||
|
"study.environment.value.display_format": "{0:F1} dB",
|
||||||
|
"study.environment.value.dbfs_format": "{0:F1} dBFS",
|
||||||
|
"study.environment.settings.title": "環境ウィジェットの設定",
|
||||||
|
"study.environment.settings.desc": "右側のリアルタイムノイズ値表示を設定します。",
|
||||||
|
"study.environment.settings.show_display_db": "表示dBを表示",
|
||||||
|
"study.environment.settings.show_dbfs": "dBFSを表示",
|
||||||
|
"study.environment.settings.hint": "少なくとも1つの表示モードを有効にしておく必要があります。",
|
||||||
|
"removable_storage.settings.desc": "接続されたUSBドライブを表示し、クイックオープンと取り出しアクションを提供します。",
|
||||||
|
"removable_storage.settings.behavior_title": "動作",
|
||||||
|
"removable_storage.settings.behavior_desc": "ウィジェットはリムーバブルドライブを自動的に監視し、最新の挿入されたUSBドライブに切り替わります。",
|
||||||
|
"removable_storage.action.open": "開く",
|
||||||
|
"removable_storage.action.eject": "取り出し",
|
||||||
|
"removable_storage.widget.default_name": "リムーバブルドライブ",
|
||||||
|
"removable_storage.widget.empty_title": "デバイスが挿入されていません",
|
||||||
|
"removable_storage.widget.empty_subtitle": "USBドライブを挿入してここに表示します。",
|
||||||
|
"removable_storage.widget.empty_hint": "リムーバブルデバイスが挿入されるまで、ボタンは無効のままです。",
|
||||||
|
"removable_storage.widget.ready": "開くか取り出す準備ができました。",
|
||||||
|
"removable_storage.widget.ejecting": "ドライブを取り出し中...",
|
||||||
|
"removable_storage.widget.eject_failed": "このドライブを取り出せませんでした。上のファイルを閉じて再試行してください。",
|
||||||
|
"removable_storage.widget.open_failed": "このドライブを開けませんでした。",
|
||||||
|
"removable_storage.widget.refresh_failed": "ドライブリストの更新に失敗しました。",
|
||||||
|
"study.session_control.action.start": "学習セッションを開始",
|
||||||
|
"study.session_control.action.stop": "学習セッションを停止",
|
||||||
|
"study.session_control.idle_hint": "右のボタンをタップして開始",
|
||||||
|
"study.session_control.report_preview": "レポートをプレビュー",
|
||||||
|
"study.session_control.report_confirm_hint": "右のボタンをタップして確認",
|
||||||
|
"study.session_control.running_elapsed_format": "経過 {0}",
|
||||||
|
"study.session_control.last_session_format": "前回 {0}",
|
||||||
|
"study.session_control.start_failed": "セッションを開始できません",
|
||||||
|
"study.session_control.stop_failed": "セッションを停止できません",
|
||||||
|
"study.session_history.title": "セッション履歴",
|
||||||
|
"study.session_history.empty": "セッション履歴なし",
|
||||||
|
"study.session_history.select_failed": "セッションを切り替えられません",
|
||||||
|
"study.session_history.rename_failed": "セッション名を変更できません",
|
||||||
|
"study.session_history.delete_failed": "セッションを削除できません",
|
||||||
|
"study.session_history.rename_placeholder": "セッション名を入力",
|
||||||
|
"study.session_history.rename_confirm": "名前変更を確認",
|
||||||
|
"study.session_history.rename_cancel": "名前変更をキャンセル",
|
||||||
|
"study.session_history.loading": "データを読み込み中...",
|
||||||
|
"study.session_history.loaded": "データが読み込まれました",
|
||||||
|
"study.session_history.duration_format": "{0:hh\\:mm\\:ss}",
|
||||||
|
"study.session_history.meta_format": "{0} · 平均 {1:F1}",
|
||||||
|
"study.session_history.action.view": "表示",
|
||||||
|
"study.session_history.action.rename": "名前変更",
|
||||||
|
"study.session_history.action.delete": "削除",
|
||||||
|
"study.session_history.dialog.rename_title": "セッション名を変更",
|
||||||
|
"study.session_history.dialog.rename_message": "「{0}」の新しい名前を入力してください。",
|
||||||
|
"study.session_history.dialog.delete_title": "セッションを削除",
|
||||||
|
"study.session_history.dialog.delete_message": "「{0}」を削除しますか?これは元に戻せません。",
|
||||||
|
"study.session_history.dialog.delete_confirm": "削除",
|
||||||
|
"study.noise_curve.value_format": "{0:F1} dB",
|
||||||
|
"study.noise_curve.axis.now": "現在",
|
||||||
|
"study.noise_distribution.title": "ノイズレベル分布",
|
||||||
|
"study.noise_distribution.mode.realtime": "リアルタイム",
|
||||||
|
"study.noise_distribution.mode.session": "セッション",
|
||||||
|
"study.noise_distribution.summary.mainly_format": "主に: {0}",
|
||||||
|
"study.noise_distribution.summary.latest_format": "最新: {0}",
|
||||||
|
"study.noise_distribution.summary.compact_format": "主 {0} · 新 {1}",
|
||||||
|
"study.noise_distribution.level.quiet": "静か",
|
||||||
|
"study.noise_distribution.level.normal": "普通",
|
||||||
|
"study.noise_distribution.level.noisy": "うるさい",
|
||||||
|
"study.noise_distribution.level.extreme": "極端",
|
||||||
|
"study.noise_distribution.axis.extreme": "極端",
|
||||||
|
"study.noise_distribution.axis.noisy": "うるさい",
|
||||||
|
"study.noise_distribution.axis.normal": "普通",
|
||||||
|
"study.noise_distribution.axis.quiet": "静か",
|
||||||
|
"study.noise_distribution.axis.now": "現在",
|
||||||
|
"study.score_overview.title": "学習スコア",
|
||||||
|
"study.score_overview.mode.realtime": "リアルタイム",
|
||||||
|
"study.score_overview.mode.session": "セッション",
|
||||||
|
"study.score_overview.current": "現在",
|
||||||
|
"study.score_overview.average": "平均",
|
||||||
|
"study.score_overview.minimum": "最小",
|
||||||
|
"study.score_overview.maximum": "最大",
|
||||||
|
"study.score_overview.average_short": "平均",
|
||||||
|
"study.score_overview.minimum_short": "最小",
|
||||||
|
"study.score_overview.maximum_short": "最大",
|
||||||
|
"study.score_overview.unavailable": "--",
|
||||||
|
"study.deduction.title": "減点理由",
|
||||||
|
"study.deduction.mode.realtime": "リアルタイム",
|
||||||
|
"study.deduction.mode.session": "セッション",
|
||||||
|
"study.deduction.reason.sustained": "持続ノイズ",
|
||||||
|
"study.deduction.reason.time": "閾値超過時間",
|
||||||
|
"study.deduction.reason.segment": "中断頻度",
|
||||||
|
"study.deduction.reason.sustained_short": "持続",
|
||||||
|
"study.deduction.reason.time_short": "時間",
|
||||||
|
"study.deduction.reason.segment_short": "中断",
|
||||||
|
"study.deduction.metric.sustained_format": "p50 {0:F1} dBFS",
|
||||||
|
"study.deduction.metric.sustained_short_format": "p50 {0:F1}",
|
||||||
|
"study.deduction.metric.time_format": "{0:F1}%超過",
|
||||||
|
"study.deduction.metric.time_short_format": "{0:F1}%",
|
||||||
|
"study.deduction.metric.segment_format": "{0:F1}/分",
|
||||||
|
"study.deduction.metric.segment_short_format": "{0:F1}/分",
|
||||||
|
"study.deduction.loss_format": "-{0:F1}",
|
||||||
|
"study.deduction.total_loss_format": "合計 -{0:F1}",
|
||||||
|
"study.deduction.total_score_format": "スコア {0:F1}",
|
||||||
|
"study.deduction.total_loss_unavailable": "合計 {0}",
|
||||||
|
"study.deduction.total_score_unavailable": "スコア {0}",
|
||||||
|
"study.deduction.unavailable": "--",
|
||||||
|
"study.interrupt_density.title": "中断密度",
|
||||||
|
"study.interrupt_density.mode.realtime": "リアルタイム",
|
||||||
|
"study.interrupt_density.mode.session": "セッション",
|
||||||
|
"study.interrupt_density.unit": "/分",
|
||||||
|
"study.interrupt_density.segment_count": "中断回数",
|
||||||
|
"study.interrupt_density.segment_count_short": "回数",
|
||||||
|
"study.interrupt_density.duration": "期間",
|
||||||
|
"study.interrupt_density.duration_short": "時間",
|
||||||
|
"study.interrupt_density.density_value_format": "{0:F1}",
|
||||||
|
"study.interrupt_density.segment_count_value_format": "{0}",
|
||||||
|
"study.interrupt_density.level_format": "レベル {0}",
|
||||||
|
"study.interrupt_density.level.calm": "穏やか",
|
||||||
|
"study.interrupt_density.level.normal": "普通",
|
||||||
|
"study.interrupt_density.level.frequent": "頻繁",
|
||||||
|
"study.interrupt_density.level.severe": "深刻",
|
||||||
|
"study.interrupt_density.threshold_format": "ペナルティ閾値 {0:F1}/分",
|
||||||
|
"study.interrupt_density.unavailable": "--",
|
||||||
|
"desktop.add_page": "ページを追加",
|
||||||
|
"desktop.delete_page": "ページを削除",
|
||||||
|
"placement.fill": "フィル",
|
||||||
|
"placement.fit": "フィット",
|
||||||
|
"placement.stretch": "ストレッチ",
|
||||||
|
"placement.center": "中央",
|
||||||
|
"placement.tile": "タイル",
|
||||||
|
"single_instance.notice.title": "アプリは既に実行中",
|
||||||
|
"single_instance.notice.description": "アプリは既に実行中です。複数回クリックして開く必要はありません。",
|
||||||
|
"single_instance.notice.button": "OK",
|
||||||
|
"market.status.install_success_restart_format": "✓ プラグイン「{0}」が正常にインストールされました!有効にするには、アプリケーションを再起動してください。",
|
||||||
|
"market.dialog.restart_message_format": "プラグイン「{0}」が正常にインストールされました。\n\nこのプラグインを使用するには、今すぐアプリケーションを再起動する必要があります。\n\n再起動しますか?",
|
||||||
|
"component.settings.color_scheme": "カラースキーム"
|
||||||
|
}
|
||||||
976
LanMountainDesktop/Localization/ko-KR.json
Normal file
976
LanMountainDesktop/Localization/ko-KR.json
Normal file
@@ -0,0 +1,976 @@
|
|||||||
|
{
|
||||||
|
"app.title": "LanMountainDesktop",
|
||||||
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
|
"tray.menu.show_desktop": "바탕화면 열기",
|
||||||
|
"tray.menu.settings": "설정",
|
||||||
|
"tray.menu.component_library": "위젯 라이브러리",
|
||||||
|
"tray.menu.restart": "앱 재시작",
|
||||||
|
"tray.menu.exit": "앱 종료",
|
||||||
|
"button.back_to_windows": "Windows로 돌아가기",
|
||||||
|
"button.back_to_platform": "{0}로 돌아가기",
|
||||||
|
"tooltip.back_to_windows": "Windows로 돌아가기",
|
||||||
|
"tooltip.back_to_platform": "{0}로 돌아가기",
|
||||||
|
"platform.windows": "Windows",
|
||||||
|
"platform.linux": "Linux",
|
||||||
|
"platform.macos": "macOS",
|
||||||
|
"tooltip.open_settings": "설정",
|
||||||
|
"settings.title": "설정",
|
||||||
|
"settings.shell.title": "설정",
|
||||||
|
"settings.shell.subtitle": "LanMountainDesktop 독립 설정 모듈",
|
||||||
|
"settings.shell.sidebar_hint": "카테고리를 선택하여 앱 동작, 바탕화면 레이아웃 및 외관을 조정합니다.",
|
||||||
|
"settings.shell.footer_hint": "트레이에서 열리는 설정은 이 독립 설정 모듈에서 관리됩니다.",
|
||||||
|
"settings.back_to_desktop": "바탕화면으로 돌아가기",
|
||||||
|
"settings.nav_header": "설정",
|
||||||
|
"settings.nav.group_desktop": "바탕화면",
|
||||||
|
"settings.nav.group_system": "시스템",
|
||||||
|
"settings.nav.group_extensions": "확장",
|
||||||
|
"settings.nav.wallpaper": "배경화면",
|
||||||
|
"settings.nav.grid": "컴포넌트",
|
||||||
|
"settings.nav.color": "색상",
|
||||||
|
"settings.nav.status_bar": "상태 표시줄",
|
||||||
|
"settings.nav.weather": "날씨",
|
||||||
|
"settings.nav.region": "지역",
|
||||||
|
"settings.nav.update": "업데이트",
|
||||||
|
"settings.nav.privacy": "개인정보",
|
||||||
|
"settings.nav.launcher": "앱 런처",
|
||||||
|
"settings.nav.plugins": "플러그인",
|
||||||
|
"settings.nav.about": "정보",
|
||||||
|
"settings.wallpaper.title": "배경화면",
|
||||||
|
"settings.wallpaper.description": "이미지 또는 비디오를 선택하여 앱 창의 배경화면으로 즉시 적용합니다.",
|
||||||
|
"settings.wallpaper.current_label": "현재 배경화면",
|
||||||
|
"settings.wallpaper.type_label": "배경화면 유형",
|
||||||
|
"settings.wallpaper.type.image": "이미지",
|
||||||
|
"settings.wallpaper.type.solid_color": "단색",
|
||||||
|
"settings.wallpaper.type.system": "시스템 배경화면",
|
||||||
|
"settings.wallpaper.system.label": "시스템 배경화면",
|
||||||
|
"settings.wallpaper.system.unavailable": "시스템 배경화면을 불러올 수 없습니다",
|
||||||
|
"settings.wallpaper.refresh_interval": "새로고침 간격",
|
||||||
|
"settings.wallpaper.refresh_now": "지금 새로고침",
|
||||||
|
"settings.wallpaper.refresh.30s": "30초",
|
||||||
|
"settings.wallpaper.refresh.1m": "1분",
|
||||||
|
"settings.wallpaper.refresh.5m": "5분",
|
||||||
|
"settings.wallpaper.refresh.10m": "10분",
|
||||||
|
"settings.wallpaper.refresh.15m": "15분",
|
||||||
|
"settings.wallpaper.refresh.30m": "30분",
|
||||||
|
"settings.wallpaper.refresh.1h": "1시간",
|
||||||
|
"settings.wallpaper.refresh.2h": "2시간",
|
||||||
|
"settings.wallpaper.refresh.4h": "4시간",
|
||||||
|
"settings.wallpaper.refresh.8h": "8시간",
|
||||||
|
"settings.wallpaper.refresh.12h": "12시간",
|
||||||
|
"settings.wallpaper.refresh.24h": "24시간",
|
||||||
|
"settings.wallpaper.color_label": "배경화면 색상",
|
||||||
|
"settings.wallpaper.placement_label": "배치",
|
||||||
|
"settings.wallpaper.placement_desc": "이미지가 바탕화면에 표시되는 방식을 조정합니다.",
|
||||||
|
"settings.wallpaper.pick_button": "파일 찾아보기",
|
||||||
|
"settings.wallpaper.clear_button": "단색으로 재설정",
|
||||||
|
"settings.wallpaper.no_selection": "배경화면이 선택되지 않았습니다.",
|
||||||
|
"settings.wallpaper.storage_unavailable": "저장소 제공자를 사용할 수 없습니다.",
|
||||||
|
"settings.wallpaper.import_failed": "배경화면 파일 가져오기에 실패했습니다.",
|
||||||
|
"settings.wallpaper.image_applied": "이미지 배경화면이 적용되었습니다.",
|
||||||
|
"settings.wallpaper.video_applied": "비디오 배경화면이 적용되었습니다.",
|
||||||
|
"settings.wallpaper.unsupported_file": "선택한 파일 형식은 지원되지 않습니다.",
|
||||||
|
"settings.wallpaper.apply_failed_format": "배경화면 적용 실패: {0}",
|
||||||
|
"settings.wallpaper.mode_format": "배경화면 모드: {0}.",
|
||||||
|
"settings.wallpaper.video_mode": "비디오 배경화면은 자동 채우기 모드를 사용합니다.",
|
||||||
|
"settings.wallpaper.cleared": "배경이 단색으로 재설정되었습니다.",
|
||||||
|
"settings.wallpaper.default_status": "현재 배경은 단색을 사용합니다.",
|
||||||
|
"settings.wallpaper.saved_not_found": "저장된 배경화면 파일을 찾을 수 없습니다. 단색 배경을 사용합니다.",
|
||||||
|
"settings.wallpaper.restored": "저장된 설정에서 배경화면이 복원되었습니다.",
|
||||||
|
"settings.wallpaper.video_restored": "저장된 설정에서 비디오 배경화면이 복원되었습니다.",
|
||||||
|
"settings.wallpaper.restore_failed": "저장된 배경화면 복원에 실패했습니다. 단색 배경을 사용합니다.",
|
||||||
|
"settings.wallpaper.video_not_found": "비디오 배경화면 파일을 찾을 수 없습니다.",
|
||||||
|
"settings.wallpaper.video_player_unavailable": "비디오 플레이어를 사용할 수 없습니다.",
|
||||||
|
"settings.wallpaper.video_play_failed_format": "비디오 배경화면 재생 실패: {0}",
|
||||||
|
"settings.grid.title": "그리드 레이아웃",
|
||||||
|
"settings.grid.description": "모든 컴포넌트는 최소 하나의 셀을 차지해야 합니다 (최소 1x1).",
|
||||||
|
"settings.grid.short_side_label": "짧은 쪽 셀 수",
|
||||||
|
"settings.grid.spacing_label": "그리드 간격",
|
||||||
|
"settings.grid.spacing_relaxed": "여유 있음 (iOS)",
|
||||||
|
"settings.grid.spacing_compact": "컴팩트 (Android)",
|
||||||
|
"settings.grid.edge_inset_label": "화면 여백",
|
||||||
|
"settings.grid.edge_inset_px_format": "≈ {0:F1}px",
|
||||||
|
"settings.grid.apply_button": "적용",
|
||||||
|
"settings.grid.info_format": "그리드: {0}열 x {1}행 | 셀 {2:F1}px (1:1)",
|
||||||
|
"settings.color.title": "색상",
|
||||||
|
"settings.color.description": "주야간 모드를 전환하고 앱 강조 색상을 선택합니다.",
|
||||||
|
"settings.color.day_night_label": "주야간 모드",
|
||||||
|
"settings.color.day_night_on": "야간",
|
||||||
|
"settings.color.day_night_off": "주간",
|
||||||
|
"settings.color.recommended_label": "추천 색상",
|
||||||
|
"settings.color.system_monet_label": "시스템 Monet 색상",
|
||||||
|
"settings.color.refresh_button": "새로고침",
|
||||||
|
"settings.color.mode_night": "야간 모드 활성화됨",
|
||||||
|
"settings.color.mode_day": "주간 모드 활성화됨",
|
||||||
|
"settings.color.mode_status_format": "테마 모드: {0}.",
|
||||||
|
"settings.color.monet_refreshed": "Monet 색상이 새로고침되었습니다.",
|
||||||
|
"settings.color.theme_ready_format": "테마 색상 준비됨: {0}.",
|
||||||
|
"settings.color.theme_applied_format": "{0} 테마 색상 적용됨: {1}.",
|
||||||
|
"settings.color.theme_updated_wallpaper": "배경화면이 업데이트되어 Monet 색상이 새로고침되었습니다.",
|
||||||
|
"settings.color.theme_cleared_wallpaper": "배경화면이 제거되어 Monet 색상이 새로고침되었습니다.",
|
||||||
|
"settings.status_bar.title": "상태 표시줄",
|
||||||
|
"settings.status_bar.description": "상단 상태 표시줄에 표시할 컴포넌트를 선택합니다.",
|
||||||
|
"settings.status_bar.clock_header": "시계 컴포넌트",
|
||||||
|
"settings.status_bar.clock_description": "상단 상태 표시줄에 시계를 표시합니다.",
|
||||||
|
"settings.status_bar.clock_transparent_background_label": "투명 배경",
|
||||||
|
"settings.status_bar.clock_transparent_background_desc": "캡슐 배경을 제거하고 시계 텍스트만 유지합니다.",
|
||||||
|
"settings.status_bar.spacing_header": "컴포넌트 간격",
|
||||||
|
"settings.status_bar.spacing_desc": "상태 표시줄 컴포넌트 사이의 간격을 조정합니다.",
|
||||||
|
"settings.status_bar.spacing_mode_compact": "컴팩트",
|
||||||
|
"settings.status_bar.spacing_mode_relaxed": "여유 있음",
|
||||||
|
"settings.status_bar.spacing_mode_custom": "사용자 지정",
|
||||||
|
"settings.status_bar.spacing_custom_label": "사용자 지정 간격 (%)",
|
||||||
|
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||||
|
"settings.privacy.title": "개인정보",
|
||||||
|
"settings.privacy.description": "선택적 익명 업로드 설정을 관리하여 앱 경험을 점진적으로 개선하는 데 도움을 줍니다.",
|
||||||
|
"settings.privacy.crash_upload_title": "익명 충돌 데이터 업로드",
|
||||||
|
"settings.privacy.crash_upload_description": "앱 안정성 향상에 도움을 줍니다.",
|
||||||
|
"settings.privacy.usage_upload_title": "익명 사용 데이터 업로드",
|
||||||
|
"settings.privacy.usage_upload_description": "앱 기능 개선에 도움을 줍니다.",
|
||||||
|
"settings.privacy.device_id_title": "기기 식별자",
|
||||||
|
"settings.privacy.device_id_description": "이 기기의 고유 식별자입니다. 새로고침을 클릭하여 재생성합니다.",
|
||||||
|
"settings.privacy.refresh_device_id": "새로고침",
|
||||||
|
"settings.privacy.policy_hint_prefix": "자세한 내용은",
|
||||||
|
"settings.privacy.view_policy": "개인정보 처리방침 보기",
|
||||||
|
"settings.weather.title": "날씨",
|
||||||
|
"settings.weather.description": "날씨 위치, Xiaomi 날씨 미리보기 및 시작 시 위치 새로고침 동작을 구성합니다.",
|
||||||
|
"settings.weather.location_source_header": "위치 소스",
|
||||||
|
"settings.weather.location_source_desc": "날씨 컴포넌트가 현재 위치를 해석하는 방법을 선택합니다.",
|
||||||
|
"settings.weather.mode_city_search": "도시 검색",
|
||||||
|
"settings.weather.mode_coordinates": "좌표 입력",
|
||||||
|
"settings.weather.auto_refresh": "시작 시 위치 자동 새로고침",
|
||||||
|
"settings.weather.city_search_header": "도시 검색",
|
||||||
|
"settings.weather.city_search_desc": "도시를 검색하고 날씨 위치를 적용합니다.",
|
||||||
|
"settings.weather.search_placeholder": "예: 서울",
|
||||||
|
"settings.weather.search_button": "검색",
|
||||||
|
"settings.weather.apply_city_button": "도시 적용",
|
||||||
|
"settings.weather.search_hint": "도시 이름을 입력하여 검색한 후 결과를 적용합니다.",
|
||||||
|
"settings.weather.search_required": "먼저 도시 키워드를 입력하세요.",
|
||||||
|
"settings.weather.search_no_results": "일치하는 위치를 찾을 수 없습니다.",
|
||||||
|
"settings.weather.search_failed_format": "검색 실패: {0}",
|
||||||
|
"settings.weather.search_result_count_format": "총 {0}개 위치를 찾았습니다.",
|
||||||
|
"settings.weather.search_select_required": "먼저 검색 결과에서 위치를 선택하세요.",
|
||||||
|
"settings.weather.search_applied_format": "위치 적용됨: {0}",
|
||||||
|
"settings.weather.coordinates_header": "좌표 입력",
|
||||||
|
"settings.weather.coordinates_desc": "위도와 경도를 설정하고 선택적으로 위치 키와 표시 이름을 입력합니다.",
|
||||||
|
"settings.weather.latitude_label": "위도",
|
||||||
|
"settings.weather.longitude_label": "경도",
|
||||||
|
"settings.weather.location_key_placeholder": "위치 키 (선택)",
|
||||||
|
"settings.weather.location_name_placeholder": "표시 이름 (선택)",
|
||||||
|
"settings.weather.apply_coordinates_button": "좌표 적용",
|
||||||
|
"settings.weather.coordinates_saved_format": "좌표 저장됨: {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.coordinates_default_name_format": "좌표 {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.location_services_header": "위치 서비스",
|
||||||
|
"settings.weather.location_services_desc": "현재 Windows 위치를 사용하고 시작 시 날씨 위치를 자동으로 새로고침할지 결정합니다.",
|
||||||
|
"settings.weather.use_current_location": "현재 위치 사용",
|
||||||
|
"settings.weather.location_unsupported": "현재 플랫폼에서 현재 위치 가져오기를 지원하지 않습니다.",
|
||||||
|
"settings.weather.location_ready": "현재 Windows 위치를 사용할 수 있습니다.",
|
||||||
|
"settings.weather.location_refreshing": "현재 위치 가져오는 중...",
|
||||||
|
"settings.weather.location_refresh_success_format": "현재 위치 적용됨: {0}",
|
||||||
|
"settings.weather.location_refresh_failed_format": "현재 위치 가져오기 실패: {0}",
|
||||||
|
"settings.weather.preview_header": "연결 테스트",
|
||||||
|
"settings.weather.preview_desc": "테스트 요청을 보내 현재 구성이 사용 가능한지 확인합니다.",
|
||||||
|
"settings.weather.preview_button": "테스트 가져오기",
|
||||||
|
"settings.weather.preview_section": "날씨 미리보기",
|
||||||
|
"settings.weather.settings_section": "설정",
|
||||||
|
"settings.weather.preview_panel_header": "날씨 미리보기",
|
||||||
|
"settings.weather.preview_panel_desc": "현재 날씨 서비스 상태를 새로고침하고 확인합니다.",
|
||||||
|
"settings.weather.refresh_button": "새로고침",
|
||||||
|
"settings.weather.preview_updated_format": "{0}에 업데이트됨",
|
||||||
|
"settings.weather.preview_hint": "테스트 가져오기를 통해 날씨 구성을 빠르게 확인할 수 있습니다.",
|
||||||
|
"settings.weather.preview_missing_location": "테스트 전에 먼저 날씨 위치를 적용하세요.",
|
||||||
|
"settings.weather.preview_success_format": "테스트 성공: {0} · {1} · {2}",
|
||||||
|
"settings.weather.preview_failed_format": "테스트 실패: {0}",
|
||||||
|
"settings.weather.preview_unknown": "알 수 없음",
|
||||||
|
"settings.weather.alert_filter_header": "제외된 날씨 경보",
|
||||||
|
"settings.weather.alert_filter_desc": "다음 키워드가 포함된 경보는 표시되지 않습니다. 한 줄에 하나의 규칙.",
|
||||||
|
"settings.weather.alert_filter_placeholder": "한 줄에 하나의 키워드 입력",
|
||||||
|
"settings.weather.icon_style_header": "날씨 아이콘 스타일",
|
||||||
|
"settings.weather.icon_style_desc": "날씨 기호에 사용할 Fluent Icon 스타일을 선택합니다.",
|
||||||
|
"settings.weather.icon_style_fluent_regular": "Fluent 윤곽선",
|
||||||
|
"settings.weather.icon_style_fluent_filled": "Fluent 채우기",
|
||||||
|
"settings.weather.no_tls_header": "TLS 없이 날씨 가져오기",
|
||||||
|
"settings.weather.no_tls_desc": "권장하지 않음, 네트워크 호환성이 낮을 때만 시도하세요.",
|
||||||
|
"settings.weather.status_city_empty": "도시 위치가 아직 구성되지 않았습니다.",
|
||||||
|
"settings.weather.status_city_format": "모드: {0}|{1}|키: {2}",
|
||||||
|
"settings.weather.status_coordinates_format": "모드: {0}|위도 {1:F4}, 경도 {2:F4}|키: {3}",
|
||||||
|
"settings.weather.city_selection_label": "도시 선택",
|
||||||
|
"settings.weather.coordinates_selection_label": "좌표 위치",
|
||||||
|
"settings.weather.location_city_summary_desc": "날씨 조회에 사용할 현재 도시를 선택합니다.",
|
||||||
|
"settings.weather.location_coordinates_summary_desc": "날씨 조회에 사용할 위도와 경도 및 선택적 위치 이름을 설정합니다.",
|
||||||
|
"settings.weather.location_not_selected": "위치가 선택되지 않음",
|
||||||
|
"settings.weather.alert_list_label": "제외 목록",
|
||||||
|
"settings.weather.alert_list_desc": "한 줄에 하나의 제외 항목.",
|
||||||
|
"settings.weather.no_tls_toggle": "호환성이 낮은 네트워크 환경에서 비 TLS 요청으로 대체 허용",
|
||||||
|
"settings.weather.footer_hint": "바탕화면의 날씨 컴포넌트는 여기서 구성한 날씨 위치와 경보 제외 규칙을 공유합니다.",
|
||||||
|
"settings.weather.location_header": "날씨 위치",
|
||||||
|
"settings.weather.location_desc": "날씨 컴포넌트에 사용할 위치를 설정합니다.",
|
||||||
|
"settings.weather.location_placeholder": "예: 서울",
|
||||||
|
"settings.weather.location_apply": "저장",
|
||||||
|
"settings.weather.location_empty": "날씨 위치가 아직 설정되지 않았습니다.",
|
||||||
|
"settings.weather.location_required": "날씨 위치는 비워둘 수 없습니다.",
|
||||||
|
"settings.weather.location_current_format": "현재 날씨 위치: {0}",
|
||||||
|
"settings.weather.location_saved_format": "날씨 위치 저장됨: {0}",
|
||||||
|
"weather.widget.location_not_configured": "날씨 위치가 구성되지 않음",
|
||||||
|
"weather.widget.configure_hint": "설정 > 날씨에서 구성을 완료하세요",
|
||||||
|
"weather.widget.loading": "로딩 중...",
|
||||||
|
"weather.widget.fetch_failed": "날씨 가져오기 실패",
|
||||||
|
"weather.widget.retrying": "나중에 자동으로 재시도",
|
||||||
|
"weather.widget.location_unknown": "알 수 없는 위치",
|
||||||
|
"weather.widget.condition_clear": "맑음",
|
||||||
|
"weather.widget.condition_cloudy": "흐림",
|
||||||
|
"weather.widget.condition_rain": "비",
|
||||||
|
"weather.widget.condition_storm": "뇌우",
|
||||||
|
"weather.widget.condition_snow": "눈",
|
||||||
|
"weather.widget.condition_fog": "안개",
|
||||||
|
"weather.widget.condition_unknown": "알 수 없는 날씨",
|
||||||
|
"weather.widget.range_unknown": "-- / --",
|
||||||
|
"weather.widget.range_format": "{0} / {1}",
|
||||||
|
"schedule.widget.no_source": "ClassIsland 시간표를 읽지 못함",
|
||||||
|
"schedule.widget.no_class_today": "오늘 수업 없음",
|
||||||
|
"schedule.widget.layout_missing": "시간표 레이아웃 누락",
|
||||||
|
"schedule.widget.subject_fallback": "이름 없는 수업",
|
||||||
|
"schedule.widget.detail_fallback": "상세 정보 없음",
|
||||||
|
"schedule.settings.title": "시간표 가져오기",
|
||||||
|
"schedule.settings.desc": "ClassIsland CSES 시간표 파일을 가져오고 활성화 항목을 선택합니다.",
|
||||||
|
"schedule.settings.add": "시간표 추가",
|
||||||
|
"schedule.settings.empty": "가져온 시간표 없음",
|
||||||
|
"schedule.settings.unnamed": "이름 없는 시간표",
|
||||||
|
"schedule.settings.delete": "삭제",
|
||||||
|
"schedule.settings.picker_title": "ClassIsland 시간표 파일 선택",
|
||||||
|
"schedule.settings.picker_file_type.all": "ClassIsland 시간표 파일",
|
||||||
|
"schedule.settings.picker_file_type.json": "ClassIsland 아카이브 (JSON)",
|
||||||
|
"schedule.settings.picker_file_type.cses": "CSES 시간표 (YAML)",
|
||||||
|
"schedule.settings.semester.title": "학기 설정",
|
||||||
|
"schedule.settings.semester.start_date": "학기 시작일",
|
||||||
|
"schedule.settings.semester.week_cycle": "주 순환",
|
||||||
|
"schedule.settings.semester.week_cycle_desc": "다주 시간표 순환 주기를 설정하여 현재 몇 주차인지 계산합니다.",
|
||||||
|
"schedule.settings.semester.week_cycle_format": "{0}주 순환",
|
||||||
|
"worldclock.settings.title": "세계 시계 설정",
|
||||||
|
"worldclock.settings.desc": "네 개의 시계에 대해 각각 시간대를 선택합니다.",
|
||||||
|
"worldclock.settings.clock_1": "시계 1",
|
||||||
|
"worldclock.settings.clock_2": "시계 2",
|
||||||
|
"worldclock.settings.clock_3": "시계 3",
|
||||||
|
"worldclock.settings.clock_4": "시계 4",
|
||||||
|
"worldclock.settings.second_mode_label": "초침 방식",
|
||||||
|
"worldclock.widget.today": "오늘",
|
||||||
|
"worldclock.widget.yesterday": "어제",
|
||||||
|
"worldclock.widget.tomorrow": "내일",
|
||||||
|
"worldclock.widget.offset_same": "0시간",
|
||||||
|
"worldclock.widget.offset_ahead_hours": "{0}시간 빠름",
|
||||||
|
"worldclock.widget.offset_behind_hours": "{0}시간 늦음",
|
||||||
|
"worldclock.widget.offset_ahead_hm": "{0}시간 {1}분 빠름",
|
||||||
|
"worldclock.widget.offset_behind_hm": "{0}시간 {1}분 늦음",
|
||||||
|
"weather.widget.aqi_unknown": "AQI --",
|
||||||
|
"weather.widget.aqi_format": "AQI {0}",
|
||||||
|
"weather.widget.updated_format": "{0:HH:mm}에 업데이트됨",
|
||||||
|
"weather.hourly.now": "현재",
|
||||||
|
"weather.hourly.sunset": "일몰",
|
||||||
|
"weather.multiday.today": "오늘",
|
||||||
|
"weather.multiday.tomorrow": "내일",
|
||||||
|
"weather.multiday.aqi_format": "공기 좋음 {0}",
|
||||||
|
"weather.multiday.aqi_unknown": "공기 --",
|
||||||
|
"settings.region.title": "지역",
|
||||||
|
"settings.region.description": "언어를 선택하고 설정 및 주요 인터페이스에 즉시 적용합니다.",
|
||||||
|
"settings.region.language_header": "언어",
|
||||||
|
"settings.region.language_label": "언어",
|
||||||
|
"settings.region.language_zh": "중국어",
|
||||||
|
"settings.region.language_en": "영어",
|
||||||
|
"settings.region.language_ja": "일본어",
|
||||||
|
"settings.region.language_ko": "한국어",
|
||||||
|
"settings.region.timezone_header": "시간대",
|
||||||
|
"settings.region.timezone_desc": "시간대를 선택합니다. 시계와 달력 컴포넌트가 이 시간대를 사용합니다.",
|
||||||
|
"settings.region.applied_format": "언어가 {0}(으)로 전환되었습니다",
|
||||||
|
"settings.region.follow_system": "시스템 기본값 따르기",
|
||||||
|
"settings.general.title": "일반 설정",
|
||||||
|
"settings.general.description": "언어, 시간대 및 런타임 동작을 조정합니다.",
|
||||||
|
"settings.general.basic_header": "기본 설정",
|
||||||
|
"settings.general.runtime_header": "런타임 설정",
|
||||||
|
"settings.general.preview_header": "날짜 및 시간 미리보기",
|
||||||
|
"settings.general.preview_time_label": "시간",
|
||||||
|
"settings.general.preview_date_label": "날짜",
|
||||||
|
"settings.general.render_mode_restart_message": "렌더링 모드 변경은 앱 재시작이 필요합니다.",
|
||||||
|
"settings.appearance.title": "외관",
|
||||||
|
"settings.appearance.description": "테마 소스, 시스템 소재 및 창 외관을 조정합니다.",
|
||||||
|
"settings.appearance.theme_header": "테마",
|
||||||
|
"settings.color.enable_night_mode_toggle": "야간 모드 활성화",
|
||||||
|
"settings.color.use_system_chrome_toggle": "시스템 창 제목 표시줄 사용",
|
||||||
|
"settings.color.theme_color_label": "테마 강조 색상",
|
||||||
|
"settings.appearance.theme_color_mode_label": "테마 색상 소스",
|
||||||
|
"settings.appearance.theme_color_mode.neutral": "기본 중성",
|
||||||
|
"settings.appearance.theme_color_mode.user": "사용자 테마 색상 Monet",
|
||||||
|
"settings.appearance.theme_color_mode.wallpaper": "배경화면 Monet 색상",
|
||||||
|
"settings.appearance.theme_color_mode_desc.neutral": "표준 주간 흰색 배경 검은 텍스트와 야간 검은 배경 흰색 텍스트 중성색 표면을 사용합니다.",
|
||||||
|
"settings.appearance.theme_color_mode_desc.user": "사용자가 선택한 테마 색상을 전체 바탕화면 셸의 Monet 시드 색상으로 사용합니다.",
|
||||||
|
"settings.appearance.theme_color_mode_desc.wallpaper": "배경화면 색상을 사용합니다. 앱 배경화면을 우선하고 실패 시 시스템 바탕화면 배경화면으로 대체합니다.",
|
||||||
|
"settings.appearance.theme_color_preview.app": "현재 앱 배경화면에서 추출한 색상을 미리보고 있습니다.",
|
||||||
|
"settings.appearance.theme_color_preview.system": "현재 시스템 배경화면에서 추출한 색상을 미리보고 있습니다.",
|
||||||
|
"settings.appearance.theme_color_preview.fallback": "사용 가능한 배경화면이 없어 현재 대체 강조 색상을 사용합니다.",
|
||||||
|
"component.color_scheme.follow_system": "시스템 색상 구성 따르기",
|
||||||
|
"component.color_scheme.native": "컴포넌트 사용자 지정 색상 구성 사용",
|
||||||
|
"settings.appearance.system_material.none": "없음",
|
||||||
|
"settings.appearance.system_material.mica": "Mica",
|
||||||
|
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||||
|
"settings.appearance.system_material_desc.switchable": "선택한 소재를 창, Dock, 상태 표시줄 및 컴포넌트 호스트 배경에 적용합니다.",
|
||||||
|
"settings.appearance.system_material_desc.fixed": "현재 시스템은 여기에 나열된 소재 모드만 제공합니다.",
|
||||||
|
"settings.appearance.restart_message": "테마 색상 소스 및 시스템 소재 변경은 앱 재시작이 필요합니다.",
|
||||||
|
"settings.appearance.preview.primary": "주 색상",
|
||||||
|
"settings.appearance.preview.secondary": "보조 색상",
|
||||||
|
"settings.appearance.preview.tertiary": "제3 색상",
|
||||||
|
"settings.appearance.preview.neutral": "중성 색상",
|
||||||
|
"settings.appearance.preview.seed": "시드 색상",
|
||||||
|
"settings.appearance.preview.neutral_light": "흰색",
|
||||||
|
"settings.appearance.preview.neutral_dark": "검은색",
|
||||||
|
"settings.appearance.preview.apply_seed": "적용",
|
||||||
|
"settings.appearance.preview.wallpaper_candidates": "배경화면 후보 테마 색상",
|
||||||
|
"settings.appearance.preview.wallpaper_current": "현재",
|
||||||
|
"settings.wallpaper.placement.fill": "채우기",
|
||||||
|
"settings.wallpaper.placement.fit": "맞추기",
|
||||||
|
"settings.wallpaper.placement.stretch": "늘리기",
|
||||||
|
"settings.wallpaper.placement.center": "가운데",
|
||||||
|
"settings.wallpaper.placement.tile": "바둑판",
|
||||||
|
"settings.status_bar.clock_format_label": "시계 형식",
|
||||||
|
"settings.status_bar.clock_format.hm": "시:분",
|
||||||
|
"settings.status_bar.clock_format.hms": "시:분:초",
|
||||||
|
"settings.components.title": "컴포넌트",
|
||||||
|
"settings.components.description": "컴포넌트 레이아웃과 모서리 디자인을 조정합니다.",
|
||||||
|
"settings.components.grid_header": "그리드 설정",
|
||||||
|
"settings.components.header": "그리드 설정",
|
||||||
|
"settings.components.short_side_label": "짧은 쪽 셀 수",
|
||||||
|
"settings.components.edge_inset_label": "화면 여백",
|
||||||
|
"settings.components.spacing_label": "컴포넌트 간격",
|
||||||
|
"settings.components.spacing_compact": "컴팩트",
|
||||||
|
"settings.components.spacing_relaxed": "여유 있음",
|
||||||
|
"settings.components.corner_radius.header": "모서리 디자인",
|
||||||
|
"settings.components.corner_radius.label": "컴포넌트 모서리",
|
||||||
|
"settings.components.corner_radius.description": "컴포넌트 컨테이너 모서리를 직각에서 캡슐 모양에 가깝게 연속 조정하고 모서리가 커짐에 따라 내부 안전 영역도 확장합니다.",
|
||||||
|
"settings.update.title": "업데이트",
|
||||||
|
"settings.update.current_version_label": "현재 버전",
|
||||||
|
"settings.update.latest_version_label": "최신 릴리스",
|
||||||
|
"settings.update.published_at_label": "게시일",
|
||||||
|
"settings.update.options_header": "업데이트 옵션",
|
||||||
|
"settings.update.options_desc": "업데이트 확인과 릴리스 채널을 구성합니다.",
|
||||||
|
"settings.update.auto_check_toggle": "시작 시 자동 업데이트 확인",
|
||||||
|
"settings.update.include_prerelease_toggle": "사전 릴리스 버전 포함",
|
||||||
|
"settings.update.channel_label": "업데이트 채널",
|
||||||
|
"settings.update.channel_stable": "정식 버전",
|
||||||
|
"settings.update.channel_preview": "미리보기 버전",
|
||||||
|
"settings.update.actions_header": "업데이트 작업",
|
||||||
|
"settings.update.actions_desc": "릴리스 확인, 설치 패키지 다운로드 및 업데이트 시작.",
|
||||||
|
"settings.update.check_button": "업데이트 확인",
|
||||||
|
"settings.update.download_install_button": "다운로드 및 설치",
|
||||||
|
"settings.update.download_progress_idle": "다운로드 진행률: -",
|
||||||
|
"settings.update.download_progress_format": "다운로드 진행률: {0:F0}%",
|
||||||
|
"settings.update.status_ready": "업데이트 확인을 시작할 수 있습니다.",
|
||||||
|
"settings.update.status_channel_changed": "업데이트 채널이 변경되었습니다. 다시 업데이트를 확인하세요.",
|
||||||
|
"settings.update.status_channel_changed_format": "업데이트 채널이 {0}(으)로 전환되었습니다. 다시 업데이트를 확인하세요.",
|
||||||
|
"settings.update.status_windows_only": "자동 설치 패키지 업데이트는 현재 Windows만 지원합니다.",
|
||||||
|
"settings.update.status_checking": "GitHub Release 확인 중...",
|
||||||
|
"settings.update.status_check_failed_format": "업데이트 확인 실패: {0}",
|
||||||
|
"settings.update.status_up_to_date": "현재 최신 버전입니다.",
|
||||||
|
"settings.update.status_asset_missing": "새 버전이 발견되었지만 호환되는 설치 패키지를 찾을 수 없습니다.",
|
||||||
|
"settings.update.status_available_format": "새 버전 {0}이(가) 발견되었습니다. \"다운로드 및 설치\"를 클릭하여 계속하세요.",
|
||||||
|
"settings.update.status_downloading": "설치 패키지 다운로드 중...",
|
||||||
|
"settings.update.status_download_failed_format": "다운로드 실패: {0}",
|
||||||
|
"settings.update.status_launching_installer": "다운로드 완료, 설치 프로그램 시작 중...",
|
||||||
|
"settings.update.status_installer_missing": "다운로드 후 설치 패키지 파일을 찾을 수 없습니다.",
|
||||||
|
"settings.update.status_installer_started": "설치 프로그램이 시작되었습니다. 앱이 업데이트를 위해 종료됩니다.",
|
||||||
|
"settings.update.status_elevation_cancelled": "관리자 권한이 부여되지 않아 업데이트가 취소되었습니다.",
|
||||||
|
"settings.update.status_launch_failed_format": "설치 프로그램 시작 실패: {0}",
|
||||||
|
"settings.about.title": "정보",
|
||||||
|
"settings.about.version_format": "버전: {0}",
|
||||||
|
"settings.about.codename_format": "버전 코드명: {0}",
|
||||||
|
"settings.about.font_format": "글꼴: {0}",
|
||||||
|
"settings.about.startup_header": "Windows 자동 시작",
|
||||||
|
"settings.about.startup_desc": "Windows 로그인 시 앱을 자동으로 시작합니다.",
|
||||||
|
"settings.about.startup_toggle": "Windows 로그인 시 시작",
|
||||||
|
"settings.about.render_mode_header": "앱 렌더링 모드",
|
||||||
|
"settings.about.render_mode_desc": "앱 렌더링 백엔드를 선택합니다. 변경 후 앱 재시작이 필요합니다. 지원하지 않는 모드는 소프트웨어 렌더링으로 대체됩니다.",
|
||||||
|
"settings.about.render_mode.default": "기본",
|
||||||
|
"settings.about.render_mode.software": "소프트웨어",
|
||||||
|
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||||
|
"settings.about.render_mode.wgl": "WGL",
|
||||||
|
"settings.about.render_mode.vulkan": "Vulkan",
|
||||||
|
"settings.about.render_mode.unknown": "알 수 없음",
|
||||||
|
"settings.about.render_mode.current_label": "현재 실제 렌더링 백엔드",
|
||||||
|
"settings.about.render_mode.current_format": "현재 백엔드: {0}",
|
||||||
|
"settings.about.render_mode.impl_format": "런타임 구현: {0}",
|
||||||
|
"settings.about.render_mode.impl_unavailable": "현재 런타임 구현 정보를 가져올 수 없습니다.",
|
||||||
|
"settings.about.description": "앱 정보.",
|
||||||
|
"settings.update.description": "업데이트 확인, 릴리스 채널 및 다운로드 소스 선택, 업데이트 설치 방법 제어.",
|
||||||
|
"settings.update.status_card_title": "업데이트 상태",
|
||||||
|
"settings.update.status_card_description": "새 버전 확인, 릴리스 정보 보기, 업데이트 시 다운로드 또는 설치 계속.",
|
||||||
|
"settings.update.preferences_header": "업데이트 설정",
|
||||||
|
"settings.update.preferences_description": "릴리스 채널, 설치 패키지 다운로드 소스, 설치 방법 및 다운로드 병렬 스레드 수를 선택합니다.",
|
||||||
|
"settings.update.last_checked_label": "마지막 확인",
|
||||||
|
"settings.update.source_label": "다운로드 소스",
|
||||||
|
"settings.update.source_github": "GitHub",
|
||||||
|
"settings.update.source_ghproxy": "gh-proxy",
|
||||||
|
"settings.update.source_github_desc": "GitHub에서 직접 릴리스 설치 패키지를 다운로드합니다.",
|
||||||
|
"settings.update.source_ghproxy_desc": "GitHub 릴리스 설치 패키지를 다운로드할 때 gh-proxy 미러를 사용합니다.",
|
||||||
|
"settings.update.mode_label": "업데이트 모드",
|
||||||
|
"settings.update.mode_manual": "수동 업데이트",
|
||||||
|
"settings.update.mode_download_then_confirm": "자동 다운로드",
|
||||||
|
"settings.update.mode_silent_on_exit": "자동 설치",
|
||||||
|
"settings.update.mode_manual_desc": "업데이트만 확인합니다. 다운로드와 설치 시기는 사용자가 결정합니다.",
|
||||||
|
"settings.update.mode_download_then_confirm_desc": "백그라운드에서 업데이트를 다운로드하고 완료 후 설치 여부를 확인합니다.",
|
||||||
|
"settings.update.mode_silent_on_exit_desc": "백그라운드에서 업데이트를 다운로드하고 다음 앱 종료 시 자동으로 설치합니다.",
|
||||||
|
"settings.update.channel_stable_desc": "정식 버전은 안정성을 우선하며 대부분의 사용자에게 적합합니다.",
|
||||||
|
"settings.update.channel_preview_desc": "미리보기 버전은 더 빠른 새 기능을 포함할 수 있지만 안정성이 낮을 수 있습니다.",
|
||||||
|
"settings.update.download_threads_label": "다운로드 스레드 수",
|
||||||
|
"settings.update.download_threads_desc": "앱 업데이트 설치 패키지에 사용할 병렬 다운로드 스레드 수를 설정합니다.",
|
||||||
|
"settings.update.force_check_label": "강제 업데이트 확인",
|
||||||
|
"settings.update.force_check_desc": "버전 비교를 무시하고 GitHub에서 강제로 최신 버전을 가져옵니다.",
|
||||||
|
"settings.update.status_force_checking": "GitHub 릴리스 강제 확인 중...",
|
||||||
|
"settings.update.status_force_no_asset": "릴리스를 찾았지만 호환되는 설치 프로그램이 없습니다.",
|
||||||
|
"settings.update.status_force_available_format": "릴리스 {0}을(를) 사용할 수 있습니다. '다운로드 및 설치'를 클릭하세요.",
|
||||||
|
"settings.update.install_now_button": "지금 설치",
|
||||||
|
"settings.update.status_downloaded_confirm": "업데이트가 다운로드되었습니다. 확인 후 설치 시기를 선택하세요.",
|
||||||
|
"settings.update.status_downloaded_exit": "업데이트가 다운로드되었습니다. 앱 종료 시 설치됩니다.",
|
||||||
|
"settings.about.app_info_header": "앱 정보",
|
||||||
|
"settings.about.update_header": "업데이트",
|
||||||
|
"settings.about.version_label": "버전",
|
||||||
|
"settings.about.codename_label": "버전 코드명",
|
||||||
|
"settings.about.render_backend_label": "렌더링 백엔드",
|
||||||
|
"settings.about.render_backend_format": "렌더링 백엔드: {0}",
|
||||||
|
"settings.restart_dialog.title": "앱 재시작 필요",
|
||||||
|
"settings.restart_dialog.render_mode_message": "렌더링 모드를 \"{0}\"에서 \"{1}\"(으)로 변경하려면 앱을 재시작해야 합니다. 지금 재시작하시겠습니까?",
|
||||||
|
"settings.restart_dialog.restart": "지금 재시작",
|
||||||
|
"settings.restart_dialog.later": "나중에",
|
||||||
|
"settings.restart_dialog.cancel": "취소",
|
||||||
|
"settings.restart_dock.title": "앱 재시작 필요",
|
||||||
|
"settings.restart_dock.description": "일부 변경 사항은 앱 재시작 후에 적용됩니다.",
|
||||||
|
"settings.restart_dock.button": "앱 재시작",
|
||||||
|
"settings.footer": "LanMountainDesktop 설정",
|
||||||
|
"filepicker.title": "배경화면 선택",
|
||||||
|
"filepicker.image_files": "이미지 파일",
|
||||||
|
"common.day": "주간",
|
||||||
|
"common.night": "야간",
|
||||||
|
"common.back": "뒤로",
|
||||||
|
"common.close": "닫기",
|
||||||
|
"common.unknown": "알 수 없는 오류",
|
||||||
|
"common.recommended": "추천",
|
||||||
|
"common.monet": "Monet",
|
||||||
|
"desktop.page_index_format": "바탕화면 {0}",
|
||||||
|
"launcher.title": "앱 런처",
|
||||||
|
"launcher.folder": "폴더",
|
||||||
|
"launcher.subtitle": "Windows 시작 메뉴 구조에 따라 모든 앱과 폴더 표시",
|
||||||
|
"launcher.subtitle_linux": "Linux .desktop 항목에서 스캔한 설치된 앱 표시",
|
||||||
|
"launcher.empty": "시작 메뉴 항목을 찾을 수 없습니다.",
|
||||||
|
"launcher.empty_linux": "Linux .desktop 앱 항목을 찾을 수 없습니다.",
|
||||||
|
"launcher.empty_folder": "이 폴더는 비어 있습니다.",
|
||||||
|
"launcher.folder_items_format": "{0}개 앱",
|
||||||
|
"launcher.context.hide_icon": "아이콘 숨기기",
|
||||||
|
"launcher.action.hide": "숨기기",
|
||||||
|
"settings.launcher.title": "앱 런처",
|
||||||
|
"settings.launcher.description": "앱 런처에서 숨겨진 앱과 폴더를 관리합니다.",
|
||||||
|
"settings.launcher.hidden_header": "숨겨진 항목",
|
||||||
|
"settings.launcher.hidden_desc": "숨겨진 런처 항목을 보고 다시 표시합니다.",
|
||||||
|
"settings.launcher.hidden_hint": "바탕화면 편집 모드에서 런처 아이콘을 선택하고 \"숨기기\"를 클릭하면 숨겨진 항목이 여기에 표시됩니다.",
|
||||||
|
"settings.launcher.hidden_empty": "숨겨진 항목이 없습니다.",
|
||||||
|
"settings.launcher.hidden_summary_format": "총 {0}개 숨겨진 항목",
|
||||||
|
"settings.launcher.hidden_type_folder": "폴더",
|
||||||
|
"settings.launcher.hidden_type_shortcut": "앱",
|
||||||
|
"settings.launcher.restore_button": "숨기기 해제",
|
||||||
|
"settings.plugins.title": "플러그인",
|
||||||
|
"settings.plugins.runtime_header": "플러그인 런타임",
|
||||||
|
"settings.plugins.runtime_desc": "플러그인 런타임 상태, 로드 결과 및 진단 정보를 확인합니다.",
|
||||||
|
"settings.plugins.runtime_hint": "설치된 플러그인의 발견 결과, 로드 상태 및 런타임 진단 정보가 여기에 표시됩니다.",
|
||||||
|
"settings.plugins.runtime_status": "플러그인 스캔이 완료되면 런타임 상태가 여기에 표시됩니다.",
|
||||||
|
"settings.plugins.description": "설치된 플러그인을 관리하고 런타임 상태를 확인합니다.",
|
||||||
|
"settings.plugins.initial_status": "플러그인 상태를 새로고침하여 최신 설치된 플러그인을 확인하세요.",
|
||||||
|
"settings.plugins.refresh_button": "플러그인 새로고침",
|
||||||
|
"settings.plugins.refresh_success_installed_format": "{0}개 설치된 플러그인을 로드했습니다.",
|
||||||
|
"settings.plugins.refresh_success_format": "{0}개 설치된 플러그인과 {1}개 마켓 항목을 로드했습니다.",
|
||||||
|
"settings.plugins.refresh_failed": "플러그인 카탈로그 인덱스 로드 실패.",
|
||||||
|
"settings.plugins.marketplace_header": "플러그인 카탈로그",
|
||||||
|
"settings.plugins.marketplace_empty": "현재 사용 가능한 마켓 플러그인이 없습니다.",
|
||||||
|
"settings.plugins.delete_button_short": "삭제",
|
||||||
|
"settings.plugins.install_button_short": "설치",
|
||||||
|
"settings.plugins.restart_required": "플러그인 변경 사항은 재시작 후 적용됩니다.",
|
||||||
|
"settings.plugins.toggle_unchanged_format": "플러그인 \"{0}\"에 변경 사항이 없습니다.",
|
||||||
|
"settings.plugins.delete_failed_name_format": "플러그인 \"{0}\" 제거 실패.",
|
||||||
|
"settings.plugins.install_failed_name_format": "플러그인 \"{0}\" 설치 실패.",
|
||||||
|
"settings.plugins.installed_header": "설치된 플러그인",
|
||||||
|
"settings.plugins.installed_desc": "여기서 설치된 플러그인을 보고 삭제합니다.",
|
||||||
|
"settings.plugins.import_header": "설치 패키지에서 가져오기",
|
||||||
|
"settings.plugins.import_desc": ".laapp 플러그인 패키지를 열고 로컬 플러그인 디렉토리에 스테이징합니다.",
|
||||||
|
"settings.plugins.restart_hint": "플러그인 설치 및 삭제 변경 사항은 앱 재시작 후 적용됩니다.",
|
||||||
|
"settings.plugins.empty": "플러그인을 찾을 수 없습니다.",
|
||||||
|
"settings.plugins.runtime_unavailable": "플러그인 런타임을 사용할 수 없습니다.",
|
||||||
|
"settings.plugins.summary_format": "총 {0}개 플러그인 감지됨; {1}개 활성화됨; {2}개 로드됨; {3}개 설정 페이지; {4}개 컴포넌트; {5}개 실패.",
|
||||||
|
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||||
|
"settings.plugins.state.enabled": "활성화됨",
|
||||||
|
"settings.plugins.state.enabled_failed": "활성화됨 / 로드 실패",
|
||||||
|
"settings.plugins.state.disabled": "비활성화됨",
|
||||||
|
"settings.plugins.state.loaded": "로드됨",
|
||||||
|
"settings.plugins.state.load_failed": "로드 실패",
|
||||||
|
"settings.plugins.toggle_on": "활성화",
|
||||||
|
"settings.plugins.toggle_off": "비활성화",
|
||||||
|
"settings.plugins.toggle_result_format": "플러그인 \"{0}\"이(가) 다음 시작 시 {1}(으)로 설정되었습니다. 앱 재시작 후 설정 페이지와 컴포넌트 변경 사항이 적용됩니다.",
|
||||||
|
"settings.plugins.toggle_state_enabled": "활성화",
|
||||||
|
"settings.plugins.toggle_state_disabled": "비활성화",
|
||||||
|
"settings.plugins.toggle_failed_detail_format": "플러그인 \"{0}\" 상태 업데이트 실패: {1}",
|
||||||
|
"settings.plugins.install_button": ".laapp 플러그인 패키지 열기",
|
||||||
|
"settings.plugins.install_unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 .laapp 플러그인 패키지를 설치할 수 없습니다.",
|
||||||
|
"settings.plugins.install_hint_format": ".laapp 플러그인 패키지를 열어 설치: {0}",
|
||||||
|
"settings.plugins.install_picker_title": "플러그인 설치 패키지 선택",
|
||||||
|
"settings.plugins.install_file_type": ".laapp 플러그인 패키지",
|
||||||
|
"settings.plugins.install_picker_unavailable": "파일 저장소 제공자를 사용할 수 없습니다.",
|
||||||
|
"settings.plugins.install_copy_failed": "선택한 .laapp 플러그인 패키지 복사 실패.",
|
||||||
|
"settings.plugins.install_success_format": "플러그인 \"{0}\" 설치 완료. 앱 재시작 후 새 설정 페이지와 컴포넌트가 적용됩니다.",
|
||||||
|
"settings.plugins.install_failed_format": "플러그인 패키지 설치 실패: {0}",
|
||||||
|
"settings.plugins.delete_button": "플러그인 삭제",
|
||||||
|
"settings.plugins.delete_success_format": "플러그인 \"{0}\"이(가) 삭제 예정입니다. 앱 재시작 후 제거가 완료됩니다.",
|
||||||
|
"settings.plugins.delete_failed_format": "플러그인 삭제 실패: {0}",
|
||||||
|
"settings.plugins.delete_failed_detail_format": "플러그인 \"{0}\" 삭제 실패: {1}",
|
||||||
|
"settings.plugins.publisher_format": "게시자: {0}",
|
||||||
|
"settings.plugins.publisher_unknown": "알 수 없는 게시자",
|
||||||
|
"settings.plugins.source_package": ".laapp 패키지",
|
||||||
|
"settings.plugins.source_manifest": "매니페스트 파일",
|
||||||
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
|
"settings.plugins.detail_format": "설정 페이지: {0} | 컴포넌트: {1}",
|
||||||
|
"settings.nav.plugin_catalog": "플러그인 카탈로그",
|
||||||
|
"settings.plugin_catalog.title": "플러그인 카탈로그",
|
||||||
|
"settings.plugin_catalog.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
|
||||||
|
"settings.plugin_catalog.unavailable": "플러그인 런타임을 사용할 수 없어 일시적으로 공식 카탈로그를 열 수 없습니다.",
|
||||||
|
"settings.update.status_idle": "아직 업데이트 확인이 수행되지 않았습니다.",
|
||||||
|
"settings.update.status_preferences_saved": "업데이트 설정이 저장되었습니다.",
|
||||||
|
"settings.update.status_check_failed": "업데이트 확인 실패.",
|
||||||
|
"settings.update.status_available_summary_format": "업데이트 발견: {0} (현재: {1}).",
|
||||||
|
"settings.update.status_up_to_date_format": "현재 최신 버전입니다 ({0}).",
|
||||||
|
"settings.window.drawer_default": "상세 정보",
|
||||||
|
"market.toolbar.search_placeholder": "플러그인 검색",
|
||||||
|
"market.toolbar.refresh": "새로고침",
|
||||||
|
"market.status.loading": "공식 플러그인 카탈로그 로딩 중...",
|
||||||
|
"market.status.loaded_network_format": "공식 소스에서 {0}개 플러그인을 로드했습니다.",
|
||||||
|
"market.status.loaded_cache_format": "공식 소스를 일시적으로 사용할 수 없어 캐시에서 {0}개 플러그인을 로드했습니다. 원인: {1}",
|
||||||
|
"market.status.load_failed_format": "플러그인 카탈로그 로드 실패: {0}",
|
||||||
|
"market.status.installing_format": "플러그인 \"{0}\" 다운로드 및 스테이징 중...",
|
||||||
|
"market.status.install_success_format": "플러그인 \"{0}\" 스테이징 완료. 앱 재시작 후 적용됩니다.",
|
||||||
|
"market.status.install_failed_format": "플러그인 설치 실패: {0}",
|
||||||
|
"market.status.host_incompatible_format": "현재 호스트 버전이 너무 낮습니다. 최소 {0}이(가) 필요합니다.",
|
||||||
|
"market.list.empty": "플러그인 카탈로그이 아직 로드되지 않았습니다.",
|
||||||
|
"market.list.no_results": "현재 검색과 일치하는 플러그인이 없습니다.",
|
||||||
|
"market.card.subtitle_format": "{0} | v{1}",
|
||||||
|
"market.card.loaded": "로드됨",
|
||||||
|
"market.card.pending_restart": "재시작 필요",
|
||||||
|
"market.detail.placeholder": "왼쪽에서 플러그인을 선택하여 상세 정보를 확인하세요.",
|
||||||
|
"market.detail.author": "게시자",
|
||||||
|
"market.detail.version": "버전",
|
||||||
|
"market.detail.api_version": "API 버전",
|
||||||
|
"market.detail.min_host_version": "최소 호스트 버전",
|
||||||
|
"market.detail.installed_version": "설치된 버전",
|
||||||
|
"market.detail.not_installed": "미설치",
|
||||||
|
"market.detail.readme": "README",
|
||||||
|
"market.detail.plugin_information": "플러그인 정보",
|
||||||
|
"market.detail.author_subtitle_format": "작성자: {0}",
|
||||||
|
"market.detail.package_size": "패키지 크기",
|
||||||
|
"market.detail.published_at": "최초 게시",
|
||||||
|
"market.detail.updated_at": "최근 업데이트",
|
||||||
|
"market.detail.tags": "태그",
|
||||||
|
"market.detail.project": "프로젝트",
|
||||||
|
"market.detail.state": "설치 상태",
|
||||||
|
"market.detail.market_source": "마켓 소스",
|
||||||
|
"market.detail.homepage": "홈페이지",
|
||||||
|
"market.detail.repository": "저장소",
|
||||||
|
"market.detail.release_notes": "릴리스 노트",
|
||||||
|
"market.detail.dependencies": "의존성",
|
||||||
|
"market.detail.dependencies_empty": "이 플러그인은 SharedContracts 의존성을 선언하지 않았습니다.",
|
||||||
|
"market.detail.readme_loading": "README 로딩 중...",
|
||||||
|
"market.detail.readme_empty": "README가 비어 있습니다.",
|
||||||
|
"market.detail.readme_error_format": "README 로드 실패: {0}",
|
||||||
|
"market.detail.state.not_installed": "미설치",
|
||||||
|
"market.detail.state.update_available": "업데이트 가능",
|
||||||
|
"market.detail.state.installed": "설치됨",
|
||||||
|
"market.detail.unknown": "알 수 없음",
|
||||||
|
"market.button.install": "설치",
|
||||||
|
"market.button.update": "업데이트",
|
||||||
|
"market.button.installed": "설치됨",
|
||||||
|
"market.button.installing": "설치 중...",
|
||||||
|
"market.button.restart": "재시작 후 적용",
|
||||||
|
"button.component_library": "바탕화면 편집",
|
||||||
|
"tooltip.component_library": "바탕화면 편집",
|
||||||
|
"component_library.title": "바탕화면 편집",
|
||||||
|
"component_library.empty": "좌우로 스와이프하여 카테고리를 선택하고 클릭하여 진입한 후 컴포넌트를 바탕화면에 드래그하여 배치하세요.",
|
||||||
|
"component_library.drag_hint": "드래그하여 배치",
|
||||||
|
"component.delete": "삭제",
|
||||||
|
"component.edit": "편집",
|
||||||
|
"component.editor.instance_scope": "설정은 현재 컴포넌트 인스턴스에만 적용됩니다.",
|
||||||
|
"component.editor.info_header": "컴포넌트 정보",
|
||||||
|
"component.editor.id_label": "컴포넌트 ID",
|
||||||
|
"component.editor.placement_label": "인스턴스 ID",
|
||||||
|
"component.editor.scope_label": "범위",
|
||||||
|
"component.editor.scope_instance": "인스턴스 수준 편집기",
|
||||||
|
"component_category.clock": "시계",
|
||||||
|
"component_category.date": "달력",
|
||||||
|
"component_category.weather": "날씨",
|
||||||
|
"component_category.board": "화이트보드",
|
||||||
|
"component_category.media": "미디어",
|
||||||
|
"component_category.info": "정보 추천",
|
||||||
|
"component_category.calculator": "계산기",
|
||||||
|
"component_category.study": "공부",
|
||||||
|
"component_category.file": "파일",
|
||||||
|
"component.date": "달력",
|
||||||
|
"component.month_calendar": "월간 달력",
|
||||||
|
"component.lunar_calendar": "음력",
|
||||||
|
"component.desktop_clock": "시계",
|
||||||
|
"component.weather_clock": "날씨 시계",
|
||||||
|
"component.world_clock": "세계 시계",
|
||||||
|
"component.desktop_timer": "타이머",
|
||||||
|
"component.desktop_weather": "날씨",
|
||||||
|
"component.hourly_weather": "시간별 날씨",
|
||||||
|
"component.multiday_weather": "다일 날씨",
|
||||||
|
"component.extended_weather": "확장 날씨",
|
||||||
|
"component.class_schedule": "시간표",
|
||||||
|
"component.music_control": "음악 제어",
|
||||||
|
"component.audio_recorder": "녹음",
|
||||||
|
"component.daily_poetry": "매일 시",
|
||||||
|
"component.daily_artwork": "매일 명화",
|
||||||
|
"component.daily_word": "매일 단어",
|
||||||
|
"component.daily_word_2x2": "매일 단어 2x2",
|
||||||
|
"component.cnr_daily_news": "CNR 뉴스",
|
||||||
|
"component.ifeng_news": "Ifeng 뉴스",
|
||||||
|
"component.bilibili_hot_search": "Bilibili 인기 검색",
|
||||||
|
"component.baidu_hot_search": "Baidu 인기 검색",
|
||||||
|
"component.stcn24_forum": "STCN 24",
|
||||||
|
"component.exchange_rate_converter": "환율 변환기",
|
||||||
|
"component.whiteboard": "세로 작은 칠판",
|
||||||
|
"component.blackboard_landscape": "가로 작은 칠판",
|
||||||
|
"component.browser": "브라우저",
|
||||||
|
"component.office_recent_documents": "최근 문서",
|
||||||
|
"whiteboard.settings.desc": "각 작은 칠판은 독립적으로 자신의 노트 기록을 저장합니다.",
|
||||||
|
"whiteboard.settings.retention.title": "노트 보존 기간",
|
||||||
|
"whiteboard.settings.retention.desc": "이 작은 칠판에서 만료된 노트가 자동 삭제되기 전에 저장된 노트를 얼마나 오래 보존할지 선택합니다.",
|
||||||
|
"whiteboard.settings.retention.option": "{0}일",
|
||||||
|
"whiteboard.settings.instance_scope": "이 보존 기간 설정은 각 작은 칠판 컴포넌트 인스턴스별로 개별 저장됩니다.",
|
||||||
|
"office_recent_documents.settings.desc": "이 위젯이 스캔할 Windows 및 Office 최근 문서 소스를 선택합니다.",
|
||||||
|
"office_recent_documents.settings.sources_title": "최근 문서 소스",
|
||||||
|
"office_recent_documents.settings.sources_desc": "여러 소스를 동시에 선택할 수 있습니다. 레지스트리 소스를 선택하면 Office interop MRU 대체도 유지됩니다.",
|
||||||
|
"office_recent_documents.settings.source.registry": "Office 레지스트리 MRU",
|
||||||
|
"office_recent_documents.settings.source.recent_folders": "Windows 최근 폴더",
|
||||||
|
"office_recent_documents.settings.source.jump_lists": "Windows 점프 목록",
|
||||||
|
"office_recent_documents.settings.hint": "모든 소스를 끄면 최소 하나의 소스를 다시 활성화할 때까지 이 위젯은 비어 있게 됩니다.",
|
||||||
|
"component.holiday_calendar": "공휴일 달력",
|
||||||
|
"component.study_environment": "환경",
|
||||||
|
"component.study_session_control": "공부 시간 제어",
|
||||||
|
"component.study_session_history": "기록 시간 데이터",
|
||||||
|
"component.study_noise_curve": "소음 곡선",
|
||||||
|
"component.study_noise_distribution": "소음 레벨 분포",
|
||||||
|
"component.study_score_overview": "공부 점수 개요",
|
||||||
|
"component.study_deduction_reasons": "감점 원인",
|
||||||
|
"component.study_interrupt_density": "방해 밀도",
|
||||||
|
"desktop_clock.settings.title": "시계 설정",
|
||||||
|
"desktop_clock.settings.desc": "단일 시계의 시간대를 선택합니다.",
|
||||||
|
"desktop_clock.settings.timezone_label": "시간대",
|
||||||
|
"desktop_clock.settings.second_mode_label": "초침 방식",
|
||||||
|
"clock.second_mode.tick": "똑딱이",
|
||||||
|
"clock.second_mode.sweep": "스윕",
|
||||||
|
"poetry.widget.loading_content": "시 불러오는 중",
|
||||||
|
"poetry.widget.loading_author": "로딩 중",
|
||||||
|
"poetry.widget.fetch_failed": "시 가져오기 실패",
|
||||||
|
"poetry.widget.fallback_content": "오늘의 시를 사용할 수 없습니다",
|
||||||
|
"poetry.widget.fallback_author": "나중에 다시 시도하세요",
|
||||||
|
"poetry.widget.unknown_author": "익명",
|
||||||
|
"artwork.widget.loading": "로딩 중",
|
||||||
|
"artwork.widget.loading_title": "매일 명화",
|
||||||
|
"artwork.widget.loading_subtitle": "오늘의 명화 가져오는 중",
|
||||||
|
"artwork.widget.fetch_failed": "명화 가져오기 실패",
|
||||||
|
"artwork.widget.fallback_title": "매일 명화",
|
||||||
|
"artwork.widget.fallback_artist": "추천 서비스를 사용할 수 없습니다",
|
||||||
|
"artwork.widget.fallback_year": "나중에 다시 시도하세요",
|
||||||
|
"artwork.widget.unknown_artist": "알 수 없는 작가",
|
||||||
|
"dailyword.widget.loading": "로딩 중...",
|
||||||
|
"dailyword.widget.loading_word": "매일 단어",
|
||||||
|
"dailyword.widget.loading_pronunciation": "발음 가져오는 중",
|
||||||
|
"dailyword.widget.loading_meaning": "뜻 가져오는 중",
|
||||||
|
"dailyword.widget.loading_example": "예문 가져오는 중",
|
||||||
|
"dailyword.widget.loading_example_translation": "로딩 중",
|
||||||
|
"dailyword.widget.fetch_failed": "매일 단어 가져오기 실패",
|
||||||
|
"dailyword.widget.fallback_word": "매일 단어",
|
||||||
|
"dailyword.widget.fallback_pronunciation": "발음을 사용할 수 없습니다",
|
||||||
|
"dailyword.widget.fallback_meaning": "Youdao 사전을 사용할 수 없습니다",
|
||||||
|
"dailyword.widget.fallback_example": "오른쪽 상단 새로고침을 클릭하여 다시 시도하세요",
|
||||||
|
"dailyword.widget.fallback_example_translation": "네트워크 복구 후 자동 업데이트됩니다",
|
||||||
|
"dailyword2x2.widget.tap_to_show": "탭하여 뜻 보기",
|
||||||
|
"cnrnews.widget.loading": "로딩 중...",
|
||||||
|
"cnrnews.widget.loading_title": "뉴스 헤드라인 가져오는 중",
|
||||||
|
"cnrnews.widget.loading_subtitle": "잠시 기다려주세요",
|
||||||
|
"cnrnews.widget.fetch_failed": "뉴스 가져오기 실패",
|
||||||
|
"cnrnews.widget.fallback_title": "CNR 뉴스를 사용할 수 없습니다",
|
||||||
|
"cnrnews.widget.fallback_subtitle": "오른쪽 상단을 클릭하여 나중에 다시 시도하세요",
|
||||||
|
"cnrnews.widget.hot_label": "핫",
|
||||||
|
"bilihot.widget.brand": "bilibili 인기 검색",
|
||||||
|
"bilihot.widget.top_right_label": "bilibili 인기 검색",
|
||||||
|
"bilihot.widget.search_entry": "검색",
|
||||||
|
"bilihot.widget.search_placeholder": "인기 검색어 검색",
|
||||||
|
"bilihot.widget.loading": "로딩 중...",
|
||||||
|
"bilihot.widget.loading_item": "로딩 중...",
|
||||||
|
"bilihot.widget.fetch_failed": "인기 검색 가져오기 실패",
|
||||||
|
"bilihot.widget.fallback_item": "인기 검색 없음",
|
||||||
|
"bilihot.widget.more_hot": "더 많은 인기 검색",
|
||||||
|
"baiduhot.widget.brand": "Baidu 인기 검색",
|
||||||
|
"baiduhot.widget.loading": "로딩 중...",
|
||||||
|
"baiduhot.widget.loading_item": "로딩 중...",
|
||||||
|
"baiduhot.widget.fetch_failed": "인기 검색 가져오기 실패",
|
||||||
|
"baiduhot.widget.fallback_item": "인기 검색 없음",
|
||||||
|
"baiduhot.widget.refresh_tooltip": "새로고침",
|
||||||
|
"ifeng.widget.brand": "Ifeng 뉴스",
|
||||||
|
"ifeng.widget.loading": "로딩 중...",
|
||||||
|
"ifeng.widget.loading_item": "로딩 중...",
|
||||||
|
"ifeng.widget.fetch_failed": "뉴스 가져오기 실패",
|
||||||
|
"ifeng.widget.fallback_item": "뉴스 없음",
|
||||||
|
"ifeng.widget.refresh_tooltip": "새로고침",
|
||||||
|
"dailyword.settings.title": "매일 단어 설정",
|
||||||
|
"dailyword.settings.desc": "자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"dailyword.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"dailyword.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"dailyword.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"bilihot.settings.title": "Bilibili 인기 검색 설정",
|
||||||
|
"bilihot.settings.desc": "자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"bilihot.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"bilihot.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"bilihot.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"baiduhot.settings.title": "Baidu 인기 검색 설정",
|
||||||
|
"baiduhot.settings.desc": "데이터 소스, 자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"baiduhot.settings.source_label": "데이터 소스",
|
||||||
|
"baiduhot.settings.source_official": "Baidu 공식 소스",
|
||||||
|
"baiduhot.settings.source_rss": "타사 RSS 소스",
|
||||||
|
"baiduhot.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"baiduhot.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"baiduhot.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"ifeng.settings.title": "Ifeng 뉴스 설정",
|
||||||
|
"ifeng.settings.desc": "채널, 자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"ifeng.settings.channel_label": "뉴스 채널",
|
||||||
|
"ifeng.settings.channel_comprehensive": "종합",
|
||||||
|
"ifeng.settings.channel_mainland": "중국 본토",
|
||||||
|
"ifeng.settings.channel_taiwan": "대만",
|
||||||
|
"ifeng.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"ifeng.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"ifeng.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"refresh.frequency.5m": "5분",
|
||||||
|
"refresh.frequency.10m": "10분",
|
||||||
|
"refresh.frequency.12m": "12분",
|
||||||
|
"refresh.frequency.15m": "15분",
|
||||||
|
"refresh.frequency.20m": "20분",
|
||||||
|
"refresh.frequency.30m": "30분",
|
||||||
|
"refresh.frequency.40m": "40분",
|
||||||
|
"refresh.frequency.1h": "1시간",
|
||||||
|
"refresh.frequency.3h": "3시간",
|
||||||
|
"refresh.frequency.6h": "6시간",
|
||||||
|
"refresh.frequency.12h": "12시간",
|
||||||
|
"refresh.frequency.24h": "24시간",
|
||||||
|
"weather.widget.settings.title": "날씨 컴포넌트 설정",
|
||||||
|
"weather.widget.settings.desc": "모든 날씨 컴포넌트의 자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"weather.widget.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"weather.widget.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"weather.widget.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"weather.widget.settings.frequency_10m": "10분",
|
||||||
|
"weather.widget.settings.frequency_12m": "12분",
|
||||||
|
"weather.widget.settings.frequency_15m": "15분",
|
||||||
|
"weather.widget.settings.frequency_30m": "30분",
|
||||||
|
"weather.widget.settings.frequency_1h": "1시간",
|
||||||
|
"weather.widget.settings.frequency_3h": "3시간",
|
||||||
|
"stcn24.widget.loading": "로딩 중...",
|
||||||
|
"stcn24.widget.loading_item": "로딩 중...",
|
||||||
|
"stcn24.widget.fetch_failed": "게시물 가져오기 실패",
|
||||||
|
"stcn24.widget.fallback_item": "게시물 없음",
|
||||||
|
"stcn24.settings.title": "STCN 24 설정",
|
||||||
|
"stcn24.settings.desc": "정보 소스, 자동 새로고침 설정과 새로고침 빈도를 구성합니다.",
|
||||||
|
"stcn24.settings.source_label": "정보 소스",
|
||||||
|
"stcn24.settings.source_latest_created": "최신 게시",
|
||||||
|
"stcn24.settings.source_latest_activity": "최신 답변",
|
||||||
|
"stcn24.settings.source_most_replies": "답변 많음",
|
||||||
|
"stcn24.settings.source_earliest_created": "가장 오래된 게시",
|
||||||
|
"stcn24.settings.source_earliest_activity": "가장 오래된 답변",
|
||||||
|
"stcn24.settings.source_least_replies": "답변 적음",
|
||||||
|
"stcn24.settings.source_frontpage_latest": "프론트 추천 (신규)",
|
||||||
|
"stcn24.settings.source_frontpage_earliest": "프론트 추천 (구형)",
|
||||||
|
"stcn24.settings.auto_refresh_label": "자동 새로고침",
|
||||||
|
"stcn24.settings.auto_refresh_enabled": "자동 새로고침 활성화",
|
||||||
|
"stcn24.settings.frequency_label": "새로고침 빈도",
|
||||||
|
"stcn24.settings.frequency_5m": "5분",
|
||||||
|
"stcn24.settings.frequency_10m": "10분",
|
||||||
|
"stcn24.settings.frequency_20m": "20분",
|
||||||
|
"stcn24.settings.frequency_30m": "30분",
|
||||||
|
"stcn24.settings.frequency_1h": "1시간",
|
||||||
|
"stcn24.settings.frequency_3h": "3시간",
|
||||||
|
"exchange.widget.loading": "환율 로딩 중...",
|
||||||
|
"exchange.widget.fetch_failed": "환율 가져오기 실패",
|
||||||
|
"cnrnews.settings.title": "CNR 뉴스 설정",
|
||||||
|
"cnrnews.settings.desc": "뉴스 자동 순환과 새로고침 빈도를 구성합니다.",
|
||||||
|
"cnrnews.settings.auto_rotate_label": "자동 순환",
|
||||||
|
"cnrnews.settings.auto_rotate_enabled": "자동 순환 활성화",
|
||||||
|
"cnrnews.settings.frequency_label": "순환 빈도",
|
||||||
|
"cnrnews.settings.frequency_5m": "5분",
|
||||||
|
"cnrnews.settings.frequency_10m": "10분",
|
||||||
|
"cnrnews.settings.frequency_40m": "40분",
|
||||||
|
"cnrnews.settings.frequency_1h": "1시간",
|
||||||
|
"cnrnews.settings.frequency_12h": "12시간",
|
||||||
|
"cnrnews.settings.frequency_24h": "24시간",
|
||||||
|
"artwork.settings.title": "매일 이미지 설정",
|
||||||
|
"artwork.settings.desc": "매일 이미지의 데이터 소스를 전환합니다.",
|
||||||
|
"artwork.settings.source_label": "미러 소스",
|
||||||
|
"artwork.settings.source_domestic": "국내 미러",
|
||||||
|
"artwork.settings.source_overseas": "해외 미러",
|
||||||
|
"artwork.settings.source_status_domestic": "현재 소스: 국내 미러 (중국 네트워크 우선)",
|
||||||
|
"artwork.settings.source_status_overseas": "현재 소스: 해외 미러 (미술관 추천)",
|
||||||
|
"music.widget.unsupported": "현재 플랫폼에서 음악 제어를 지원하지 않습니다",
|
||||||
|
"music.widget.unsupported_hint": "이 컴포넌트는 Windows SMTC만 지원합니다",
|
||||||
|
"music.widget.no_session": "음원 없음",
|
||||||
|
"music.widget.no_session_hint": "앱 스토어에서 \"QQ Music/Kugou Music/NetEase Cloud Music\"을 다운로드한 후 사용하세요",
|
||||||
|
"music.widget.open_player": "플레이어 열기",
|
||||||
|
"music.widget.unknown_title": "알 수 없는 곡",
|
||||||
|
"music.widget.unknown_artist": "알 수 없는 아티스트",
|
||||||
|
"music.widget.status.opened": "열림",
|
||||||
|
"music.widget.status.changing": "전환 중",
|
||||||
|
"music.widget.status.stopped": "정지됨",
|
||||||
|
"music.widget.status.playing": "재생 중",
|
||||||
|
"music.widget.status.paused": "일시정지됨",
|
||||||
|
"recording.widget.title": "녹음",
|
||||||
|
"recording.widget.hint.ready": "빨간 버튼을 클릭하여 시작",
|
||||||
|
"recording.widget.hint.recording": "녹음 중",
|
||||||
|
"recording.widget.hint.paused": "일시정지됨",
|
||||||
|
"recording.widget.hint.unsupported": "마이크를 사용할 수 없음",
|
||||||
|
"recording.widget.hint.error": "녹음 실패",
|
||||||
|
"recording.widget.hint.saved_format": "{0} 저장됨",
|
||||||
|
"recording.widget.save_picker_title": "녹음 파일 저장",
|
||||||
|
"recording.widget.save_picker_type": "WAV 오디오",
|
||||||
|
"study.environment.status_label": "환경 상태",
|
||||||
|
"study.environment.status.initializing": "초기화 중",
|
||||||
|
"study.environment.status.ready": "대기",
|
||||||
|
"study.environment.status.quiet": "조용함",
|
||||||
|
"study.environment.status.noisy": "시끄러움",
|
||||||
|
"study.environment.status.paused": "일시정지됨",
|
||||||
|
"study.environment.status.error": "오류",
|
||||||
|
"study.environment.status.unsupported": "지원하지 않음",
|
||||||
|
"study.environment.value.unavailable": "--",
|
||||||
|
"study.environment.value.display_format": "{0:F1} dB",
|
||||||
|
"study.environment.value.dbfs_format": "{0:F1} dBFS",
|
||||||
|
"component.removable_storage": "이동식 저장소",
|
||||||
|
"removable_storage.settings.desc": "연결된 USB 드라이브를 바탕화면에 표시하고 열기 및 꺼내기 작업을 제공합니다.",
|
||||||
|
"removable_storage.settings.behavior_title": "동작",
|
||||||
|
"removable_storage.settings.behavior_desc": "컴포넌트는 이동식 저장 장치를 자동으로 모니터링하고 가장 최근에 연결된 USB 드라이브를 우선 표시합니다.",
|
||||||
|
"removable_storage.action.open": "열기",
|
||||||
|
"removable_storage.action.eject": "꺼내기",
|
||||||
|
"removable_storage.widget.default_name": "이동식 디스크",
|
||||||
|
"removable_storage.widget.empty_title": "연결된 기기 없음",
|
||||||
|
"removable_storage.widget.empty_subtitle": "USB 드라이브를 연결하면 여기에 자동으로 표시됩니다.",
|
||||||
|
"removable_storage.widget.empty_hint": "이동식 기기를 연결하기 전까지 하단 버튼은 비활성화됩니다.",
|
||||||
|
"removable_storage.widget.ready": "준비 완료, 바로 열거나 꺼낼 수 있습니다.",
|
||||||
|
"removable_storage.widget.ejecting": "기기 꺼내는 중...",
|
||||||
|
"removable_storage.widget.eject_failed": "이 기기를 꺼낼 수 없습니다. 사용 중인 파일을 닫은 후 다시 시도하세요.",
|
||||||
|
"removable_storage.widget.open_failed": "이 기기를 열지 못했습니다.",
|
||||||
|
"removable_storage.widget.refresh_failed": "이동식 저장소 목록 새로고침 실패.",
|
||||||
|
"study.environment.settings.title": "환경 컴포넌트 설정",
|
||||||
|
"study.environment.settings.desc": "오른쪽 실시간 소음 값 표시 내용을 구성합니다.",
|
||||||
|
"study.environment.settings.show_display_db": "display dB 표시",
|
||||||
|
"study.environment.settings.show_dbfs": "dBFS 표시",
|
||||||
|
"study.environment.settings.hint": "최소 하나의 표시 방식을 활성화하세요.",
|
||||||
|
"study.session_control.action.start": "공부 시간 시작",
|
||||||
|
"study.session_control.action.stop": "공부 시간 종료",
|
||||||
|
"study.session_control.idle_hint": "오른쪽 버튼을 클릭하여 시작",
|
||||||
|
"study.session_control.report_preview": "보고서 미리보기",
|
||||||
|
"study.session_control.report_confirm_hint": "오른쪽을 클릭하여 보기 종료 확인",
|
||||||
|
"study.session_control.running_elapsed_format": "{0} 진행됨",
|
||||||
|
"study.session_control.last_session_format": "마지막 시간 {0}",
|
||||||
|
"study.session_control.start_failed": "시작 실패",
|
||||||
|
"study.session_control.stop_failed": "종료 실패",
|
||||||
|
"study.session_history.title": "기록 시간",
|
||||||
|
"study.session_history.empty": "기록 시간 없음",
|
||||||
|
"study.session_history.select_failed": "전환 실패",
|
||||||
|
"study.session_history.rename_failed": "이름 변경 실패",
|
||||||
|
"study.session_history.delete_failed": "삭제 실패",
|
||||||
|
"study.session_history.rename_placeholder": "시간 이름 입력",
|
||||||
|
"study.session_history.rename_confirm": "이름 변경 확인",
|
||||||
|
"study.session_history.rename_cancel": "이름 변경 취소",
|
||||||
|
"study.session_history.loading": "데이터 로딩 중...",
|
||||||
|
"study.session_history.loaded": "데이터 로드됨",
|
||||||
|
"study.session_history.duration_format": "{0:hh\\:mm\\:ss}",
|
||||||
|
"study.session_history.meta_format": "{0} · 평균 {1:F1}",
|
||||||
|
"study.session_history.action.view": "보기",
|
||||||
|
"study.session_history.action.rename": "이름 변경",
|
||||||
|
"study.session_history.action.delete": "삭제",
|
||||||
|
"study.session_history.dialog.rename_title": "시간 이름 변경",
|
||||||
|
"study.session_history.dialog.rename_message": "\"{0}\"의 새 이름을 입력하세요.",
|
||||||
|
"study.session_history.dialog.delete_title": "시간 삭제",
|
||||||
|
"study.session_history.dialog.delete_message": "\"{0}\"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
|
"study.session_history.dialog.delete_confirm": "삭제 확인",
|
||||||
|
"study.noise_curve.value_format": "{0:F1} dB",
|
||||||
|
"study.noise_curve.axis.now": "현재",
|
||||||
|
"study.noise_distribution.title": "소음 레벨 분포",
|
||||||
|
"study.noise_distribution.mode.realtime": "실시간",
|
||||||
|
"study.noise_distribution.mode.session": "시간",
|
||||||
|
"study.noise_distribution.summary.mainly_format": "주로: {0}",
|
||||||
|
"study.noise_distribution.summary.latest_format": "최신: {0}",
|
||||||
|
"study.noise_distribution.summary.compact_format": "주 {0} · 신 {1}",
|
||||||
|
"study.noise_distribution.level.quiet": "조용함",
|
||||||
|
"study.noise_distribution.level.normal": "보통",
|
||||||
|
"study.noise_distribution.level.noisy": "시끄러움",
|
||||||
|
"study.noise_distribution.level.extreme": "매우 시끄러움",
|
||||||
|
"study.noise_distribution.axis.extreme": "매우 시끄러움",
|
||||||
|
"study.noise_distribution.axis.noisy": "시끄러움",
|
||||||
|
"study.noise_distribution.axis.normal": "보통",
|
||||||
|
"study.noise_distribution.axis.quiet": "조용함",
|
||||||
|
"study.noise_distribution.axis.now": "현재",
|
||||||
|
"study.score_overview.title": "공부 점수",
|
||||||
|
"study.score_overview.mode.realtime": "실시간",
|
||||||
|
"study.score_overview.mode.session": "시간",
|
||||||
|
"study.score_overview.current": "현재",
|
||||||
|
"study.score_overview.average": "평균",
|
||||||
|
"study.score_overview.minimum": "최저",
|
||||||
|
"study.score_overview.maximum": "최고",
|
||||||
|
"study.score_overview.average_short": "평",
|
||||||
|
"study.score_overview.minimum_short": "저",
|
||||||
|
"study.score_overview.maximum_short": "고",
|
||||||
|
"study.score_overview.unavailable": "--",
|
||||||
|
"study.deduction.title": "감점 원인",
|
||||||
|
"study.deduction.mode.realtime": "실시간",
|
||||||
|
"study.deduction.mode.session": "시간",
|
||||||
|
"study.deduction.reason.sustained": "지속적 소음",
|
||||||
|
"study.deduction.reason.time": "임계 초과 시간",
|
||||||
|
"study.deduction.reason.segment": "방해 빈도",
|
||||||
|
"study.deduction.reason.sustained_short": "지속",
|
||||||
|
"study.deduction.reason.time_short": "시간",
|
||||||
|
"study.deduction.reason.segment_short": "방해",
|
||||||
|
"study.deduction.metric.sustained_format": "p50 {0:F1} dBFS",
|
||||||
|
"study.deduction.metric.sustained_short_format": "p50 {0:F1}",
|
||||||
|
"study.deduction.metric.time_format": "임계 초과 {0:F1}%",
|
||||||
|
"study.deduction.metric.time_short_format": "{0:F1}%",
|
||||||
|
"study.deduction.metric.segment_format": "{0:F1}회/분",
|
||||||
|
"study.deduction.metric.segment_short_format": "{0:F1}/분",
|
||||||
|
"study.deduction.loss_format": "-{0:F1}",
|
||||||
|
"study.deduction.total_loss_format": "총 감점 -{0:F1}",
|
||||||
|
"study.deduction.total_score_format": "점수 {0:F1}",
|
||||||
|
"study.deduction.total_loss_unavailable": "총 감점 {0}",
|
||||||
|
"study.deduction.total_score_unavailable": "점수 {0}",
|
||||||
|
"study.deduction.unavailable": "--",
|
||||||
|
"study.interrupt_density.title": "방해 밀도",
|
||||||
|
"study.interrupt_density.mode.realtime": "실시간",
|
||||||
|
"study.interrupt_density.mode.session": "시간",
|
||||||
|
"study.interrupt_density.unit": "회/분",
|
||||||
|
"study.interrupt_density.segment_count": "방해 횟수",
|
||||||
|
"study.interrupt_density.segment_count_short": "횟수",
|
||||||
|
"study.interrupt_density.duration": "통계 시간",
|
||||||
|
"study.interrupt_density.duration_short": "시간",
|
||||||
|
"study.interrupt_density.density_value_format": "{0:F1}",
|
||||||
|
"study.interrupt_density.segment_count_value_format": "{0}",
|
||||||
|
"study.interrupt_density.level_format": "방해 레벨: {0}",
|
||||||
|
"study.interrupt_density.level.calm": "낮음",
|
||||||
|
"study.interrupt_density.level.normal": "보통",
|
||||||
|
"study.interrupt_density.level.frequent": "높음",
|
||||||
|
"study.interrupt_density.level.severe": "매우 높음",
|
||||||
|
"study.interrupt_density.threshold_format": "최대 감점 임계값 {0:F1}회/분",
|
||||||
|
"study.interrupt_density.unavailable": "--",
|
||||||
|
"desktop.add_page": "새 페이지 추가",
|
||||||
|
"desktop.delete_page": "페이지 삭제",
|
||||||
|
"placement.fill": "채우기",
|
||||||
|
"placement.fit": "맞추기",
|
||||||
|
"placement.stretch": "늘리기",
|
||||||
|
"placement.center": "가운데",
|
||||||
|
"placement.tile": "바둑판",
|
||||||
|
"single_instance.notice.title": "앱이 이미 실행 중입니다",
|
||||||
|
"single_instance.notice.description": "앱이 이미 실행 중이므로 여러 번 클릭하여 열 필요가 없습니다.",
|
||||||
|
"single_instance.notice.button": "확인",
|
||||||
|
"market.status.install_success_restart_format": "✓ 플러그인 '{0}' 설치 성공! 활성화하려면 앱을 재시작하세요.",
|
||||||
|
"market.dialog.restart_message_format": "플러그인 '{0}'이(가) 성공적으로 설치되었습니다.\n\n이 플러그인을 사용하려면 앱을 즉시 재시작해야 합니다.\n\n지금 재시작하시겠습니까?"
|
||||||
|
}
|
||||||
@@ -271,6 +271,7 @@
|
|||||||
"settings.region.language_label": "语言",
|
"settings.region.language_label": "语言",
|
||||||
"settings.region.language_zh": "中文",
|
"settings.region.language_zh": "中文",
|
||||||
"settings.region.language_en": "英文",
|
"settings.region.language_en": "英文",
|
||||||
|
"settings.region.language_ja": "日文",
|
||||||
"settings.region.timezone_header": "时区",
|
"settings.region.timezone_header": "时区",
|
||||||
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
|
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
|
||||||
"settings.region.applied_format": "语言已切换为:{0}",
|
"settings.region.applied_format": "语言已切换为:{0}",
|
||||||
@@ -412,6 +413,11 @@
|
|||||||
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
|
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
|
||||||
"settings.update.download_threads_label": "下载线程数",
|
"settings.update.download_threads_label": "下载线程数",
|
||||||
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
|
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
|
||||||
|
"settings.update.force_check_label": "强制检查更新",
|
||||||
|
"settings.update.force_check_desc": "强制从 GitHub 获取最新版本,忽略版本比较。",
|
||||||
|
"settings.update.status_force_checking": "正在强制检查 GitHub Release...",
|
||||||
|
"settings.update.status_force_no_asset": "已找到发布版本,但没有可用的兼容安装包。",
|
||||||
|
"settings.update.status_force_available_format": "发布版本 {0} 可用,点击“下载并安装”继续。",
|
||||||
"settings.update.install_now_button": "立即安装",
|
"settings.update.install_now_button": "立即安装",
|
||||||
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
|
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
|
||||||
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
|
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
|
||||||
@@ -470,8 +476,8 @@
|
|||||||
"settings.plugins.refresh_button": "刷新插件",
|
"settings.plugins.refresh_button": "刷新插件",
|
||||||
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
|
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
|
||||||
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
|
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
|
||||||
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
|
"settings.plugins.refresh_failed": "加载插件目录索引失败。",
|
||||||
"settings.plugins.marketplace_header": "插件市场",
|
"settings.plugins.marketplace_header": "插件目录",
|
||||||
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
|
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
|
||||||
"settings.plugins.delete_button_short": "删除",
|
"settings.plugins.delete_button_short": "删除",
|
||||||
"settings.plugins.install_button_short": "安装",
|
"settings.plugins.install_button_short": "安装",
|
||||||
@@ -518,10 +524,10 @@
|
|||||||
"settings.plugins.source_manifest": "散装清单",
|
"settings.plugins.source_manifest": "散装清单",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||||
"settings.nav.plugin_market": "插件市场",
|
"settings.nav.plugin_catalog": "插件目录",
|
||||||
"settings.plugin_market.title": "插件市场",
|
"settings.plugin_catalog.title": "插件目录",
|
||||||
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
"settings.plugin_catalog.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||||
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
"settings.plugin_catalog.unavailable": "插件运行时不可用,暂时无法打开官方目录。",
|
||||||
"settings.update.status_idle": "尚未执行更新检查。",
|
"settings.update.status_idle": "尚未执行更新检查。",
|
||||||
"settings.update.status_preferences_saved": "更新偏好已保存。",
|
"settings.update.status_preferences_saved": "更新偏好已保存。",
|
||||||
"settings.update.status_check_failed": "检查更新失败。",
|
"settings.update.status_check_failed": "检查更新失败。",
|
||||||
@@ -530,15 +536,15 @@
|
|||||||
"settings.window.drawer_default": "详情",
|
"settings.window.drawer_default": "详情",
|
||||||
"market.toolbar.search_placeholder": "搜索插件",
|
"market.toolbar.search_placeholder": "搜索插件",
|
||||||
"market.toolbar.refresh": "刷新",
|
"market.toolbar.refresh": "刷新",
|
||||||
"market.status.loading": "正在加载官方插件市场...",
|
"market.status.loading": "正在加载官方插件目录...",
|
||||||
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
|
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
|
||||||
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||||
"market.status.load_failed_format": "加载插件市场失败:{0}",
|
"market.status.load_failed_format": "加载插件目录失败:{0}",
|
||||||
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
|
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
|
||||||
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
|
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
|
||||||
"market.status.install_failed_format": "安装插件失败:{0}",
|
"market.status.install_failed_format": "安装插件失败:{0}",
|
||||||
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
|
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
|
||||||
"market.list.empty": "插件市场尚未加载。",
|
"market.list.empty": "插件目录尚未加载。",
|
||||||
"market.list.no_results": "没有匹配当前搜索的插件。",
|
"market.list.no_results": "没有匹配当前搜索的插件。",
|
||||||
"market.card.subtitle_format": "{0} | v{1}",
|
"market.card.subtitle_format": "{0} | v{1}",
|
||||||
"market.card.loaded": "已加载",
|
"market.card.loaded": "已加载",
|
||||||
@@ -952,6 +958,10 @@
|
|||||||
"study.interrupt_density.unavailable": "--",
|
"study.interrupt_density.unavailable": "--",
|
||||||
"desktop.add_page": "新增页面",
|
"desktop.add_page": "新增页面",
|
||||||
"desktop.delete_page": "删除页面",
|
"desktop.delete_page": "删除页面",
|
||||||
|
"desktop.delete_page_confirm.title": "确认删除页面",
|
||||||
|
"desktop.delete_page_confirm.message": "确定要删除当前页面吗?\n\n此操作将删除当前页面上的所有组件,且无法撤销。",
|
||||||
|
"desktop.delete_page_confirm.primary": "删除",
|
||||||
|
"desktop.delete_page_confirm.close": "取消",
|
||||||
"placement.fill": "填充",
|
"placement.fill": "填充",
|
||||||
"placement.fit": "适应",
|
"placement.fit": "适应",
|
||||||
"placement.stretch": "拉伸",
|
"placement.stretch": "拉伸",
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public long? LastUpdateCheckUtcMs { get; set; }
|
public long? LastUpdateCheckUtcMs { get; set; }
|
||||||
|
|
||||||
|
public string? PendingUpdateSha256 { get; set; }
|
||||||
|
|
||||||
public List<string> TopStatusComponentIds { get; set; } = [];
|
public List<string> TopStatusComponentIds { get; set; } = [];
|
||||||
|
|
||||||
public List<string> PinnedTaskbarActions { get; set; } =
|
public List<string> PinnedTaskbarActions { get; set; } =
|
||||||
|
|||||||
261
LanMountainDesktop/Services/ComponentPreviewImageService.cs
Normal file
261
LanMountainDesktop/Services/ComponentPreviewImageService.cs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class ComponentPreviewImageService : IComponentPreviewImageService
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<ComponentPreviewKey, ComponentPreviewImageEntry> _entries = new(ComponentPreviewKeyComparer.Instance);
|
||||||
|
private readonly Dictionary<ComponentPreviewKey, Task<ComponentPreviewImageEntry>> _inFlightRequests = new(ComponentPreviewKeyComparer.Instance);
|
||||||
|
private Task _queueTail = Task.CompletedTask;
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = new ComponentPreviewImageEntry(key, visualSignature);
|
||||||
|
_entries[key] = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
entry = existing;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _entries.Values.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||||
|
ComponentPreviewKey key,
|
||||||
|
string visualSignature,
|
||||||
|
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(generationWork);
|
||||||
|
|
||||||
|
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entry = GetOrCreateEntryCore(key);
|
||||||
|
|
||||||
|
if (entry.State == ComponentPreviewImageState.Ready &&
|
||||||
|
entry.Bitmap is not null &&
|
||||||
|
StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||||
|
{
|
||||||
|
return Task.FromResult(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_inFlightRequests.TryGetValue(key, out var inFlight))
|
||||||
|
{
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedRevision = entry.BeginGeneration(normalizedSignature);
|
||||||
|
var previousTask = _queueTail;
|
||||||
|
var queuedTask = RunGenerationAsync(
|
||||||
|
previousTask,
|
||||||
|
key,
|
||||||
|
entry,
|
||||||
|
expectedRevision,
|
||||||
|
normalizedSignature,
|
||||||
|
generationWork,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
_inFlightRequests[key] = queuedTask;
|
||||||
|
_queueTail = queuedTask.ContinueWith(
|
||||||
|
static _ => { },
|
||||||
|
CancellationToken.None,
|
||||||
|
TaskContinuationOptions.ExecuteSynchronously,
|
||||||
|
TaskScheduler.Default);
|
||||||
|
return queuedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bitmap);
|
||||||
|
|
||||||
|
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entry = GetOrCreateEntryCore(key);
|
||||||
|
entry.StoreBitmap(bitmap, normalizedSignature);
|
||||||
|
_inFlightRequests.Remove(key);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null)
|
||||||
|
{
|
||||||
|
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entry = GetOrCreateEntryCore(key);
|
||||||
|
entry.StoreFailure(normalizedSignature, errorMessage);
|
||||||
|
_inFlightRequests.Remove(key);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Invalidate(ComponentPreviewKey key, string? visualSignature = null)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_entries.TryGetValue(key, out var entry))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Invalidate(visualSignature);
|
||||||
|
_inFlightRequests.Remove(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int RemovePlacementPreviews(string placementId)
|
||||||
|
{
|
||||||
|
var normalizedPlacementId = NormalizeRequired(placementId, nameof(placementId));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entriesToRemove = _entries
|
||||||
|
.Where(static pair => pair.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||||
|
.Where(pair => StringComparer.OrdinalIgnoreCase.Equals(pair.Key.PlacementId, normalizedPlacementId))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var pair in entriesToRemove)
|
||||||
|
{
|
||||||
|
pair.Value.DisposeBitmap();
|
||||||
|
_entries.Remove(pair.Key);
|
||||||
|
_inFlightRequests.Remove(pair.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entriesToRemove.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int InvalidateVisualSignature(string visualSignature)
|
||||||
|
{
|
||||||
|
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var entriesToInvalidate = _entries.Values
|
||||||
|
.Where(entry => StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var entry in entriesToInvalidate)
|
||||||
|
{
|
||||||
|
entry.Invalidate(normalizedSignature);
|
||||||
|
_inFlightRequests.Remove(entry.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entriesToInvalidate.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ComponentPreviewImageEntry> RunGenerationAsync(
|
||||||
|
Task previousTask,
|
||||||
|
ComponentPreviewKey key,
|
||||||
|
ComponentPreviewImageEntry entry,
|
||||||
|
long expectedRevision,
|
||||||
|
string visualSignature,
|
||||||
|
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await previousTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep serial queue processing even if previous work faulted.
|
||||||
|
}
|
||||||
|
|
||||||
|
IImage? bitmap;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bitmap = await generationWork(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
entry.TryApplyFailure(expectedRevision, visualSignature, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (bitmap is null)
|
||||||
|
{
|
||||||
|
entry.TryApplyFailure(expectedRevision, visualSignature, "Preview generation returned no bitmap.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entry.TryApplyGeneratedBitmap(expectedRevision, bitmap, visualSignature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_inFlightRequests.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewImageEntry GetOrCreateEntryCore(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(key, out var existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = new ComponentPreviewImageEntry(key);
|
||||||
|
_entries[key] = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRequired(string? value, string paramName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
281
LanMountainDesktop/Services/ComponentPreviewTypes.cs
Normal file
281
LanMountainDesktop/Services/ComponentPreviewTypes.cs
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public enum ComponentPreviewKeyKind
|
||||||
|
{
|
||||||
|
ComponentType = 0,
|
||||||
|
PlacementInstance = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct ComponentPreviewKey
|
||||||
|
{
|
||||||
|
private ComponentPreviewKey(
|
||||||
|
ComponentPreviewKeyKind kind,
|
||||||
|
string componentTypeId,
|
||||||
|
string? placementId,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells)
|
||||||
|
{
|
||||||
|
Kind = kind;
|
||||||
|
ComponentTypeId = NormalizeRequired(componentTypeId, nameof(componentTypeId));
|
||||||
|
PlacementId = kind == ComponentPreviewKeyKind.PlacementInstance
|
||||||
|
? NormalizeRequired(placementId, nameof(placementId))
|
||||||
|
: null;
|
||||||
|
WidthCells = NormalizeSpan(widthCells, nameof(widthCells));
|
||||||
|
HeightCells = NormalizeSpan(heightCells, nameof(heightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewKeyKind Kind { get; }
|
||||||
|
|
||||||
|
public string ComponentTypeId { get; }
|
||||||
|
|
||||||
|
public string? PlacementId { get; }
|
||||||
|
|
||||||
|
public int WidthCells { get; }
|
||||||
|
|
||||||
|
public int HeightCells { get; }
|
||||||
|
|
||||||
|
public static ComponentPreviewKey ForComponentType(string componentTypeId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
return new ComponentPreviewKey(ComponentPreviewKeyKind.ComponentType, componentTypeId, null, widthCells, heightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComponentPreviewKey ForPlacementInstance(string componentTypeId, string placementId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
return new ComponentPreviewKey(
|
||||||
|
ComponentPreviewKeyKind.PlacementInstance,
|
||||||
|
componentTypeId,
|
||||||
|
placementId,
|
||||||
|
widthCells,
|
||||||
|
heightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return Kind == ComponentPreviewKeyKind.ComponentType
|
||||||
|
? $"Type:{ComponentTypeId}[{WidthCells}x{HeightCells}]"
|
||||||
|
: $"Placement:{ComponentTypeId}@{PlacementId}[{WidthCells}x{HeightCells}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRequired(string? value, string paramName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizeSpan(int value, string paramName)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(paramName, value, "Span must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ComponentPreviewImageState
|
||||||
|
{
|
||||||
|
Pending = 0,
|
||||||
|
Ready = 1,
|
||||||
|
Failed = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ComponentPreviewImageEntry : ObservableObject
|
||||||
|
{
|
||||||
|
private IImage? _bitmap;
|
||||||
|
private ComponentPreviewImageState _state = ComponentPreviewImageState.Pending;
|
||||||
|
private string _visualSignature = string.Empty;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private long _revision;
|
||||||
|
private DateTimeOffset _lastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry(ComponentPreviewKey key, string? visualSignature = null)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewKey Key { get; }
|
||||||
|
|
||||||
|
public IImage? Bitmap
|
||||||
|
{
|
||||||
|
get => _bitmap;
|
||||||
|
private set => SetProperty(ref _bitmap, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewImageState State
|
||||||
|
{
|
||||||
|
get => _state;
|
||||||
|
private set => SetProperty(ref _state, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string VisualSignature
|
||||||
|
{
|
||||||
|
get => _visualSignature;
|
||||||
|
private set => SetProperty(ref _visualSignature, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ErrorMessage
|
||||||
|
{
|
||||||
|
get => _errorMessage;
|
||||||
|
private set => SetProperty(ref _errorMessage, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long Revision
|
||||||
|
{
|
||||||
|
get => _revision;
|
||||||
|
private set => SetProperty(ref _revision, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset LastUpdatedUtc
|
||||||
|
{
|
||||||
|
get => _lastUpdatedUtc;
|
||||||
|
private set => SetProperty(ref _lastUpdatedUtc, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal long BeginGeneration(string visualSignature)
|
||||||
|
{
|
||||||
|
var normalizedVisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
var nextRevision = Revision + 1;
|
||||||
|
Revision = nextRevision;
|
||||||
|
VisualSignature = normalizedVisualSignature;
|
||||||
|
State = ComponentPreviewImageState.Pending;
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
return nextRevision;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryApplyGeneratedBitmap(long expectedRevision, IImage bitmap, string visualSignature)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bitmap);
|
||||||
|
|
||||||
|
if (Revision != expectedRevision)
|
||||||
|
{
|
||||||
|
DisposeIfNeeded(bitmap);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
State = ComponentPreviewImageState.Ready;
|
||||||
|
ReplaceBitmap(bitmap);
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool TryApplyFailure(long expectedRevision, string visualSignature, string? errorMessage)
|
||||||
|
{
|
||||||
|
if (Revision != expectedRevision)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
State = ComponentPreviewImageState.Failed;
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void StoreBitmap(IImage bitmap, string visualSignature)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bitmap);
|
||||||
|
|
||||||
|
Revision += 1;
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
State = ComponentPreviewImageState.Ready;
|
||||||
|
ReplaceBitmap(bitmap);
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void StoreFailure(string visualSignature, string? errorMessage)
|
||||||
|
{
|
||||||
|
Revision += 1;
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
State = ComponentPreviewImageState.Failed;
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Invalidate(string? visualSignature = null)
|
||||||
|
{
|
||||||
|
Revision += 1;
|
||||||
|
if (visualSignature is not null)
|
||||||
|
{
|
||||||
|
VisualSignature = NormalizeSignature(visualSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
State = ComponentPreviewImageState.Pending;
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
ErrorMessage = null;
|
||||||
|
LastUpdatedUtc = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void DisposeBitmap()
|
||||||
|
{
|
||||||
|
ReplaceBitmap(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReplaceBitmap(IImage? bitmap)
|
||||||
|
{
|
||||||
|
var previous = _bitmap;
|
||||||
|
if (ReferenceEquals(previous, bitmap))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bitmap = bitmap;
|
||||||
|
DisposeIfNeeded(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeIfNeeded(IImage? bitmap)
|
||||||
|
{
|
||||||
|
if (bitmap is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeSignature(string? visualSignature)
|
||||||
|
{
|
||||||
|
return visualSignature?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ComponentPreviewKeyComparer : IEqualityComparer<ComponentPreviewKey>
|
||||||
|
{
|
||||||
|
public static ComponentPreviewKeyComparer Instance { get; } = new();
|
||||||
|
|
||||||
|
public bool Equals(ComponentPreviewKey x, ComponentPreviewKey y)
|
||||||
|
{
|
||||||
|
return x.Kind == y.Kind &&
|
||||||
|
StringComparer.OrdinalIgnoreCase.Equals(x.ComponentTypeId, y.ComponentTypeId) &&
|
||||||
|
StringComparer.OrdinalIgnoreCase.Equals(x.PlacementId, y.PlacementId) &&
|
||||||
|
x.WidthCells == y.WidthCells &&
|
||||||
|
x.HeightCells == y.HeightCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetHashCode(ComponentPreviewKey obj)
|
||||||
|
{
|
||||||
|
var hash = new HashCode();
|
||||||
|
hash.Add(obj.Kind);
|
||||||
|
hash.Add(obj.ComponentTypeId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
hash.Add(obj.PlacementId, StringComparer.OrdinalIgnoreCase);
|
||||||
|
hash.Add(obj.WidthCells);
|
||||||
|
hash.Add(obj.HeightCells);
|
||||||
|
return hash.ToHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
LanMountainDesktop/Services/FontFamilyService.cs
Normal file
48
LanMountainDesktop/Services/FontFamilyService.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class FontFamilyService
|
||||||
|
{
|
||||||
|
private const string FontsBasePath = "avares://LanMountainDesktop/Assets/Fonts";
|
||||||
|
|
||||||
|
public static readonly FontFamily DefaultFontFamily =
|
||||||
|
new($"{FontsBasePath}#MiSans");
|
||||||
|
|
||||||
|
public static readonly FontFamily JapaneseFontFamily =
|
||||||
|
new($"{FontsBasePath}#MiSans");
|
||||||
|
|
||||||
|
public static readonly FontFamily KoreanFontFamily =
|
||||||
|
new($"Malgun Gothic, {FontsBasePath}#MiSans");
|
||||||
|
|
||||||
|
public FontFamily GetFontFamilyForLanguage(string? languageCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(languageCode))
|
||||||
|
{
|
||||||
|
return DefaultFontFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
return languageCode.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"ja-jp" or "ja" => JapaneseFontFamily,
|
||||||
|
"ko-kr" or "ko" => KoreanFontFamily,
|
||||||
|
_ => DefaultFontFamily
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetFontFamilyResourceKey(string? languageCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(languageCode))
|
||||||
|
{
|
||||||
|
return "AppFontFamily";
|
||||||
|
}
|
||||||
|
|
||||||
|
return languageCode.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"ja-jp" or "ja" => "AppFontFamilyJP",
|
||||||
|
"ko-kr" or "ko" => "AppFontFamilyKR",
|
||||||
|
_ => "AppFontFamily"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -14,7 +15,8 @@ namespace LanMountainDesktop.Services;
|
|||||||
public sealed record GitHubReleaseAsset(
|
public sealed record GitHubReleaseAsset(
|
||||||
string Name,
|
string Name,
|
||||||
string BrowserDownloadUrl,
|
string BrowserDownloadUrl,
|
||||||
long SizeBytes);
|
long SizeBytes,
|
||||||
|
string? Sha256 = null);
|
||||||
|
|
||||||
public sealed record GitHubReleaseInfo(
|
public sealed record GitHubReleaseInfo(
|
||||||
string TagName,
|
string TagName,
|
||||||
@@ -31,12 +33,16 @@ public sealed record UpdateCheckResult(
|
|||||||
string LatestVersionText,
|
string LatestVersionText,
|
||||||
GitHubReleaseInfo? Release,
|
GitHubReleaseInfo? Release,
|
||||||
GitHubReleaseAsset? PreferredAsset,
|
GitHubReleaseAsset? PreferredAsset,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage,
|
||||||
|
bool ForceMode = false);
|
||||||
|
|
||||||
public sealed record UpdateDownloadResult(
|
public sealed record UpdateDownloadResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
string? FilePath,
|
string? FilePath,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage,
|
||||||
|
bool HashVerified = false,
|
||||||
|
string? ExpectedHash = null,
|
||||||
|
string? ActualHash = null);
|
||||||
|
|
||||||
public sealed class GitHubReleaseUpdateService : IDisposable
|
public sealed class GitHubReleaseUpdateService : IDisposable
|
||||||
{
|
{
|
||||||
@@ -169,6 +175,80 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
bool includePrerelease,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
|
||||||
|
{
|
||||||
|
return new UpdateCheckResult(
|
||||||
|
Success: false,
|
||||||
|
IsUpdateAvailable: false,
|
||||||
|
CurrentVersionText: normalizedCurrentVersionText,
|
||||||
|
LatestVersionText: "-",
|
||||||
|
Release: null,
|
||||||
|
PreferredAsset: null,
|
||||||
|
ErrorMessage: "Repository information is not configured.",
|
||||||
|
ForceMode: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var release = includePrerelease
|
||||||
|
? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken)
|
||||||
|
: await GetLatestStableReleaseAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (release is null)
|
||||||
|
{
|
||||||
|
return new UpdateCheckResult(
|
||||||
|
Success: false,
|
||||||
|
IsUpdateAvailable: false,
|
||||||
|
CurrentVersionText: normalizedCurrentVersionText,
|
||||||
|
LatestVersionText: "-",
|
||||||
|
Release: null,
|
||||||
|
PreferredAsset: null,
|
||||||
|
ErrorMessage: "No release data was returned from GitHub.",
|
||||||
|
ForceMode: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
|
||||||
|
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
|
||||||
|
? parsedTagVersion.ToString(3)
|
||||||
|
: release.TagName;
|
||||||
|
|
||||||
|
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
|
||||||
|
|
||||||
|
return new UpdateCheckResult(
|
||||||
|
Success: true,
|
||||||
|
IsUpdateAvailable: true,
|
||||||
|
CurrentVersionText: normalizedCurrentVersionText,
|
||||||
|
LatestVersionText: latestVersionText,
|
||||||
|
Release: release,
|
||||||
|
PreferredAsset: preferredAsset,
|
||||||
|
ErrorMessage: null,
|
||||||
|
ForceMode: true);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new UpdateCheckResult(
|
||||||
|
Success: false,
|
||||||
|
IsUpdateAvailable: false,
|
||||||
|
CurrentVersionText: normalizedCurrentVersionText,
|
||||||
|
LatestVersionText: "-",
|
||||||
|
Release: null,
|
||||||
|
PreferredAsset: null,
|
||||||
|
ErrorMessage: ex.Message,
|
||||||
|
ForceMode: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<UpdateDownloadResult> DownloadAssetAsync(
|
public async Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
@@ -206,9 +286,128 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
progressAdapter,
|
progressAdapter,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return result.Success
|
if (!result.Success)
|
||||||
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
|
{
|
||||||
: new UpdateDownloadResult(false, null, result.ErrorMessage);
|
return new UpdateDownloadResult(false, null, result.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
var filePath = result.FilePath ?? destinationFilePath;
|
||||||
|
var (hashVerified, actualHash) = await VerifyFileHashAsync(filePath, asset.Sha256, cancellationToken);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(asset.Sha256) && !hashVerified)
|
||||||
|
{
|
||||||
|
return new UpdateDownloadResult(
|
||||||
|
false,
|
||||||
|
filePath,
|
||||||
|
$"Hash verification failed. Expected: {asset.Sha256}, Actual: {actualHash}",
|
||||||
|
false,
|
||||||
|
asset.Sha256,
|
||||||
|
actualHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UpdateDownloadResult(true, filePath, null, hashVerified, asset.Sha256, actualHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||||
|
GitHubReleaseAsset asset,
|
||||||
|
string destinationFilePath,
|
||||||
|
string downloadSource,
|
||||||
|
int maxParallelSegments,
|
||||||
|
IProgress<double>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (File.Exists(destinationFilePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(destinationFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Update", $"Failed to delete existing file for redownload: {destinationFilePath}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var partFile = destinationFilePath + ".part";
|
||||||
|
if (File.Exists(partFile))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(partFile);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Update", $"Failed to delete part file for redownload: {partFile}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var packageFile = destinationFilePath + ".download";
|
||||||
|
if (File.Exists(packageFile))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(packageFile);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Update", $"Failed to delete package file for redownload: {packageFile}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await DownloadAssetAsync(asset, destinationFilePath, downloadSource, maxParallelSegments, progress, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<(bool Success, string? Hash)> VerifyFileHashAsync(
|
||||||
|
string filePath,
|
||||||
|
string? expectedHash,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(expectedHash))
|
||||||
|
{
|
||||||
|
var computedHash = await ComputeFileSha256Async(filePath, cancellationToken);
|
||||||
|
return (true, computedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
var actualHash = await ComputeFileSha256Async(filePath, cancellationToken);
|
||||||
|
var verified = string.Equals(
|
||||||
|
expectedHash?.Trim().ToLowerInvariant(),
|
||||||
|
actualHash?.Trim().ToLowerInvariant(),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return (verified, actualHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string?> ComputeFileSha256Async(string filePath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new FileStream(
|
||||||
|
filePath,
|
||||||
|
FileMode.Open,
|
||||||
|
FileAccess.Read,
|
||||||
|
FileShare.Read,
|
||||||
|
81920,
|
||||||
|
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||||
|
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Update", $"Failed to compute SHA256 for file: {filePath}", ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
||||||
@@ -343,13 +542,102 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes));
|
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sha256Map = BuildSha256MapFromAssets(assets, element);
|
||||||
|
|
||||||
|
if (sha256Map.Count > 0)
|
||||||
|
{
|
||||||
|
assets = assets.Select(a =>
|
||||||
|
sha256Map.TryGetValue(a.Name, out var hash)
|
||||||
|
? a with { Sha256 = hash }
|
||||||
|
: a).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
|
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> BuildSha256MapFromAssets(List<GitHubReleaseAsset> assets, JsonElement releaseElement)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var asset in assets)
|
||||||
|
{
|
||||||
|
if (asset.Name.EndsWith(".sha256", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
asset.Name.EndsWith(".sha256sum", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var baseName = asset.Name[..asset.Name.LastIndexOf('.')];
|
||||||
|
var targetAsset = assets.FirstOrDefault(a =>
|
||||||
|
a.Name.Equals(baseName, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
a.Name.StartsWith(baseName + ".", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (targetAsset is not null && !map.ContainsKey(targetAsset.Name))
|
||||||
|
{
|
||||||
|
map[targetAsset.Name] = asset.BrowserDownloadUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseElement.TryGetProperty("body", out var bodyNode) &&
|
||||||
|
bodyNode.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var body = bodyNode.GetString() ?? string.Empty;
|
||||||
|
ParseSha256FromBody(body, assets, map);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseSha256FromBody(string body, List<GitHubReleaseAsset> assets, Dictionary<string, string> map)
|
||||||
|
{
|
||||||
|
var lines = body.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var trimmedLine = line.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith("#"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = trimmedLine.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
var hash = parts[0];
|
||||||
|
var fileName = parts[1];
|
||||||
|
|
||||||
|
if (hash.Length == 64 && IsHexString(hash))
|
||||||
|
{
|
||||||
|
foreach (var asset in assets)
|
||||||
|
{
|
||||||
|
if (asset.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
fileName.Equals("*" + asset.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!map.ContainsKey(asset.Name))
|
||||||
|
{
|
||||||
|
map[asset.Name] = hash.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHexString(string value)
|
||||||
|
{
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
if (!Uri.IsHexDigit(c))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
|
||||||
{
|
{
|
||||||
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
|
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
|
||||||
|
|||||||
32
LanMountainDesktop/Services/IComponentPreviewImageService.cs
Normal file
32
LanMountainDesktop/Services/IComponentPreviewImageService.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public interface IComponentPreviewImageService
|
||||||
|
{
|
||||||
|
ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null);
|
||||||
|
|
||||||
|
bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry);
|
||||||
|
|
||||||
|
IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot();
|
||||||
|
|
||||||
|
Task<ComponentPreviewImageEntry> QueueGenerationAsync(
|
||||||
|
ComponentPreviewKey key,
|
||||||
|
string visualSignature,
|
||||||
|
Func<CancellationToken, Task<IImage?>> generationWork,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature);
|
||||||
|
|
||||||
|
ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null);
|
||||||
|
|
||||||
|
bool Invalidate(ComponentPreviewKey key, string? visualSignature = null);
|
||||||
|
|
||||||
|
int RemovePlacementPreviews(string placementId);
|
||||||
|
|
||||||
|
int InvalidateVisualSignature(string visualSignature);
|
||||||
|
}
|
||||||
@@ -36,9 +36,18 @@ public sealed class LocalizationService
|
|||||||
|
|
||||||
public string NormalizeLanguageCode(string? languageCode)
|
public string NormalizeLanguageCode(string? languageCode)
|
||||||
{
|
{
|
||||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
if (string.IsNullOrWhiteSpace(languageCode))
|
||||||
? "en-US"
|
{
|
||||||
: "zh-CN";
|
return "zh-CN";
|
||||||
|
}
|
||||||
|
|
||||||
|
return languageCode.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"en-us" or "en" => "en-US",
|
||||||
|
"ja-jp" or "ja" => "ja-JP",
|
||||||
|
"ko-kr" or "ko" => "ko-KR",
|
||||||
|
_ => "zh-CN"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetString(string languageCode, string key, string fallback)
|
public string GetString(string languageCode, string key, string fallback)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.PluginMarket;
|
||||||
using LanMountainDesktop.Settings.Core;
|
using LanMountainDesktop.Settings.Core;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.Settings;
|
namespace LanMountainDesktop.Services.Settings
|
||||||
|
{
|
||||||
|
|
||||||
public enum WallpaperMediaType
|
public enum WallpaperMediaType
|
||||||
{
|
{
|
||||||
@@ -64,44 +67,173 @@ public sealed record UpdateSettingsState(
|
|||||||
string? PendingUpdateInstallerPath,
|
string? PendingUpdateInstallerPath,
|
||||||
string? PendingUpdateVersion,
|
string? PendingUpdateVersion,
|
||||||
long? PendingUpdatePublishedAtUtcMs,
|
long? PendingUpdatePublishedAtUtcMs,
|
||||||
long? LastUpdateCheckUtcMs);
|
long? LastUpdateCheckUtcMs,
|
||||||
|
string? PendingUpdateSha256);
|
||||||
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
|
||||||
public sealed record PluginMarketDependencyInfo(
|
public enum PluginPackageSourceKind
|
||||||
|
{
|
||||||
|
ReleaseAsset = 0,
|
||||||
|
RawFallback = 1,
|
||||||
|
WorkspaceLocal = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogSourceInfo(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string? SourceUrl,
|
||||||
|
string? CachePath,
|
||||||
|
bool IsOfficial,
|
||||||
|
int Priority);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogSharedContractInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Version,
|
string Version,
|
||||||
string AssemblyName);
|
string AssemblyName);
|
||||||
public sealed record PluginMarketPluginInfo(
|
|
||||||
|
public sealed record PluginCapabilityInfo(
|
||||||
|
string Id,
|
||||||
|
string? Version,
|
||||||
|
string? AssemblyName);
|
||||||
|
|
||||||
|
public sealed record PluginPackageSourceInfo(
|
||||||
|
PluginPackageSourceKind Kind,
|
||||||
|
string Url,
|
||||||
|
string Sha256,
|
||||||
|
long PackageSizeBytes);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogManifestInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
string Description,
|
string Description,
|
||||||
string Author,
|
string Author,
|
||||||
string Version,
|
string Version,
|
||||||
string ApiVersion,
|
string ApiVersion,
|
||||||
|
string EntranceAssembly,
|
||||||
|
IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogCompatibilityInfo(
|
||||||
string MinHostVersion,
|
string MinHostVersion,
|
||||||
string DownloadUrl,
|
string ApiVersion);
|
||||||
string ReleaseTag,
|
|
||||||
string ReleaseAssetName,
|
public sealed record PluginCatalogRepositoryInfo(
|
||||||
string IconUrl,
|
string IconUrl,
|
||||||
|
string ProjectUrl,
|
||||||
string ReadmeUrl,
|
string ReadmeUrl,
|
||||||
string HomepageUrl,
|
string HomepageUrl,
|
||||||
string RepositoryUrl,
|
string RepositoryUrl,
|
||||||
IReadOnlyList<string> Tags,
|
IReadOnlyList<string> Tags,
|
||||||
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
|
string ReleaseNotes);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogPublicationInfo(
|
||||||
|
string ReleaseTag,
|
||||||
|
string ReleaseAssetName,
|
||||||
DateTimeOffset PublishedAt,
|
DateTimeOffset PublishedAt,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt,
|
||||||
public sealed record PluginMarketIndexResult(
|
long PackageSizeBytes,
|
||||||
|
string Sha256,
|
||||||
|
string? Md5);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogItemInfo(
|
||||||
|
PluginCatalogManifestInfo Manifest,
|
||||||
|
PluginCatalogCompatibilityInfo Compatibility,
|
||||||
|
PluginCatalogRepositoryInfo Repository,
|
||||||
|
PluginCatalogPublicationInfo Publication,
|
||||||
|
IReadOnlyList<PluginPackageSourceInfo> PackageSources,
|
||||||
|
IReadOnlyList<PluginCapabilityInfo> Capabilities)
|
||||||
|
{
|
||||||
|
public string Id => Manifest.Id;
|
||||||
|
|
||||||
|
public string Name => Manifest.Name;
|
||||||
|
|
||||||
|
public string Description => Manifest.Description;
|
||||||
|
|
||||||
|
public string Author => Manifest.Author;
|
||||||
|
|
||||||
|
public string Version => Manifest.Version;
|
||||||
|
|
||||||
|
public string ApiVersion => Manifest.ApiVersion;
|
||||||
|
|
||||||
|
public string MinHostVersion => Compatibility.MinHostVersion;
|
||||||
|
|
||||||
|
public string DownloadUrl => PackageSources.FirstOrDefault()?.Url ?? string.Empty;
|
||||||
|
|
||||||
|
public string Sha256 => Publication.Sha256;
|
||||||
|
|
||||||
|
public long PackageSizeBytes => Publication.PackageSizeBytes;
|
||||||
|
|
||||||
|
public string IconUrl => Repository.IconUrl;
|
||||||
|
|
||||||
|
public string ProjectUrl => Repository.ProjectUrl;
|
||||||
|
|
||||||
|
public string ReadmeUrl => Repository.ReadmeUrl;
|
||||||
|
|
||||||
|
public string HomepageUrl => Repository.HomepageUrl;
|
||||||
|
|
||||||
|
public string RepositoryUrl => Repository.RepositoryUrl;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Tags => Repository.Tags;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
|
||||||
|
|
||||||
|
public DateTimeOffset PublishedAt => Publication.PublishedAt;
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
|
||||||
|
|
||||||
|
public string ReleaseTag => Publication.ReleaseTag;
|
||||||
|
|
||||||
|
public string ReleaseAssetName => Publication.ReleaseAssetName;
|
||||||
|
|
||||||
|
public string ReleaseNotes => Repository.ReleaseNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PluginCatalogIndexResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
IReadOnlyList<PluginMarketPluginInfo> Plugins,
|
IReadOnlyList<PluginCatalogItemInfo> Plugins,
|
||||||
|
IReadOnlyList<PluginCatalogSourceInfo> Sources,
|
||||||
string? Source,
|
string? Source,
|
||||||
string? SourceLocation,
|
string? SourceLocation,
|
||||||
string? WarningMessage,
|
string? WarningMessage,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage);
|
||||||
public sealed record PluginMarketInstallResult(
|
|
||||||
|
public sealed record PluginInstallDiagnostic(
|
||||||
|
string Code,
|
||||||
|
string Message,
|
||||||
|
string? Details = null);
|
||||||
|
|
||||||
|
public sealed record PluginCatalogInstallResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
string? PluginId,
|
string? PluginId,
|
||||||
string? PluginName,
|
string? PluginName,
|
||||||
|
PluginManifest? InstalledManifest,
|
||||||
|
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
public interface IPluginCatalogSourceProvider
|
||||||
|
{
|
||||||
|
Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginCatalogService : IPluginCatalogSourceProvider
|
||||||
|
{
|
||||||
|
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPackageSourceResolver
|
||||||
|
{
|
||||||
|
IReadOnlyList<PluginPackageSourceInfo> ResolveSources(PluginCatalogItemInfo item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginCompatibilityEvaluator
|
||||||
|
{
|
||||||
|
PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginInstallOrchestrator
|
||||||
|
{
|
||||||
|
Task<PluginCatalogInstallResult> InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
public interface IGridSettingsService
|
public interface IGridSettingsService
|
||||||
{
|
{
|
||||||
GridSettingsState Get();
|
GridSettingsState Get();
|
||||||
@@ -194,6 +326,7 @@ public interface IUpdateSettingsService
|
|||||||
UpdateSettingsState Get();
|
UpdateSettingsState Get();
|
||||||
void Save(UpdateSettingsState state);
|
void Save(UpdateSettingsState state);
|
||||||
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||||
|
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
|
||||||
Task<UpdateDownloadResult> DownloadAssetAsync(
|
Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
@@ -201,6 +334,13 @@ public interface IUpdateSettingsService
|
|||||||
int maxParallelSegments,
|
int maxParallelSegments,
|
||||||
IProgress<double>? progress = null,
|
IProgress<double>? progress = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||||
|
GitHubReleaseAsset asset,
|
||||||
|
string destinationFilePath,
|
||||||
|
string downloadSource,
|
||||||
|
int maxParallelSegments,
|
||||||
|
IProgress<double>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ILauncherCatalogService
|
public interface ILauncherCatalogService
|
||||||
@@ -223,10 +363,10 @@ public interface IPluginManagementSettingsService
|
|||||||
bool DeleteInstalledPlugin(string pluginId);
|
bool DeleteInstalledPlugin(string pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IPluginMarketSettingsService
|
public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
|
||||||
{
|
{
|
||||||
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
|
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
|
||||||
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IApplicationInfoService
|
public interface IApplicationInfoService
|
||||||
@@ -252,6 +392,18 @@ public interface ISettingsFacadeService
|
|||||||
ILauncherCatalogService LauncherCatalog { get; }
|
ILauncherCatalogService LauncherCatalog { get; }
|
||||||
ILauncherPolicyService LauncherPolicy { get; }
|
ILauncherPolicyService LauncherPolicy { get; }
|
||||||
IPluginManagementSettingsService PluginManagement { get; }
|
IPluginManagementSettingsService PluginManagement { get; }
|
||||||
IPluginMarketSettingsService PluginMarket { get; }
|
IPluginCatalogSettingsService PluginCatalog { get; }
|
||||||
IApplicationInfoService ApplicationInfo { get; }
|
IApplicationInfoService ApplicationInfo { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.PluginMarket
|
||||||
|
{
|
||||||
|
internal enum PluginPackageSourceKind
|
||||||
|
{
|
||||||
|
ReleaseAsset = 0,
|
||||||
|
RawFallback = 1,
|
||||||
|
WorkspaceLocal = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -678,7 +678,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
snapshot.PendingUpdateInstallerPath,
|
snapshot.PendingUpdateInstallerPath,
|
||||||
snapshot.PendingUpdateVersion,
|
snapshot.PendingUpdateVersion,
|
||||||
snapshot.PendingUpdatePublishedAtUtcMs,
|
snapshot.PendingUpdatePublishedAtUtcMs,
|
||||||
snapshot.LastUpdateCheckUtcMs);
|
snapshot.LastUpdateCheckUtcMs,
|
||||||
|
snapshot.PendingUpdateSha256);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Save(UpdateSettingsState state)
|
public void Save(UpdateSettingsState state)
|
||||||
@@ -707,6 +708,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0
|
snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0
|
||||||
? state.LastUpdateCheckUtcMs
|
? state.LastUpdateCheckUtcMs
|
||||||
: null;
|
: null;
|
||||||
|
snapshot.PendingUpdateSha256 = string.IsNullOrWhiteSpace(state.PendingUpdateSha256)
|
||||||
|
? null
|
||||||
|
: state.PendingUpdateSha256.Trim().ToLowerInvariant();
|
||||||
_settingsService.SaveSnapshot(
|
_settingsService.SaveSnapshot(
|
||||||
SettingsScope.App,
|
SettingsScope.App,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -721,7 +725,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
|
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
|
||||||
nameof(AppSettingsSnapshot.PendingUpdateVersion),
|
nameof(AppSettingsSnapshot.PendingUpdateVersion),
|
||||||
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
|
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
|
||||||
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs)
|
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs),
|
||||||
|
nameof(AppSettingsSnapshot.PendingUpdateSha256)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,6 +738,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
bool includePrerelease,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
public Task<UpdateDownloadResult> DownloadAssetAsync(
|
||||||
GitHubReleaseAsset asset,
|
GitHubReleaseAsset asset,
|
||||||
string destinationFilePath,
|
string destinationFilePath,
|
||||||
@@ -750,6 +763,23 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<UpdateDownloadResult> RedownloadAssetAsync(
|
||||||
|
GitHubReleaseAsset asset,
|
||||||
|
string destinationFilePath,
|
||||||
|
string downloadSource,
|
||||||
|
int maxParallelSegments,
|
||||||
|
IProgress<double>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _releaseUpdateService.RedownloadAssetAsync(
|
||||||
|
asset,
|
||||||
|
destinationFilePath,
|
||||||
|
downloadSource,
|
||||||
|
maxParallelSegments,
|
||||||
|
progress,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_releaseUpdateService.Dispose();
|
_releaseUpdateService.Dispose();
|
||||||
@@ -829,14 +859,14 @@ internal sealed class PluginManagementSettingsService : IPluginManagementSetting
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable
|
internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsService, IDisposable
|
||||||
{
|
{
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
private AirAppMarketIndexService _indexService;
|
private AirAppMarketIndexService _indexService;
|
||||||
private AirAppMarketInstallService? _installService;
|
private AirAppMarketInstallService? _installService;
|
||||||
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService)
|
public PluginCatalogSettingsService(PluginRuntimeService? pluginRuntimeService)
|
||||||
{
|
{
|
||||||
_pluginRuntimeService = pluginRuntimeService;
|
_pluginRuntimeService = pluginRuntimeService;
|
||||||
|
|
||||||
@@ -870,14 +900,29 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
|||||||
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default)
|
public Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var result = await _indexService.LoadAsync(cancellationToken);
|
return LoadCatalogCoreAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PluginCatalogInstallResult> InstallAsync(
|
||||||
|
string pluginId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return InstallCatalogCoreAsync(pluginId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sources = BuildCatalogSources(result.Source?.ToString(), result.SourceLocation, result.WarningMessage);
|
||||||
if (!result.Success || result.Document is null)
|
if (!result.Success || result.Document is null)
|
||||||
{
|
{
|
||||||
return new PluginMarketIndexResult(
|
_cachedPlugins.Clear();
|
||||||
|
return new PluginCatalogIndexResult(
|
||||||
false,
|
false,
|
||||||
[],
|
[],
|
||||||
|
sources,
|
||||||
result.Source?.ToString(),
|
result.Source?.ToString(),
|
||||||
result.SourceLocation,
|
result.SourceLocation,
|
||||||
result.WarningMessage,
|
result.WarningMessage,
|
||||||
@@ -889,81 +934,191 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
|
|||||||
.Select(entry =>
|
.Select(entry =>
|
||||||
{
|
{
|
||||||
_cachedPlugins[entry.Id] = entry;
|
_cachedPlugins[entry.Id] = entry;
|
||||||
return new PluginMarketPluginInfo(
|
return MapCatalogItem(entry);
|
||||||
entry.Id,
|
|
||||||
entry.Name,
|
|
||||||
entry.Description,
|
|
||||||
entry.Author,
|
|
||||||
entry.Version,
|
|
||||||
entry.ApiVersion,
|
|
||||||
entry.MinHostVersion,
|
|
||||||
entry.DownloadUrl,
|
|
||||||
entry.ReleaseTag,
|
|
||||||
entry.ReleaseAssetName,
|
|
||||||
entry.IconUrl,
|
|
||||||
entry.ReadmeUrl,
|
|
||||||
entry.HomepageUrl,
|
|
||||||
entry.RepositoryUrl,
|
|
||||||
entry.Tags,
|
|
||||||
entry.SharedContracts
|
|
||||||
.Select(contract => new PluginMarketDependencyInfo(
|
|
||||||
contract.Id,
|
|
||||||
contract.Version,
|
|
||||||
contract.AssemblyName))
|
|
||||||
.ToArray(),
|
|
||||||
entry.PublishedAt,
|
|
||||||
entry.UpdatedAt);
|
|
||||||
})
|
})
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return new PluginMarketIndexResult(
|
return new PluginCatalogIndexResult(
|
||||||
true,
|
true,
|
||||||
plugins,
|
plugins,
|
||||||
|
sources,
|
||||||
result.Source?.ToString(),
|
result.Source?.ToString(),
|
||||||
result.SourceLocation,
|
result.SourceLocation,
|
||||||
result.WarningMessage,
|
result.WarningMessage,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PluginMarketInstallResult> InstallAsync(
|
private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
|
||||||
string pluginId,
|
string pluginId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(pluginId))
|
if (string.IsNullOrWhiteSpace(pluginId))
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, null, null, "Plugin id is required.");
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("invalid_request", "Plugin id is required.")],
|
||||||
|
"Plugin id is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_installService is null || _pluginRuntimeService is null)
|
if (_installService is null || _pluginRuntimeService is null)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(
|
return new PluginCatalogInstallResult(
|
||||||
false,
|
false,
|
||||||
pluginId,
|
pluginId,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
|
||||||
"Plugin runtime is unavailable.");
|
"Plugin runtime is unavailable.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
|
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
|
||||||
{
|
{
|
||||||
var load = await LoadIndexAsync(cancellationToken);
|
var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if (!load.Success)
|
if (!load.Success)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage);
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
pluginId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("catalog_load_failed", load.ErrorMessage ?? "Failed to load the plugin catalog.")],
|
||||||
|
load.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
|
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index.");
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
pluginId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("not_found", "Plugin was not found in the official catalog.")],
|
||||||
|
"Plugin was not found in the official catalog.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _installService.InstallAsync(entry, cancellationToken);
|
var result = await _installService.InstallAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage);
|
return new PluginCatalogInstallResult(
|
||||||
|
false,
|
||||||
|
entry.Id,
|
||||||
|
entry.Name,
|
||||||
|
null,
|
||||||
|
[new PluginInstallDiagnostic("install_failed", result.ErrorMessage ?? "Plugin install failed.")],
|
||||||
|
result.ErrorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null);
|
return new PluginCatalogInstallResult(
|
||||||
|
true,
|
||||||
|
result.Manifest?.Id ?? entry.Id,
|
||||||
|
result.Manifest?.Name ?? entry.Name,
|
||||||
|
result.Manifest,
|
||||||
|
[],
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginCatalogItemInfo MapCatalogItem(AirAppMarketPluginEntry entry)
|
||||||
|
{
|
||||||
|
var manifest = new PluginCatalogManifestInfo(
|
||||||
|
entry.Id,
|
||||||
|
entry.Name,
|
||||||
|
entry.Description,
|
||||||
|
entry.Author,
|
||||||
|
entry.Version,
|
||||||
|
entry.ApiVersion,
|
||||||
|
string.Empty,
|
||||||
|
entry.SharedContracts
|
||||||
|
.Select(contract => new PluginCatalogSharedContractInfo(
|
||||||
|
contract.Id,
|
||||||
|
contract.Version,
|
||||||
|
contract.AssemblyName))
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
var compatibility = new PluginCatalogCompatibilityInfo(
|
||||||
|
entry.MinHostVersion,
|
||||||
|
entry.ApiVersion);
|
||||||
|
|
||||||
|
var repository = new PluginCatalogRepositoryInfo(
|
||||||
|
entry.IconUrl,
|
||||||
|
entry.ProjectUrl,
|
||||||
|
entry.ReadmeUrl,
|
||||||
|
entry.HomepageUrl,
|
||||||
|
entry.RepositoryUrl,
|
||||||
|
entry.Tags.ToArray(),
|
||||||
|
entry.ReleaseNotes);
|
||||||
|
|
||||||
|
var publication = new PluginCatalogPublicationInfo(
|
||||||
|
entry.ReleaseTag,
|
||||||
|
entry.ReleaseAssetName,
|
||||||
|
entry.PublishedAt,
|
||||||
|
entry.UpdatedAt,
|
||||||
|
entry.PackageSizeBytes,
|
||||||
|
entry.Sha256,
|
||||||
|
null);
|
||||||
|
|
||||||
|
var sources = BuildPackageSources(entry);
|
||||||
|
|
||||||
|
return new PluginCatalogItemInfo(
|
||||||
|
manifest,
|
||||||
|
compatibility,
|
||||||
|
repository,
|
||||||
|
publication,
|
||||||
|
sources,
|
||||||
|
[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
|
||||||
|
{
|
||||||
|
var sources = entry.GetPackageSourcesInInstallOrder();
|
||||||
|
if (sources.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources
|
||||||
|
.Select(source => new PluginPackageSourceInfo(
|
||||||
|
source.SourceKind switch
|
||||||
|
{
|
||||||
|
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.ReleaseAsset => PluginPackageSourceKind.ReleaseAsset,
|
||||||
|
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.RawFallback => PluginPackageSourceKind.RawFallback,
|
||||||
|
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.WorkspaceLocal => PluginPackageSourceKind.WorkspaceLocal,
|
||||||
|
_ => PluginPackageSourceKind.RawFallback
|
||||||
|
},
|
||||||
|
source.Url,
|
||||||
|
entry.Sha256,
|
||||||
|
entry.PackageSizeBytes))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
|
||||||
|
string? sourceId,
|
||||||
|
string? sourceLocation,
|
||||||
|
string? warningMessage)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceId) && string.IsNullOrWhiteSpace(sourceLocation))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSourceId = string.IsNullOrWhiteSpace(sourceId)
|
||||||
|
? "plugin-catalog"
|
||||||
|
: sourceId.Trim();
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new PluginCatalogSourceInfo(
|
||||||
|
normalizedSourceId,
|
||||||
|
normalizedSourceId,
|
||||||
|
string.IsNullOrWhiteSpace(warningMessage) ? null : warningMessage.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(sourceLocation) ? null : sourceLocation.Trim(),
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
0)
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -1030,7 +1185,7 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
|
|||||||
internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable
|
internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly UpdateSettingsService _updateSettingsService;
|
private readonly UpdateSettingsService _updateSettingsService;
|
||||||
private readonly PluginMarketSettingsService _pluginMarketSettingsService;
|
private readonly PluginCatalogSettingsService _pluginCatalogSettingsService;
|
||||||
private readonly PluginManagementSettingsService _pluginManagementSettingsService;
|
private readonly PluginManagementSettingsService _pluginManagementSettingsService;
|
||||||
private readonly WeatherSettingsService _weatherSettingsService;
|
private readonly WeatherSettingsService _weatherSettingsService;
|
||||||
|
|
||||||
@@ -1053,8 +1208,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
|||||||
LauncherPolicy = new LauncherPolicyService();
|
LauncherPolicy = new LauncherPolicyService();
|
||||||
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
|
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
|
||||||
PluginManagement = _pluginManagementSettingsService;
|
PluginManagement = _pluginManagementSettingsService;
|
||||||
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
|
_pluginCatalogSettingsService = new PluginCatalogSettingsService(pluginRuntimeService);
|
||||||
PluginMarket = _pluginMarketSettingsService;
|
PluginCatalog = _pluginCatalogSettingsService;
|
||||||
ApplicationInfo = new ApplicationInfoService();
|
ApplicationInfo = new ApplicationInfoService();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,20 +1241,20 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
|||||||
|
|
||||||
public IPluginManagementSettingsService PluginManagement { get; }
|
public IPluginManagementSettingsService PluginManagement { get; }
|
||||||
|
|
||||||
public IPluginMarketSettingsService PluginMarket { get; }
|
public IPluginCatalogSettingsService PluginCatalog { get; }
|
||||||
|
|
||||||
public IApplicationInfoService ApplicationInfo { get; }
|
public IApplicationInfoService ApplicationInfo { get; }
|
||||||
|
|
||||||
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
|
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
|
||||||
{
|
{
|
||||||
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
|
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
|
||||||
_pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService);
|
_pluginCatalogSettingsService.SetPluginRuntime(pluginRuntimeService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_weatherSettingsService.Dispose();
|
_weatherSettingsService.Dispose();
|
||||||
_updateSettingsService.Dispose();
|
_updateSettingsService.Dispose();
|
||||||
_pluginMarketSettingsService.Dispose();
|
_pluginCatalogSettingsService.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ namespace LanMountainDesktop.Services;
|
|||||||
public sealed record UpdatePendingInfo(
|
public sealed record UpdatePendingInfo(
|
||||||
string InstallerPath,
|
string InstallerPath,
|
||||||
string VersionText,
|
string VersionText,
|
||||||
DateTimeOffset? PublishedAt);
|
DateTimeOffset? PublishedAt,
|
||||||
|
string? Sha256 = null);
|
||||||
|
|
||||||
|
public sealed record UpdateVerifyResult(
|
||||||
|
bool Success,
|
||||||
|
bool HashMatched,
|
||||||
|
string? ExpectedHash,
|
||||||
|
string? ActualHash,
|
||||||
|
string? ErrorMessage);
|
||||||
|
|
||||||
public sealed record UpdateInstallerLaunchResult(
|
public sealed record UpdateInstallerLaunchResult(
|
||||||
bool Success,
|
bool Success,
|
||||||
@@ -56,6 +64,7 @@ public sealed class UpdateWorkflowService
|
|||||||
|
|
||||||
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
|
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
|
||||||
Version currentVersion,
|
Version currentVersion,
|
||||||
|
bool isForce = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var state = _settingsFacade.Update.Get();
|
var state = _settingsFacade.Update.Get();
|
||||||
@@ -64,10 +73,15 @@ public sealed class UpdateWorkflowService
|
|||||||
UpdateSettingsValues.ChannelPreview,
|
UpdateSettingsValues.ChannelPreview,
|
||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var result = await _settingsFacade.Update.CheckForUpdatesAsync(
|
var result = isForce
|
||||||
currentVersion,
|
? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
|
||||||
includePrerelease,
|
currentVersion,
|
||||||
cancellationToken);
|
includePrerelease,
|
||||||
|
cancellationToken)
|
||||||
|
: await _settingsFacade.Update.CheckForUpdatesAsync(
|
||||||
|
currentVersion,
|
||||||
|
includePrerelease,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
SaveState(state with
|
SaveState(state with
|
||||||
{
|
{
|
||||||
@@ -77,6 +91,13 @@ public sealed class UpdateWorkflowService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
|
||||||
|
Version currentVersion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<UpdateDownloadResult> DownloadReleaseAsync(
|
public async Task<UpdateDownloadResult> DownloadReleaseAsync(
|
||||||
UpdateCheckResult checkResult,
|
UpdateCheckResult checkResult,
|
||||||
IProgress<double>? progress = null,
|
IProgress<double>? progress = null,
|
||||||
@@ -95,7 +116,13 @@ public sealed class UpdateWorkflowService
|
|||||||
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
|
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
|
||||||
File.Exists(existingPending.InstallerPath))
|
File.Exists(existingPending.InstallerPath))
|
||||||
{
|
{
|
||||||
return new UpdateDownloadResult(true, existingPending.InstallerPath, null);
|
var verifyResult = await VerifyPendingUpdateAsync();
|
||||||
|
if (verifyResult.Success)
|
||||||
|
{
|
||||||
|
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(_updatesDirectory);
|
Directory.CreateDirectory(_updatesDirectory);
|
||||||
@@ -119,13 +146,111 @@ public sealed class UpdateWorkflowService
|
|||||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||||
? null
|
? null
|
||||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
PendingUpdateSha256 = result.ActualHash
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
|
||||||
|
UpdateCheckResult checkResult,
|
||||||
|
IProgress<double>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(checkResult);
|
||||||
|
|
||||||
|
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
|
||||||
|
{
|
||||||
|
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = _settingsFacade.Update.Get();
|
||||||
|
var existingPending = GetPendingUpdate(state);
|
||||||
|
|
||||||
|
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(existingPending.InstallerPath);
|
||||||
|
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearPendingUpdate();
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_updatesDirectory);
|
||||||
|
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
|
||||||
|
var destinationPath = Path.Combine(_updatesDirectory, fileName);
|
||||||
|
|
||||||
|
state = _settingsFacade.Update.Get();
|
||||||
|
|
||||||
|
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||||
|
checkResult.PreferredAsset,
|
||||||
|
destinationPath,
|
||||||
|
state.UpdateDownloadSource,
|
||||||
|
state.UpdateDownloadThreads,
|
||||||
|
progress,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
SaveState(state with
|
||||||
|
{
|
||||||
|
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
|
||||||
|
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||||
|
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||||
|
? null
|
||||||
|
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||||
|
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
PendingUpdateSha256 = result.ActualHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
|
||||||
|
{
|
||||||
|
var state = _settingsFacade.Update.Get();
|
||||||
|
var pending = GetPendingUpdate(state);
|
||||||
|
|
||||||
|
if (pending is null)
|
||||||
|
{
|
||||||
|
return new UpdateVerifyResult(false, false, null, null, "No pending update available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(pending.InstallerPath))
|
||||||
|
{
|
||||||
|
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedHash = pending.Sha256;
|
||||||
|
var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(expectedHash))
|
||||||
|
{
|
||||||
|
return new UpdateVerifyResult(true, true, null, actualHash, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hashMatched = string.Equals(
|
||||||
|
expectedHash?.Trim().ToLowerInvariant(),
|
||||||
|
actualHash?.Trim().ToLowerInvariant(),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return new UpdateVerifyResult(
|
||||||
|
hashMatched,
|
||||||
|
hashMatched,
|
||||||
|
expectedHash,
|
||||||
|
actualHash,
|
||||||
|
hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task AutoCheckIfEnabledAsync(
|
public async Task AutoCheckIfEnabledAsync(
|
||||||
Version currentVersion,
|
Version currentVersion,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -135,7 +260,7 @@ public sealed class UpdateWorkflowService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Always check for updates on startup (removed AutoCheckUpdates check)
|
// Always check for updates on startup (removed AutoCheckUpdates check)
|
||||||
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
|
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
|
||||||
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
|
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -193,7 +318,8 @@ public sealed class UpdateWorkflowService
|
|||||||
{
|
{
|
||||||
PendingUpdateInstallerPath = null,
|
PendingUpdateInstallerPath = null,
|
||||||
PendingUpdateVersion = null,
|
PendingUpdateVersion = null,
|
||||||
PendingUpdatePublishedAtUtcMs = null
|
PendingUpdatePublishedAtUtcMs = null,
|
||||||
|
PendingUpdateSha256 = null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +388,8 @@ public sealed class UpdateWorkflowService
|
|||||||
return new UpdatePendingInfo(
|
return new UpdatePendingInfo(
|
||||||
installerPath,
|
installerPath,
|
||||||
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
|
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
|
||||||
publishedAt);
|
publishedAt,
|
||||||
|
state.PendingUpdateSha256);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SaveState(UpdateSettingsState state)
|
private void SaveState(UpdateSettingsState state)
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<Styles.Resources>
|
<Styles.Resources>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.16</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.20</x:TimeSpan>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.20</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.28</x:TimeSpan>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.32</x:TimeSpan>
|
||||||
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</x:TimeSpan>
|
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.40</x:TimeSpan>
|
||||||
|
|
||||||
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
|
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
|
||||||
</Styles.Resources>
|
</Styles.Resources>
|
||||||
|
|||||||
151
LanMountainDesktop/Styles/NavigationStyles.axaml
Normal file
151
LanMountainDesktop/Styles/NavigationStyles.axaml
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
|
||||||
|
|
||||||
|
<Styles.Resources>
|
||||||
|
<x:Double x:Key="PaneToggleButtonWidth">40</x:Double>
|
||||||
|
<x:Double x:Key="PaneToggleButtonHeight">40</x:Double>
|
||||||
|
<x:Double x:Key="NavigationViewItemIconBoxHeight">20</x:Double>
|
||||||
|
<GridLength x:Key="PaneToggleButtonHeightGridLength">40</GridLength>
|
||||||
|
</Styles.Resources>
|
||||||
|
|
||||||
|
<Style Selector="Button.pane-toggle-button">
|
||||||
|
<Setter Property="Width" Value="{DynamicResource PaneToggleButtonWidth}" />
|
||||||
|
<Setter Property="Height" Value="{DynamicResource PaneToggleButtonHeight}" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<Border x:Name="LayoutRoot"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="{TemplateBinding CornerRadius}">
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
<Grid x:Name="ContentRoot"
|
||||||
|
ColumnDefinitions="Auto,*">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="{DynamicResource PaneToggleButtonHeightGridLength}" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Border Width="{TemplateBinding Width}">
|
||||||
|
<ContentPresenter x:Name="IconPresenter"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Content="{TemplateBinding Content}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ContentPresenter x:Name="ContentPresenter"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
Content="{TemplateBinding Tag}"
|
||||||
|
FontSize="{TemplateBinding FontSize}"
|
||||||
|
Padding="4,0,0,0"
|
||||||
|
Grid.Column="1" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.pane-toggle-button:pointerover /template/ Border#LayoutRoot">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.pane-toggle-button:pressed /template/ Border#LayoutRoot">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.nav-back">
|
||||||
|
<Setter Property="Width" Value="{DynamicResource PaneToggleButtonWidth}" />
|
||||||
|
<Setter Property="Height" Value="{DynamicResource PaneToggleButtonHeight}" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<Border x:Name="LayoutRoot"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="{TemplateBinding CornerRadius}">
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
<Grid x:Name="ContentRoot"
|
||||||
|
ColumnDefinitions="Auto,*">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="{DynamicResource PaneToggleButtonHeightGridLength}" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Border Width="{TemplateBinding Width}">
|
||||||
|
<fi:FluentIcon Icon="ChevronLeft"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="16"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ContentPresenter x:Name="ContentPresenter"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
FontSize="{TemplateBinding FontSize}"
|
||||||
|
Padding="4,0,0,0"
|
||||||
|
Grid.Column="1" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.nav-back:pointerover /template/ Border#LayoutRoot">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.nav-back:pressed /template/ Border#LayoutRoot">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationView.settings-navigation-view">
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="Opacity" Duration="0:0:0.2" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationView.settings-navigation-view /template/ Border#NavigationViewBorder">
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.167" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationViewItem.settings-nav-item">
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationViewItem.settings-nav-item:pointerover">
|
||||||
|
<Setter Property="RenderTransform" Value="scale(1.01)" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ui|NavigationViewItem.settings-nav-item:pressed">
|
||||||
|
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</Styles>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
||||||
|
|
||||||
@@ -16,17 +16,17 @@
|
|||||||
<Setter Property="Opacity" Value="0" />
|
<Setter Property="Opacity" Value="0" />
|
||||||
<Setter Property="RenderTransform">
|
<Setter Property="RenderTransform">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<TranslateTransform Y="14" />
|
<TranslateTransform Y="24" />
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
||||||
<Style.Animations>
|
<Style.Animations>
|
||||||
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
|
<Animation Duration="0:0:0.65"
|
||||||
FillMode="Both"
|
FillMode="Both"
|
||||||
Easing="0.22,1,0.36,1">
|
Easing="0.05, 0.75, 0.10, 1.00">
|
||||||
<KeyFrame Cue="0%">
|
<KeyFrame Cue="0%">
|
||||||
<Setter Property="Opacity" Value="0" />
|
<Setter Property="Opacity" Value="0" />
|
||||||
<Setter Property="TranslateTransform.Y" Value="14" />
|
<Setter Property="TranslateTransform.Y" Value="24" />
|
||||||
</KeyFrame>
|
</KeyFrame>
|
||||||
<KeyFrame Cue="100%">
|
<KeyFrame Cue="100%">
|
||||||
<Setter Property="Opacity" Value="1" />
|
<Setter Property="Opacity" Value="1" />
|
||||||
@@ -53,9 +53,9 @@
|
|||||||
<Setter Property="MinHeight" Value="34" />
|
<Setter Property="MinHeight" Value="34" />
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
<Style Selector=".settings-scope ComboBox">
|
<Style Selector=".settings-scope ComboBox">
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -87,8 +87,8 @@
|
|||||||
<Style Selector=".settings-scope ToggleSwitch">
|
<Style Selector=".settings-scope ToggleSwitch">
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
|
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
|
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
|
||||||
|
|
||||||
<Style Selector="StackPanel.settings-page-container">
|
<Style Selector="StackPanel.settings-page-container">
|
||||||
<Setter Property="Spacing" Value="0" />
|
<Setter Property="Spacing" Value="0" />
|
||||||
@@ -9,6 +10,34 @@
|
|||||||
<Setter Property="MaxWidth" Value="{DynamicResource SettingsContainerMaxWidth}" />
|
<Setter Property="MaxWidth" Value="{DynamicResource SettingsContainerMaxWidth}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.settings-page-animated">
|
||||||
|
<Setter Property="behaviors:PanelIntroAnimationBehavior.IsEnabled" Value="True" />
|
||||||
|
<Style Selector="^ > :is(Control)[(behaviors|PanelIntroAnimationBehavior.CanPlayAnimation)=True]">
|
||||||
|
<Setter Property="Opacity" Value="0" />
|
||||||
|
<Setter Property="RenderTransform">
|
||||||
|
<Setter.Value>
|
||||||
|
<TranslateTransform Y="20" />
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
|
||||||
|
<Style.Animations>
|
||||||
|
<Animation Duration="0:0:0.55"
|
||||||
|
FillMode="Both"
|
||||||
|
Easing="0.05, 0.75, 0.10, 1.00">
|
||||||
|
<KeyFrame Cue="0%">
|
||||||
|
<Setter Property="Opacity" Value="0" />
|
||||||
|
<Setter Property="TranslateTransform.Y" Value="20" />
|
||||||
|
</KeyFrame>
|
||||||
|
<KeyFrame Cue="100%">
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
<Setter Property="TranslateTransform.Y" Value="0" />
|
||||||
|
</KeyFrame>
|
||||||
|
</Animation>
|
||||||
|
</Style.Animations>
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.settings-section-title">
|
<Style Selector="TextBlock.settings-section-title">
|
||||||
<Setter Property="FontSize" Value="30" />
|
<Setter Property="FontSize" Value="30" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
@@ -39,10 +68,10 @@
|
|||||||
<Transitions>
|
<Transitions>
|
||||||
<BrushTransition Property="Background"
|
<BrushTransition Property="Background"
|
||||||
Duration="{StaticResource FluttermotionToken.Duration.Standard}"
|
Duration="{StaticResource FluttermotionToken.Duration.Standard}"
|
||||||
Easing="0.22,1,0.36,1" />
|
Easing="0.05,0.75,0.10,1.00" />
|
||||||
<BoxShadowsTransition Property="BoxShadow"
|
<BoxShadowsTransition Property="BoxShadow"
|
||||||
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
|
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
|
||||||
Easing="0.22,1,0.36,1" />
|
Easing="0.05,0.75,0.10,1.00" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -238,7 +267,7 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.plugin-market-row-button">
|
<Style Selector="Button.plugin-catalog-row-button">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
@@ -246,11 +275,11 @@
|
|||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.plugin-market-row-button:pointerover">
|
<Style Selector="Button.plugin-catalog-row-button:pointerover">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.plugin-market-icon-button">
|
<Style Selector="Button.plugin-catalog-icon-button">
|
||||||
<Setter Property="Width" Value="36" />
|
<Setter Property="Width" Value="36" />
|
||||||
<Setter Property="Height" Value="36" />
|
<Setter Property="Height" Value="36" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
@@ -261,11 +290,11 @@
|
|||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.plugin-market-icon-button:pointerover">
|
<Style Selector="Button.plugin-catalog-icon-button:pointerover">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.plugin-market-icon-button fi|SymbolIcon">
|
<Style Selector="Button.plugin-catalog-icon-button fi|SymbolIcon">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="16" />
|
<Setter Property="FontSize" Value="16" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ namespace LanMountainDesktop.Theme;
|
|||||||
public static class FluttermotionToken
|
public static class FluttermotionToken
|
||||||
{
|
{
|
||||||
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
|
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
|
||||||
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160);
|
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(200);
|
||||||
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(200);
|
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(280);
|
||||||
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(240);
|
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(320);
|
||||||
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(320);
|
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(400);
|
||||||
|
|
||||||
public static readonly TimeSpan StaggerStepInterval = TimeSpan.FromMilliseconds(24);
|
public static readonly TimeSpan StaggerStepInterval = TimeSpan.FromMilliseconds(32);
|
||||||
public static readonly TimeSpan WeatherAnimationFrameInterval = TimeSpan.FromMilliseconds(64);
|
public static readonly TimeSpan WeatherAnimationFrameInterval = TimeSpan.FromMilliseconds(64);
|
||||||
|
|
||||||
public const string StandardBezier = "0.22, 1, 0.36, 1";
|
public const string StandardBezier = "0.05, 0.75, 0.10, 1.00";
|
||||||
|
public const string DecelerateBezier = "0.05, 0.75, 0.10, 1.00";
|
||||||
|
public const string AccelerateBezier = "0.30, 0.00, 0.60, 0.00";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using Avalonia.Controls;
|
using System.ComponentModel;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
using FluentIcons.Common;
|
using FluentIcons.Common;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace LanMountainDesktop.ViewModels;
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
|
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public string Title { get; set; } = "Widgets";
|
private string _title = "Widgets";
|
||||||
|
|
||||||
|
public string Title
|
||||||
|
{
|
||||||
|
get => _title;
|
||||||
|
set => SetProperty(ref _title, value);
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
|
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
|
||||||
|
|
||||||
@@ -38,20 +46,134 @@ public sealed class ComponentLibraryCategoryViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ComponentLibraryItemViewModel
|
public sealed class ComponentLibraryItemViewModel
|
||||||
|
: ObservableObject
|
||||||
{
|
{
|
||||||
|
private readonly string _loadingPreviewText;
|
||||||
|
private readonly string _previewUnavailableText;
|
||||||
|
private string _displayName;
|
||||||
|
private ComponentPreviewKey _previewKey;
|
||||||
|
private ComponentPreviewImageEntry? _previewImageEntry;
|
||||||
|
private ComponentPreviewImageState _previewState;
|
||||||
|
private string? _previewErrorMessage;
|
||||||
|
private string _previewStatusText;
|
||||||
|
|
||||||
public ComponentLibraryItemViewModel(
|
public ComponentLibraryItemViewModel(
|
||||||
string componentId,
|
string componentId,
|
||||||
string displayName,
|
string displayName,
|
||||||
Control? previewControl)
|
ComponentPreviewKey previewKey,
|
||||||
|
string loadingPreviewText = "Loading preview...",
|
||||||
|
string previewUnavailableText = "Preview unavailable",
|
||||||
|
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||||
{
|
{
|
||||||
ComponentId = componentId;
|
ComponentId = componentId;
|
||||||
DisplayName = displayName;
|
_displayName = displayName;
|
||||||
PreviewControl = previewControl;
|
_previewKey = previewKey;
|
||||||
|
_loadingPreviewText = loadingPreviewText;
|
||||||
|
_previewUnavailableText = previewUnavailableText;
|
||||||
|
_previewStatusText = loadingPreviewText;
|
||||||
|
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ComponentId { get; }
|
public string ComponentId { get; }
|
||||||
|
|
||||||
public string DisplayName { get; }
|
public string DisplayName
|
||||||
|
{
|
||||||
|
get => _displayName;
|
||||||
|
set => SetProperty(ref _displayName, value);
|
||||||
|
}
|
||||||
|
|
||||||
public Control? PreviewControl { get; }
|
public ComponentPreviewKey PreviewKey
|
||||||
|
{
|
||||||
|
get => _previewKey;
|
||||||
|
set => SetProperty(ref _previewKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry;
|
||||||
|
|
||||||
|
public object? PreviewBitmap => _previewImageEntry?.Bitmap;
|
||||||
|
|
||||||
|
public ComponentPreviewImageState PreviewState => _previewState;
|
||||||
|
|
||||||
|
public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending;
|
||||||
|
|
||||||
|
public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null;
|
||||||
|
|
||||||
|
public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed;
|
||||||
|
|
||||||
|
public string? PreviewErrorMessage => _previewErrorMessage;
|
||||||
|
|
||||||
|
public string PreviewStatusText => _previewStatusText;
|
||||||
|
|
||||||
|
public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry)
|
||||||
|
{
|
||||||
|
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged)
|
||||||
|
{
|
||||||
|
if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_previewImageEntry is not null)
|
||||||
|
{
|
||||||
|
_previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_previewImageEntry = previewImageEntry;
|
||||||
|
_previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||||
|
_previewErrorMessage = previewImageEntry?.ErrorMessage;
|
||||||
|
|
||||||
|
_previewStatusText = _previewState switch
|
||||||
|
{
|
||||||
|
ComponentPreviewImageState.Ready => string.Empty,
|
||||||
|
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||||
|
? _previewUnavailableText
|
||||||
|
: _previewErrorMessage!,
|
||||||
|
_ => _loadingPreviewText
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_previewImageEntry is not null)
|
||||||
|
{
|
||||||
|
_previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
RaisePreviewDependentProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
if (string.IsNullOrWhiteSpace(e.PropertyName) ||
|
||||||
|
e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or
|
||||||
|
nameof(ComponentPreviewImageEntry.State) or
|
||||||
|
nameof(ComponentPreviewImageEntry.ErrorMessage))
|
||||||
|
{
|
||||||
|
_previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
|
||||||
|
_previewErrorMessage = _previewImageEntry?.ErrorMessage;
|
||||||
|
_previewStatusText = _previewState switch
|
||||||
|
{
|
||||||
|
ComponentPreviewImageState.Ready => string.Empty,
|
||||||
|
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
|
||||||
|
? _previewUnavailableText
|
||||||
|
: _previewErrorMessage!,
|
||||||
|
_ => _loadingPreviewText
|
||||||
|
};
|
||||||
|
|
||||||
|
RaisePreviewDependentProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaisePreviewDependentProperties()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(PreviewImageEntry));
|
||||||
|
OnPropertyChanged(nameof(PreviewBitmap));
|
||||||
|
OnPropertyChanged(nameof(PreviewState));
|
||||||
|
OnPropertyChanged(nameof(IsPreviewPending));
|
||||||
|
OnPropertyChanged(nameof(IsPreviewReady));
|
||||||
|
OnPropertyChanged(nameof(IsPreviewFailed));
|
||||||
|
OnPropertyChanged(nameof(PreviewErrorMessage));
|
||||||
|
OnPropertyChanged(nameof(PreviewStatusText));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ using LanMountainDesktop.Services.Settings;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.ViewModels;
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
public enum PluginMarketPrimaryActionState
|
public enum PluginCatalogPrimaryActionState
|
||||||
{
|
{
|
||||||
Install,
|
Install,
|
||||||
Update,
|
Update,
|
||||||
@@ -24,14 +24,14 @@ public enum PluginMarketPrimaryActionState
|
|||||||
Incompatible
|
Incompatible
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
public sealed partial class PluginCatalogItemViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly LocalizationService _localizationService;
|
private readonly LocalizationService _localizationService;
|
||||||
private readonly string _languageCode;
|
private readonly string _languageCode;
|
||||||
private bool _isLoadingIcon;
|
private bool _isLoadingIcon;
|
||||||
|
|
||||||
public PluginMarketItemViewModel(
|
public PluginCatalogItemViewModel(
|
||||||
PluginMarketPluginInfo plugin,
|
PluginCatalogItemInfo plugin,
|
||||||
LocalizationService localizationService,
|
LocalizationService localizationService,
|
||||||
string languageCode)
|
string languageCode)
|
||||||
{
|
{
|
||||||
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
ActionTooltip = L("market.button.install", "Install");
|
ActionTooltip = L("market.button.install", "Install");
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginMarketPluginInfo Info { get; }
|
public PluginCatalogItemInfo Info { get; }
|
||||||
|
|
||||||
public string PluginId => Info.Id;
|
public string PluginId => Info.Id;
|
||||||
|
|
||||||
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
|
|
||||||
public string ReadmeUrl => Info.ReadmeUrl;
|
public string ReadmeUrl => Info.ReadmeUrl;
|
||||||
|
|
||||||
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies;
|
public IReadOnlyList<PluginCatalogSharedContractInfo> Dependencies => Info.SharedContracts;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Info.PackageSources;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Info.Capabilities;
|
||||||
|
|
||||||
public string IconFallbackText { get; }
|
public string IconFallbackText { get; }
|
||||||
|
|
||||||
@@ -100,7 +104,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool HasIcon => IconBitmap is not null;
|
public bool HasIcon => IconBitmap is not null;
|
||||||
|
|
||||||
public PluginMarketPrimaryActionState ActionState { get; private set; }
|
public PluginCatalogPrimaryActionState ActionState { get; private set; }
|
||||||
|
|
||||||
partial void OnIconBitmapChanged(Bitmap? value)
|
partial void OnIconBitmapChanged(Bitmap? value)
|
||||||
{
|
{
|
||||||
@@ -160,7 +164,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (IsInstalling)
|
if (IsInstalling)
|
||||||
{
|
{
|
||||||
ActionState = IsUpdateAvailable ? PluginMarketPrimaryActionState.Update : PluginMarketPrimaryActionState.Install;
|
ActionState = IsUpdateAvailable ? PluginCatalogPrimaryActionState.Update : PluginCatalogPrimaryActionState.Install;
|
||||||
ActionSymbol = Symbol.ArrowClockwise;
|
ActionSymbol = Symbol.ArrowClockwise;
|
||||||
ActionTooltip = L("market.button.installing", "Installing...");
|
ActionTooltip = L("market.button.installing", "Installing...");
|
||||||
IsActionEnabled = false;
|
IsActionEnabled = false;
|
||||||
@@ -169,7 +173,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
|
|
||||||
if (!IsCompatibleWithHost)
|
if (!IsCompatibleWithHost)
|
||||||
{
|
{
|
||||||
ActionState = PluginMarketPrimaryActionState.Incompatible;
|
ActionState = PluginCatalogPrimaryActionState.Incompatible;
|
||||||
ActionSymbol = Symbol.Warning;
|
ActionSymbol = Symbol.Warning;
|
||||||
ActionTooltip = string.Format(
|
ActionTooltip = string.Format(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
@@ -181,7 +185,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
|
|
||||||
if (RequiresRestart)
|
if (RequiresRestart)
|
||||||
{
|
{
|
||||||
ActionState = PluginMarketPrimaryActionState.RestartRequired;
|
ActionState = PluginCatalogPrimaryActionState.RestartRequired;
|
||||||
ActionSymbol = Symbol.ArrowClockwise;
|
ActionSymbol = Symbol.ArrowClockwise;
|
||||||
ActionTooltip = L("market.button.restart", "Restart to apply");
|
ActionTooltip = L("market.button.restart", "Restart to apply");
|
||||||
IsActionEnabled = true;
|
IsActionEnabled = true;
|
||||||
@@ -190,7 +194,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
|
|
||||||
if (IsUpdateAvailable)
|
if (IsUpdateAvailable)
|
||||||
{
|
{
|
||||||
ActionState = PluginMarketPrimaryActionState.Update;
|
ActionState = PluginCatalogPrimaryActionState.Update;
|
||||||
ActionSymbol = Symbol.ArrowSync;
|
ActionSymbol = Symbol.ArrowSync;
|
||||||
ActionTooltip = L("market.button.update", "Update");
|
ActionTooltip = L("market.button.update", "Update");
|
||||||
IsActionEnabled = true;
|
IsActionEnabled = true;
|
||||||
@@ -199,14 +203,14 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
|
|
||||||
if (IsInstalled)
|
if (IsInstalled)
|
||||||
{
|
{
|
||||||
ActionState = PluginMarketPrimaryActionState.Installed;
|
ActionState = PluginCatalogPrimaryActionState.Installed;
|
||||||
ActionSymbol = Symbol.CheckmarkCircle;
|
ActionSymbol = Symbol.CheckmarkCircle;
|
||||||
ActionTooltip = L("market.button.installed", "Installed");
|
ActionTooltip = L("market.button.installed", "Installed");
|
||||||
IsActionEnabled = false;
|
IsActionEnabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionState = PluginMarketPrimaryActionState.Install;
|
ActionState = PluginCatalogPrimaryActionState.Install;
|
||||||
ActionSymbol = Symbol.ArrowDownload;
|
ActionSymbol = Symbol.ArrowDownload;
|
||||||
ActionTooltip = L("market.button.install", "Install");
|
ActionTooltip = L("market.button.install", "Install");
|
||||||
IsActionEnabled = true;
|
IsActionEnabled = true;
|
||||||
@@ -238,20 +242,20 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
|
|||||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
public sealed partial class PluginCatalogDetailViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly LocalizationService _localizationService;
|
private readonly LocalizationService _localizationService;
|
||||||
private readonly string _languageCode;
|
private readonly string _languageCode;
|
||||||
private readonly AirAppMarketReadmeService _readmeService;
|
private readonly AirAppMarketReadmeService _readmeService;
|
||||||
private readonly Func<PluginMarketItemViewModel, Task> _primaryActionAsync;
|
private readonly Func<PluginCatalogItemViewModel, Task> _primaryActionAsync;
|
||||||
private bool _isInitialized;
|
private bool _isInitialized;
|
||||||
|
|
||||||
public PluginMarketDetailViewModel(
|
public PluginCatalogDetailViewModel(
|
||||||
PluginMarketItemViewModel item,
|
PluginCatalogItemViewModel item,
|
||||||
LocalizationService localizationService,
|
LocalizationService localizationService,
|
||||||
string languageCode,
|
string languageCode,
|
||||||
AirAppMarketReadmeService readmeService,
|
AirAppMarketReadmeService readmeService,
|
||||||
Func<PluginMarketItemViewModel, Task> primaryActionAsync)
|
Func<PluginCatalogItemViewModel, Task> primaryActionAsync)
|
||||||
{
|
{
|
||||||
Item = item;
|
Item = item;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
_readmeService = readmeService;
|
_readmeService = readmeService;
|
||||||
_primaryActionAsync = primaryActionAsync;
|
_primaryActionAsync = primaryActionAsync;
|
||||||
|
|
||||||
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies);
|
Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
|
||||||
VersionLabel = L("market.detail.version", "Version");
|
VersionLabel = L("market.detail.version", "Version");
|
||||||
PublisherLabel = L("market.detail.author", "Author");
|
PublisherLabel = L("market.detail.author", "Author");
|
||||||
ApiVersionLabel = L("market.detail.api_version", "API Version");
|
ApiVersionLabel = L("market.detail.api_version", "API Version");
|
||||||
@@ -269,9 +273,9 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
EmptyDependenciesText = L("market.detail.dependencies_empty", "No dependencies were declared by this plugin.");
|
EmptyDependenciesText = L("market.detail.dependencies_empty", "No dependencies were declared by this plugin.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginMarketItemViewModel Item { get; }
|
public PluginCatalogItemViewModel Item { get; }
|
||||||
|
|
||||||
public ObservableCollection<PluginMarketDependencyInfo> Dependencies { get; }
|
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
|
||||||
|
|
||||||
public string DrawerTitle => Item.Name;
|
public string DrawerTitle => Item.Name;
|
||||||
|
|
||||||
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
|
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
if (_isInitialized)
|
if (_isInitialized)
|
||||||
@@ -367,9 +375,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
|
|||||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly IPluginCatalogSettingsService _pluginCatalog;
|
||||||
private readonly LocalizationService _localizationService;
|
private readonly LocalizationService _localizationService;
|
||||||
private readonly AirAppMarketIconService _iconService;
|
private readonly AirAppMarketIconService _iconService;
|
||||||
private readonly AirAppMarketReadmeService _readmeService;
|
private readonly AirAppMarketReadmeService _readmeService;
|
||||||
@@ -377,31 +386,32 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Version? _hostVersion;
|
private readonly Version? _hostVersion;
|
||||||
private bool _isInitialized;
|
private bool _isInitialized;
|
||||||
private bool _hasLoadedMarket;
|
private bool _hasLoadedCatalog;
|
||||||
|
|
||||||
public PluginMarketSettingsPageViewModel(
|
public PluginCatalogSettingsPageViewModel(
|
||||||
ISettingsFacadeService settingsFacade,
|
ISettingsFacadeService settingsFacade,
|
||||||
LocalizationService localizationService,
|
LocalizationService localizationService,
|
||||||
AirAppMarketIconService iconService,
|
AirAppMarketIconService iconService,
|
||||||
AirAppMarketReadmeService readmeService)
|
AirAppMarketReadmeService readmeService)
|
||||||
{
|
{
|
||||||
_settingsFacade = settingsFacade;
|
_settingsFacade = settingsFacade;
|
||||||
|
_pluginCatalog = _settingsFacade.PluginCatalog;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
_iconService = iconService;
|
_iconService = iconService;
|
||||||
_readmeService = readmeService;
|
_readmeService = readmeService;
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||||
Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion);
|
Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion);
|
||||||
RefreshLocalizedText();
|
RefreshLocalizedText();
|
||||||
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
|
||||||
}
|
}
|
||||||
|
|
||||||
public event Action<string?>? RestartRequested;
|
public event Action<string?>? RestartRequested;
|
||||||
|
|
||||||
public event Action<PluginMarketItemViewModel>? DetailsRequested;
|
public event Action<PluginCatalogItemViewModel>? DetailsRequested;
|
||||||
|
|
||||||
public ObservableCollection<PluginMarketItemViewModel> MarketPlugins { get; } = [];
|
public ObservableCollection<PluginCatalogItemViewModel> CatalogPlugins { get; } = [];
|
||||||
|
|
||||||
public ObservableCollection<PluginMarketItemViewModel> FilteredPlugins { get; } = [];
|
public ObservableCollection<PluginCatalogItemViewModel> FilteredPlugins { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _statusMessage = string.Empty;
|
private string _statusMessage = string.Empty;
|
||||||
@@ -444,9 +454,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
await RefreshAsync();
|
await RefreshAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginMarketDetailViewModel CreateDetailViewModel(PluginMarketItemViewModel item)
|
public PluginCatalogDetailViewModel CreateDetailViewModel(PluginCatalogItemViewModel item)
|
||||||
{
|
{
|
||||||
return new PluginMarketDetailViewModel(
|
return new PluginCatalogDetailViewModel(
|
||||||
item,
|
item,
|
||||||
_localizationService,
|
_localizationService,
|
||||||
_languageCode,
|
_languageCode,
|
||||||
@@ -465,35 +475,35 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
|
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
|
||||||
RefreshInstalledSnapshot();
|
RefreshInstalledSnapshot();
|
||||||
|
|
||||||
var result = await _settingsFacade.PluginMarket.LoadIndexAsync();
|
var result = await _pluginCatalog.LoadCatalogAsync();
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
_hasLoadedMarket = false;
|
_hasLoadedCatalog = false;
|
||||||
MarketPlugins.Clear();
|
CatalogPlugins.Clear();
|
||||||
FilteredPlugins.Clear();
|
FilteredPlugins.Clear();
|
||||||
ShowEmptyState = true;
|
ShowEmptyState = true;
|
||||||
EmptyStateText = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
EmptyStateText = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||||
? L("market.list.empty", "The plugin market has not been loaded yet.")
|
? L("market.list.empty", "The plugin catalog has not been loaded yet.")
|
||||||
: result.ErrorMessage;
|
: result.ErrorMessage;
|
||||||
StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||||
? L("market.status.load_failed_format", "Failed to load the plugin market: Unknown")
|
? L("market.status.load_failed_format", "Failed to load the plugin catalog: Unknown")
|
||||||
: string.Format(
|
: string.Format(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
L("market.status.load_failed_format", "Failed to load the plugin market: {0}"),
|
L("market.status.load_failed_format", "Failed to load the plugin catalog: {0}"),
|
||||||
result.ErrorMessage);
|
result.ErrorMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_hasLoadedMarket = true;
|
_hasLoadedCatalog = true;
|
||||||
MarketPlugins.Clear();
|
CatalogPlugins.Clear();
|
||||||
foreach (var plugin in result.Plugins)
|
foreach (var plugin in result.Plugins)
|
||||||
{
|
{
|
||||||
var item = new PluginMarketItemViewModel(plugin, _localizationService, _languageCode);
|
var item = new PluginCatalogItemViewModel(plugin, _localizationService, _languageCode);
|
||||||
item.ApplyInstallState(ResolveInstalledPlugin(plugin.Id), _hostVersion);
|
item.ApplyInstallState(ResolveInstalledPlugin(plugin.Id), _hostVersion);
|
||||||
MarketPlugins.Add(item);
|
CatalogPlugins.Add(item);
|
||||||
_ = item.EnsureIconLoadedAsync(_iconService);
|
_ = item.EnsureIconLoadedAsync(_iconService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,12 +513,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
? string.Format(
|
? string.Format(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
L("market.status.loaded_cache_format", "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}"),
|
L("market.status.loaded_cache_format", "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}"),
|
||||||
MarketPlugins.Count,
|
CatalogPlugins.Count,
|
||||||
result.WarningMessage ?? L("market.detail.unknown", "Unknown"))
|
result.WarningMessage ?? L("market.detail.unknown", "Unknown"))
|
||||||
: string.Format(
|
: string.Format(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."),
|
L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."),
|
||||||
MarketPlugins.Count);
|
CatalogPlugins.Count);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -517,7 +527,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenDetails(PluginMarketItemViewModel? item)
|
private void OpenDetails(PluginCatalogItemViewModel? item)
|
||||||
{
|
{
|
||||||
if (item is null)
|
if (item is null)
|
||||||
{
|
{
|
||||||
@@ -528,19 +538,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task ExecutePrimaryActionAsync(PluginMarketItemViewModel? item)
|
private Task ExecutePrimaryActionAsync(PluginCatalogItemViewModel? item)
|
||||||
{
|
{
|
||||||
return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item);
|
return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExecutePrimaryActionCoreAsync(PluginMarketItemViewModel item)
|
private async Task ExecutePrimaryActionCoreAsync(PluginCatalogItemViewModel item)
|
||||||
{
|
{
|
||||||
if (item.IsInstalling)
|
if (item.IsInstalling)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.ActionState == PluginMarketPrimaryActionState.RestartRequired)
|
if (item.ActionState == PluginCatalogPrimaryActionState.RestartRequired)
|
||||||
{
|
{
|
||||||
RestartRequested?.Invoke(RestartRequiredMessage);
|
RestartRequested?.Invoke(RestartRequiredMessage);
|
||||||
return;
|
return;
|
||||||
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
|
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
|
||||||
item.Name);
|
item.Name);
|
||||||
|
|
||||||
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
|
var result = await _pluginCatalog.InstallAsync(item.PluginId);
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
{
|
{
|
||||||
RefreshInstalledSnapshot();
|
RefreshInstalledSnapshot();
|
||||||
@@ -604,7 +614,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
private void RefreshItemStates()
|
private void RefreshItemStates()
|
||||||
{
|
{
|
||||||
foreach (var item in MarketPlugins)
|
foreach (var item in CatalogPlugins)
|
||||||
{
|
{
|
||||||
item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion);
|
item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion);
|
||||||
}
|
}
|
||||||
@@ -632,7 +642,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
FilteredPlugins.Clear();
|
FilteredPlugins.Clear();
|
||||||
|
|
||||||
IEnumerable<PluginMarketItemViewModel> filtered = MarketPlugins;
|
IEnumerable<PluginCatalogItemViewModel> filtered = CatalogPlugins;
|
||||||
var query = SearchText?.Trim();
|
var query = SearchText?.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(query))
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
{
|
{
|
||||||
@@ -650,8 +660,8 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
ShowEmptyState = FilteredPlugins.Count == 0;
|
ShowEmptyState = FilteredPlugins.Count == 0;
|
||||||
EmptyStateText = !_hasLoadedMarket
|
EmptyStateText = !_hasLoadedCatalog
|
||||||
? L("market.list.empty", "The plugin market has not been loaded yet.")
|
? L("market.list.empty", "The plugin catalog has not been loaded yet.")
|
||||||
: string.IsNullOrWhiteSpace(query)
|
: string.IsNullOrWhiteSpace(query)
|
||||||
? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.")
|
? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.")
|
||||||
: L("market.list.no_results", "No plugins match the current search.");
|
: L("market.list.no_results", "No plugins match the current search.");
|
||||||
@@ -659,12 +669,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
{
|
{
|
||||||
PageTitle = L("settings.plugin_market.title", "Plugin Market");
|
PageTitle = L("settings.plugin_catalog.title", "Plugin Catalog");
|
||||||
PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
|
PageDescription = L("settings.plugin_catalog.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
|
||||||
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
|
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
|
||||||
RefreshButtonText = L("market.toolbar.refresh", "Refresh");
|
RefreshButtonText = L("market.toolbar.refresh", "Refresh");
|
||||||
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
|
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
|
||||||
EmptyStateText = L("market.list.empty", "The plugin market has not been loaded yet.");
|
EmptyStateText = L("market.list.empty", "The plugin catalog has not been loaded yet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string L(string key, string fallback)
|
private string L(string key, string fallback)
|
||||||
@@ -326,7 +326,9 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
|
|||||||
return
|
return
|
||||||
[
|
[
|
||||||
new SelectionOption("zh-CN", L("settings.region.language_zh", "中文")),
|
new SelectionOption("zh-CN", L("settings.region.language_zh", "中文")),
|
||||||
new SelectionOption("en-US", L("settings.region.language_en", "English"))
|
new SelectionOption("en-US", L("settings.region.language_en", "English")),
|
||||||
|
new SelectionOption("ja-JP", L("settings.region.language_ja", "日本語")),
|
||||||
|
new SelectionOption("ko-KR", L("settings.region.language_ko", "한국어"))
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,6 +1517,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _installNowButtonText = string.Empty;
|
private string _installNowButtonText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _redownloadButtonText = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _latestVersionText = string.Empty;
|
private string _latestVersionText = string.Empty;
|
||||||
|
|
||||||
@@ -1554,6 +1559,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _downloadThreadsDescription = string.Empty;
|
private string _downloadThreadsDescription = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _forceCheckUpdateLabel = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _forceCheckUpdateDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _stableChannelText = string.Empty;
|
private string _stableChannelText = string.Empty;
|
||||||
|
|
||||||
@@ -1617,6 +1628,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool IsInstallButtonVisible => HasPendingInstaller;
|
public bool IsInstallButtonVisible => HasPendingInstaller;
|
||||||
|
|
||||||
|
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
|
||||||
|
|
||||||
public string DownloadThreadsValueText =>
|
public string DownloadThreadsValueText =>
|
||||||
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
@@ -1836,6 +1849,19 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
|
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
|
||||||
private async Task CheckForUpdatesAsync()
|
private async Task CheckForUpdatesAsync()
|
||||||
|
{
|
||||||
|
await CheckForUpdatesCoreAsync(isForce: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanForceCheckUpdate() => !IsBusy;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanForceCheckUpdate))]
|
||||||
|
private async Task ForceCheckUpdateAsync()
|
||||||
|
{
|
||||||
|
await CheckForUpdatesCoreAsync(isForce: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckForUpdatesCoreAsync(bool isForce)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1843,9 +1869,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
IsDownloadProgressVisible = false;
|
IsDownloadProgressVisible = false;
|
||||||
DownloadProgressValue = 0;
|
DownloadProgressValue = 0;
|
||||||
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||||
UpdateStatus = L("settings.update.status_checking", "Checking GitHub releases...");
|
UpdateStatus = isForce
|
||||||
|
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
|
||||||
|
: L("settings.update.status_checking", "Checking GitHub releases...");
|
||||||
|
|
||||||
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion);
|
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
|
||||||
_lastCheckResult = result.Success ? result : null;
|
_lastCheckResult = result.Success ? result : null;
|
||||||
RefreshLastCheckedFromSettings();
|
RefreshLastCheckedFromSettings();
|
||||||
|
|
||||||
@@ -1861,16 +1889,16 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplyCheckResultDisplay(result);
|
ApplyCheckResultDisplay(result);
|
||||||
if (!result.IsUpdateAvailable)
|
if (!result.IsUpdateAvailable && !isForce)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.PreferredAsset is null)
|
if (result.PreferredAsset is null)
|
||||||
{
|
{
|
||||||
UpdateStatus = L(
|
UpdateStatus = isForce
|
||||||
"settings.update.status_asset_missing",
|
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
|
||||||
"A new release is available, but no compatible installer was found.");
|
: L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1882,7 +1910,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
UpdateStatus = string.Format(
|
UpdateStatus = string.Format(
|
||||||
CultureInfo.CurrentCulture,
|
CultureInfo.CurrentCulture,
|
||||||
L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
|
isForce
|
||||||
|
? L("settings.update.status_force_available_format", "Release {0} is available. Click Download & Install.")
|
||||||
|
: L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
|
||||||
result.LatestVersionText);
|
result.LatestVersionText);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -1924,6 +1954,59 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download."));
|
result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanRedownloadUpdate() => !IsBusy && HasPendingInstaller && _lastCheckResult is not null;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
|
||||||
|
private async Task RedownloadUpdateAsync()
|
||||||
|
{
|
||||||
|
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
|
||||||
|
{
|
||||||
|
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IsDownloading = true;
|
||||||
|
IsDownloadProgressVisible = true;
|
||||||
|
DownloadProgressValue = 0;
|
||||||
|
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
|
||||||
|
UpdateStatus = L("settings.update.status_redownloading", "Redownloading installer...");
|
||||||
|
|
||||||
|
var progress = new Progress<double>(value =>
|
||||||
|
{
|
||||||
|
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
|
||||||
|
DownloadProgressText = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
|
||||||
|
DownloadProgressValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
var downloadResult = await _updateWorkflowService.RedownloadReleaseAsync(_lastCheckResult, progress);
|
||||||
|
if (!downloadResult.Success)
|
||||||
|
{
|
||||||
|
UpdateStatus = string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("settings.update.status_redownload_failed_format", "Redownload failed: {0}"),
|
||||||
|
downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyPendingState(_settingsFacade.Update.Get());
|
||||||
|
UpdateStatus = downloadResult.HashVerified
|
||||||
|
? BuildPendingReadyStatus()
|
||||||
|
: string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"),
|
||||||
|
downloadResult.ActualHash ?? "N/A");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsDownloading = false;
|
||||||
|
IsDownloadProgressVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
{
|
{
|
||||||
PageTitle = L("settings.update.title", "Update");
|
PageTitle = L("settings.update.title", "Update");
|
||||||
@@ -1937,9 +2020,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
|
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
|
||||||
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
|
||||||
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
|
||||||
|
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
|
||||||
|
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
|
||||||
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
|
||||||
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
|
||||||
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
|
||||||
|
RedownloadButtonText = L("settings.update.redownload_button", "Redownload");
|
||||||
CurrentVersionLabel = L("settings.update.current_version_label", "Current Version");
|
CurrentVersionLabel = L("settings.update.current_version_label", "Current Version");
|
||||||
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
|
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
|
||||||
PublishedAtLabel = L("settings.update.published_at_label", "Published At");
|
PublishedAtLabel = L("settings.update.published_at_label", "Published At");
|
||||||
@@ -2145,7 +2231,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsDownloadButtonVisible));
|
OnPropertyChanged(nameof(IsDownloadButtonVisible));
|
||||||
OnPropertyChanged(nameof(IsInstallButtonVisible));
|
OnPropertyChanged(nameof(IsInstallButtonVisible));
|
||||||
|
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
|
||||||
OnPropertyChanged(nameof(DownloadThreadsValueText));
|
OnPropertyChanged(nameof(DownloadThreadsValueText));
|
||||||
|
RedownloadUpdateCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
|
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
|
||||||
|
|||||||
@@ -99,9 +99,48 @@
|
|||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||||
Padding="8">
|
Padding="8">
|
||||||
<ContentControl HorizontalAlignment="Center"
|
<Grid>
|
||||||
VerticalAlignment="Center"
|
<Image Source="{Binding PreviewBitmap}"
|
||||||
Content="{Binding PreviewControl}" />
|
Stretch="Uniform"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||||
|
IsVisible="{Binding IsPreviewReady}" />
|
||||||
|
|
||||||
|
<Border IsVisible="{Binding IsPreviewPending}"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8">
|
||||||
|
<ProgressBar Width="96"
|
||||||
|
IsIndeterminate="True" />
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="{Binding PreviewStatusText}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border IsVisible="{Binding IsPreviewFailed}"
|
||||||
|
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||||
|
<StackPanel HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8">
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
Text="{Binding PreviewStatusText}" />
|
||||||
|
<TextBlock HorizontalAlignment="Center"
|
||||||
|
TextAlignment="Center"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||||
|
Text="{Binding PreviewErrorMessage}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
private IComponentLibraryService? _componentLibraryService;
|
private IComponentLibraryService? _componentLibraryService;
|
||||||
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
|
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
|
||||||
private Func<string, string, string>? _localize;
|
private Func<string, string, string>? _localize;
|
||||||
|
private Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? _previewKeyResolver;
|
||||||
|
private Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? _previewEntryResolver;
|
||||||
|
private Action<ComponentPreviewKey>? _warmPreviewRequested;
|
||||||
|
private Action<ComponentPreviewKey>? _renderPreviewRequested;
|
||||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||||
|
|
||||||
public ComponentLibraryWindow()
|
public ComponentLibraryWindow()
|
||||||
@@ -25,12 +29,20 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
public ComponentLibraryWindow(
|
public ComponentLibraryWindow(
|
||||||
IComponentLibraryService componentLibraryService,
|
IComponentLibraryService componentLibraryService,
|
||||||
Func<double, ComponentLibraryCreateContext> createContextFactory,
|
Func<double, ComponentLibraryCreateContext> createContextFactory,
|
||||||
Func<string, string, string> localize)
|
Func<string, string, string> localize,
|
||||||
|
Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? previewKeyResolver = null,
|
||||||
|
Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? previewEntryResolver = null,
|
||||||
|
Action<ComponentPreviewKey>? warmPreviewRequested = null,
|
||||||
|
Action<ComponentPreviewKey>? renderPreviewRequested = null)
|
||||||
: this()
|
: this()
|
||||||
{
|
{
|
||||||
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
|
||||||
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
|
||||||
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
|
||||||
|
_previewKeyResolver = previewKeyResolver;
|
||||||
|
_previewEntryResolver = previewEntryResolver;
|
||||||
|
_warmPreviewRequested = warmPreviewRequested;
|
||||||
|
_renderPreviewRequested = renderPreviewRequested;
|
||||||
Reload();
|
Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +50,7 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
|
|
||||||
public void Reload()
|
public void Reload()
|
||||||
{
|
{
|
||||||
if (_componentLibraryService is null ||
|
if (_componentLibraryService is null || _localize is null)
|
||||||
_createContextFactory is null ||
|
|
||||||
_localize is null)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -75,32 +85,26 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
|
|
||||||
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
|
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
|
||||||
{
|
{
|
||||||
if (_componentLibraryService is null ||
|
var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
||||||
_createContextFactory is null ||
|
? entry.DisplayName
|
||||||
_localize is null)
|
: _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName;
|
||||||
|
var previewKey = ResolvePreviewKey(entry);
|
||||||
|
var previewEntry = _previewEntryResolver?.Invoke(previewKey);
|
||||||
|
var item = new ComponentLibraryItemViewModel(
|
||||||
|
entry.ComponentId,
|
||||||
|
displayName,
|
||||||
|
previewKey,
|
||||||
|
_localize?.Invoke("component_library.preview.loading", "Loading preview...") ?? "Loading preview...",
|
||||||
|
_localize?.Invoke("component_library.preview.unavailable", "Preview unavailable") ?? "Preview unavailable",
|
||||||
|
previewEntry);
|
||||||
|
|
||||||
|
if (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending)
|
||||||
{
|
{
|
||||||
return new ComponentLibraryItemViewModel(entry.ComponentId, entry.DisplayName, previewControl: null);
|
_warmPreviewRequested?.Invoke(previewKey);
|
||||||
|
_renderPreviewRequested?.Invoke(previewKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
Control? previewControl = null;
|
return item;
|
||||||
_componentLibraryService.TryCreateControl(
|
|
||||||
entry.ComponentId,
|
|
||||||
_createContextFactory(42),
|
|
||||||
out previewControl,
|
|
||||||
out _);
|
|
||||||
|
|
||||||
if (previewControl is not null)
|
|
||||||
{
|
|
||||||
previewControl.IsHitTestVisible = false;
|
|
||||||
previewControl.Focusable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ComponentLibraryItemViewModel(
|
|
||||||
entry.ComponentId,
|
|
||||||
string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
|
|
||||||
? entry.DisplayName
|
|
||||||
: _localize(entry.DisplayNameLocalizationKey, entry.DisplayName),
|
|
||||||
previewControl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
@@ -118,6 +122,8 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
{
|
{
|
||||||
_viewModel.Components.Add(component);
|
_viewModel.Components.Add(component);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RequestPreviewWarmup(selectedCategory.Components);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -140,6 +146,51 @@ public partial class ComponentLibraryWindow : Window
|
|||||||
Hide();
|
Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void UpdatePreviewImage(ComponentPreviewImageEntry previewImageEntry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(previewImageEntry);
|
||||||
|
|
||||||
|
foreach (var category in _viewModel.Categories)
|
||||||
|
{
|
||||||
|
foreach (var component in category.Components)
|
||||||
|
{
|
||||||
|
if (component.PreviewKey.Equals(previewImageEntry.Key))
|
||||||
|
{
|
||||||
|
component.UpdatePreviewImageEntry(previewImageEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewKey ResolvePreviewKey(ComponentLibraryComponentEntry entry)
|
||||||
|
{
|
||||||
|
if (_previewKeyResolver is not null)
|
||||||
|
{
|
||||||
|
return _previewKeyResolver(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ComponentPreviewKey.ForComponentType(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestPreviewWarmup(IEnumerable<ComponentLibraryItemViewModel> components)
|
||||||
|
{
|
||||||
|
if (_warmPreviewRequested is null && _renderPreviewRequested is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var component in components)
|
||||||
|
{
|
||||||
|
if (!component.IsPreviewPending)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_warmPreviewRequested?.Invoke(component.PreviewKey);
|
||||||
|
_renderPreviewRequested?.Invoke(component.PreviewKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Symbol ResolveCategoryIcon(string categoryId)
|
private Symbol ResolveCategoryIcon(string categoryId)
|
||||||
{
|
{
|
||||||
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
BorderBrush="Transparent"
|
BorderBrush="Transparent"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
Padding="16,14,16,14">
|
Padding="16,14,16,14">
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
|
<Grid x:Name="ContentGrid"
|
||||||
|
RowDefinitions="Auto,Auto,Auto,Auto"
|
||||||
RowSpacing="8">
|
RowSpacing="8">
|
||||||
<Grid Grid.Row="0"
|
<Grid Grid.Row="0"
|
||||||
ColumnDefinitions="*,Auto"
|
ColumnDefinitions="*,Auto"
|
||||||
|
|||||||
@@ -625,17 +625,18 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
{
|
{
|
||||||
var scale = ResolveScale();
|
var scale = ResolveScale();
|
||||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||||
|
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||||
|
|
||||||
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
|
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
|
||||||
RootBorder.CornerRadius = unifiedMainRectangle;
|
RootBorder.CornerRadius = unifiedMainRectangle;
|
||||||
RootBorder.Padding = new Thickness(0);
|
RootBorder.Padding = new Thickness(0);
|
||||||
|
|
||||||
|
var horizontalPadding = Math.Clamp(16 * scale, 8, 24);
|
||||||
|
var verticalPadding = Math.Clamp(14 * scale, 7, 22);
|
||||||
CardBorder.CornerRadius = unifiedMainRectangle;
|
CardBorder.CornerRadius = unifiedMainRectangle;
|
||||||
CardBorder.Padding = new Thickness(
|
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||||
Math.Clamp(16 * scale, 8, 24),
|
|
||||||
Math.Clamp(14 * scale, 7, 22),
|
var innerWidth = Math.Max(100, totalWidth - horizontalPadding * 2);
|
||||||
Math.Clamp(16 * scale, 8, 24),
|
|
||||||
Math.Clamp(14 * scale, 7, 22));
|
|
||||||
|
|
||||||
var headlineFont = Math.Clamp(24 * scale, 12, 34);
|
var headlineFont = Math.Clamp(24 * scale, 12, 34);
|
||||||
BrandPrimaryTextBlock.FontSize = headlineFont;
|
BrandPrimaryTextBlock.FontSize = headlineFont;
|
||||||
@@ -649,7 +650,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
|
RefreshGlyphIcon.FontSize = Math.Clamp(19 * scale, 11, 24);
|
||||||
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
|
RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29);
|
||||||
|
|
||||||
var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170);
|
var imageWidth = Math.Clamp(innerWidth * 0.22, 60, 170);
|
||||||
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
|
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
|
||||||
News1ImageHost.Width = imageWidth;
|
News1ImageHost.Width = imageWidth;
|
||||||
News1ImageHost.Height = imageHeight;
|
News1ImageHost.Height = imageHeight;
|
||||||
@@ -657,6 +658,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
News2ImageHost.Height = imageHeight;
|
News2ImageHost.Height = imageHeight;
|
||||||
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||||
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||||
|
News1ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||||
|
News2ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||||
|
|
||||||
var columnGap = Math.Clamp(12 * scale, 6, 18);
|
var columnGap = Math.Clamp(12 * scale, 6, 18);
|
||||||
NewsItem1Grid.ColumnSpacing = columnGap;
|
NewsItem1Grid.ColumnSpacing = columnGap;
|
||||||
@@ -664,25 +667,29 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||||
NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||||
|
|
||||||
var availableTextWidth = Math.Max(
|
var availableTextWidth = Math.Max(80, innerWidth - imageWidth - columnGap);
|
||||||
84,
|
|
||||||
totalWidth - imageWidth - columnGap - Math.Clamp(20 * scale, 10, 32));
|
|
||||||
News1TitleTextBlock.MaxWidth = availableTextWidth;
|
News1TitleTextBlock.MaxWidth = availableTextWidth;
|
||||||
News2TitleTextBlock.MaxWidth = availableTextWidth;
|
News2TitleTextBlock.MaxWidth = availableTextWidth;
|
||||||
|
|
||||||
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
|
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
|
||||||
News1TitleTextBlock.FontSize = newsFont;
|
News1TitleTextBlock.FontSize = newsFont;
|
||||||
News2TitleTextBlock.FontSize = newsFont;
|
News2TitleTextBlock.FontSize = newsFont;
|
||||||
var mainNewsLineHeight = newsFont * 1.14;
|
var mainNewsLineHeight = newsFont * 1.2;
|
||||||
News1TitleTextBlock.LineHeight = mainNewsLineHeight;
|
News1TitleTextBlock.LineHeight = mainNewsLineHeight;
|
||||||
News2TitleTextBlock.LineHeight = mainNewsLineHeight;
|
News2TitleTextBlock.LineHeight = mainNewsLineHeight;
|
||||||
var mainNewsMinHeight = mainNewsLineHeight * 2;
|
var mainNewsMinHeight = mainNewsLineHeight * 2.2;
|
||||||
News1TitleTextBlock.MinHeight = mainNewsMinHeight;
|
News1TitleTextBlock.MinHeight = mainNewsMinHeight;
|
||||||
News2TitleTextBlock.MinHeight = mainNewsMinHeight;
|
News2TitleTextBlock.MinHeight = mainNewsMinHeight;
|
||||||
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
|
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
|
||||||
News1TitleTextBlock.MaxLines = 2;
|
News1TitleTextBlock.MaxLines = 2;
|
||||||
News2TitleTextBlock.MaxLines = 2;
|
News2TitleTextBlock.MaxLines = 2;
|
||||||
|
|
||||||
|
var rowSpacing = Math.Clamp(8 * scale, 4, 14);
|
||||||
|
if (ContentGrid is Grid contentGrid && contentGrid.RowDefinitions.Count >= 4)
|
||||||
|
{
|
||||||
|
contentGrid.RowSpacing = rowSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var row in _extraNewsRows)
|
foreach (var row in _extraNewsRows)
|
||||||
{
|
{
|
||||||
row.RootGrid.ColumnSpacing = columnGap;
|
row.RootGrid.ColumnSpacing = columnGap;
|
||||||
@@ -694,11 +701,12 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
row.ImageHost.Width = imageWidth;
|
row.ImageHost.Width = imageWidth;
|
||||||
row.ImageHost.Height = imageHeight;
|
row.ImageHost.Height = imageHeight;
|
||||||
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||||
|
row.ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||||
|
|
||||||
row.TitleTextBlock.MaxWidth = availableTextWidth;
|
row.TitleTextBlock.MaxWidth = availableTextWidth;
|
||||||
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
|
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);
|
||||||
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.12;
|
row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.2;
|
||||||
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2;
|
row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2.2;
|
||||||
row.TitleTextBlock.MaxLines = 2;
|
row.TitleTextBlock.MaxLines = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
105
LanMountainDesktop/Views/Components/DailyNewsView.axaml
Normal file
105
LanMountainDesktop/Views/Components/DailyNewsView.axaml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.DailyNewsView">
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
<Style Selector="Button.link-button">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="4"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
|
<StackPanel x:Name="RootStackPanel" Spacing="16">
|
||||||
|
<Border x:Name="CoverImageBorder"
|
||||||
|
CornerRadius="12"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Background="#f8f5ec"
|
||||||
|
PointerPressed="OnCoverImagePointerPressed"
|
||||||
|
Cursor="Hand">
|
||||||
|
<Image x:Name="CoverImage"
|
||||||
|
Stretch="UniformToFill"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock x:Name="DateTextBlock"
|
||||||
|
Grid.Column="0"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="#bb5649"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Button x:Name="BilibiliButton"
|
||||||
|
Classes="link-button"
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="16"
|
||||||
|
Background="#FB7299"
|
||||||
|
Cursor="Hand"
|
||||||
|
Click="OnBilibiliButtonClick"
|
||||||
|
ToolTip.Tip="观看视频版">
|
||||||
|
<Path Stretch="Uniform"
|
||||||
|
Width="18"
|
||||||
|
Height="18"
|
||||||
|
Fill="White"
|
||||||
|
Data="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button x:Name="WechatButton"
|
||||||
|
Classes="link-button"
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="16"
|
||||||
|
Background="#07C160"
|
||||||
|
Cursor="Hand"
|
||||||
|
Click="OnWechatButtonClick"
|
||||||
|
ToolTip.Tip="阅读原文">
|
||||||
|
<Path Stretch="Uniform"
|
||||||
|
Width="18"
|
||||||
|
Height="18"
|
||||||
|
Fill="White"
|
||||||
|
Data="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border x:Name="OverviewBorder"
|
||||||
|
Background="#f8f5ec"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="12"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<StackPanel x:Name="OverviewStackPanel" Spacing="12"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button x:Name="ShowMoreButton"
|
||||||
|
Content="展开更多新闻 ▼"
|
||||||
|
FontSize="14"
|
||||||
|
Padding="16,8"
|
||||||
|
CornerRadius="8"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="#bb5649"
|
||||||
|
BorderThickness="1"
|
||||||
|
Foreground="#bb5649"
|
||||||
|
Cursor="Hand"
|
||||||
|
Click="OnShowMoreButtonClick"/>
|
||||||
|
|
||||||
|
<StackPanel x:Name="DetailedNewsStackPanel"
|
||||||
|
Spacing="16"
|
||||||
|
IsVisible="False"/>
|
||||||
|
|
||||||
|
<Border x:Name="DateSeparatorBorder"
|
||||||
|
Height="1"
|
||||||
|
Background="#e6e6e6"
|
||||||
|
Margin="0,8,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
526
LanMountainDesktop/Views/Components/DailyNewsView.axaml.cs
Normal file
526
LanMountainDesktop/Views/Components/DailyNewsView.axaml.cs
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class DailyNewsView : UserControl
|
||||||
|
{
|
||||||
|
private static readonly HttpClient HttpClient = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(10)
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly JuyaDailyNews _news;
|
||||||
|
private Bitmap? _coverBitmap;
|
||||||
|
private bool _isNightMode;
|
||||||
|
private bool _isExpanded;
|
||||||
|
|
||||||
|
public event EventHandler? CoverImageClicked;
|
||||||
|
public event EventHandler<string>? NewsItemClicked;
|
||||||
|
|
||||||
|
public DailyNewsView(JuyaDailyNews news, bool isNightMode)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_news = news;
|
||||||
|
_isNightMode = isNightMode;
|
||||||
|
|
||||||
|
var dateStr = news.Date.ToString("yyyy年M月d日");
|
||||||
|
var dayOfWeek = news.Date.ToString("dddd");
|
||||||
|
DateTextBlock.Text = $"{dateStr} {dayOfWeek}";
|
||||||
|
|
||||||
|
_ = LoadCoverImageAsync(news.CoverImageUrl);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(news.BilibiliUrl))
|
||||||
|
{
|
||||||
|
BilibiliButton.IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(news.IssueUrl))
|
||||||
|
{
|
||||||
|
WechatButton.IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (news.OverviewCategories.Any())
|
||||||
|
{
|
||||||
|
foreach (var category in news.OverviewCategories)
|
||||||
|
{
|
||||||
|
var categoryPanel = new StackPanel { Spacing = 6 };
|
||||||
|
|
||||||
|
var categoryHeader = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{category.Icon} {category.Name}",
|
||||||
|
FontSize = 15,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#d4736a") : Color.Parse("#bb5649"))
|
||||||
|
};
|
||||||
|
categoryPanel.Children.Add(categoryHeader);
|
||||||
|
|
||||||
|
foreach (var item in category.Items)
|
||||||
|
{
|
||||||
|
var itemPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
||||||
|
Spacing = 4
|
||||||
|
};
|
||||||
|
|
||||||
|
var bulletText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "•",
|
||||||
|
FontSize = 13,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575"))
|
||||||
|
};
|
||||||
|
itemPanel.Children.Add(bulletText);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(item.Url))
|
||||||
|
{
|
||||||
|
var linkButton = new HyperlinkButton
|
||||||
|
{
|
||||||
|
Content = item.Title,
|
||||||
|
NavigateUri = new Uri(item.Url),
|
||||||
|
FontSize = 13,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575")),
|
||||||
|
Padding = new Thickness(0)
|
||||||
|
};
|
||||||
|
itemPanel.Children.Add(linkButton);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var titleText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = item.Title,
|
||||||
|
FontSize = 13,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#9a9590") : Color.Parse("#757575"))
|
||||||
|
};
|
||||||
|
itemPanel.Children.Add(titleText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Number.HasValue)
|
||||||
|
{
|
||||||
|
var numberText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"#{item.Number}",
|
||||||
|
FontSize = 11,
|
||||||
|
Foreground = new SolidColorBrush(isNightMode ? Color.Parse("#d4736a") : Color.Parse("#bb5649")),
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
itemPanel.Children.Add(numberText);
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryPanel.Children.Add(itemPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
OverviewStackPanel.Children.Add(categoryPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OverviewBorder.IsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!news.DetailedNews.Any())
|
||||||
|
{
|
||||||
|
ShowMoreButton.IsVisible = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var detailedItem in news.DetailedNews)
|
||||||
|
{
|
||||||
|
var newsPanel = CreateDetailedNewsPanel(detailedItem, isNightMode);
|
||||||
|
DetailedNewsStackPanel.Children.Add(newsPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyNightMode(isNightMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateDetailedNewsPanel(JuyaDetailedNewsItem detailedItem, bool isNightMode)
|
||||||
|
{
|
||||||
|
var primaryColor = isNightMode ? "#d4736a" : "#bb5649";
|
||||||
|
var textColor = isNightMode ? "#e8e4e0" : "#34495e";
|
||||||
|
var secondaryTextColor = isNightMode ? "#9a9590" : "#757575";
|
||||||
|
|
||||||
|
var mainBorder = new Border
|
||||||
|
{
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#e6e6e6")),
|
||||||
|
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||||
|
Padding = new Thickness(0, 0, 0, 16)
|
||||||
|
};
|
||||||
|
|
||||||
|
var mainStack = new StackPanel { Spacing = 12 };
|
||||||
|
mainBorder.Child = mainStack;
|
||||||
|
|
||||||
|
var headerPanel = new StackPanel
|
||||||
|
{
|
||||||
|
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
||||||
|
Spacing = 8
|
||||||
|
};
|
||||||
|
|
||||||
|
if (detailedItem.Number > 0)
|
||||||
|
{
|
||||||
|
var numberBadge = new Border
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.Parse(primaryColor)),
|
||||||
|
CornerRadius = new CornerRadius(4),
|
||||||
|
Padding = new Thickness(6, 2),
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
var numberText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"#{detailedItem.Number}",
|
||||||
|
FontSize = 12,
|
||||||
|
FontWeight = FontWeight.Bold,
|
||||||
|
Foreground = Brushes.White
|
||||||
|
};
|
||||||
|
numberBadge.Child = numberText;
|
||||||
|
headerPanel.Children.Add(numberBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = detailedItem.Title,
|
||||||
|
FontSize = 16,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse(textColor)),
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
headerPanel.Children.Add(titleText);
|
||||||
|
mainStack.Children.Add(headerPanel);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(detailedItem.BodyText))
|
||||||
|
{
|
||||||
|
var bodyText = new TextBlock
|
||||||
|
{
|
||||||
|
Text = detailedItem.BodyText,
|
||||||
|
FontSize = 14,
|
||||||
|
LineHeight = 22,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse(textColor))
|
||||||
|
};
|
||||||
|
mainStack.Children.Add(bodyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailedItem.RelatedLinks.Any())
|
||||||
|
{
|
||||||
|
var linksPanel = new StackPanel { Spacing = 4 };
|
||||||
|
|
||||||
|
var linksHeader = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "相关链接:",
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor))
|
||||||
|
};
|
||||||
|
linksPanel.Children.Add(linksHeader);
|
||||||
|
|
||||||
|
foreach (var link in detailedItem.RelatedLinks.Take(3))
|
||||||
|
{
|
||||||
|
var linkButton = new HyperlinkButton
|
||||||
|
{
|
||||||
|
Content = link.Length > 50 ? link.Substring(0, 50) + "..." : link,
|
||||||
|
NavigateUri = new Uri(link),
|
||||||
|
FontSize = 12,
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse(primaryColor))
|
||||||
|
};
|
||||||
|
linksPanel.Children.Add(linkButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainStack.Children.Add(linksPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainBorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnShowMoreButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_isExpanded = !_isExpanded;
|
||||||
|
DetailedNewsStackPanel.IsVisible = _isExpanded;
|
||||||
|
ShowMoreButton.Content = _isExpanded ? "收起新闻 ▲" : "展开更多新闻 ▼";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBilibiliButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_news.BilibiliUrl))
|
||||||
|
{
|
||||||
|
TryOpenUrl(_news.BilibiliUrl);
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWechatButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_news.IssueUrl))
|
||||||
|
{
|
||||||
|
TryOpenUrl(_news.IssueUrl);
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryOpenUrl(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = url,
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCoverImageAsync(string? imageUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await HttpClient.GetAsync(imageUrl);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
var bitmap = new Bitmap(stream);
|
||||||
|
_coverBitmap = bitmap;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
CoverImage.Source = bitmap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCoverImagePointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
{
|
||||||
|
CoverImageClicked?.Invoke(this, EventArgs.Empty);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyNightMode(bool isNightMode)
|
||||||
|
{
|
||||||
|
_isNightMode = isNightMode;
|
||||||
|
var primaryColor = isNightMode ? "#d4736a" : "#bb5649";
|
||||||
|
var textColor = isNightMode ? "#e8e4e0" : "#34495e";
|
||||||
|
var secondaryTextColor = isNightMode ? "#9a9590" : "#757575";
|
||||||
|
var separatorColor = isNightMode ? "#3d3a3a" : "#e6e6e6";
|
||||||
|
var coverBgColor = isNightMode ? "#3d3a3a" : "#f8f5ec";
|
||||||
|
var overviewBgColor = isNightMode ? "#3d3a3a" : "#f8f5ec";
|
||||||
|
|
||||||
|
DateTextBlock.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
DateSeparatorBorder.Background = new SolidColorBrush(Color.Parse(separatorColor));
|
||||||
|
CoverImageBorder.Background = new SolidColorBrush(Color.Parse(coverBgColor));
|
||||||
|
OverviewBorder.Background = new SolidColorBrush(Color.Parse(overviewBgColor));
|
||||||
|
|
||||||
|
ShowMoreButton.BorderBrush = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
ShowMoreButton.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
|
||||||
|
foreach (var child in OverviewStackPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is StackPanel categoryPanel && categoryPanel.Children.Count > 0)
|
||||||
|
{
|
||||||
|
if (categoryPanel.Children[0] is TextBlock categoryHeader)
|
||||||
|
{
|
||||||
|
categoryHeader.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 1; i < categoryPanel.Children.Count; i++)
|
||||||
|
{
|
||||||
|
if (categoryPanel.Children[i] is StackPanel itemPanel)
|
||||||
|
{
|
||||||
|
foreach (var itemChild in itemPanel.Children)
|
||||||
|
{
|
||||||
|
if (itemChild is TextBlock textBlock)
|
||||||
|
{
|
||||||
|
if (textBlock.Text.StartsWith("#"))
|
||||||
|
{
|
||||||
|
textBlock.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
textBlock.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (itemChild is HyperlinkButton linkBtn)
|
||||||
|
{
|
||||||
|
linkBtn.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in DetailedNewsStackPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is Border mainBorder && mainBorder.Child is StackPanel mainStack)
|
||||||
|
{
|
||||||
|
mainBorder.BorderBrush = new SolidColorBrush(Color.Parse(separatorColor));
|
||||||
|
|
||||||
|
foreach (var stackChild in mainStack.Children)
|
||||||
|
{
|
||||||
|
if (stackChild is StackPanel headerPanel)
|
||||||
|
{
|
||||||
|
foreach (var headerChild in headerPanel.Children)
|
||||||
|
{
|
||||||
|
if (headerChild is Border numberBadge && numberBadge.Child is TextBlock numberText)
|
||||||
|
{
|
||||||
|
numberBadge.Background = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
}
|
||||||
|
else if (headerChild is TextBlock titleText)
|
||||||
|
{
|
||||||
|
titleText.Foreground = new SolidColorBrush(Color.Parse(textColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (stackChild is TextBlock bodyText)
|
||||||
|
{
|
||||||
|
bodyText.Foreground = new SolidColorBrush(Color.Parse(textColor));
|
||||||
|
}
|
||||||
|
else if (stackChild is StackPanel linksPanel)
|
||||||
|
{
|
||||||
|
foreach (var linkChild in linksPanel.Children)
|
||||||
|
{
|
||||||
|
if (linkChild is TextBlock linksHeader)
|
||||||
|
{
|
||||||
|
linksHeader.Foreground = new SolidColorBrush(Color.Parse(secondaryTextColor));
|
||||||
|
}
|
||||||
|
else if (linkChild is HyperlinkButton linkButton)
|
||||||
|
{
|
||||||
|
linkButton.Foreground = new SolidColorBrush(Color.Parse(primaryColor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateLayout(double scale, double availableWidth)
|
||||||
|
{
|
||||||
|
var coverHeight = availableWidth * 9 / 16;
|
||||||
|
CoverImageBorder.Width = availableWidth;
|
||||||
|
CoverImageBorder.Height = coverHeight;
|
||||||
|
|
||||||
|
DateTextBlock.FontSize = Math.Clamp(20 * scale, 16, 26);
|
||||||
|
|
||||||
|
ShowMoreButton.FontSize = Math.Clamp(14 * scale, 12, 16);
|
||||||
|
|
||||||
|
var buttonSize = Math.Clamp(32 * scale, 24, 40);
|
||||||
|
BilibiliButton.Width = buttonSize;
|
||||||
|
BilibiliButton.Height = buttonSize;
|
||||||
|
BilibiliButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||||
|
|
||||||
|
WechatButton.Width = buttonSize;
|
||||||
|
WechatButton.Height = buttonSize;
|
||||||
|
WechatButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||||
|
|
||||||
|
foreach (var child in OverviewStackPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is StackPanel categoryPanel && categoryPanel.Children.Count > 0)
|
||||||
|
{
|
||||||
|
if (categoryPanel.Children[0] is TextBlock categoryHeader)
|
||||||
|
{
|
||||||
|
categoryHeader.FontSize = Math.Clamp(15 * scale, 13, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 1; i < categoryPanel.Children.Count; i++)
|
||||||
|
{
|
||||||
|
if (categoryPanel.Children[i] is StackPanel itemPanel)
|
||||||
|
{
|
||||||
|
foreach (var itemChild in itemPanel.Children)
|
||||||
|
{
|
||||||
|
if (itemChild is TextBlock textBlock)
|
||||||
|
{
|
||||||
|
textBlock.FontSize = Math.Clamp(13 * scale, 11, 15);
|
||||||
|
}
|
||||||
|
else if (itemChild is HyperlinkButton linkBtn)
|
||||||
|
{
|
||||||
|
linkBtn.FontSize = Math.Clamp(13 * scale, 11, 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in DetailedNewsStackPanel.Children)
|
||||||
|
{
|
||||||
|
if (child is Border mainBorder && mainBorder.Child is StackPanel mainStack)
|
||||||
|
{
|
||||||
|
foreach (var stackChild in mainStack.Children)
|
||||||
|
{
|
||||||
|
if (stackChild is StackPanel headerPanel)
|
||||||
|
{
|
||||||
|
foreach (var headerChild in headerPanel.Children)
|
||||||
|
{
|
||||||
|
if (headerChild is Border numberBadge && numberBadge.Child is TextBlock numberText)
|
||||||
|
{
|
||||||
|
numberText.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||||
|
}
|
||||||
|
else if (headerChild is TextBlock titleText)
|
||||||
|
{
|
||||||
|
titleText.FontSize = Math.Clamp(16 * scale, 14, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (stackChild is TextBlock bodyText)
|
||||||
|
{
|
||||||
|
bodyText.FontSize = Math.Clamp(14 * scale, 12, 16);
|
||||||
|
bodyText.LineHeight = 22 * scale;
|
||||||
|
}
|
||||||
|
else if (stackChild is StackPanel linksPanel)
|
||||||
|
{
|
||||||
|
foreach (var linkChild in linksPanel.Children)
|
||||||
|
{
|
||||||
|
if (linkChild is TextBlock linksHeader)
|
||||||
|
{
|
||||||
|
linksHeader.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||||
|
}
|
||||||
|
else if (linkChild is HyperlinkButton linkButton)
|
||||||
|
{
|
||||||
|
linkButton.FontSize = Math.Clamp(12 * scale, 10, 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDetachedFromVisualTree(e);
|
||||||
|
_coverBitmap?.Dispose();
|
||||||
|
_coverBitmap = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -428,6 +428,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
BuiltInComponentIds.DesktopIfengNews,
|
BuiltInComponentIds.DesktopIfengNews,
|
||||||
"component.ifeng_news",
|
"component.ifeng_news",
|
||||||
() => new IfengNewsWidget()),
|
() => new IfengNewsWidget()),
|
||||||
|
new DesktopComponentRuntimeRegistration(
|
||||||
|
BuiltInComponentIds.DesktopJuyaNews,
|
||||||
|
"component.juya_news",
|
||||||
|
() => new JuyaNewsWidget()),
|
||||||
new DesktopComponentRuntimeRegistration(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
BuiltInComponentIds.DesktopBilibiliHotSearch,
|
||||||
"component.bilibili_hot_search",
|
"component.bilibili_hot_search",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -22,19 +22,36 @@
|
|||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
Padding="14,14,14,14">
|
Padding="14,14,14,14">
|
||||||
<Grid x:Name="ContentGrid"
|
<Grid x:Name="ContentGrid"
|
||||||
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
|
RowDefinitions="Auto,*">
|
||||||
RowSpacing="8">
|
|
||||||
<Grid x:Name="HeaderGrid"
|
<Grid x:Name="HeaderGrid"
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
ColumnDefinitions="*,Auto"
|
ColumnDefinitions="Auto,*"
|
||||||
ColumnSpacing="10">
|
ColumnSpacing="10"
|
||||||
<TextBlock x:Name="BrandTextBlock"
|
Margin="0,0,0,8">
|
||||||
Text="凤凰网新闻"
|
<StackPanel Orientation="Horizontal"
|
||||||
Foreground="#E24B2D"
|
Spacing="0"
|
||||||
FontSize="28"
|
VerticalAlignment="Center">
|
||||||
FontWeight="Bold"
|
<TextBlock x:Name="BrandTextBlock"
|
||||||
VerticalAlignment="Center"
|
Text="鳳凰網"
|
||||||
TextTrimming="CharacterEllipsis" />
|
Foreground="#E24B2D"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Border x:Name="NewsBadge"
|
||||||
|
Background="#E24B2D"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="6,2"
|
||||||
|
Margin="4,0,0,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="NewsBadgeText"
|
||||||
|
Text="新聞"
|
||||||
|
Foreground="White"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<Button x:Name="RefreshButton"
|
<Button x:Name="RefreshButton"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
@@ -58,129 +75,18 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border x:Name="NewsItem1Host"
|
<ScrollViewer x:Name="NewsScrollViewer"
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Tag="0"
|
VerticalScrollBarVisibility="Auto">
|
||||||
Background="Transparent"
|
<StackPanel x:Name="NewsStackPanel" Spacing="6">
|
||||||
Padding="0,2"
|
<TextBlock x:Name="LoadingTextBlock"
|
||||||
PointerPressed="OnNewsItemPointerPressed">
|
Text="正在加载..."
|
||||||
<Grid x:Name="NewsItem1Grid"
|
Foreground="#6A6F77"
|
||||||
ColumnDefinitions="*,Auto"
|
FontSize="14"
|
||||||
ColumnSpacing="10">
|
HorizontalAlignment="Center"
|
||||||
<TextBlock x:Name="NewsItem1TextBlock"
|
IsVisible="False" />
|
||||||
Text="新闻标题"
|
</StackPanel>
|
||||||
Foreground="#202327"
|
</ScrollViewer>
|
||||||
FontSize="22"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="2"
|
|
||||||
VerticalAlignment="Top" />
|
|
||||||
<Border x:Name="NewsItem1ImageHost"
|
|
||||||
Grid.Column="1"
|
|
||||||
Width="148"
|
|
||||||
Height="84"
|
|
||||||
CornerRadius="12"
|
|
||||||
ClipToBounds="True"
|
|
||||||
Background="#E6E8EC">
|
|
||||||
<Image x:Name="NewsItem1Image"
|
|
||||||
Stretch="UniformToFill" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border x:Name="NewsItem2Host"
|
|
||||||
Grid.Row="2"
|
|
||||||
Tag="1"
|
|
||||||
Background="Transparent"
|
|
||||||
Padding="0,2"
|
|
||||||
PointerPressed="OnNewsItemPointerPressed">
|
|
||||||
<Grid x:Name="NewsItem2Grid"
|
|
||||||
ColumnDefinitions="*,Auto"
|
|
||||||
ColumnSpacing="10">
|
|
||||||
<TextBlock x:Name="NewsItem2TextBlock"
|
|
||||||
Text="新闻标题"
|
|
||||||
Foreground="#202327"
|
|
||||||
FontSize="22"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="2"
|
|
||||||
VerticalAlignment="Top" />
|
|
||||||
<Border x:Name="NewsItem2ImageHost"
|
|
||||||
Grid.Column="1"
|
|
||||||
Width="148"
|
|
||||||
Height="84"
|
|
||||||
CornerRadius="12"
|
|
||||||
ClipToBounds="True"
|
|
||||||
Background="#E6E8EC">
|
|
||||||
<Image x:Name="NewsItem2Image"
|
|
||||||
Stretch="UniformToFill" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border x:Name="NewsItem3Host"
|
|
||||||
Grid.Row="3"
|
|
||||||
Tag="2"
|
|
||||||
Background="Transparent"
|
|
||||||
Padding="0,2"
|
|
||||||
PointerPressed="OnNewsItemPointerPressed">
|
|
||||||
<Grid x:Name="NewsItem3Grid"
|
|
||||||
ColumnDefinitions="*,Auto"
|
|
||||||
ColumnSpacing="10">
|
|
||||||
<TextBlock x:Name="NewsItem3TextBlock"
|
|
||||||
Text="新闻标题"
|
|
||||||
Foreground="#202327"
|
|
||||||
FontSize="22"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="2"
|
|
||||||
VerticalAlignment="Top" />
|
|
||||||
<Border x:Name="NewsItem3ImageHost"
|
|
||||||
Grid.Column="1"
|
|
||||||
Width="148"
|
|
||||||
Height="84"
|
|
||||||
CornerRadius="12"
|
|
||||||
ClipToBounds="True"
|
|
||||||
Background="#E6E8EC">
|
|
||||||
<Image x:Name="NewsItem3Image"
|
|
||||||
Stretch="UniformToFill" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border x:Name="NewsItem4Host"
|
|
||||||
Grid.Row="4"
|
|
||||||
Tag="3"
|
|
||||||
Background="Transparent"
|
|
||||||
Padding="0,2"
|
|
||||||
PointerPressed="OnNewsItemPointerPressed">
|
|
||||||
<Grid x:Name="NewsItem4Grid"
|
|
||||||
ColumnDefinitions="*,Auto"
|
|
||||||
ColumnSpacing="10">
|
|
||||||
<TextBlock x:Name="NewsItem4TextBlock"
|
|
||||||
Text="新闻标题"
|
|
||||||
Foreground="#202327"
|
|
||||||
FontSize="22"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="2"
|
|
||||||
VerticalAlignment="Top" />
|
|
||||||
<Border x:Name="NewsItem4ImageHost"
|
|
||||||
Grid.Column="1"
|
|
||||||
Width="148"
|
|
||||||
Height="84"
|
|
||||||
CornerRadius="12"
|
|
||||||
ClipToBounds="True"
|
|
||||||
Background="#E6E8EC">
|
|
||||||
<Image x:Name="NewsItem4Image"
|
|
||||||
Stretch="UniformToFill" />
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
private const double BaseCellSize = 48d;
|
private const double BaseCellSize = 48d;
|
||||||
private const int BaseWidthCells = 4;
|
private const int BaseWidthCells = 4;
|
||||||
private const int BaseHeightCells = 4;
|
private const int BaseHeightCells = 4;
|
||||||
private const int MaxDisplayItemCount = 4;
|
private const int MaxDisplayItemCount = 12;
|
||||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||||
|
|
||||||
private readonly DispatcherTimer _refreshTimer = new()
|
private readonly DispatcherTimer _refreshTimer = new()
|
||||||
@@ -47,9 +47,9 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
private LanMountainDesktop.PluginSdk.ISettingsService _appSettingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
private LanMountainDesktop.PluginSdk.ISettingsService _appSettingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||||
private IComponentInstanceSettingsStore _componentSettingsService = HostComponentSettingsStoreProvider.GetOrCreate();
|
private IComponentInstanceSettingsStore _componentSettingsService = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly List<DailyNewsItemSnapshot> _activeItems = [];
|
private readonly Dictionary<string, DailyNewsItemSnapshot> _newsByUrl = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly List<NewsItemVisual> _itemVisuals = [];
|
private readonly List<NewsItemControl> _itemControls = [];
|
||||||
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[MaxDisplayItemCount];
|
private readonly Dictionary<string, Bitmap> _imageCache = new();
|
||||||
|
|
||||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||||
private CancellationTokenSource? _refreshCts;
|
private CancellationTokenSource? _refreshCts;
|
||||||
@@ -61,28 +61,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
private bool _autoRefreshEnabled = true;
|
private bool _autoRefreshEnabled = true;
|
||||||
private bool _isNightVisual = true;
|
private bool _isNightVisual = true;
|
||||||
|
|
||||||
private sealed record NewsItemVisual(
|
|
||||||
Border Host,
|
|
||||||
Grid RowGrid,
|
|
||||||
TextBlock TitleTextBlock,
|
|
||||||
Border ImageHost,
|
|
||||||
Image ImageControl);
|
|
||||||
|
|
||||||
public IfengNewsWidget()
|
public IfengNewsWidget()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
BrandTextBlock.FontFamily = MiSansFontFamily;
|
BrandTextBlock.FontFamily = MiSansFontFamily;
|
||||||
NewsItem1TextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
NewsItem2TextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
NewsItem3TextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
NewsItem4TextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||||
|
LoadingTextBlock.FontFamily = MiSansFontFamily;
|
||||||
_itemVisuals.Add(new NewsItemVisual(NewsItem1Host, NewsItem1Grid, NewsItem1TextBlock, NewsItem1ImageHost, NewsItem1Image));
|
|
||||||
_itemVisuals.Add(new NewsItemVisual(NewsItem2Host, NewsItem2Grid, NewsItem2TextBlock, NewsItem2ImageHost, NewsItem2Image));
|
|
||||||
_itemVisuals.Add(new NewsItemVisual(NewsItem3Host, NewsItem3Grid, NewsItem3TextBlock, NewsItem3ImageHost, NewsItem3Image));
|
|
||||||
_itemVisuals.Add(new NewsItemVisual(NewsItem4Host, NewsItem4Grid, NewsItem4TextBlock, NewsItem4ImageHost, NewsItem4Image));
|
|
||||||
|
|
||||||
_refreshTimer.Tick += OnRefreshTimerTick;
|
_refreshTimer.Tick += OnRefreshTimerTick;
|
||||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
@@ -135,7 +120,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
_isAttached = false;
|
_isAttached = false;
|
||||||
_refreshTimer.Stop();
|
_refreshTimer.Stop();
|
||||||
CancelRefreshRequest();
|
CancelRefreshRequest();
|
||||||
DisposeNewsBitmaps();
|
DisposeImageCache();
|
||||||
UpdateRefreshButtonState();
|
UpdateRefreshButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,18 +176,19 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||||
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
|
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
|
||||||
|
|
||||||
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF6B5A") : Color.Parse("#E24B2D"));
|
||||||
|
NewsBadge.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#FF6B5A") : Color.Parse("#E24B2D"));
|
||||||
|
|
||||||
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
|
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
|
||||||
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||||
|
|
||||||
foreach (var visual in _itemVisuals)
|
|
||||||
{
|
|
||||||
visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA"));
|
|
||||||
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||||
|
LoadingTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
|
||||||
|
|
||||||
|
foreach (var control in _itemControls)
|
||||||
|
{
|
||||||
|
control.ApplyNightMode(_isNightVisual);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||||
@@ -217,22 +203,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNewsItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
|
|
||||||
sender is not Border host ||
|
|
||||||
host.Tag is null ||
|
|
||||||
!int.TryParse(host.Tag.ToString(), out var index) ||
|
|
||||||
index < 0 ||
|
|
||||||
index >= _activeItems.Count)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
TryOpenUrl(_activeItems[index].Url);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshNewsAsync(bool forceRefresh)
|
private async Task RefreshNewsAsync(bool forceRefresh)
|
||||||
{
|
{
|
||||||
if (!_isAttached || _isRefreshing)
|
if (!_isAttached || _isRefreshing)
|
||||||
@@ -272,7 +242,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Ignore canceled requests.
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -296,100 +265,90 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
|
|
||||||
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
|
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
|
|
||||||
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
||||||
|
|
||||||
_activeItems.Clear();
|
var newItems = snapshot.Items
|
||||||
foreach (var item in snapshot.Items)
|
.Where(item => !string.IsNullOrWhiteSpace(item.Url) && !_newsByUrl.ContainsKey(item.Url))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newItems.Count == 0 && _itemControls.Count == 0)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
|
ApplyEmptyState();
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_activeItems.Add(item);
|
|
||||||
if (_activeItems.Count >= MaxDisplayItemCount)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallbackText = L("ifeng.widget.fallback_item", "暂无新闻");
|
|
||||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
|
||||||
{
|
|
||||||
var visual = _itemVisuals[i];
|
|
||||||
visual.Host.IsVisible = true;
|
|
||||||
visual.TitleTextBlock.Text = i < _activeItems.Count
|
|
||||||
? NormalizeCompactText(_activeItems[i].Title)
|
|
||||||
: fallbackText;
|
|
||||||
SetNewsBitmap(i, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusTextBlock.IsVisible = false;
|
|
||||||
UpdateInteractionState();
|
|
||||||
UpdateAdaptiveLayout();
|
|
||||||
|
|
||||||
var tasks = Enumerable.Range(0, MaxDisplayItemCount)
|
|
||||||
.Select(index => TryDownloadBitmapAsync(
|
|
||||||
index < _activeItems.Count ? _activeItems[index].ImageUrl : null,
|
|
||||||
cancellationToken))
|
|
||||||
.ToArray();
|
|
||||||
var bitmaps = await Task.WhenAll(tasks);
|
|
||||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
|
||||||
{
|
|
||||||
foreach (var bitmap in bitmaps)
|
|
||||||
{
|
|
||||||
bitmap?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var i = 0; i < bitmaps.Length; i++)
|
foreach (var item in newItems)
|
||||||
{
|
{
|
||||||
SetNewsBitmap(i, bitmaps[i]);
|
_newsByUrl[item.Url] = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
|
||||||
|
LoadingTextBlock.IsVisible = false;
|
||||||
|
StatusTextBlock.IsVisible = false;
|
||||||
|
|
||||||
|
foreach (var item in newItems)
|
||||||
|
{
|
||||||
|
var control = new NewsItemControl(item, _isNightVisual);
|
||||||
|
control.Clicked += (s, url) => TryOpenUrl(url);
|
||||||
|
NewsStackPanel.Children.Insert(NewsStackPanel.Children.Count - 1, control);
|
||||||
|
_itemControls.Add(control);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
});
|
||||||
|
|
||||||
|
var imageTasks = newItems.Select(async item =>
|
||||||
|
{
|
||||||
|
var bitmap = await TryDownloadBitmapAsync(item.ImageUrl, cancellationToken);
|
||||||
|
if (bitmap != null && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (_imageCache.TryGetValue(item.Url, out var oldBitmap))
|
||||||
|
{
|
||||||
|
oldBitmap.Dispose();
|
||||||
|
}
|
||||||
|
_imageCache[item.Url] = bitmap;
|
||||||
|
|
||||||
|
var control = _itemControls.FirstOrDefault(c => c.NewsUrl == item.Url);
|
||||||
|
control?.SetImage(bitmap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(imageTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyLoadingState()
|
private void ApplyLoadingState()
|
||||||
{
|
{
|
||||||
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
|
|
||||||
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
||||||
|
|
||||||
_activeItems.Clear();
|
LoadingTextBlock.Text = L("ifeng.widget.loading", "加载中...");
|
||||||
var loadingText = L("ifeng.widget.loading_item", "加载中...");
|
LoadingTextBlock.IsVisible = true;
|
||||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
StatusTextBlock.IsVisible = false;
|
||||||
{
|
|
||||||
var visual = _itemVisuals[i];
|
|
||||||
visual.Host.IsVisible = true;
|
|
||||||
visual.TitleTextBlock.Text = loadingText;
|
|
||||||
SetNewsBitmap(i, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusTextBlock.Text = L("ifeng.widget.loading", "加载中...");
|
|
||||||
StatusTextBlock.IsVisible = true;
|
|
||||||
UpdateInteractionState();
|
|
||||||
UpdateAdaptiveLayout();
|
UpdateAdaptiveLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyFailedState()
|
private void ApplyFailedState()
|
||||||
{
|
{
|
||||||
BrandTextBlock.Text = L("ifeng.widget.brand", "凤凰网新闻");
|
|
||||||
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
||||||
|
|
||||||
_activeItems.Clear();
|
LoadingTextBlock.IsVisible = false;
|
||||||
var fallbackText = L("ifeng.widget.fallback_item", "暂无新闻");
|
|
||||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
|
||||||
{
|
|
||||||
var visual = _itemVisuals[i];
|
|
||||||
visual.Host.IsVisible = true;
|
|
||||||
visual.TitleTextBlock.Text = fallbackText;
|
|
||||||
SetNewsBitmap(i, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusTextBlock.Text = L("ifeng.widget.fetch_failed", "新闻获取失败");
|
StatusTextBlock.Text = L("ifeng.widget.fetch_failed", "新闻获取失败");
|
||||||
StatusTextBlock.IsVisible = true;
|
StatusTextBlock.IsVisible = true;
|
||||||
UpdateInteractionState();
|
UpdateAdaptiveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyEmptyState()
|
||||||
|
{
|
||||||
|
ToolTip.SetTip(RefreshButton, L("ifeng.widget.refresh_tooltip", "刷新"));
|
||||||
|
|
||||||
|
LoadingTextBlock.IsVisible = false;
|
||||||
|
StatusTextBlock.Text = L("ifeng.widget.fallback_item", "暂无新闻");
|
||||||
|
StatusTextBlock.IsVisible = true;
|
||||||
UpdateAdaptiveLayout();
|
UpdateAdaptiveLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,26 +367,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
|
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
|
||||||
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||||
|
|
||||||
var rowSpacing = Math.Clamp(8 * softScale, 4, 12);
|
var headerHeight = Math.Clamp(totalHeight * 0.10, 28, 54);
|
||||||
ContentGrid.RowSpacing = rowSpacing;
|
HeaderGrid.Height = headerHeight;
|
||||||
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
|
HeaderGrid.Margin = new Thickness(0, 0, 0, Math.Clamp(8 * softScale, 4, 12));
|
||||||
|
|
||||||
var innerWidth = Math.Max(150, totalWidth - horizontalPadding * 2d);
|
var brandFontSize = Math.Clamp(headerHeight * 0.62, 14, 30);
|
||||||
var innerHeight = Math.Max(160, totalHeight - verticalPadding * 2d);
|
BrandTextBlock.FontSize = brandFontSize;
|
||||||
var availableRowsHeight = Math.Max(120, innerHeight - rowSpacing * 4d);
|
NewsBadgeText.FontSize = brandFontSize;
|
||||||
var headerHeight = Math.Clamp(availableRowsHeight * 0.16, 24, 54);
|
|
||||||
var itemHeight = Math.Max(32, (availableRowsHeight - headerHeight) / 4d);
|
|
||||||
|
|
||||||
if (ContentGrid.RowDefinitions.Count >= 5)
|
|
||||||
{
|
|
||||||
ContentGrid.RowDefinitions[0].Height = new GridLength(headerHeight);
|
|
||||||
for (var i = 1; i <= 4; i++)
|
|
||||||
{
|
|
||||||
ContentGrid.RowDefinitions[i].Height = new GridLength(itemHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BrandTextBlock.FontSize = Math.Clamp(headerHeight * 0.62, 14, 30);
|
|
||||||
|
|
||||||
var refreshSize = Math.Clamp(headerHeight * 0.84, 22, 44);
|
var refreshSize = Math.Clamp(headerHeight * 0.84, 22, 44);
|
||||||
RefreshButton.Width = refreshSize;
|
RefreshButton.Width = refreshSize;
|
||||||
@@ -435,51 +381,25 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
|
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
|
||||||
RefreshGlyphIcon.FontSize = Math.Clamp(refreshSize * 0.44, 10, 20);
|
RefreshGlyphIcon.FontSize = Math.Clamp(refreshSize * 0.44, 10, 20);
|
||||||
|
|
||||||
|
var innerWidth = Math.Max(150, totalWidth - horizontalPadding * 2d);
|
||||||
var imageWidth = Math.Clamp(innerWidth * 0.27, 82, 176);
|
var imageWidth = Math.Clamp(innerWidth * 0.27, 82, 176);
|
||||||
var imageHeight = Math.Clamp(imageWidth * 0.56, 46, 98);
|
var imageHeight = Math.Clamp(imageWidth * 0.56, 46, 98);
|
||||||
var columnGap = Math.Clamp(itemHeight * 0.20, 6, 14);
|
|
||||||
var rowPadding = Math.Clamp(itemHeight * 0.08, 1, 5);
|
var baseTitleFont = 14;
|
||||||
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
|
var areaFactor = (totalWidth * totalHeight) / (BaseWidthCells * BaseCellSize * BaseHeightCells * BaseCellSize);
|
||||||
var titleFont = Math.Clamp(itemHeight * 0.32, 12, 24);
|
var adaptiveTitleFont = baseTitleFont * Math.Sqrt(Math.Clamp(areaFactor, 0.6, 2.5));
|
||||||
|
var titleFont = Math.Clamp(adaptiveTitleFont, 11, 26);
|
||||||
|
|
||||||
foreach (var visual in _itemVisuals)
|
foreach (var control in _itemControls)
|
||||||
{
|
{
|
||||||
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
|
control.UpdateLayout(softScale, innerWidth, imageWidth, imageHeight, titleFont);
|
||||||
visual.RowGrid.ColumnSpacing = columnGap;
|
|
||||||
if (visual.RowGrid.ColumnDefinitions.Count > 1)
|
|
||||||
{
|
|
||||||
visual.RowGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
visual.ImageHost.Width = imageWidth;
|
|
||||||
visual.ImageHost.Height = imageHeight;
|
|
||||||
visual.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
|
|
||||||
|
|
||||||
visual.TitleTextBlock.MaxWidth = textWidth;
|
|
||||||
visual.TitleTextBlock.FontSize = titleFont;
|
|
||||||
visual.TitleTextBlock.LineHeight = titleFont * 1.12;
|
|
||||||
visual.TitleTextBlock.MinHeight = visual.TitleTextBlock.LineHeight * 2;
|
|
||||||
visual.TitleTextBlock.MaxLines = 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StatusTextBlock.FontSize = Math.Clamp(titleFont, 10, 20);
|
StatusTextBlock.FontSize = Math.Clamp(titleFont, 10, 24);
|
||||||
|
LoadingTextBlock.FontSize = Math.Clamp(titleFont, 10, 24);
|
||||||
ApplyNightModeVisual();
|
ApplyNightModeVisual();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateInteractionState()
|
|
||||||
{
|
|
||||||
for (var i = 0; i < _itemVisuals.Count; i++)
|
|
||||||
{
|
|
||||||
var visual = _itemVisuals[i];
|
|
||||||
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
|
|
||||||
visual.Host.IsHitTestVisible = enabled;
|
|
||||||
visual.Host.Opacity = enabled ? 1.0 : 0.68;
|
|
||||||
visual.Host.Cursor = enabled
|
|
||||||
? new Cursor(StandardCursorType.Hand)
|
|
||||||
: new Cursor(StandardCursorType.Arrow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateRefreshButtonState()
|
private void UpdateRefreshButtonState()
|
||||||
{
|
{
|
||||||
var enabled = _isAttached && !_isRefreshing;
|
var enabled = _isAttached && !_isRefreshing;
|
||||||
@@ -515,7 +435,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Keep fallback defaults.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_autoRefreshEnabled = enabled;
|
_autoRefreshEnabled = enabled;
|
||||||
@@ -614,7 +533,6 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Ignore malformed URLs or shell launch failures.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,32 +558,13 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
return uri.ToString();
|
return uri.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetNewsBitmap(int index, Bitmap? bitmap)
|
private void DisposeImageCache()
|
||||||
{
|
{
|
||||||
if (index < 0 || index >= _newsBitmaps.Length)
|
foreach (var bitmap in _imageCache.Values)
|
||||||
{
|
{
|
||||||
bitmap?.Dispose();
|
bitmap.Dispose();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var visual = _itemVisuals[index];
|
|
||||||
var oldBitmap = _newsBitmaps[index];
|
|
||||||
if (ReferenceEquals(visual.ImageControl.Source, oldBitmap))
|
|
||||||
{
|
|
||||||
visual.ImageControl.Source = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
oldBitmap?.Dispose();
|
|
||||||
_newsBitmaps[index] = bitmap;
|
|
||||||
visual.ImageControl.Source = bitmap;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DisposeNewsBitmaps()
|
|
||||||
{
|
|
||||||
for (var i = 0; i < _newsBitmaps.Length; i++)
|
|
||||||
{
|
|
||||||
SetNewsBitmap(i, null);
|
|
||||||
}
|
}
|
||||||
|
_imageCache.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private double ResolveScale()
|
private double ResolveScale()
|
||||||
@@ -715,4 +614,142 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
cts.Cancel();
|
cts.Cancel();
|
||||||
cts.Dispose();
|
cts.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class NewsItemControl : Border
|
||||||
|
{
|
||||||
|
private readonly DailyNewsItemSnapshot _item;
|
||||||
|
private readonly Grid _grid;
|
||||||
|
private readonly TextBlock _titleTextBlock;
|
||||||
|
private readonly Border _imageHost;
|
||||||
|
private readonly Image _imageControl;
|
||||||
|
private bool _isNightVisual;
|
||||||
|
private Point _pointerPressedPosition;
|
||||||
|
private bool _isPointerPressed;
|
||||||
|
|
||||||
|
public string NewsUrl => _item.Url;
|
||||||
|
|
||||||
|
public NewsItemControl(DailyNewsItemSnapshot item, bool isNightVisual)
|
||||||
|
{
|
||||||
|
_item = item;
|
||||||
|
_isNightVisual = isNightVisual;
|
||||||
|
|
||||||
|
Padding = new Thickness(0, 4);
|
||||||
|
Background = Brushes.Transparent;
|
||||||
|
Cursor = new Cursor(StandardCursorType.Hand);
|
||||||
|
|
||||||
|
PointerPressed += OnPointerPressed;
|
||||||
|
PointerReleased += OnPointerReleased;
|
||||||
|
PointerCaptureLost += OnPointerCaptureLost;
|
||||||
|
|
||||||
|
_titleTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = NormalizeCompactText(item.Title),
|
||||||
|
Foreground = new SolidColorBrush(isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327")),
|
||||||
|
FontFamily = MiSansFontFamily,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
MaxLines = 2,
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top
|
||||||
|
};
|
||||||
|
|
||||||
|
_imageControl = new Image
|
||||||
|
{
|
||||||
|
Stretch = Stretch.UniformToFill
|
||||||
|
};
|
||||||
|
|
||||||
|
_imageHost = new Border
|
||||||
|
{
|
||||||
|
Width = 148,
|
||||||
|
Height = 84,
|
||||||
|
CornerRadius = new CornerRadius(12),
|
||||||
|
ClipToBounds = true,
|
||||||
|
Background = new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E8EC")),
|
||||||
|
Child = _imageControl
|
||||||
|
};
|
||||||
|
|
||||||
|
_grid = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions = ColumnDefinitions.Parse("*,Auto"),
|
||||||
|
ColumnSpacing = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetColumn(_imageHost, 1);
|
||||||
|
_grid.Children.Add(_titleTextBlock);
|
||||||
|
_grid.Children.Add(_imageHost);
|
||||||
|
|
||||||
|
Child = _grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
{
|
||||||
|
_isPointerPressed = true;
|
||||||
|
_pointerPressedPosition = e.GetPosition(this);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isPointerPressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isPointerPressed = false;
|
||||||
|
var releasePosition = e.GetPosition(this);
|
||||||
|
var distance = Math.Sqrt(
|
||||||
|
Math.Pow(releasePosition.X - _pointerPressedPosition.X, 2) +
|
||||||
|
Math.Pow(releasePosition.Y - _pointerPressedPosition.Y, 2));
|
||||||
|
|
||||||
|
if (distance < 5)
|
||||||
|
{
|
||||||
|
Clicked?.Invoke(this, _item.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
||||||
|
{
|
||||||
|
_isPointerPressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyNightMode(bool isNightVisual)
|
||||||
|
{
|
||||||
|
_isNightVisual = isNightVisual;
|
||||||
|
_titleTextBlock.Foreground = new SolidColorBrush(isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||||
|
_imageHost.Background = new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E8EC"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateLayout(double scale, double innerWidth, double imageWidth, double imageHeight, double titleFont)
|
||||||
|
{
|
||||||
|
var columnGap = Math.Clamp(imageHeight * 0.20, 6, 14);
|
||||||
|
_grid.ColumnSpacing = columnGap;
|
||||||
|
|
||||||
|
if (_grid.ColumnDefinitions.Count > 1)
|
||||||
|
{
|
||||||
|
_grid.ColumnDefinitions[1] = new ColumnDefinition(new GridLength(imageWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
_imageHost.Width = imageWidth;
|
||||||
|
_imageHost.Height = imageHeight;
|
||||||
|
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
|
||||||
|
|
||||||
|
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
|
||||||
|
_titleTextBlock.MaxWidth = textWidth;
|
||||||
|
_titleTextBlock.FontSize = titleFont;
|
||||||
|
_titleTextBlock.LineHeight = titleFont * 1.12;
|
||||||
|
_titleTextBlock.MinHeight = _titleTextBlock.LineHeight * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetImage(Bitmap bitmap)
|
||||||
|
{
|
||||||
|
_imageControl.Source = bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<string>? Clicked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
Normal file
106
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="640"
|
||||||
|
d:DesignHeight="640"
|
||||||
|
x:Class="LanMountainDesktop.Views.Components.JuyaNewsWidget">
|
||||||
|
|
||||||
|
<Border x:Name="RootBorder"
|
||||||
|
CornerRadius="24"
|
||||||
|
Background="Transparent"
|
||||||
|
ClipToBounds="True"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0">
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="CardBorder"
|
||||||
|
Background="#fefefe"
|
||||||
|
CornerRadius="24"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="16,14,16,14">
|
||||||
|
<Grid x:Name="ContentGrid"
|
||||||
|
RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Grid x:Name="HeaderGrid"
|
||||||
|
Grid.Row="0"
|
||||||
|
ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="10"
|
||||||
|
Margin="0,0,0,12">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Spacing="10">
|
||||||
|
<Border x:Name="AvatarBorder"
|
||||||
|
Width="36"
|
||||||
|
Height="36"
|
||||||
|
CornerRadius="18"
|
||||||
|
ClipToBounds="True"
|
||||||
|
Background="#f8f5ec">
|
||||||
|
<Image x:Name="AvatarImage"
|
||||||
|
Source="avares://LanMountainDesktop/Assets/juya_avatar.jpg"
|
||||||
|
Stretch="UniformToFill"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock x:Name="BrandTextBlock"
|
||||||
|
Text="橘鸦Juya"
|
||||||
|
Foreground="#bb5649"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="Bold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button x:Name="RefreshButton"
|
||||||
|
Grid.Column="1"
|
||||||
|
Padding="8,4"
|
||||||
|
CornerRadius="8"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="#bb5649"
|
||||||
|
BorderThickness="1"
|
||||||
|
Foreground="#bb5649"
|
||||||
|
Focusable="False"
|
||||||
|
ToolTip.Tip="刷新今日新闻"
|
||||||
|
Click="OnRefreshButtonClick">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<fi:SymbolIcon x:Name="RefreshIcon"
|
||||||
|
Symbol="ArrowSync"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="#bb5649" />
|
||||||
|
<TextBlock x:Name="RefreshButtonText"
|
||||||
|
Text="刷新"
|
||||||
|
FontSize="13"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 滚动内容区 -->
|
||||||
|
<ScrollViewer x:Name="ContentScrollViewer"
|
||||||
|
Grid.Row="1"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
ScrollChanged="OnScrollChanged">
|
||||||
|
<StackPanel x:Name="NewsStackPanel" Spacing="16">
|
||||||
|
|
||||||
|
<!-- 加载提示 -->
|
||||||
|
<TextBlock x:Name="LoadingTextBlock"
|
||||||
|
Text="正在加载..."
|
||||||
|
Foreground="#757575"
|
||||||
|
FontSize="14"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsVisible="False" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="StatusTextBlock"
|
||||||
|
IsVisible="False"
|
||||||
|
Text="Loading"
|
||||||
|
Foreground="#757575"
|
||||||
|
FontSize="16"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
827
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
Normal file
827
LanMountainDesktop/Views/Components/JuyaNewsWidget.axaml.cs
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
|
public partial class JuyaNewsWidget : UserControl, IDesktopComponentWidget
|
||||||
|
{
|
||||||
|
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
||||||
|
private static readonly HttpClient HttpClient = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(15)
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string RssUrl = "https://imjuya.github.io/juya-ai-daily/rss.xml";
|
||||||
|
private const double BaseCellSize = 48d;
|
||||||
|
private const int BaseWidthCells = 4;
|
||||||
|
private const int BaseHeightCells = 4;
|
||||||
|
private const int InitialLoadDays = 3;
|
||||||
|
private const int LoadMoreDays = 3;
|
||||||
|
private const int MaxCachedDays = 30;
|
||||||
|
|
||||||
|
private readonly Dictionary<DateTime, JuyaDailyNews> _cachedNews = new();
|
||||||
|
private readonly List<DateTime> _loadedDates = new();
|
||||||
|
private readonly List<DailyNewsView> _dailyViews = new();
|
||||||
|
|
||||||
|
private double _currentCellSize = BaseCellSize;
|
||||||
|
private bool _isAttached;
|
||||||
|
private bool _isLoading;
|
||||||
|
private bool _isNightVisual;
|
||||||
|
private DateTime _earliestLoadedDate = DateTime.Today;
|
||||||
|
|
||||||
|
public JuyaNewsWidget()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
BrandTextBlock.FontFamily = MiSansFontFamily;
|
||||||
|
LoadingTextBlock.FontFamily = MiSansFontFamily;
|
||||||
|
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||||
|
|
||||||
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||||
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||||
|
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
ApplyLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyCellSize(double cellSize)
|
||||||
|
{
|
||||||
|
_currentCellSize = Math.Max(1, cellSize);
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttached = true;
|
||||||
|
_ = LoadInitialNewsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
_isAttached = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyCellSize(_currentCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_isNightVisual = ResolveNightMode();
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ResolveNightMode()
|
||||||
|
{
|
||||||
|
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ActualThemeVariant == ThemeVariant.Light)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||||
|
value is ISolidColorBrush brush)
|
||||||
|
{
|
||||||
|
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateRelativeLuminance(Color color)
|
||||||
|
{
|
||||||
|
static double ToLinear(double channel)
|
||||||
|
{
|
||||||
|
return channel <= 0.03928
|
||||||
|
? channel / 12.92
|
||||||
|
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = ToLinear(color.R / 255d);
|
||||||
|
var g = ToLinear(color.G / 255d);
|
||||||
|
var b = ToLinear(color.B / 255d);
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyNightModeVisual()
|
||||||
|
{
|
||||||
|
// 卡片背景
|
||||||
|
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2d2a2a") : Color.Parse("#fefefe"));
|
||||||
|
|
||||||
|
// 品牌标题
|
||||||
|
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||||
|
|
||||||
|
// 刷新按钮
|
||||||
|
RefreshButton.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||||
|
RefreshButton.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#d4736a") : Color.Parse("#bb5649"));
|
||||||
|
|
||||||
|
// 头像背景
|
||||||
|
AvatarBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3d3a3a") : Color.Parse("#f8f5ec"));
|
||||||
|
|
||||||
|
// 状态文字
|
||||||
|
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
|
||||||
|
LoadingTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#9a9590") : Color.Parse("#757575"));
|
||||||
|
|
||||||
|
// 更新所有日期视图的样式
|
||||||
|
foreach (var view in _dailyViews)
|
||||||
|
{
|
||||||
|
view.ApplyNightMode(_isNightVisual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadInitialNewsAsync()
|
||||||
|
{
|
||||||
|
if (!_isAttached || _isLoading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
LoadingTextBlock.IsVisible = true;
|
||||||
|
StatusTextBlock.IsVisible = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 解析RSS获取所有新闻
|
||||||
|
var allNews = await FetchJuyaNewsAsync();
|
||||||
|
|
||||||
|
if (!_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存新闻数据
|
||||||
|
foreach (var news in allNews)
|
||||||
|
{
|
||||||
|
_cachedNews[news.Date.Date] = news;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载最近几天的新闻
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var datesToLoad = Enumerable.Range(0, InitialLoadDays)
|
||||||
|
.Select(i => today.AddDays(-i))
|
||||||
|
.Where(d => _cachedNews.ContainsKey(d))
|
||||||
|
.OrderByDescending(d => d)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
|
||||||
|
NewsStackPanel.Children.Clear();
|
||||||
|
_dailyViews.Clear();
|
||||||
|
_loadedDates.Clear();
|
||||||
|
|
||||||
|
foreach (var date in datesToLoad)
|
||||||
|
{
|
||||||
|
AddDailyNewsToView(_cachedNews[date]);
|
||||||
|
_loadedDates.Add(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_loadedDates.Any())
|
||||||
|
{
|
||||||
|
_earliestLoadedDate = _loadedDates.Min();
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingTextBlock.IsVisible = false;
|
||||||
|
StatusTextBlock.IsVisible = false;
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
StatusTextBlock.Text = "加载失败";
|
||||||
|
StatusTextBlock.IsVisible = true;
|
||||||
|
LoadingTextBlock.IsVisible = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<JuyaDailyNews>> FetchJuyaNewsAsync()
|
||||||
|
{
|
||||||
|
var result = new List<JuyaDailyNews>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 使用字节数组获取内容,确保正确解码 UTF-8
|
||||||
|
var response = await HttpClient.GetByteArrayAsync(RssUrl);
|
||||||
|
var rssContent = System.Text.Encoding.UTF8.GetString(response);
|
||||||
|
var doc = XDocument.Parse(rssContent);
|
||||||
|
|
||||||
|
var contentNs = XNamespace.Get("http://purl.org/rss/1.0/modules/content/");
|
||||||
|
|
||||||
|
var items = doc.Descendants("item");
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var title = item.Element("title")?.Value ?? "";
|
||||||
|
var link = item.Element("link")?.Value ?? "";
|
||||||
|
var pubDate = item.Element("pubDate")?.Value ?? "";
|
||||||
|
var contentEncoded = item.Element(contentNs + "encoded")?.Value ?? "";
|
||||||
|
|
||||||
|
// 解析日期
|
||||||
|
if (!DateTime.TryParse(pubDate, out var date))
|
||||||
|
{
|
||||||
|
date = DateTime.Today;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取封面图URL
|
||||||
|
var coverImageUrl = ExtractCoverImageUrl(contentEncoded);
|
||||||
|
|
||||||
|
// 提取视频链接
|
||||||
|
var (bilibiliUrl, youtubeUrl) = ExtractVideoUrls(contentEncoded);
|
||||||
|
|
||||||
|
// 解析概览(简短列表)
|
||||||
|
var overviewCategories = ParseOverview(contentEncoded);
|
||||||
|
|
||||||
|
// 解析详细内容
|
||||||
|
var detailedNews = ParseDetailedNews(contentEncoded);
|
||||||
|
|
||||||
|
var news = new JuyaDailyNews(
|
||||||
|
Date: date,
|
||||||
|
Title: title,
|
||||||
|
CoverImageUrl: coverImageUrl,
|
||||||
|
IssueUrl: link,
|
||||||
|
BilibiliUrl: bilibiliUrl,
|
||||||
|
YoutubeUrl: youtubeUrl,
|
||||||
|
OverviewCategories: overviewCategories,
|
||||||
|
DetailedNews: detailedNews,
|
||||||
|
FetchedAt: DateTimeOffset.Now
|
||||||
|
);
|
||||||
|
|
||||||
|
result.Add(news);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 返回空列表
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.OrderByDescending(n => n.Date).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractCoverImageUrl(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = Regex.Match(content, @"<img[^>]+src=[""']([^""']+)[""']", RegexOptions.IgnoreCase);
|
||||||
|
return match.Success ? match.Groups[1].Value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string bilibili, string youtube) ExtractVideoUrls(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return ("", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
string bilibiliUrl = "";
|
||||||
|
string youtubeUrl = "";
|
||||||
|
|
||||||
|
var bilibiliMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?bilibili\.com/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||||
|
if (bilibiliMatch.Success)
|
||||||
|
{
|
||||||
|
bilibiliUrl = bilibiliMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var youtubeMatch = Regex.Match(content, @"<a[^>]+href=[""'](https?://(?:www\.)?(?:youtube\.com|youtu\.be)/[^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||||
|
if (youtubeMatch.Success)
|
||||||
|
{
|
||||||
|
youtubeUrl = youtubeMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bilibiliUrl, youtubeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<JuyaOverviewCategory> ParseOverview(string content)
|
||||||
|
{
|
||||||
|
var categories = new List<JuyaOverviewCategory>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryIcons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["要闻"] = "📌",
|
||||||
|
["开发生态"] = "💻",
|
||||||
|
["产品应用"] = "📱",
|
||||||
|
["产品发布"] = "🚀",
|
||||||
|
["模型发布"] = "🤖",
|
||||||
|
["行业动态"] = "📈",
|
||||||
|
["技术与洞察"] = "🔍",
|
||||||
|
["学术研究"] = "📚",
|
||||||
|
["研究"] = "🔬",
|
||||||
|
["开源"] = "🔓",
|
||||||
|
["投资"] = "💰",
|
||||||
|
["融资"] = "💵",
|
||||||
|
["商业"] = "💼",
|
||||||
|
["市场"] = "📊",
|
||||||
|
["AI绘画"] = "🎨",
|
||||||
|
["设计"] = "✏️",
|
||||||
|
["创意"] = "💡",
|
||||||
|
["前瞻与传闻"] = "🔮",
|
||||||
|
["趋势"] = "📉",
|
||||||
|
["预测"] = "🔭",
|
||||||
|
["政策"] = "📋",
|
||||||
|
["法规"] = "⚖️",
|
||||||
|
["监管"] = "🛡️",
|
||||||
|
["硬件"] = "🔧",
|
||||||
|
["芯片"] = "🖥️",
|
||||||
|
["基础设施"] = "🏗️",
|
||||||
|
["其他"] = "•",
|
||||||
|
["要点"] = "📋",
|
||||||
|
["摘要"] = "📝"
|
||||||
|
};
|
||||||
|
|
||||||
|
var overviewMatch = Regex.Match(content, @"<h2>\s*概览\s*</h2>(.*?)(?:<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (!overviewMatch.Success)
|
||||||
|
{
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
var overviewContent = overviewMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var h3Matches = Regex.Matches(overviewContent, @"<h3>([^<]+)</h3>\s*<ul>(.*?)</ul>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
foreach (Match match in h3Matches)
|
||||||
|
{
|
||||||
|
var categoryName = match.Groups[1].Value.Trim();
|
||||||
|
var listContent = match.Groups[2].Value;
|
||||||
|
|
||||||
|
var icon = categoryIcons.GetValueOrDefault(categoryName, "•");
|
||||||
|
|
||||||
|
var items = new List<JuyaOverviewItem>();
|
||||||
|
var itemMatches = Regex.Matches(listContent, @"<li>(.*?)</li>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
foreach (Match itemMatch in itemMatches)
|
||||||
|
{
|
||||||
|
var itemText = itemMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
string itemTitle;
|
||||||
|
string itemUrl;
|
||||||
|
int? number = null;
|
||||||
|
|
||||||
|
var linkMatch = Regex.Match(itemText, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (linkMatch.Success)
|
||||||
|
{
|
||||||
|
itemUrl = linkMatch.Groups[1].Value;
|
||||||
|
var linkText = Regex.Replace(linkMatch.Groups[2].Value, @"<[^>]+>", "").Trim();
|
||||||
|
|
||||||
|
var beforeLink = itemText.Substring(0, itemText.IndexOf("<a", StringComparison.OrdinalIgnoreCase));
|
||||||
|
itemTitle = Regex.Replace(beforeLink, @"<[^>]+>", "").Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(itemTitle))
|
||||||
|
{
|
||||||
|
itemTitle = linkText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
itemTitle = Regex.Replace(itemText, @"<[^>]+>", "").Trim();
|
||||||
|
itemUrl = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var numberMatch = Regex.Match(itemText, @"<code>\s*#(\d+)\s*</code>|#(\d+)");
|
||||||
|
if (numberMatch.Success)
|
||||||
|
{
|
||||||
|
number = int.Parse(numberMatch.Groups[1].Success ? numberMatch.Groups[1].Value : numberMatch.Groups[2].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
itemTitle = Regex.Replace(itemTitle, @"^\s*#\d+\s*", "").Trim();
|
||||||
|
itemTitle = Regex.Replace(itemTitle, @"[→↗\s]+$", "").Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(itemTitle) && itemTitle.Length > 1)
|
||||||
|
{
|
||||||
|
items.Add(new JuyaOverviewItem(itemTitle, itemUrl, number));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.Any())
|
||||||
|
{
|
||||||
|
categories.Add(new JuyaOverviewCategory(categoryName, icon, items));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<JuyaDetailedNewsItem> ParseDetailedNews(string content)
|
||||||
|
{
|
||||||
|
var newsItems = new List<JuyaDetailedNewsItem>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return newsItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailedMatch = Regex.Match(content, @"<hr>(.*)$", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
if (!detailedMatch.Success)
|
||||||
|
{
|
||||||
|
return newsItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailedContent = detailedMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
var newsMatches = Regex.Matches(detailedContent, @"<h2>(.*?)</h2>(.*?)(?=<h2>|<hr>|$)", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
foreach (Match match in newsMatches)
|
||||||
|
{
|
||||||
|
var headerContent = match.Groups[1].Value;
|
||||||
|
var bodyContent = match.Groups[2].Value;
|
||||||
|
|
||||||
|
var numberMatch = Regex.Match(headerContent, @"<code>\s*#(\d+)\s*</code>");
|
||||||
|
if (!numberMatch.Success)
|
||||||
|
{
|
||||||
|
numberMatch = Regex.Match(headerContent, @"#(\d+)");
|
||||||
|
}
|
||||||
|
|
||||||
|
int? number = numberMatch.Success ? int.Parse(numberMatch.Groups[1].Value) : null;
|
||||||
|
|
||||||
|
string title;
|
||||||
|
var linkMatch = Regex.Match(headerContent, @"<a[^>]*>(.*?)</a>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
if (linkMatch.Success)
|
||||||
|
{
|
||||||
|
title = Regex.Replace(linkMatch.Groups[1].Value, @"<[^>]+>", "").Trim();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
title = Regex.Replace(headerContent, @"<code>.*?</code>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
title = Regex.Replace(title, @"<[^>]+>", "").Trim();
|
||||||
|
title = Regex.Replace(title, @"#\d+", "").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyText = ExtractBodyText(bodyContent);
|
||||||
|
|
||||||
|
var relatedLinks = new List<string>();
|
||||||
|
var linkMatches = Regex.Matches(bodyContent, @"<a[^>]+href=[""']([^""']+)[""'][^>]*>", RegexOptions.IgnoreCase);
|
||||||
|
foreach (Match linkMatch2 in linkMatches)
|
||||||
|
{
|
||||||
|
var url = linkMatch2.Groups[1].Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(url) && !relatedLinks.Contains(url))
|
||||||
|
{
|
||||||
|
relatedLinks.Add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(bodyText))
|
||||||
|
{
|
||||||
|
newsItems.Add(new JuyaDetailedNewsItem(title, number ?? 0, bodyText, relatedLinks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newsItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractBodyText(string htmlContent)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(htmlContent))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 blockquote 内容
|
||||||
|
var blockquoteMatch = Regex.Match(htmlContent, @"<blockquote>(.*?)</blockquote>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
if (blockquoteMatch.Success)
|
||||||
|
{
|
||||||
|
var text = blockquoteMatch.Groups[1].Value;
|
||||||
|
// 移除 <p> 标签但保留内容
|
||||||
|
text = Regex.Replace(text, @"<p>(.*?)</p>", "$1\n\n", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
// 移除其他 HTML 标签
|
||||||
|
text = Regex.Replace(text, @"<[^>]+>", "");
|
||||||
|
// 清理多余空白
|
||||||
|
text = Regex.Replace(text, @"\n{3,}", "\n\n");
|
||||||
|
return text.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 blockquote,提取所有 <p> 标签内容
|
||||||
|
var paragraphs = Regex.Matches(htmlContent, @"<p>(.*?)</p>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||||
|
if (paragraphs.Count > 0)
|
||||||
|
{
|
||||||
|
var text = string.Join("\n\n", paragraphs.Cast<Match>().Select(m =>
|
||||||
|
Regex.Replace(m.Groups[1].Value, @"<[^>]+>", "").Trim()));
|
||||||
|
return text.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后尝试直接移除所有 HTML 标签
|
||||||
|
return Regex.Replace(htmlContent, @"<[^>]+>", "").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddDailyNewsToView(JuyaDailyNews news)
|
||||||
|
{
|
||||||
|
var view = new DailyNewsView(news, _isNightVisual);
|
||||||
|
view.CoverImageClicked += (s, e) => TryOpenUrl(news.IssueUrl);
|
||||||
|
view.NewsItemClicked += (s, url) => TryOpenUrl(url);
|
||||||
|
NewsStackPanel.Children.Add(view);
|
||||||
|
_dailyViews.Add(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnScrollChanged(object? sender, ScrollChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isLoading || !_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scrollViewer = (ScrollViewer)sender!;
|
||||||
|
|
||||||
|
var offset = scrollViewer.Offset;
|
||||||
|
var extent = scrollViewer.Extent;
|
||||||
|
var viewport = scrollViewer.Viewport;
|
||||||
|
|
||||||
|
if (offset.Y >= extent.Height - viewport.Height - 200)
|
||||||
|
{
|
||||||
|
await LoadMoreNewsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMoreNewsAsync()
|
||||||
|
{
|
||||||
|
if (_isLoading || !_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextDates = Enumerable.Range(1, LoadMoreDays)
|
||||||
|
.Select(i => _earliestLoadedDate.AddDays(-i))
|
||||||
|
.Where(d => _cachedNews.ContainsKey(d) && !_loadedDates.Contains(d))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!nextDates.Any())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
LoadingTextBlock.IsVisible = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
|
||||||
|
foreach (var date in nextDates.OrderByDescending(d => d))
|
||||||
|
{
|
||||||
|
AddDailyNewsToView(_cachedNews[date]);
|
||||||
|
_loadedDates.Add(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
_earliestLoadedDate = _loadedDates.Min();
|
||||||
|
LoadingTextBlock.IsVisible = false;
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
if (_isLoading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = true;
|
||||||
|
RefreshButtonText.Text = "刷新中...";
|
||||||
|
RefreshIcon.IsEnabled = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var allNews = await FetchJuyaNewsAsync();
|
||||||
|
|
||||||
|
if (!_isAttached)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var todayNews = allNews.FirstOrDefault(n => n.Date.Date == today);
|
||||||
|
|
||||||
|
if (todayNews != null)
|
||||||
|
{
|
||||||
|
_cachedNews[today] = todayNews;
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (!_isAttached) return;
|
||||||
|
|
||||||
|
var existingIndex = _loadedDates.IndexOf(today);
|
||||||
|
if (existingIndex >= 0 && _dailyViews.Count > existingIndex)
|
||||||
|
{
|
||||||
|
var oldView = _dailyViews[existingIndex];
|
||||||
|
var insertIndex = NewsStackPanel.Children.IndexOf(oldView);
|
||||||
|
|
||||||
|
if (insertIndex >= 0)
|
||||||
|
{
|
||||||
|
NewsStackPanel.Children.RemoveAt(insertIndex);
|
||||||
|
_dailyViews.RemoveAt(existingIndex);
|
||||||
|
|
||||||
|
var newView = new DailyNewsView(todayNews, _isNightVisual);
|
||||||
|
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
|
||||||
|
|
||||||
|
NewsStackPanel.Children.Insert(insertIndex, newView);
|
||||||
|
_dailyViews.Insert(existingIndex, newView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newView = new DailyNewsView(todayNews, _isNightVisual);
|
||||||
|
newView.CoverImageClicked += (s, e) => TryOpenUrl(todayNews.IssueUrl);
|
||||||
|
|
||||||
|
NewsStackPanel.Children.Insert(0, newView);
|
||||||
|
_dailyViews.Insert(0, newView);
|
||||||
|
_loadedDates.Insert(0, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
UpdateAdaptiveLayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
RefreshButtonText.Text = "刷新";
|
||||||
|
RefreshIcon.IsEnabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryOpenUrl(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = url,
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLoadingState()
|
||||||
|
{
|
||||||
|
StatusTextBlock.Text = "加载中...";
|
||||||
|
StatusTextBlock.IsVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAdaptiveLayout()
|
||||||
|
{
|
||||||
|
var scale = ResolveScale();
|
||||||
|
var softScale = Math.Clamp(scale, 0.80, 1.32);
|
||||||
|
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
||||||
|
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||||
|
|
||||||
|
var unifiedMainRectangle = ResolveUnifiedMainRectangle();
|
||||||
|
RootBorder.CornerRadius = unifiedMainRectangle;
|
||||||
|
CardBorder.CornerRadius = unifiedMainRectangle;
|
||||||
|
|
||||||
|
var horizontalPadding = Math.Clamp(16 * softScale, 10, 24);
|
||||||
|
var verticalPadding = Math.Clamp(14 * softScale, 8, 20);
|
||||||
|
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
|
||||||
|
|
||||||
|
var headerHeight = Math.Clamp(40 * softScale, 28, 56);
|
||||||
|
HeaderGrid.Height = headerHeight;
|
||||||
|
|
||||||
|
BrandTextBlock.FontSize = Math.Clamp(20 * softScale, 14, 26);
|
||||||
|
|
||||||
|
var avatarSize = Math.Clamp(36 * softScale, 24, 48);
|
||||||
|
AvatarBorder.Width = avatarSize;
|
||||||
|
AvatarBorder.Height = avatarSize;
|
||||||
|
AvatarBorder.CornerRadius = new CornerRadius(avatarSize / 2);
|
||||||
|
|
||||||
|
var buttonFontSize = Math.Clamp(13 * softScale, 10, 16);
|
||||||
|
RefreshButton.FontSize = buttonFontSize;
|
||||||
|
RefreshButton.Padding = new Thickness(
|
||||||
|
Math.Clamp(8 * softScale, 6, 12),
|
||||||
|
Math.Clamp(4 * softScale, 2, 6)
|
||||||
|
);
|
||||||
|
|
||||||
|
StatusTextBlock.FontSize = Math.Clamp(16 * softScale, 12, 22);
|
||||||
|
LoadingTextBlock.FontSize = Math.Clamp(14 * softScale, 11, 18);
|
||||||
|
|
||||||
|
foreach (var view in _dailyViews)
|
||||||
|
{
|
||||||
|
view.UpdateLayout(softScale, totalWidth - horizontalPadding * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyNightModeVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveScale()
|
||||||
|
{
|
||||||
|
var expectedWidth = _currentCellSize * BaseWidthCells;
|
||||||
|
var expectedHeight = _currentCellSize * BaseHeightCells;
|
||||||
|
if (expectedWidth <= 0 || expectedHeight <= 0)
|
||||||
|
{
|
||||||
|
return 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
|
||||||
|
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
|
||||||
|
var scaleX = actualWidth / expectedWidth;
|
||||||
|
var scaleY = actualHeight / expectedHeight;
|
||||||
|
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||||
|
|
||||||
|
private static double ResolveUnifiedMainRadiusValue() =>
|
||||||
|
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据模型
|
||||||
|
public sealed record JuyaDailyNews(
|
||||||
|
DateTime Date,
|
||||||
|
string Title,
|
||||||
|
string CoverImageUrl,
|
||||||
|
string IssueUrl,
|
||||||
|
string BilibiliUrl,
|
||||||
|
string YoutubeUrl,
|
||||||
|
IReadOnlyList<JuyaOverviewCategory> OverviewCategories,
|
||||||
|
IReadOnlyList<JuyaDetailedNewsItem> DetailedNews,
|
||||||
|
DateTimeOffset FetchedAt);
|
||||||
|
|
||||||
|
public sealed record JuyaOverviewCategory(
|
||||||
|
string Name,
|
||||||
|
string Icon,
|
||||||
|
IReadOnlyList<JuyaOverviewItem> Items);
|
||||||
|
|
||||||
|
public sealed record JuyaOverviewItem(
|
||||||
|
string Title,
|
||||||
|
string Url,
|
||||||
|
int? Number);
|
||||||
|
|
||||||
|
public sealed record JuyaDetailedNewsItem(
|
||||||
|
string Title,
|
||||||
|
int Number,
|
||||||
|
string BodyText,
|
||||||
|
IReadOnlyList<string> RelatedLinks);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -473,6 +473,11 @@ public partial class StudySessionHistoryWidget : UserControl, IDesktopComponentW
|
|||||||
_dialogSessionId = null;
|
_dialogSessionId = null;
|
||||||
_dialogSessionLabel = string.Empty;
|
_dialogSessionLabel = string.Empty;
|
||||||
DialogRenameTextBox.Text = string.Empty;
|
DialogRenameTextBox.Text = string.Empty;
|
||||||
|
DialogOverlayBorder.IsVisible = false;
|
||||||
|
if (_currentSnapshot is not null)
|
||||||
|
{
|
||||||
|
RenderSnapshot(_currentSnapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e)
|
private void OnDialogRenameTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -9,86 +9,124 @@
|
|||||||
d:DesignHeight="480"
|
d:DesignHeight="480"
|
||||||
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
|
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
|
||||||
|
|
||||||
<Border x:Name="RootBorder"
|
<Grid>
|
||||||
Background="#F1F4F9"
|
<Border x:Name="RootBorder"
|
||||||
CornerRadius="20"
|
Background="#F1F4F9"
|
||||||
ClipToBounds="True"
|
CornerRadius="20"
|
||||||
Padding="8">
|
ClipToBounds="True"
|
||||||
<Grid RowDefinitions="*,Auto"
|
Padding="8">
|
||||||
RowSpacing="8">
|
<Grid RowDefinitions="*,Auto"
|
||||||
<Border x:Name="CanvasBorder"
|
RowSpacing="8">
|
||||||
Grid.Row="0"
|
<Border x:Name="CanvasBorder"
|
||||||
Background="#FFFFFF"
|
Grid.Row="0"
|
||||||
BorderBrush="#24000000"
|
Background="#FFFFFF"
|
||||||
BorderThickness="1"
|
BorderBrush="#24000000"
|
||||||
CornerRadius="14"
|
BorderThickness="1"
|
||||||
ClipToBounds="True">
|
CornerRadius="14"
|
||||||
<inking:InkCanvas x:Name="InkCanvas" />
|
ClipToBounds="True">
|
||||||
</Border>
|
<inking:InkCanvas x:Name="InkCanvas" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Border x:Name="ToolbarBorder"
|
<Border x:Name="ToolbarBorder"
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Background="#E6FFFFFF"
|
Background="#E6FFFFFF"
|
||||||
BorderBrush="#16000000"
|
BorderBrush="#16000000"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="14"
|
||||||
|
Padding="8,6">
|
||||||
|
<StackPanel x:Name="ToolbarButtonsPanel"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="8">
|
||||||
|
<Button x:Name="PenButton"
|
||||||
|
Width="30"
|
||||||
|
Height="30"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="15"
|
||||||
|
ToolTip.Tip="Pen"
|
||||||
|
Click="OnPenButtonClick">
|
||||||
|
<fi:SymbolIcon x:Name="PenIcon"
|
||||||
|
Symbol="Pen"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
<Button x:Name="EraserButton"
|
||||||
|
Width="30"
|
||||||
|
Height="30"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="15"
|
||||||
|
ToolTip.Tip="Eraser"
|
||||||
|
Click="OnEraserButtonClick">
|
||||||
|
<fi:SymbolIcon x:Name="EraserIcon"
|
||||||
|
Symbol="EraserTool"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
<Button x:Name="ClearButton"
|
||||||
|
Width="30"
|
||||||
|
Height="30"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="15"
|
||||||
|
ToolTip.Tip="Clear"
|
||||||
|
Click="OnClearButtonClick">
|
||||||
|
<fi:SymbolIcon x:Name="ClearIcon"
|
||||||
|
Symbol="Delete"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
<Button x:Name="ExportButton"
|
||||||
|
Width="30"
|
||||||
|
Height="30"
|
||||||
|
Padding="0"
|
||||||
|
CornerRadius="15"
|
||||||
|
ToolTip.Tip="Export SVG"
|
||||||
|
Click="OnExportButtonClick">
|
||||||
|
<fi:SymbolIcon x:Name="ExportIcon"
|
||||||
|
Symbol="ArrowExport"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Popup x:Name="ColorPickerPopup"
|
||||||
|
Placement="Top"
|
||||||
|
PlacementTarget="{Binding #PenButton}"
|
||||||
|
IsLightDismissEnabled="True"
|
||||||
|
WindowManagerAddShadowHint="False">
|
||||||
|
<Border Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
||||||
|
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
CornerRadius="14"
|
CornerRadius="8"
|
||||||
Padding="8,6">
|
Padding="12">
|
||||||
<StackPanel x:Name="ToolbarButtonsPanel"
|
<StackPanel Spacing="12">
|
||||||
Orientation="Horizontal"
|
<ColorView x:Name="InkColorPicker"
|
||||||
HorizontalAlignment="Center"
|
IsAlphaEnabled="False"
|
||||||
VerticalAlignment="Center"
|
IsColorSpectrumVisible="True"
|
||||||
Spacing="8">
|
IsColorPaletteVisible="True"
|
||||||
<Button x:Name="PenButton"
|
IsHexInputVisible="True"
|
||||||
Width="30"
|
ColorChanged="OnColorPickerColorChanged" />
|
||||||
Height="30"
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
Padding="0"
|
ColumnSpacing="8">
|
||||||
CornerRadius="15"
|
<TextBlock Grid.Column="0"
|
||||||
ToolTip.Tip="Pen"
|
Text="粗细"
|
||||||
Click="OnPenButtonClick">
|
VerticalAlignment="Center"
|
||||||
<fi:SymbolIcon x:Name="PenIcon"
|
FontSize="12" />
|
||||||
Symbol="Pen"
|
<Slider x:Name="InkThicknessSlider"
|
||||||
IconVariant="Regular"
|
Grid.Column="1"
|
||||||
FontSize="14" />
|
Minimum="1"
|
||||||
</Button>
|
Maximum="8"
|
||||||
<Button x:Name="EraserButton"
|
Value="2.5"
|
||||||
Width="30"
|
SmallChange="0.5"
|
||||||
Height="30"
|
LargeChange="1"
|
||||||
Padding="0"
|
ValueChanged="OnInkThicknessSliderValueChanged" />
|
||||||
CornerRadius="15"
|
</Grid>
|
||||||
ToolTip.Tip="Eraser"
|
|
||||||
Click="OnEraserButtonClick">
|
|
||||||
<fi:SymbolIcon x:Name="EraserIcon"
|
|
||||||
Symbol="EraserTool"
|
|
||||||
IconVariant="Regular"
|
|
||||||
FontSize="14" />
|
|
||||||
</Button>
|
|
||||||
<Button x:Name="ClearButton"
|
|
||||||
Width="30"
|
|
||||||
Height="30"
|
|
||||||
Padding="0"
|
|
||||||
CornerRadius="15"
|
|
||||||
ToolTip.Tip="Clear"
|
|
||||||
Click="OnClearButtonClick">
|
|
||||||
<fi:SymbolIcon x:Name="ClearIcon"
|
|
||||||
Symbol="Delete"
|
|
||||||
IconVariant="Regular"
|
|
||||||
FontSize="14" />
|
|
||||||
</Button>
|
|
||||||
<Button x:Name="ExportButton"
|
|
||||||
Width="30"
|
|
||||||
Height="30"
|
|
||||||
Padding="0"
|
|
||||||
CornerRadius="15"
|
|
||||||
ToolTip.Tip="Export SVG"
|
|
||||||
Click="OnExportButtonClick">
|
|
||||||
<fi:SymbolIcon x:Name="ExportIcon"
|
|
||||||
Symbol="ArrowExport"
|
|
||||||
IconVariant="Regular"
|
|
||||||
FontSize="14" />
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Popup>
|
||||||
</Border>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Reflection;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
@@ -38,7 +39,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
private double _currentCellSize = 48;
|
private double _currentCellSize = 48;
|
||||||
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
||||||
private bool? _isNightModeApplied;
|
private bool? _isNightModeApplied;
|
||||||
private SKColor _currentInkColor = SKColors.Black;
|
private SKColor _selectedInkColor = SKColors.Black;
|
||||||
|
private float _selectedInkThickness = 2.5f;
|
||||||
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
||||||
private string _placementId = string.Empty;
|
private string _placementId = string.Empty;
|
||||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
@@ -66,9 +68,27 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
RefreshFromSettings();
|
RefreshFromSettings();
|
||||||
ApplyThemeVisual(force: true);
|
ApplyThemeVisual(force: true);
|
||||||
|
InitializeColorPicker();
|
||||||
SetToolMode(WhiteboardToolMode.Pen);
|
SetToolMode(WhiteboardToolMode.Pen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InitializeColorPicker()
|
||||||
|
{
|
||||||
|
if (InkColorPicker is not null)
|
||||||
|
{
|
||||||
|
InkColorPicker.Color = new Color(
|
||||||
|
_selectedInkColor.Alpha,
|
||||||
|
_selectedInkColor.Red,
|
||||||
|
_selectedInkColor.Green,
|
||||||
|
_selectedInkColor.Blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (InkThicknessSlider is not null)
|
||||||
|
{
|
||||||
|
InkThicknessSlider.Value = _selectedInkThickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public int NoteRetentionDays => _noteRetentionDays;
|
public int NoteRetentionDays => _noteRetentionDays;
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
@@ -97,7 +117,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
|
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
|
||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||||
settings.IgnorePressure = true;
|
settings.IgnorePressure = true;
|
||||||
settings.InkThickness = 2.5f;
|
settings.InkThickness = _selectedInkThickness;
|
||||||
settings.EraserSize = new Size(20, 20);
|
settings.EraserSize = new Size(20, 20);
|
||||||
settings.IsBitmapCacheEnabled = true;
|
settings.IsBitmapCacheEnabled = true;
|
||||||
settings.MaxBitmapCacheSize = 2048;
|
settings.MaxBitmapCacheSize = 2048;
|
||||||
@@ -135,7 +155,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||||
settings.InkThickness = (float)Math.Clamp(_currentCellSize * 0.06, 2.0, 6.0);
|
|
||||||
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
||||||
settings.EraserSize = new Size(eraserSize, eraserSize);
|
settings.EraserSize = new Size(eraserSize, eraserSize);
|
||||||
}
|
}
|
||||||
@@ -149,7 +168,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isNightModeApplied = isNightMode;
|
_isNightModeApplied = isNightMode;
|
||||||
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
|
|
||||||
|
|
||||||
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
||||||
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
|
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
|
||||||
@@ -157,8 +175,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
||||||
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
||||||
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
|
|
||||||
RecolorAllStrokes(_currentInkColor);
|
|
||||||
RefreshToolButtonVisuals();
|
RefreshToolButtonVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +220,30 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ForceSaveNote()
|
||||||
|
{
|
||||||
|
if (_disposed || !HasValidPersistenceContext())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_noteDirty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteDirty = false;
|
||||||
|
_noteSaveTimer.Stop();
|
||||||
|
var noteSnapshot = BuildNoteSnapshot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -300,12 +340,31 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
|
|
||||||
if (mode == WhiteboardToolMode.Pen)
|
if (mode == WhiteboardToolMode.Pen)
|
||||||
{
|
{
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshToolButtonVisuals();
|
RefreshToolButtonVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetInkColor(SKColor color)
|
||||||
|
{
|
||||||
|
_selectedInkColor = color;
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen)
|
||||||
|
{
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||||
|
}
|
||||||
|
RefreshToolButtonVisuals();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetInkThickness(float thickness)
|
||||||
|
{
|
||||||
|
_selectedInkThickness = Math.Clamp(thickness, 1.0f, 8.0f);
|
||||||
|
if (_toolMode == WhiteboardToolMode.Pen)
|
||||||
|
{
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkThickness = _selectedInkThickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshToolButtonVisuals()
|
private void RefreshToolButtonVisuals()
|
||||||
{
|
{
|
||||||
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
||||||
@@ -350,7 +409,32 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
|
|
||||||
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
|
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
SetToolMode(WhiteboardToolMode.Pen);
|
if (_toolMode == WhiteboardToolMode.Pen && ColorPickerPopup is not null)
|
||||||
|
{
|
||||||
|
if (ColorPickerPopup.IsOpen)
|
||||||
|
{
|
||||||
|
ColorPickerPopup.Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ColorPickerPopup.Open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SetToolMode(WhiteboardToolMode.Pen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var color = e.NewColor;
|
||||||
|
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
|
{
|
||||||
|
SetInkThickness((float)e.NewValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
|
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -509,14 +593,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
_noteDirty = false;
|
_noteDirty = false;
|
||||||
_noteSaveTimer.Stop();
|
_noteSaveTimer.Stop();
|
||||||
var noteSnapshot = BuildNoteSnapshot();
|
var noteSnapshot = BuildNoteSnapshot();
|
||||||
var componentId = _componentId;
|
try
|
||||||
var placementId = _placementId;
|
{
|
||||||
var retentionDays = _noteRetentionDays;
|
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
||||||
_ = Task.Run(() => _notePersistenceService.SaveNote(
|
}
|
||||||
componentId,
|
catch
|
||||||
placementId,
|
{
|
||||||
noteSnapshot,
|
}
|
||||||
retentionDays));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void SchedulePersistedNoteLoad()
|
private async void SchedulePersistedNoteLoad()
|
||||||
@@ -553,7 +636,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
|||||||
{
|
{
|
||||||
ClearAllStrokes();
|
ClearAllStrokes();
|
||||||
ApplyNoteSnapshot(noteSnapshot);
|
ApplyNoteSnapshot(noteSnapshot);
|
||||||
RecolorAllStrokes(_currentInkColor);
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
600
LanMountainDesktop/Views/MainWindow.ComponentPreviewImages.cs
Normal file
600
LanMountainDesktop/Views/MainWindow.ComponentPreviewImages.cs
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private const double PreviewRenderCellSizeMin = 42;
|
||||||
|
private const double PreviewRenderCellSizeMax = 112;
|
||||||
|
|
||||||
|
private readonly IComponentPreviewImageService _componentPreviewImageService = new ComponentPreviewImageService();
|
||||||
|
private readonly Dictionary<ComponentPreviewKey, List<ComponentLibraryPreviewVisualTarget>> _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance);
|
||||||
|
private bool _componentLibraryPreviewWarmupStarted;
|
||||||
|
|
||||||
|
private sealed record ComponentLibraryPreviewVisualTarget(Image Image, Control Fallback);
|
||||||
|
|
||||||
|
private void EnsureComponentLibraryPreviewWarmup()
|
||||||
|
{
|
||||||
|
if (_componentLibraryCategories.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeCategoryId = _componentLibraryActiveCategoryId ??
|
||||||
|
_componentLibraryCategories[Math.Clamp(_componentLibraryCategoryIndex, 0, _componentLibraryCategories.Count - 1)].Id;
|
||||||
|
if (!_componentLibraryPreviewWarmupStarted)
|
||||||
|
{
|
||||||
|
_componentLibraryPreviewWarmupStarted = true;
|
||||||
|
_ = WarmComponentLibraryPreviewsSeriallyAsync(activeCategoryId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeCategory = _componentLibraryCategories.FirstOrDefault(category =>
|
||||||
|
string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (activeCategory is not null)
|
||||||
|
{
|
||||||
|
_ = WarmComponentLibraryCategoryPreviewsAsync(activeCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WarmComponentLibraryPreviewsSeriallyAsync(string activeCategoryId)
|
||||||
|
{
|
||||||
|
var prioritized = _componentLibraryCategories
|
||||||
|
.OrderBy(category => string.Equals(category.Id, activeCategoryId, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var category in prioritized)
|
||||||
|
{
|
||||||
|
await WarmComponentLibraryCategoryPreviewsAsync(category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WarmComponentLibraryCategoryPreviewsAsync(ComponentLibraryCategory category)
|
||||||
|
{
|
||||||
|
foreach (var component in category.Components)
|
||||||
|
{
|
||||||
|
var span = NormalizeComponentCellSpan(
|
||||||
|
component.ComponentId,
|
||||||
|
(component.MinWidthCells, component.MinHeightCells));
|
||||||
|
await EnsureComponentTypePreviewImageAsync(component.ComponentId, span.WidthCells, span.HeightCells);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IImage?> EnsureComponentTypePreviewImageAsync(string componentId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(componentId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||||
|
var cached = ResolvePreviewImageFromService(key);
|
||||||
|
if (cached is not null)
|
||||||
|
{
|
||||||
|
ApplyPreviewEntryToEmbeddedVisuals(key);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await QueuePreviewGenerationAsync(
|
||||||
|
key,
|
||||||
|
pageIndex: null,
|
||||||
|
action: "ComponentTypePreview",
|
||||||
|
forceRefresh: false);
|
||||||
|
return entry.Bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IImage?> RefreshPlacementPreviewImageAsync(DesktopComponentPlacementSnapshot? placement, bool forceRefresh)
|
||||||
|
{
|
||||||
|
if (placement is null ||
|
||||||
|
string.IsNullOrWhiteSpace(placement.ComponentId) ||
|
||||||
|
string.IsNullOrWhiteSpace(placement.PlacementId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsPlacementPresent(placement.PlacementId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = ClonePlacementSnapshot(placement);
|
||||||
|
var key = CreatePlacementPreviewKey(
|
||||||
|
snapshot.ComponentId,
|
||||||
|
snapshot.PlacementId,
|
||||||
|
snapshot.WidthCells,
|
||||||
|
snapshot.HeightCells);
|
||||||
|
if (!forceRefresh)
|
||||||
|
{
|
||||||
|
var cached = ResolvePreviewImageFromService(key);
|
||||||
|
if (cached is not null)
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_componentPreviewImageService.RemovePlacementPreviews(snapshot.PlacementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await QueuePreviewGenerationAsync(
|
||||||
|
key,
|
||||||
|
snapshot.PageIndex,
|
||||||
|
action: "PlacementPreview",
|
||||||
|
forceRefresh: false);
|
||||||
|
if (!IsPlacementPresent(snapshot.PlacementId))
|
||||||
|
{
|
||||||
|
RemovePlacementPreviewImage(snapshot.PlacementId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ComponentPreviewImageEntry> QueuePreviewGenerationAsync(
|
||||||
|
ComponentPreviewKey key,
|
||||||
|
int? pageIndex,
|
||||||
|
string action,
|
||||||
|
bool forceRefresh,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
|
||||||
|
var visualSignature = BuildPreviewVisualSignature(key, renderCellSize);
|
||||||
|
if (forceRefresh)
|
||||||
|
{
|
||||||
|
_componentPreviewImageService.Invalidate(key, visualSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = await _componentPreviewImageService.QueueGenerationAsync(
|
||||||
|
key,
|
||||||
|
visualSignature,
|
||||||
|
async ct =>
|
||||||
|
{
|
||||||
|
_ = ct;
|
||||||
|
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
|
||||||
|
!IsPlacementPresent(key.PlacementId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bitmap = await CapturePreviewImageAsync(
|
||||||
|
key.ComponentTypeId,
|
||||||
|
key.PlacementId,
|
||||||
|
pageIndex,
|
||||||
|
key.WidthCells,
|
||||||
|
key.HeightCells,
|
||||||
|
renderCellSize,
|
||||||
|
action);
|
||||||
|
if (key.Kind == ComponentPreviewKeyKind.PlacementInstance &&
|
||||||
|
!IsPlacementPresent(key.PlacementId))
|
||||||
|
{
|
||||||
|
DisposeImageIfNeeded(bitmap);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitmap;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
NotifyPreviewEntryUpdated(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IImage?> CapturePreviewImageAsync(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int? pageIndex,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells,
|
||||||
|
double renderCellSize,
|
||||||
|
string action)
|
||||||
|
{
|
||||||
|
if (ComponentPreviewStagingHost is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeWidthCells = Math.Max(1, widthCells);
|
||||||
|
var safeHeightCells = Math.Max(1, heightCells);
|
||||||
|
var safeCellSize = Math.Clamp(renderCellSize, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
|
||||||
|
var previewWidth = safeWidthCells * safeCellSize;
|
||||||
|
var previewHeight = safeHeightCells * safeCellSize;
|
||||||
|
|
||||||
|
var previewControl = CreateDesktopComponentControl(
|
||||||
|
componentId,
|
||||||
|
safeCellSize,
|
||||||
|
placementId,
|
||||||
|
pageIndex,
|
||||||
|
action);
|
||||||
|
if (previewControl is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewControl.IsHitTestVisible = false;
|
||||||
|
previewControl.Focusable = false;
|
||||||
|
|
||||||
|
var stage = new Border
|
||||||
|
{
|
||||||
|
Width = previewWidth,
|
||||||
|
Height = previewHeight,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
ClipToBounds = true,
|
||||||
|
Child = previewControl
|
||||||
|
};
|
||||||
|
|
||||||
|
Canvas.SetLeft(stage, -20000);
|
||||||
|
Canvas.SetTop(stage, -20000);
|
||||||
|
ComponentPreviewStagingHost.Children.Add(stage);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stage.Measure(new Size(previewWidth, previewHeight));
|
||||||
|
stage.Arrange(new Rect(0, 0, previewWidth, previewHeight));
|
||||||
|
stage.UpdateLayout();
|
||||||
|
await WaitForPreviewRenderPassAsync();
|
||||||
|
|
||||||
|
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||||
|
var pixelSize = new PixelSize(
|
||||||
|
Math.Max(1, (int)Math.Ceiling(previewWidth * renderScale)),
|
||||||
|
Math.Max(1, (int)Math.Ceiling(previewHeight * renderScale)));
|
||||||
|
var bitmap = new RenderTargetBitmap(pixelSize, new Vector(96 * renderScale, 96 * renderScale));
|
||||||
|
bitmap.Render(stage);
|
||||||
|
return bitmap;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"ComponentPreview",
|
||||||
|
$"Action={action}; ComponentId={componentId}; PlacementId={placementId ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false",
|
||||||
|
ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ComponentPreviewStagingHost.Children.Remove(stage);
|
||||||
|
ClearTimeZoneServiceBindings(stage);
|
||||||
|
if (previewControl is IDisposable disposableControl)
|
||||||
|
{
|
||||||
|
disposableControl.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForPreviewRenderPassAsync()
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Background);
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(static () => { }, DispatcherPriority.Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolvePreviewRenderCellSize(int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
var baseCellSize = _currentDesktopCellSize > 0
|
||||||
|
? _currentDesktopCellSize * 1.10
|
||||||
|
: 74;
|
||||||
|
var densityBoost = Math.Max(widthCells, heightCells) >= 4 ? 8 : 0;
|
||||||
|
return Math.Clamp(baseCellSize + densityBoost, PreviewRenderCellSizeMin, PreviewRenderCellSizeMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPreviewVisualSignature(ComponentPreviewKey key, double renderCellSize)
|
||||||
|
{
|
||||||
|
var appearance = _appearanceThemeService.GetCurrent();
|
||||||
|
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.GlobalCornerRadiusScale:F3}|Accent={FormatSignatureColor(appearance.AccentColor)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||||
|
return ComponentPreviewKey.ForComponentType(componentId, span.WidthCells, span.HeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewKey CreatePlacementPreviewKey(string componentId, string placementId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
var span = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||||
|
return ComponentPreviewKey.ForPlacementInstance(componentId, placementId, span.WidthCells, span.HeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPlacementPresent(string? placementId)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(placementId) &&
|
||||||
|
_desktopComponentPlacements.Any(candidate =>
|
||||||
|
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildCurrentVisualSignature(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
var renderCellSize = ResolvePreviewRenderCellSize(key.WidthCells, key.HeightCells);
|
||||||
|
return BuildPreviewVisualSignature(key, renderCellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetReusablePreviewEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
|
||||||
|
{
|
||||||
|
if (!_componentPreviewImageService.TryGetEntry(key, out entry) ||
|
||||||
|
entry is null ||
|
||||||
|
entry.State != ComponentPreviewImageState.Ready ||
|
||||||
|
entry.Bitmap is null)
|
||||||
|
{
|
||||||
|
entry = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedSignature = BuildCurrentVisualSignature(key);
|
||||||
|
if (!string.Equals(entry.VisualSignature, expectedSignature, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
entry = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IImage? ResolvePreviewImageFromService(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
if (!TryGetReusablePreviewEntry(key, out var entry) || entry is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.Bitmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewImageEntry? ResolvePreviewEntry(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
if (!_componentPreviewImageService.TryGetEntry(key, out var entry) || entry is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State != ComponentPreviewImageState.Ready)
|
||||||
|
{
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryGetReusablePreviewEntry(key, out var reusable) ? reusable : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IImage? ResolveComponentTypePreviewImage(string componentId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
var key = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||||
|
return ResolvePreviewImageFromService(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IImage? ResolveDesktopEditPreviewImage(string componentId, string? placementId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(placementId))
|
||||||
|
{
|
||||||
|
var placementKey = CreatePlacementPreviewKey(componentId, placementId, widthCells, heightCells);
|
||||||
|
var placementImage = ResolvePreviewImageFromService(placementKey);
|
||||||
|
if (placementImage is not null)
|
||||||
|
{
|
||||||
|
return placementImage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentTypeKey = CreateComponentTypePreviewKey(componentId, widthCells, heightCells);
|
||||||
|
return ResolvePreviewImageFromService(componentTypeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (int WidthCells, int HeightCells) ResolveOverlayPreviewSpan(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int? widthCells,
|
||||||
|
int? heightCells)
|
||||||
|
{
|
||||||
|
if (widthCells is > 0 && heightCells is > 0)
|
||||||
|
{
|
||||||
|
return NormalizeComponentCellSpan(componentId, (widthCells.Value, heightCells.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||||
|
TryGetDesktopPlacementById(placementId, out var placement))
|
||||||
|
{
|
||||||
|
return NormalizeComponentCellSpan(componentId, (placement.WidthCells, placement.HeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) &&
|
||||||
|
string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
_desktopEditSession.WidthCells > 0 &&
|
||||||
|
_desktopEditSession.HeightCells > 0)
|
||||||
|
{
|
||||||
|
return NormalizeComponentCellSpan(componentId, (_desktopEditSession.WidthCells, _desktopEditSession.HeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
|
||||||
|
{
|
||||||
|
return NormalizeComponentCellSpan(
|
||||||
|
componentId,
|
||||||
|
(descriptor.Definition.MinWidthCells, descriptor.Definition.MinHeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyDesktopEditOverlayPreviewImage(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int? widthCells = null,
|
||||||
|
int? heightCells = null)
|
||||||
|
{
|
||||||
|
var span = ResolveOverlayPreviewSpan(componentId, placementId, widthCells, heightCells);
|
||||||
|
EnsureDesktopEditOverlayPresenter();
|
||||||
|
_desktopEditOverlayPresenter?.SetPreviewImage(ResolveDesktopEditPreviewImage(componentId, placementId, span.WidthCells, span.HeightCells));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrimeDesktopEditPreviewImage(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
int pageIndex,
|
||||||
|
int widthCells,
|
||||||
|
int heightCells)
|
||||||
|
{
|
||||||
|
_ = pageIndex;
|
||||||
|
var normalized = NormalizeComponentCellSpan(componentId, (widthCells, heightCells));
|
||||||
|
_ = EnsureComponentTypePreviewImageAsync(componentId, normalized.WidthCells, normalized.HeightCells);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||||
|
TryGetDesktopPlacementById(placementId, out var placement))
|
||||||
|
{
|
||||||
|
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueuePlacementPreviewRefresh(DesktopComponentPlacementSnapshot? placement)
|
||||||
|
{
|
||||||
|
_ = RefreshPlacementPreviewImageAsync(placement, forceRefresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemovePlacementPreviewImage(string? placementId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(placementId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentPreviewImageService.RemovePlacementPreviews(placementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemovePlacementPreviewImages(IEnumerable<DesktopComponentPlacementSnapshot> placements)
|
||||||
|
{
|
||||||
|
foreach (var placementId in placements
|
||||||
|
.Select(placement => placement.PlacementId)
|
||||||
|
.Where(static id => !string.IsNullOrWhiteSpace(id))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
RemovePlacementPreviewImage(placementId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterComponentLibraryPreviewVisual(ComponentPreviewKey key, Image image, Control fallback)
|
||||||
|
{
|
||||||
|
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var visuals))
|
||||||
|
{
|
||||||
|
visuals = [];
|
||||||
|
_componentLibraryPreviewVisualTargets[key] = visuals;
|
||||||
|
}
|
||||||
|
|
||||||
|
visuals.Add(new ComponentLibraryPreviewVisualTarget(image, fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearComponentLibraryPreviewVisualTargets()
|
||||||
|
{
|
||||||
|
_componentLibraryPreviewVisualTargets.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPreviewEntryToEmbeddedVisuals(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
if (!_componentLibraryPreviewVisualTargets.TryGetValue(key, out var targets))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewImage = ResolvePreviewImageFromService(key);
|
||||||
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
target.Image.Source = previewImage;
|
||||||
|
target.Image.IsVisible = previewImage is not null;
|
||||||
|
target.Fallback.IsVisible = previewImage is null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyPreviewEntryUpdated(ComponentPreviewImageEntry entry)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
ApplyPreviewEntryToEmbeddedVisuals(entry.Key);
|
||||||
|
_detachedComponentLibraryWindow?.UpdatePreviewImage(entry);
|
||||||
|
|
||||||
|
if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||||
|
{
|
||||||
|
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, entry.Key.PlacementId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RefreshDesktopEditOverlayPreviewIfActive(entry.Key.ComponentTypeId, placementId: null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeImageIfNeeded(IImage? image)
|
||||||
|
{
|
||||||
|
if (image is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatSignatureColor(Color color)
|
||||||
|
{
|
||||||
|
return string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshDesktopEditOverlayPreviewIfActive(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
if (_desktopEditOverlayPresenter is null ||
|
||||||
|
(!_desktopEditSession.IsActive && !_isDesktopEditCommitPending) ||
|
||||||
|
string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) ||
|
||||||
|
!string.Equals(_desktopEditSession.ComponentId, componentId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(placementId) &&
|
||||||
|
!string.Equals(_desktopEditSession.PlacementId, placementId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyDesktopEditOverlayPreviewImage(
|
||||||
|
_desktopEditSession.ComponentId,
|
||||||
|
_desktopEditSession.PlacementId,
|
||||||
|
_desktopEditSession.WidthCells,
|
||||||
|
_desktopEditSession.HeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewKey ResolveDetachedLibraryPreviewKey(ComponentLibraryComponentEntry entry)
|
||||||
|
{
|
||||||
|
return CreateComponentTypePreviewKey(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComponentPreviewImageEntry? ResolveDetachedLibraryPreviewEntry(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
return ResolvePreviewEntry(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestDetachedLibraryPreviewWarm(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
_ = QueuePreviewGenerationAsync(
|
||||||
|
key,
|
||||||
|
pageIndex: null,
|
||||||
|
action: "DetachedLibraryWarm",
|
||||||
|
forceRefresh: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestDetachedLibraryPreviewRender(ComponentPreviewKey key)
|
||||||
|
{
|
||||||
|
_ = QueuePreviewGenerationAsync(
|
||||||
|
key,
|
||||||
|
pageIndex: null,
|
||||||
|
action: "DetachedLibraryRender",
|
||||||
|
forceRefresh: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
715
LanMountainDesktop/Views/MainWindow.DesktopEditing.cs
Normal file
715
LanMountainDesktop/Views/MainWindow.DesktopEditing.cs
Normal file
@@ -0,0 +1,715 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.DesktopEditing;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Theme;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan DesktopEditCommitAnimationDuration = FluttermotionToken.Standard;
|
||||||
|
private static readonly TimeSpan DesktopEditCancelAnimationDuration = FluttermotionToken.Fast;
|
||||||
|
|
||||||
|
private DesktopEditSession _desktopEditSession;
|
||||||
|
private DesktopEditOverlayPresenter? _desktopEditOverlayPresenter;
|
||||||
|
private Border? _desktopEditSourceHost;
|
||||||
|
private Rect _desktopEditOriginalRect;
|
||||||
|
private int _desktopEditStartRow;
|
||||||
|
private int _desktopEditStartColumn;
|
||||||
|
private int _desktopEditStartWidthCells;
|
||||||
|
private int _desktopEditStartHeightCells;
|
||||||
|
private int _desktopEditMinWidthCells;
|
||||||
|
private int _desktopEditMinHeightCells;
|
||||||
|
private int _desktopEditMaxWidthCells;
|
||||||
|
private int _desktopEditMaxHeightCells;
|
||||||
|
private DesktopComponentResizeMode _desktopEditResizeMode;
|
||||||
|
private int _desktopEditOverlayVersion;
|
||||||
|
private int _desktopEditCommitVersion;
|
||||||
|
private bool _isDesktopEditCommitPending;
|
||||||
|
private ComponentLibraryCollapsePresenter? _componentLibraryCollapsePresenter;
|
||||||
|
|
||||||
|
private bool HasActiveDesktopEditSession => _desktopEditSession.IsActive || _isDesktopEditCommitPending;
|
||||||
|
|
||||||
|
private bool IsDesktopEditDragMode =>
|
||||||
|
_desktopEditSession.Mode is DesktopEditSessionMode.PendingNew or DesktopEditSessionMode.DraggingNew or DesktopEditSessionMode.DraggingExisting;
|
||||||
|
|
||||||
|
private bool IsDesktopEditResizeMode =>
|
||||||
|
_desktopEditSession.Mode == DesktopEditSessionMode.ResizingExisting;
|
||||||
|
|
||||||
|
private void EnsureDesktopEditOverlayPresenter()
|
||||||
|
{
|
||||||
|
if (DesktopEditDragLayer is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopEditOverlayPresenter ??= new DesktopEditOverlayPresenter();
|
||||||
|
if (!DesktopEditDragLayer.Children.Contains(_desktopEditOverlayPresenter.Root))
|
||||||
|
{
|
||||||
|
DesktopEditDragLayer.Children.Clear();
|
||||||
|
DesktopEditDragLayer.Children.Add(_desktopEditOverlayPresenter.Root);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateDesktopEditOverlayViewportSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDesktopEditOverlayViewportSize()
|
||||||
|
{
|
||||||
|
if (_desktopEditOverlayPresenter is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var width = Math.Max(
|
||||||
|
DesktopPagesViewport?.Bounds.Width ?? 0,
|
||||||
|
DesktopEditDragLayer?.Bounds.Width ?? 0);
|
||||||
|
var height = Math.Max(
|
||||||
|
DesktopPagesViewport?.Bounds.Height ?? 0,
|
||||||
|
DesktopEditDragLayer?.Bounds.Height ?? 0);
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopEditOverlayPresenter.SetViewportSize(new Size(width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureComponentLibraryCollapsePresenter()
|
||||||
|
{
|
||||||
|
if (_componentLibraryCollapsePresenter is not null || ComponentLibraryWindow is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var collapsedChipHost = this.FindControl<Border>("ComponentLibraryCollapsedChipHost");
|
||||||
|
var collapsedChipTextBlock = this.FindControl<TextBlock>("ComponentLibraryCollapsedChipTextBlock");
|
||||||
|
var collapsedChipIcon = this.FindControl<Control>("ComponentLibraryCollapsedChipIcon");
|
||||||
|
if (collapsedChipHost is null || collapsedChipTextBlock is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentLibraryCollapsePresenter = new ComponentLibraryCollapsePresenter(
|
||||||
|
ComponentLibraryWindow,
|
||||||
|
collapsedChipHost,
|
||||||
|
collapsedChipTextBlock,
|
||||||
|
collapsedChipIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsComponentLibraryTemporarilyCollapsedForDesktopEdit()
|
||||||
|
{
|
||||||
|
EnsureComponentLibraryCollapsePresenter();
|
||||||
|
return _componentLibraryCollapsePresenter is not null &&
|
||||||
|
_componentLibraryCollapsePresenter.VisualState != ComponentLibraryCollapseVisualState.Expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SyncComponentLibraryCollapseExpandedState()
|
||||||
|
{
|
||||||
|
if (!_isComponentLibraryOpen || ComponentLibraryWindow is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureComponentLibraryCollapsePresenter();
|
||||||
|
if (_componentLibraryCollapsePresenter is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentLibraryCollapsePresenter.SyncExpandedState(ComponentLibraryWindow.Margin, ComponentLibraryWindow.Opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CollapseComponentLibraryForDesktopEdit(string? title)
|
||||||
|
{
|
||||||
|
if (!_isComponentLibraryOpen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureComponentLibraryCollapsePresenter();
|
||||||
|
if (_componentLibraryCollapsePresenter is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncComponentLibraryCollapseExpandedState();
|
||||||
|
_componentLibraryCollapsePresenter.Collapse(ResolveComponentLibraryCollapsedChipTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveComponentLibraryCollapsedChipTitle()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(ComponentLibraryTitleTextBlock?.Text))
|
||||||
|
{
|
||||||
|
return ComponentLibraryTitleTextBlock.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return L("button.component_library", "Widgets");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreComponentLibraryAfterDesktopEdit()
|
||||||
|
{
|
||||||
|
EnsureComponentLibraryCollapsePresenter();
|
||||||
|
if (_componentLibraryCollapsePresenter is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentLibraryCollapsePresenter.Restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetCurrentDesktopGridGeometry(out DesktopGridGeometry geometry)
|
||||||
|
{
|
||||||
|
geometry = default;
|
||||||
|
if (_currentDesktopCellSize <= 0 ||
|
||||||
|
_currentDesktopSurfaceIndex < 0 ||
|
||||||
|
_currentDesktopSurfaceIndex >= _desktopPageCount ||
|
||||||
|
!_desktopPageComponentGrids.TryGetValue(_currentDesktopSurfaceIndex, out var pageGrid))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var columnCount = pageGrid.ColumnDefinitions.Count;
|
||||||
|
var rowCount = pageGrid.RowDefinitions.Count;
|
||||||
|
if (columnCount <= 0 || rowCount <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
geometry = new DesktopGridGeometry(
|
||||||
|
Origin: default,
|
||||||
|
CellSize: _currentDesktopCellSize,
|
||||||
|
CellGap: _currentDesktopCellGap,
|
||||||
|
ColumnCount: columnCount,
|
||||||
|
RowCount: rowCount);
|
||||||
|
return geometry.IsValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rect? GetComponentLibraryBoundsInViewport()
|
||||||
|
{
|
||||||
|
if (!_isComponentLibraryOpen ||
|
||||||
|
IsComponentLibraryTemporarilyCollapsedForDesktopEdit() ||
|
||||||
|
ComponentLibraryWindow is null ||
|
||||||
|
DesktopPagesViewport is null ||
|
||||||
|
!ComponentLibraryWindow.IsVisible ||
|
||||||
|
ComponentLibraryWindow.Bounds.Width <= 0 ||
|
||||||
|
ComponentLibraryWindow.Bounds.Height <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var origin = ComponentLibraryWindow.TranslatePoint(default, DesktopPagesViewport);
|
||||||
|
return origin.HasValue
|
||||||
|
? new Rect(origin.Value, ComponentLibraryWindow.Bounds.Size)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Size GetComponentPixelSize(int widthCells, int heightCells, double cellSize, double cellGap)
|
||||||
|
{
|
||||||
|
var safeWidthCells = Math.Max(1, widthCells);
|
||||||
|
var safeHeightCells = Math.Max(1, heightCells);
|
||||||
|
return new Size(
|
||||||
|
safeWidthCells * cellSize + Math.Max(0, safeWidthCells - 1) * cellGap,
|
||||||
|
safeHeightCells * cellSize + Math.Max(0, safeHeightCells - 1) * cellGap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveDesktopEditTitle(string componentId)
|
||||||
|
{
|
||||||
|
return _componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor)
|
||||||
|
? descriptor.Definition.DisplayName
|
||||||
|
: componentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDesktopEditOverlayMetadata(string componentId, int widthCells, int heightCells, string? detail)
|
||||||
|
{
|
||||||
|
EnsureDesktopEditOverlayPresenter();
|
||||||
|
_desktopEditOverlayPresenter?.UpdateGhostContent(
|
||||||
|
ResolveDesktopEditTitle(componentId),
|
||||||
|
detail,
|
||||||
|
$"{Math.Max(1, widthCells)}x{Math.Max(1, heightCells)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetDesktopPlacementById(string? placementId, out DesktopComponentPlacementSnapshot placement)
|
||||||
|
{
|
||||||
|
placement = null!;
|
||||||
|
if (string.IsNullOrWhiteSpace(placementId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched = _desktopComponentPlacements.FirstOrDefault(candidate =>
|
||||||
|
string.Equals(candidate.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (matched is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
placement = matched;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetDesktopEditSourceHost(Border? host, double opacity)
|
||||||
|
{
|
||||||
|
_desktopEditSourceHost = host;
|
||||||
|
if (_desktopEditSourceHost is not null)
|
||||||
|
{
|
||||||
|
_desktopEditSourceHost.Opacity = opacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreDesktopEditSourceHost()
|
||||||
|
{
|
||||||
|
if (_desktopEditSourceHost is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopEditSourceHost.Opacity = 1;
|
||||||
|
ApplyDesktopEditStateToHost(_desktopEditSourceHost, _isComponentLibraryOpen);
|
||||||
|
_desktopEditSourceHost = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetDesktopEditState()
|
||||||
|
{
|
||||||
|
RestoreDesktopEditSourceHost();
|
||||||
|
_desktopEditSession = default;
|
||||||
|
_desktopEditOriginalRect = default;
|
||||||
|
_desktopEditStartRow = 0;
|
||||||
|
_desktopEditStartColumn = 0;
|
||||||
|
_desktopEditStartWidthCells = 0;
|
||||||
|
_desktopEditStartHeightCells = 0;
|
||||||
|
_desktopEditMinWidthCells = 0;
|
||||||
|
_desktopEditMinHeightCells = 0;
|
||||||
|
_desktopEditMaxWidthCells = 0;
|
||||||
|
_desktopEditMaxHeightCells = 0;
|
||||||
|
_desktopEditResizeMode = DesktopComponentResizeMode.Proportional;
|
||||||
|
_isDesktopEditCommitPending = false;
|
||||||
|
|
||||||
|
if (_desktopEditOverlayPresenter is not null)
|
||||||
|
{
|
||||||
|
_desktopEditOverlayPresenter.SetCandidateRect(null);
|
||||||
|
_desktopEditOverlayPresenter.Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelDesktopEditSession(bool animate)
|
||||||
|
{
|
||||||
|
RestoreComponentLibraryAfterDesktopEdit();
|
||||||
|
|
||||||
|
if (_isDesktopEditCommitPending)
|
||||||
|
{
|
||||||
|
_desktopEditCommitVersion++;
|
||||||
|
ResetDesktopEditState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_desktopEditSession.IsActive)
|
||||||
|
{
|
||||||
|
ResetDesktopEditState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = ++_desktopEditOverlayVersion;
|
||||||
|
if (animate && _desktopEditOverlayPresenter is not null)
|
||||||
|
{
|
||||||
|
_desktopEditOverlayPresenter.Cancel();
|
||||||
|
DispatcherTimer.RunOnce(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (version != _desktopEditOverlayVersion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetDesktopEditState();
|
||||||
|
},
|
||||||
|
DesktopEditCancelAnimationDuration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetDesktopEditState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanCommitDesktopEditAtRect(Rect finalRect)
|
||||||
|
{
|
||||||
|
return DesktopPlacementMath.CanCommitPlacement(finalRect, GetComponentLibraryBoundsInViewport());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RunDesktopEditCommit(Rect finalRect, Action commitAction)
|
||||||
|
{
|
||||||
|
_isDesktopEditCommitPending = true;
|
||||||
|
var overlayVersion = ++_desktopEditOverlayVersion;
|
||||||
|
var scheduledCommitVersion = ++_desktopEditCommitVersion;
|
||||||
|
_desktopEditOverlayPresenter?.Commit();
|
||||||
|
DispatcherTimer.RunOnce(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (overlayVersion != _desktopEditOverlayVersion ||
|
||||||
|
!DesktopEditCommitMath.IsPendingCommitValid(
|
||||||
|
_isDesktopEditCommitPending,
|
||||||
|
scheduledCommitVersion,
|
||||||
|
_desktopEditCommitVersion))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CanCommitDesktopEditAtRect(finalRect))
|
||||||
|
{
|
||||||
|
RestoreComponentLibraryAfterDesktopEdit();
|
||||||
|
ResetDesktopEditState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitAction();
|
||||||
|
RestoreComponentLibraryAfterDesktopEdit();
|
||||||
|
ResetDesktopEditState();
|
||||||
|
},
|
||||||
|
DesktopEditCommitAnimationDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateDesktopEditSession(Point pointerInViewport)
|
||||||
|
{
|
||||||
|
if (_isDesktopEditCommitPending || !_desktopEditSession.IsActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopEditSession = _desktopEditSession
|
||||||
|
.WithCurrentPointer(pointerInViewport)
|
||||||
|
.WithComponentLibraryBounds(GetComponentLibraryBoundsInViewport());
|
||||||
|
|
||||||
|
switch (_desktopEditSession.Mode)
|
||||||
|
{
|
||||||
|
case DesktopEditSessionMode.PendingNew:
|
||||||
|
PromotePendingNewDesktopEditIfNeeded();
|
||||||
|
break;
|
||||||
|
case DesktopEditSessionMode.DraggingNew:
|
||||||
|
case DesktopEditSessionMode.DraggingExisting:
|
||||||
|
UpdateActiveDesktopDragPreview();
|
||||||
|
break;
|
||||||
|
case DesktopEditSessionMode.ResizingExisting:
|
||||||
|
UpdateActiveDesktopResizePreview();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PromotePendingNewDesktopEditIfNeeded()
|
||||||
|
{
|
||||||
|
var threshold = DesktopPlacementMath.ComputeDragStartThreshold(_currentDesktopCellSize);
|
||||||
|
if (!_desktopEditSession.HasExceededThreshold(threshold) ||
|
||||||
|
_desktopEditSession.IsPointerInsideComponentLibrary())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopEditSession = _desktopEditSession.PromoteToDraggingNew();
|
||||||
|
CollapseComponentLibraryForDesktopEdit(ResolveDesktopEditTitle(_desktopEditSession.ComponentId ?? string.Empty));
|
||||||
|
_desktopEditSession = _desktopEditSession.WithComponentLibraryBounds(GetComponentLibraryBoundsInViewport());
|
||||||
|
EnsureDesktopEditOverlayPresenter();
|
||||||
|
_desktopEditOverlayPresenter?.Show(DesktopEditGhostVisualStyle.ElevatedFromLibrary);
|
||||||
|
UpdateActiveDesktopDragPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateActiveDesktopDragPreview()
|
||||||
|
{
|
||||||
|
if (_desktopEditSession.Mode is not (DesktopEditSessionMode.DraggingNew or DesktopEditSessionMode.DraggingExisting) ||
|
||||||
|
!TryGetCurrentDesktopGridGeometry(out var grid) ||
|
||||||
|
DesktopPagesViewport is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureDesktopEditOverlayPresenter();
|
||||||
|
|
||||||
|
var previewSize = GetComponentPixelSize(
|
||||||
|
_desktopEditSession.WidthCells,
|
||||||
|
_desktopEditSession.HeightCells,
|
||||||
|
_currentDesktopCellSize,
|
||||||
|
_currentDesktopCellGap);
|
||||||
|
var previewOrigin = DesktopPlacementMath.Subtract(
|
||||||
|
_desktopEditSession.CurrentPointerInViewport,
|
||||||
|
_desktopEditSession.PointerOffsetInViewport);
|
||||||
|
var previewRect = new Rect(previewOrigin, previewSize);
|
||||||
|
var hasSnap = DesktopPlacementMath.TryGetSnappedCell(
|
||||||
|
grid,
|
||||||
|
_desktopEditSession.CurrentPointerInViewport,
|
||||||
|
_desktopEditSession.PointerOffsetInViewport,
|
||||||
|
_desktopEditSession.WidthCells,
|
||||||
|
_desktopEditSession.HeightCells,
|
||||||
|
out var column,
|
||||||
|
out var row);
|
||||||
|
var snappedRect = hasSnap
|
||||||
|
? DesktopPlacementMath.GetCellRect(grid, column, row, _desktopEditSession.WidthCells, _desktopEditSession.HeightCells)
|
||||||
|
: default;
|
||||||
|
var withinViewport =
|
||||||
|
_desktopEditSession.CurrentPointerInViewport.X >= 0 &&
|
||||||
|
_desktopEditSession.CurrentPointerInViewport.Y >= 0 &&
|
||||||
|
_desktopEditSession.CurrentPointerInViewport.X <= DesktopPagesViewport.Bounds.Width &&
|
||||||
|
_desktopEditSession.CurrentPointerInViewport.Y <= DesktopPagesViewport.Bounds.Height;
|
||||||
|
var occludedByLibrary =
|
||||||
|
_desktopEditSession.IsPointerInsideComponentLibrary() ||
|
||||||
|
_desktopEditSession.IsPreviewOccludedByComponentLibrary(previewRect);
|
||||||
|
var canDrop = withinViewport && hasSnap && !occludedByLibrary;
|
||||||
|
|
||||||
|
_desktopEditSession = canDrop
|
||||||
|
? _desktopEditSession.WithTargetCell(row, column)
|
||||||
|
: _desktopEditSession with { TargetRow = -1, TargetColumn = -1 };
|
||||||
|
|
||||||
|
_desktopEditOverlayPresenter?.SetPreviewRect(previewRect);
|
||||||
|
_desktopEditOverlayPresenter?.SetCandidateRect(canDrop ? snappedRect : null);
|
||||||
|
_desktopEditOverlayPresenter?.SetInvalid(!canDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateActiveDesktopResizePreview()
|
||||||
|
{
|
||||||
|
if (_desktopEditSession.Mode != DesktopEditSessionMode.ResizingExisting ||
|
||||||
|
!TryGetCurrentDesktopGridGeometry(out var grid) ||
|
||||||
|
!TryGetDesktopPlacementById(_desktopEditSession.PlacementId, out var placement))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureDesktopEditOverlayPresenter();
|
||||||
|
|
||||||
|
var deltaX = _desktopEditSession.CurrentPointerInViewport.X - _desktopEditSession.StartPointerInViewport.X;
|
||||||
|
var deltaY = _desktopEditSession.CurrentPointerInViewport.Y - _desktopEditSession.StartPointerInViewport.Y;
|
||||||
|
|
||||||
|
var minSize = GetComponentPixelSize(
|
||||||
|
_desktopEditMinWidthCells,
|
||||||
|
_desktopEditMinHeightCells,
|
||||||
|
_currentDesktopCellSize,
|
||||||
|
_currentDesktopCellGap);
|
||||||
|
var maxSize = GetComponentPixelSize(
|
||||||
|
_desktopEditMaxWidthCells,
|
||||||
|
_desktopEditMaxHeightCells,
|
||||||
|
_currentDesktopCellSize,
|
||||||
|
_currentDesktopCellGap);
|
||||||
|
|
||||||
|
double previewWidth;
|
||||||
|
double previewHeight;
|
||||||
|
int widthCells;
|
||||||
|
int heightCells;
|
||||||
|
|
||||||
|
if (_desktopEditResizeMode == DesktopComponentResizeMode.Free)
|
||||||
|
{
|
||||||
|
previewWidth = Math.Clamp(_desktopEditOriginalRect.Width + deltaX, minSize.Width, maxSize.Width);
|
||||||
|
previewHeight = Math.Clamp(_desktopEditOriginalRect.Height + deltaY, minSize.Height, maxSize.Height);
|
||||||
|
widthCells = Math.Clamp(
|
||||||
|
(int)Math.Round(_desktopEditStartWidthCells + deltaX / CurrentDesktopPitch),
|
||||||
|
_desktopEditMinWidthCells,
|
||||||
|
_desktopEditMaxWidthCells);
|
||||||
|
heightCells = Math.Clamp(
|
||||||
|
(int)Math.Round(_desktopEditStartHeightCells + deltaY / CurrentDesktopPitch),
|
||||||
|
_desktopEditMinHeightCells,
|
||||||
|
_desktopEditMaxHeightCells);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var widthScale = (_desktopEditOriginalRect.Width + deltaX) / Math.Max(1, _desktopEditOriginalRect.Width);
|
||||||
|
var heightScale = (_desktopEditOriginalRect.Height + deltaY) / Math.Max(1, _desktopEditOriginalRect.Height);
|
||||||
|
var proposedScale = Math.Max(widthScale, heightScale);
|
||||||
|
var minScale = Math.Max(
|
||||||
|
(double)_desktopEditMinWidthCells / Math.Max(1, _desktopEditStartWidthCells),
|
||||||
|
(double)_desktopEditMinHeightCells / Math.Max(1, _desktopEditStartHeightCells));
|
||||||
|
var maxScale = Math.Min(
|
||||||
|
(double)_desktopEditMaxWidthCells / Math.Max(1, _desktopEditStartWidthCells),
|
||||||
|
(double)_desktopEditMaxHeightCells / Math.Max(1, _desktopEditStartHeightCells));
|
||||||
|
|
||||||
|
if (double.IsNaN(proposedScale) || double.IsInfinity(proposedScale))
|
||||||
|
{
|
||||||
|
proposedScale = minScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxScale < minScale)
|
||||||
|
{
|
||||||
|
maxScale = minScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scale = Math.Clamp(proposedScale, minScale, maxScale);
|
||||||
|
previewWidth = Math.Clamp(_desktopEditOriginalRect.Width * scale, minSize.Width, maxSize.Width);
|
||||||
|
previewHeight = Math.Clamp(_desktopEditOriginalRect.Height * scale, minSize.Height, maxSize.Height);
|
||||||
|
widthCells = Math.Clamp(
|
||||||
|
(int)Math.Round(_desktopEditStartWidthCells * scale),
|
||||||
|
_desktopEditMinWidthCells,
|
||||||
|
_desktopEditMaxWidthCells);
|
||||||
|
heightCells = Math.Clamp(
|
||||||
|
(int)Math.Round(_desktopEditStartHeightCells * scale),
|
||||||
|
_desktopEditMinHeightCells,
|
||||||
|
_desktopEditMaxHeightCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = NormalizeComponentCellSpan(_desktopEditSession.ComponentId ?? string.Empty, (widthCells, heightCells));
|
||||||
|
widthCells = Math.Clamp(normalized.WidthCells, _desktopEditMinWidthCells, _desktopEditMaxWidthCells);
|
||||||
|
heightCells = Math.Clamp(normalized.HeightCells, _desktopEditMinHeightCells, _desktopEditMaxHeightCells);
|
||||||
|
|
||||||
|
var previewRect = new Rect(_desktopEditOriginalRect.X, _desktopEditOriginalRect.Y, previewWidth, previewHeight);
|
||||||
|
var snappedRect = DesktopPlacementMath.GetCellRect(grid, placement.Column, placement.Row, widthCells, heightCells);
|
||||||
|
var occludedByLibrary =
|
||||||
|
_desktopEditSession.IsPointerInsideComponentLibrary() ||
|
||||||
|
DesktopPlacementMath.IsOccludedByComponentLibrary(previewRect, _desktopEditSession.ComponentLibraryBounds);
|
||||||
|
var canCommit = !occludedByLibrary;
|
||||||
|
|
||||||
|
_desktopEditSession = (_desktopEditSession with
|
||||||
|
{
|
||||||
|
WidthCells = widthCells,
|
||||||
|
HeightCells = heightCells,
|
||||||
|
TargetRow = canCommit ? placement.Row : -1,
|
||||||
|
TargetColumn = canCommit ? placement.Column : -1
|
||||||
|
}).WithComponentLibraryBounds(GetComponentLibraryBoundsInViewport());
|
||||||
|
|
||||||
|
UpdateDesktopEditOverlayMetadata(
|
||||||
|
_desktopEditSession.ComponentId ?? placement.ComponentId,
|
||||||
|
widthCells,
|
||||||
|
heightCells,
|
||||||
|
L("component.resize", "Resize"));
|
||||||
|
_desktopEditOverlayPresenter?.SetPreviewRect(previewRect);
|
||||||
|
_desktopEditOverlayPresenter?.SetCandidateRect(canCommit ? snappedRect : null);
|
||||||
|
_desktopEditOverlayPresenter?.SetInvalid(!canCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompleteDesktopEditSession(Point pointerInViewport)
|
||||||
|
{
|
||||||
|
if (_isDesktopEditCommitPending || !_desktopEditSession.IsActive)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateDesktopEditSession(pointerInViewport);
|
||||||
|
|
||||||
|
switch (_desktopEditSession.Mode)
|
||||||
|
{
|
||||||
|
case DesktopEditSessionMode.DraggingNew:
|
||||||
|
return CompleteNewDesktopComponentDrag();
|
||||||
|
case DesktopEditSessionMode.DraggingExisting:
|
||||||
|
return CompleteExistingDesktopComponentMove();
|
||||||
|
case DesktopEditSessionMode.ResizingExisting:
|
||||||
|
return CompleteExistingDesktopComponentResize();
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompleteNewDesktopComponentDrag()
|
||||||
|
{
|
||||||
|
if (!_desktopEditSession.HasTargetCell ||
|
||||||
|
string.IsNullOrWhiteSpace(_desktopEditSession.ComponentId) ||
|
||||||
|
!TryGetCurrentDesktopGridGeometry(out var grid))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalRect = _desktopEditSession.GetPreviewRect(grid);
|
||||||
|
if (!CanCommitDesktopEditAtRect(finalRect))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopEditOverlayPresenter?.SetPreviewRect(finalRect);
|
||||||
|
_desktopEditOverlayPresenter?.SetCandidateRect(finalRect);
|
||||||
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
|
|
||||||
|
var componentId = _desktopEditSession.ComponentId;
|
||||||
|
var pageIndex = _desktopEditSession.PageIndex;
|
||||||
|
var row = _desktopEditSession.TargetRow;
|
||||||
|
var column = _desktopEditSession.TargetColumn;
|
||||||
|
RunDesktopEditCommit(finalRect, () => PlaceDesktopComponentOnPage(componentId, pageIndex, row, column));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompleteExistingDesktopComponentMove()
|
||||||
|
{
|
||||||
|
if (!_desktopEditSession.HasTargetCell ||
|
||||||
|
string.IsNullOrWhiteSpace(_desktopEditSession.PlacementId) ||
|
||||||
|
!TryGetCurrentDesktopGridGeometry(out var grid))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalRect = _desktopEditSession.GetPreviewRect(grid);
|
||||||
|
if (!CanCommitDesktopEditAtRect(finalRect))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopEditOverlayPresenter?.SetPreviewRect(finalRect);
|
||||||
|
_desktopEditOverlayPresenter?.SetCandidateRect(finalRect);
|
||||||
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
|
|
||||||
|
var placementId = _desktopEditSession.PlacementId;
|
||||||
|
var row = _desktopEditSession.TargetRow;
|
||||||
|
var column = _desktopEditSession.TargetColumn;
|
||||||
|
if (!DesktopPlacementMath.HasCellPositionChanged(_desktopEditStartRow, _desktopEditStartColumn, row, column))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunDesktopEditCommit(finalRect, () => TryMoveExistingDesktopComponent(placementId, row, column));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CompleteExistingDesktopComponentResize()
|
||||||
|
{
|
||||||
|
if (!_desktopEditSession.HasTargetCell ||
|
||||||
|
string.IsNullOrWhiteSpace(_desktopEditSession.PlacementId) ||
|
||||||
|
!TryGetCurrentDesktopGridGeometry(out var grid))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalRect = _desktopEditSession.GetPreviewRect(grid);
|
||||||
|
if (!CanCommitDesktopEditAtRect(finalRect))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_desktopEditOverlayPresenter?.SetPreviewRect(finalRect);
|
||||||
|
_desktopEditOverlayPresenter?.SetCandidateRect(finalRect);
|
||||||
|
_desktopEditOverlayPresenter?.SetInvalid(false);
|
||||||
|
|
||||||
|
var placementId = _desktopEditSession.PlacementId;
|
||||||
|
var widthCells = Math.Max(1, _desktopEditSession.WidthCells);
|
||||||
|
var heightCells = Math.Max(1, _desktopEditSession.HeightCells);
|
||||||
|
if (!DesktopPlacementMath.HasCellSpanChanged(_desktopEditStartWidthCells, _desktopEditStartHeightCells, widthCells, heightCells))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunDesktopEditCommit(finalRect, () => ApplyExistingDesktopComponentResize(placementId, widthCells, heightCells));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyExistingDesktopComponentResize(string placementId, int widthCells, int heightCells)
|
||||||
|
{
|
||||||
|
if (!TryGetDesktopPlacementById(placementId, out var placement))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = ClonePlacementSnapshot(placement);
|
||||||
|
var changed = placement.WidthCells != widthCells || placement.HeightCells != heightCells;
|
||||||
|
placement.WidthCells = widthCells;
|
||||||
|
placement.HeightCells = heightCells;
|
||||||
|
|
||||||
|
if (_desktopEditSourceHost is not null)
|
||||||
|
{
|
||||||
|
Grid.SetColumnSpan(_desktopEditSourceHost, widthCells);
|
||||||
|
Grid.SetRowSpan(_desktopEditSourceHost, heightCells);
|
||||||
|
ApplyDesktopEditStateToHost(_desktopEditSourceHost, _isComponentLibraryOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueuePlacementPreviewRefresh(placement);
|
||||||
|
PersistSettings();
|
||||||
|
TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -200,6 +200,7 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
DesktopEditDragLayer.Width = pageWidth;
|
DesktopEditDragLayer.Width = pageWidth;
|
||||||
DesktopEditDragLayer.Height = pageHeight;
|
DesktopEditDragLayer.Height = pageHeight;
|
||||||
|
UpdateDesktopEditOverlayViewportSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
DesktopPagesHost.RowDefinitions.Clear();
|
DesktopPagesHost.RowDefinitions.Clear();
|
||||||
@@ -486,8 +487,7 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
return !_isSettingsOpen &&
|
return !_isSettingsOpen &&
|
||||||
!_isComponentLibraryOpen &&
|
!_isComponentLibraryOpen &&
|
||||||
!_isDesktopComponentDragActive &&
|
!HasActiveDesktopEditSession &&
|
||||||
!_isDesktopComponentResizeActive &&
|
|
||||||
_desktopSurfacePageWidth > 1;
|
_desktopSurfacePageWidth > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +552,6 @@ public partial class MainWindow
|
|||||||
{
|
{
|
||||||
if (node is Control control)
|
if (node is Control control)
|
||||||
{
|
{
|
||||||
// Avoid swiping pages when interacting with desktop components/widgets.
|
|
||||||
if (control.Classes.Contains("desktop-component") ||
|
if (control.Classes.Contains("desktop-component") ||
|
||||||
control.Classes.Contains("desktop-component-host"))
|
control.Classes.Contains("desktop-component-host"))
|
||||||
{
|
{
|
||||||
@@ -560,7 +559,31 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node is Button or TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch)
|
if (node is Button button && IsLauncherTileButton(button))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is TextBox or ComboBox or ListBoxItem or Slider or ToggleSwitch)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLauncherTileButton(Button? button)
|
||||||
|
{
|
||||||
|
if (button is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var node in button.GetSelfAndVisualAncestors())
|
||||||
|
{
|
||||||
|
if (node is WrapPanel panel &&
|
||||||
|
(panel.Name == "LauncherRootTilePanel" || panel.Name == "LauncherFolderTilePanel"))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -611,7 +634,17 @@ public partial class MainWindow
|
|||||||
|
|
||||||
private static bool IsDesktopSwipeBlockingNode(object node)
|
private static bool IsDesktopSwipeBlockingNode(object node)
|
||||||
{
|
{
|
||||||
if (node is Button or TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem or ScrollViewer)
|
if (node is ScrollViewer scrollViewer && IsLauncherScrollViewer(scrollViewer))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is Button button && IsLauncherTileButton(button))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is TextBox or ComboBox or Slider or ToggleSwitch or ListBoxItem)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -625,13 +658,23 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
var typeName = node.GetType().Name;
|
var typeName = node.GetType().Name;
|
||||||
return typeName.Contains("Button", StringComparison.OrdinalIgnoreCase) ||
|
return typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
|
||||||
typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
|
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
|
||||||
typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) ||
|
typeName.Contains("NumericUpDown", StringComparison.OrdinalIgnoreCase) ||
|
||||||
typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase);
|
typeName.Contains("TextPresenter", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsLauncherScrollViewer(ScrollViewer? scrollViewer)
|
||||||
|
{
|
||||||
|
if (scrollViewer is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrollViewer.Name == "LauncherRootScrollViewer" ||
|
||||||
|
scrollViewer.Name == "LauncherFolderScrollViewer";
|
||||||
|
}
|
||||||
|
|
||||||
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
||||||
{
|
{
|
||||||
point = default;
|
point = default;
|
||||||
|
|||||||
@@ -243,6 +243,15 @@
|
|||||||
|
|
||||||
<Canvas x:Name="DesktopEditDragLayer"
|
<Canvas x:Name="DesktopEditDragLayer"
|
||||||
IsHitTestVisible="False" />
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
|
<Canvas x:Name="ComponentPreviewStagingHost"
|
||||||
|
Width="1"
|
||||||
|
Height="1"
|
||||||
|
Opacity="0"
|
||||||
|
ClipToBounds="True"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -588,6 +597,44 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="ComponentLibraryCollapsedChipHost"
|
||||||
|
IsVisible="False"
|
||||||
|
IsHitTestVisible="False"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,0,88"
|
||||||
|
Padding="14,10"
|
||||||
|
CornerRadius="999"
|
||||||
|
Classes="surface-translucent-strong"
|
||||||
|
BorderBrush="{DynamicResource AdaptiveDockGlassBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
Opacity="0">
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="10">
|
||||||
|
<fi:FluentIcon x:Name="ComponentLibraryCollapsedChipIcon"
|
||||||
|
Grid.Column="0"
|
||||||
|
Icon="Apps"
|
||||||
|
IconVariant="Regular"
|
||||||
|
Width="18"
|
||||||
|
Height="18"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
|
<TextBlock x:Name="ComponentLibraryCollapsedChipTextBlock"
|
||||||
|
Grid.Column="1"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
Text="Widgets" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
var wasVisible = IsVisible;
|
var wasVisible = IsVisible;
|
||||||
var windowState = WindowState.ToString();
|
var windowState = WindowState.ToString();
|
||||||
|
|
||||||
|
SaveAllWhiteboardNotes();
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
_componentEditorWindowService.Close();
|
_componentEditorWindowService.Close();
|
||||||
if (_detachedComponentLibraryWindow is not null)
|
if (_detachedComponentLibraryWindow is not null)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.AppearanceSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.AppearanceSettingsPage"
|
||||||
x:DataType="vm:AppearanceSettingsPageViewModel">
|
x:DataType="vm:AppearanceSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<controls:IconText Icon="Color"
|
<controls:IconText Icon="Color"
|
||||||
Text="{Binding ThemeHeader}"
|
Text="{Binding ThemeHeader}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.ComponentsSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.ComponentsSettingsPage"
|
||||||
x:DataType="vm:ComponentsSettingsPageViewModel">
|
x:DataType="vm:ComponentsSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<controls:IconText Icon="Apps"
|
<controls:IconText Icon="Apps"
|
||||||
Text="{Binding ComponentsHeader}"
|
Text="{Binding ComponentsHeader}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.GeneralSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.GeneralSettingsPage"
|
||||||
x:DataType="vm:GeneralSettingsPageViewModel">
|
x:DataType="vm:GeneralSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<!-- 区域设置分组 -->
|
<!-- 区域设置分组 -->
|
||||||
<controls:IconText Icon="Globe"
|
<controls:IconText Icon="Globe"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.GeneratedPluginSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.GeneratedPluginSettingsPage"
|
||||||
x:DataType="vm:PluginGeneratedSettingsPageViewModel">
|
x:DataType="vm:PluginGeneratedSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<TextBlock Classes="settings-section-title"
|
<TextBlock Classes="settings-section-title"
|
||||||
Text="{Binding Title}" />
|
Text="{Binding Title}" />
|
||||||
<TextBlock x:Name="DescriptionTextBlock"
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
|
||||||
x:DataType="vm:LauncherSettingsPageViewModel">
|
x:DataType="vm:LauncherSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<Border Classes="settings-section-card">
|
<Border Classes="settings-section-card">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
|
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
|
||||||
xmlns:helpers="using:LanMountainDesktop.Helpers"
|
xmlns:helpers="using:LanMountainDesktop.Helpers"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketDetailDrawer"
|
x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogDetailDrawer"
|
||||||
x:DataType="vm:PluginMarketDetailViewModel">
|
x:DataType="vm:PluginCatalogDetailViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container"
|
<StackPanel Classes="settings-page-container"
|
||||||
Margin="0,0,0,8">
|
Margin="0,0,0,8">
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
Classes="plugin-market-icon-button"
|
Classes="plugin-catalog-icon-button"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Command="{Binding PerformPrimaryActionCommand}"
|
Command="{Binding PerformPrimaryActionCommand}"
|
||||||
IsEnabled="{Binding Item.IsActionEnabled}"
|
IsEnabled="{Binding Item.IsActionEnabled}"
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasReadmeContent}"
|
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasReadmeContent}"
|
||||||
Markdown="{Binding ReadmeMarkdown}"
|
Markdown="{Binding ReadmeMarkdown}"
|
||||||
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
|
Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -3,14 +3,14 @@ using LanMountainDesktop.ViewModels;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Views.SettingsPages;
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
public partial class PluginMarketDetailDrawer : UserControl
|
public partial class PluginCatalogDetailDrawer : UserControl
|
||||||
{
|
{
|
||||||
public PluginMarketDetailDrawer()
|
public PluginCatalogDetailDrawer()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginMarketDetailDrawer(PluginMarketDetailViewModel viewModel)
|
public PluginCatalogDetailDrawer(PluginCatalogDetailViewModel viewModel)
|
||||||
{
|
{
|
||||||
DataContext = viewModel;
|
DataContext = viewModel;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||||
x:Class="LanMountainDesktop.Views.SettingsPages.PluginMarketSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.PluginCatalogSettingsPage"
|
||||||
x:Name="Root"
|
x:Name="Root"
|
||||||
x:DataType="vm:PluginMarketSettingsPageViewModel">
|
x:DataType="vm:PluginCatalogSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||||
Description="{Binding StatusMessage}">
|
Description="{Binding StatusMessage}">
|
||||||
<ui:SettingsExpander.IconSource>
|
<ui:SettingsExpander.IconSource>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</ListBox.Styles>
|
</ListBox.Styles>
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:PluginMarketItemViewModel">
|
<DataTemplate x:DataType="vm:PluginCatalogItemViewModel">
|
||||||
<Border Classes="settings-list-item">
|
<Border Classes="settings-list-item">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
ColumnSpacing="14">
|
ColumnSpacing="14">
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
Classes="plugin-market-row-button"
|
Classes="plugin-catalog-row-button"
|
||||||
Command="{Binding #Root.DataContext.OpenDetailsCommand}"
|
Command="{Binding #Root.DataContext.OpenDetailsCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}">
|
||||||
<StackPanel Spacing="4"
|
<StackPanel Spacing="4"
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
Classes="plugin-market-icon-button"
|
Classes="plugin-catalog-icon-button"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Command="{Binding #Root.DataContext.ExecutePrimaryActionCommand}"
|
Command="{Binding #Root.DataContext.ExecutePrimaryActionCommand}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
@@ -9,21 +9,21 @@ using LanMountainDesktop.ViewModels;
|
|||||||
namespace LanMountainDesktop.Views.SettingsPages;
|
namespace LanMountainDesktop.Views.SettingsPages;
|
||||||
|
|
||||||
[SettingsPageInfo(
|
[SettingsPageInfo(
|
||||||
"plugin-market",
|
"plugin-catalog",
|
||||||
"Plugin Market",
|
"Plugin Catalog",
|
||||||
SettingsPageCategory.PluginMarket,
|
SettingsPageCategory.PluginCatalog,
|
||||||
IconKey = "ShoppingBag",
|
IconKey = "ShoppingBag",
|
||||||
SortOrder = 35,
|
SortOrder = 35,
|
||||||
TitleLocalizationKey = "settings.plugin_market.title",
|
TitleLocalizationKey = "settings.plugin_catalog.title",
|
||||||
DescriptionLocalizationKey = "settings.plugin_market.subtitle")]
|
DescriptionLocalizationKey = "settings.plugin_catalog.subtitle")]
|
||||||
public partial class PluginMarketSettingsPage : SettingsPageBase
|
public partial class PluginCatalogSettingsPage : SettingsPageBase
|
||||||
{
|
{
|
||||||
public PluginMarketSettingsPage()
|
public PluginCatalogSettingsPage()
|
||||||
: this(Design.IsDesignMode ? CreateDesignTimeViewModel() : CreateDefaultViewModel())
|
: this(Design.IsDesignMode ? CreateDesignTimeViewModel() : CreateDefaultViewModel())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginMarketSettingsPage(PluginMarketSettingsPageViewModel viewModel)
|
public PluginCatalogSettingsPage(PluginCatalogSettingsPageViewModel viewModel)
|
||||||
{
|
{
|
||||||
ViewModel = viewModel;
|
ViewModel = viewModel;
|
||||||
ViewModel.RestartRequested += OnRestartRequested;
|
ViewModel.RestartRequested += OnRestartRequested;
|
||||||
@@ -32,7 +32,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginMarketSettingsPageViewModel ViewModel { get; }
|
public PluginCatalogSettingsPageViewModel ViewModel { get; }
|
||||||
|
|
||||||
public override async void OnNavigatedTo(object? parameter)
|
public override async void OnNavigatedTo(object? parameter)
|
||||||
{
|
{
|
||||||
@@ -44,22 +44,22 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
|||||||
await ViewModel.InitializeAsync();
|
await ViewModel.InitializeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PluginMarketSettingsPageViewModel CreateDefaultViewModel()
|
private static PluginCatalogSettingsPageViewModel CreateDefaultViewModel()
|
||||||
{
|
{
|
||||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
var localizationService = new LocalizationService();
|
var localizationService = new LocalizationService();
|
||||||
return new PluginMarketSettingsPageViewModel(
|
return new PluginCatalogSettingsPageViewModel(
|
||||||
settingsFacade,
|
settingsFacade,
|
||||||
localizationService,
|
localizationService,
|
||||||
new AirAppMarketIconService(),
|
new AirAppMarketIconService(),
|
||||||
new AirAppMarketReadmeService());
|
new AirAppMarketReadmeService());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PluginMarketSettingsPageViewModel CreateDesignTimeViewModel()
|
private static PluginCatalogSettingsPageViewModel CreateDesignTimeViewModel()
|
||||||
{
|
{
|
||||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
var localizationService = new LocalizationService();
|
var localizationService = new LocalizationService();
|
||||||
var viewModel = new PluginMarketSettingsPageViewModel(
|
var viewModel = new PluginCatalogSettingsPageViewModel(
|
||||||
settingsFacade,
|
settingsFacade,
|
||||||
localizationService,
|
localizationService,
|
||||||
new AirAppMarketIconService(),
|
new AirAppMarketIconService(),
|
||||||
@@ -68,8 +68,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
|||||||
var previewHostVersion = new Version(1, 2, 0);
|
var previewHostVersion = new Version(1, 2, 0);
|
||||||
var items = new[]
|
var items = new[]
|
||||||
{
|
{
|
||||||
CreateMarketItem(
|
CreateCatalogItemViewModel(
|
||||||
new PluginMarketPluginInfo(
|
CreateCatalogItem(
|
||||||
"news-tiles",
|
"news-tiles",
|
||||||
"News Tiles",
|
"News Tiles",
|
||||||
"Brings editorial news cards and ticker rows to the desktop.",
|
"Brings editorial news cards and ticker rows to the desktop.",
|
||||||
@@ -91,8 +91,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
|||||||
localizationService,
|
localizationService,
|
||||||
installedPlugin: null,
|
installedPlugin: null,
|
||||||
previewHostVersion),
|
previewHostVersion),
|
||||||
CreateMarketItem(
|
CreateCatalogItemViewModel(
|
||||||
new PluginMarketPluginInfo(
|
CreateCatalogItem(
|
||||||
"workspace-pulse",
|
"workspace-pulse",
|
||||||
"Workspace Pulse",
|
"Workspace Pulse",
|
||||||
"Tracks active projects and shows a compact productivity summary.",
|
"Tracks active projects and shows a compact productivity summary.",
|
||||||
@@ -125,8 +125,8 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
|||||||
true,
|
true,
|
||||||
null),
|
null),
|
||||||
previewHostVersion),
|
previewHostVersion),
|
||||||
CreateMarketItem(
|
CreateCatalogItemViewModel(
|
||||||
new PluginMarketPluginInfo(
|
CreateCatalogItem(
|
||||||
"glass-panels",
|
"glass-panels",
|
||||||
"Glass Panels",
|
"Glass Panels",
|
||||||
"Adds experimental acrylic surfaces for plugin-powered widgets.",
|
"Adds experimental acrylic surfaces for plugin-powered widgets.",
|
||||||
@@ -152,7 +152,7 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
|||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
viewModel.MarketPlugins.Add(item);
|
viewModel.CatalogPlugins.Add(item);
|
||||||
viewModel.FilteredPlugins.Add(item);
|
viewModel.FilteredPlugins.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,24 +167,87 @@ public partial class PluginMarketSettingsPage : SettingsPageBase
|
|||||||
RequestRestart(reason ?? ViewModel.RestartRequiredMessage);
|
RequestRestart(reason ?? ViewModel.RestartRequiredMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnDetailsRequested(PluginMarketItemViewModel item)
|
private async void OnDetailsRequested(PluginCatalogItemViewModel item)
|
||||||
{
|
{
|
||||||
var detailViewModel = ViewModel.CreateDetailViewModel(item);
|
var detailViewModel = ViewModel.CreateDetailViewModel(item);
|
||||||
var drawer = new PluginMarketDetailDrawer(detailViewModel);
|
var drawer = new PluginCatalogDetailDrawer(detailViewModel);
|
||||||
OpenDrawer(drawer, detailViewModel.DrawerTitle);
|
OpenDrawer(drawer, detailViewModel.DrawerTitle);
|
||||||
await detailViewModel.InitializeAsync();
|
await detailViewModel.InitializeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PluginMarketItemViewModel CreateMarketItem(
|
private static PluginCatalogItemViewModel CreateCatalogItemViewModel(
|
||||||
PluginMarketPluginInfo plugin,
|
PluginCatalogItemInfo plugin,
|
||||||
LocalizationService localizationService,
|
LocalizationService localizationService,
|
||||||
InstalledPluginInfo? installedPlugin,
|
InstalledPluginInfo? installedPlugin,
|
||||||
Version hostVersion)
|
Version hostVersion)
|
||||||
{
|
{
|
||||||
var languageCode = localizationService.NormalizeLanguageCode(
|
var languageCode = localizationService.NormalizeLanguageCode(
|
||||||
HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode);
|
HostSettingsFacadeProvider.GetOrCreate().Region.Get().LanguageCode);
|
||||||
var item = new PluginMarketItemViewModel(plugin, localizationService, languageCode);
|
var item = new PluginCatalogItemViewModel(plugin, localizationService, languageCode);
|
||||||
item.ApplyInstallState(installedPlugin, hostVersion);
|
item.ApplyInstallState(installedPlugin, hostVersion);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static PluginCatalogItemInfo CreateCatalogItem(
|
||||||
|
string id,
|
||||||
|
string name,
|
||||||
|
string description,
|
||||||
|
string author,
|
||||||
|
string version,
|
||||||
|
string apiVersion,
|
||||||
|
string minHostVersion,
|
||||||
|
string downloadUrl,
|
||||||
|
string releaseTag,
|
||||||
|
string releaseAssetName,
|
||||||
|
string iconUrl,
|
||||||
|
string readmeUrl,
|
||||||
|
string homepageUrl,
|
||||||
|
string repositoryUrl,
|
||||||
|
string[] tags,
|
||||||
|
PluginCatalogSharedContractInfo[] sharedContracts,
|
||||||
|
DateTimeOffset publishedAt,
|
||||||
|
DateTimeOffset updatedAt)
|
||||||
|
{
|
||||||
|
return new PluginCatalogItemInfo(
|
||||||
|
new PluginCatalogManifestInfo(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
author,
|
||||||
|
version,
|
||||||
|
apiVersion,
|
||||||
|
string.Empty,
|
||||||
|
sharedContracts),
|
||||||
|
new PluginCatalogCompatibilityInfo(
|
||||||
|
minHostVersion,
|
||||||
|
apiVersion),
|
||||||
|
new PluginCatalogRepositoryInfo(
|
||||||
|
iconUrl,
|
||||||
|
homepageUrl,
|
||||||
|
readmeUrl,
|
||||||
|
homepageUrl,
|
||||||
|
repositoryUrl,
|
||||||
|
tags,
|
||||||
|
string.Empty),
|
||||||
|
new PluginCatalogPublicationInfo(
|
||||||
|
releaseTag,
|
||||||
|
releaseAssetName,
|
||||||
|
publishedAt,
|
||||||
|
updatedAt,
|
||||||
|
0,
|
||||||
|
string.Empty,
|
||||||
|
null),
|
||||||
|
string.IsNullOrWhiteSpace(downloadUrl)
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
new PluginPackageSourceInfo(
|
||||||
|
string.IsNullOrWhiteSpace(releaseTag)
|
||||||
|
? LanMountainDesktop.Services.Settings.PluginPackageSourceKind.RawFallback
|
||||||
|
: LanMountainDesktop.Services.Settings.PluginPackageSourceKind.ReleaseAsset,
|
||||||
|
downloadUrl,
|
||||||
|
string.Empty,
|
||||||
|
0)
|
||||||
|
],
|
||||||
|
[]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
x:Name="Root"
|
x:Name="Root"
|
||||||
x:DataType="vm:PluginsSettingsPageViewModel">
|
x:DataType="vm:PluginsSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
|
||||||
Description="{Binding StatusMessage}">
|
Description="{Binding StatusMessage}">
|
||||||
<ui:SettingsExpander.IconSource>
|
<ui:SettingsExpander.IconSource>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
|
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
|
||||||
Markdown="{Binding MarkdownContent}"
|
Markdown="{Binding MarkdownContent}"
|
||||||
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
|
Engine="{x:Static helpers:PluginCatalogMarkdownHelper.Engine}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacySettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacySettingsPage"
|
||||||
x:DataType="vm:PrivacySettingsPageViewModel">
|
x:DataType="vm:PrivacySettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<controls:IconText Icon="Info"
|
<controls:IconText Icon="Info"
|
||||||
Text="{Binding PrivacyHeader}"
|
Text="{Binding PrivacyHeader}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.StatusBarSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.StatusBarSettingsPage"
|
||||||
x:DataType="vm:StatusBarSettingsPageViewModel">
|
x:DataType="vm:StatusBarSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<controls:IconText Icon="Apps"
|
<controls:IconText Icon="Apps"
|
||||||
Text="{Binding ComponentsHeader}"
|
Text="{Binding ComponentsHeader}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
<TextBlock Classes="settings-section-title"
|
<TextBlock Classes="settings-section-title"
|
||||||
Text="{Binding PageTitle}" />
|
Text="{Binding PageTitle}" />
|
||||||
<TextBlock Classes="settings-section-description"
|
<TextBlock Classes="settings-section-description"
|
||||||
@@ -141,6 +141,9 @@
|
|||||||
<Button Command="{Binding DownloadLatestReleaseCommand}"
|
<Button Command="{Binding DownloadLatestReleaseCommand}"
|
||||||
Content="{Binding DownloadButtonText}"
|
Content="{Binding DownloadButtonText}"
|
||||||
IsVisible="{Binding IsDownloadButtonVisible}" />
|
IsVisible="{Binding IsDownloadButtonVisible}" />
|
||||||
|
<Button Command="{Binding RedownloadUpdateCommand}"
|
||||||
|
Content="{Binding RedownloadButtonText}"
|
||||||
|
IsVisible="{Binding IsRedownloadButtonVisible}" />
|
||||||
<Button Classes="settings-accent-button"
|
<Button Classes="settings-accent-button"
|
||||||
Command="{Binding InstallPendingUpdateCommand}"
|
Command="{Binding InstallPendingUpdateCommand}"
|
||||||
Content="{Binding InstallNowButtonText}"
|
Content="{Binding InstallNowButtonText}"
|
||||||
@@ -172,6 +175,14 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
</ui:SettingsExpander.Footer>
|
</ui:SettingsExpander.Footer>
|
||||||
|
<ui:SettingsExpanderItem Content="{Binding ForceCheckUpdateLabel}"
|
||||||
|
Description="{Binding ForceCheckUpdateDescription}"
|
||||||
|
IsClickEnabled="True"
|
||||||
|
Command="{Binding ForceCheckUpdateCommand}">
|
||||||
|
<ui:SettingsExpanderItem.IconSource>
|
||||||
|
<fi:SymbolIconSource Symbol="ArrowSync" />
|
||||||
|
</ui:SettingsExpanderItem.IconSource>
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
</ui:SettingsExpander>
|
</ui:SettingsExpander>
|
||||||
|
|
||||||
<ui:SettingsExpander Classes="settings-expander-card"
|
<ui:SettingsExpander Classes="settings-expander-card"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage"
|
||||||
x:DataType="vm:WallpaperSettingsPageViewModel">
|
x:DataType="vm:WallpaperSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<!-- 预览与颜色选择区域 -->
|
<!-- 预览与颜色选择区域 -->
|
||||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="32" Margin="0,0,0,32">
|
<Grid ColumnDefinitions="*,*" ColumnSpacing="32" Margin="0,0,0,32">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage"
|
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage"
|
||||||
x:DataType="vm:WeatherSettingsPageViewModel">
|
x:DataType="vm:WeatherSettingsPageViewModel">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Classes="settings-page-container">
|
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||||
|
|
||||||
<Border Classes="settings-section-card">
|
<Border Classes="settings-section-card">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user