Compare commits

...

13 Commits

Author SHA1 Message Date
lincube
26ff11b16b 0.7.8 2026-03-24 23:15:32 +08:00
lincube
b83cfb47b0 0.7.7.2
笔迹粗细大小调节
2026-03-24 20:16:44 +08:00
lincube
a0bb83c743 0.7.7.1 2026-03-24 17:47:54 +08:00
lincube
af2e7b4f2f 0.7.7
橘鸦新闻
2026-03-24 09:33:56 +08:00
lincube
798124e500 0.7.6.3 2026-03-23 22:43:54 +08:00
lincube
95ecb06668 0.7.6.2
在应用启动台上,也可以正常滑动
2026-03-23 21:13:08 +08:00
lincube
ac7e8db516 0.7.6.1
修复了系统标题栏的问题。
2026-03-23 12:34:04 +08:00
lincube
8ded721f46 0.7.6
加入删除页面二次确认
2026-03-23 12:14:56 +08:00
lincube
a559325f5a 0.7.5.3
设置界面动画优化
2026-03-23 11:25:24 +08:00
lincube
b60368527f 0.7.5.2
日韩支持
2026-03-22 23:30:43 +08:00
lincube
c8c3f51bff 0.7.5.1
精致
2026-03-22 20:29:44 +08:00
lincube
685323e057 0.7.5
顺滑的组件放置与调整
2026-03-22 15:21:29 +08:00
lincube
def21c79b1 0.7.4.1
动画优化
2026-03-22 14:47:15 +08:00
122 changed files with 13403 additions and 1324 deletions

91
AGENTS.md Normal file
View 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` 列出的权威来源为准。

View File

@@ -12,6 +12,7 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />

View File

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

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

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

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

View File

@@ -10,6 +10,8 @@
<Application.Resources>
<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.DataTemplates>
@@ -23,6 +25,7 @@
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/NavigationStyles.axaml" />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />

View File

@@ -47,6 +47,7 @@ public partial class App : Application
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
private readonly LocalizationService _localizationService = new();
private readonly FontFamilyService _fontFamilyService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
@@ -448,6 +449,21 @@ public partial class App : Application
CultureInfo.DefaultThreadCurrentUICulture = culture;
Thread.CurrentThread.CurrentCulture = 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()

View File

@@ -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所有。**

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View 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

View File

@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopJuyaNews = "DesktopJuyaNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum";

View File

@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopJuyaNews,
"橘鸦早报",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search",

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -276,6 +276,7 @@
"settings.region.language_label": "Language",
"settings.region.language_zh": "Chinese",
"settings.region.language_en": "English",
"settings.region.language_ja": "Japanese",
"settings.region.timezone_header": "Time 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}",
@@ -958,6 +959,10 @@
"study.interrupt_density.unavailable": "--",
"desktop.add_page": "Add 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.fit": "Fit",
"placement.stretch": "Stretch",

View File

@@ -0,0 +1,973 @@
{
"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.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_market": "プラグインマーケット",
"settings.plugin_market.title": "プラグインマーケット",
"settings.plugin_market.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
"settings.plugin_market.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": "カラースキーム"
}

View File

@@ -0,0 +1,971 @@
{
"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.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_market": "플러그인 마켓",
"settings.plugin_market.title": "플러그인 마켓",
"settings.plugin_market.subtitle": "LanAirApp 공식 소스의 플러그인을 탐색하고 로컬에 설치 스테이징합니다.",
"settings.plugin_market.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지금 재시작하시겠습니까?"
}

View File

@@ -271,6 +271,7 @@
"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}",
@@ -952,6 +953,10 @@
"study.interrupt_density.unavailable": "--",
"desktop.add_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.fit": "适应",
"placement.stretch": "拉伸",

View 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();
}
}

View 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();
}
}

View 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"
};
}
}

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

View File

@@ -36,9 +36,18 @@ public sealed class LocalizationService
public string NormalizeLanguageCode(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
? "en-US"
: "zh-CN";
if (string.IsNullOrWhiteSpace(languageCode))
{
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)

View File

@@ -1,13 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.PluginMarket;
using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services.Settings
{
public enum WallpaperMediaType
{
@@ -66,10 +69,272 @@ public sealed record UpdateSettingsState(
long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
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 Version,
string AssemblyName);
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 Name,
string Description,
string Author,
string Version,
string ApiVersion,
string EntranceAssembly,
IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts);
public sealed record PluginCatalogCompatibilityInfo(
string MinHostVersion,
string ApiVersion);
public sealed record PluginCatalogRepositoryInfo(
string IconUrl,
string ProjectUrl,
string ReadmeUrl,
string HomepageUrl,
string RepositoryUrl,
IReadOnlyList<string> Tags,
string ReleaseNotes);
public sealed record PluginCatalogPublicationInfo(
string ReleaseTag,
string ReleaseAssetName,
DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt,
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 IReadOnlyList<PluginCatalogDependencyInfo> Dependencies =>
Manifest.SharedContracts
.Select(contract => new PluginCatalogDependencyInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray();
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 static implicit operator PluginMarketPluginInfo(PluginCatalogItemInfo item)
{
return new PluginMarketPluginInfo(
item.Id,
item.Name,
item.Description,
item.Author,
item.Version,
item.ApiVersion,
item.MinHostVersion,
item.DownloadUrl,
item.ReleaseTag,
item.ReleaseAssetName,
item.IconUrl,
item.ReadmeUrl,
item.HomepageUrl,
item.RepositoryUrl,
item.Tags.ToArray(),
item.Dependencies.Select(dependency => new PluginMarketDependencyInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName)).ToArray(),
item.PublishedAt,
item.UpdatedAt);
}
public static implicit operator PluginCatalogItemInfo(PluginMarketPluginInfo plugin)
{
return new PluginCatalogItemInfo(
new PluginCatalogManifestInfo(
plugin.Id,
plugin.Name,
plugin.Description,
plugin.Author,
plugin.Version,
plugin.ApiVersion,
string.Empty,
plugin.Dependencies
.Select(dependency => new PluginCatalogSharedContractInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName))
.ToArray()),
new PluginCatalogCompatibilityInfo(
plugin.MinHostVersion,
plugin.ApiVersion),
new PluginCatalogRepositoryInfo(
plugin.IconUrl,
plugin.RepositoryUrl,
plugin.ReadmeUrl,
plugin.HomepageUrl,
plugin.RepositoryUrl,
plugin.Tags,
string.Empty),
new PluginCatalogPublicationInfo(
plugin.ReleaseTag,
plugin.ReleaseAssetName,
plugin.PublishedAt,
plugin.UpdatedAt,
0,
string.Empty,
null),
string.IsNullOrWhiteSpace(plugin.DownloadUrl)
? []
: [
new PluginPackageSourceInfo(
string.IsNullOrWhiteSpace(plugin.ReleaseTag)
? PluginPackageSourceKind.RawFallback
: PluginPackageSourceKind.ReleaseAsset,
plugin.DownloadUrl,
string.Empty,
0)
],
[]);
}
}
public sealed record PluginCatalogIndexResult(
bool Success,
IReadOnlyList<PluginCatalogItemInfo> Plugins,
IReadOnlyList<PluginCatalogSourceInfo> Sources,
string? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage)
{
public static implicit operator PluginMarketIndexResult(PluginCatalogIndexResult result)
{
return new PluginMarketIndexResult(
result.Success,
result.Plugins.Select(plugin => (PluginMarketPluginInfo)plugin).ToArray(),
result.Source,
result.SourceLocation,
result.WarningMessage,
result.ErrorMessage);
}
}
public sealed record PluginInstallDiagnostic(
string Code,
string Message,
string? Details = null);
public sealed record PluginCatalogInstallResult(
bool Success,
string? PluginId,
string? PluginName,
PluginManifest? InstalledManifest,
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
string? ErrorMessage)
{
public static implicit operator PluginMarketInstallResult(PluginCatalogInstallResult result)
{
return new PluginMarketInstallResult(
result.Success,
result.PluginId,
result.PluginName,
result.ErrorMessage);
}
}
public sealed record PluginCatalogDependencyInfo(
string Id,
string Version,
string AssemblyName)
{
public static implicit operator PluginMarketDependencyInfo(PluginCatalogDependencyInfo dependency)
{
return new PluginMarketDependencyInfo(
dependency.Id,
dependency.Version,
dependency.AssemblyName);
}
}
[Obsolete("Use PluginCatalogSharedContractInfo and PluginCatalogItemInfo instead.")]
public sealed record PluginMarketDependencyInfo(
string Id,
string Version,
string AssemblyName);
[Obsolete("Use PluginCatalogItemInfo instead.")]
public sealed record PluginMarketPluginInfo(
string Id,
string Name,
@@ -89,6 +354,8 @@ public sealed record PluginMarketPluginInfo(
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt);
[Obsolete("Use PluginCatalogIndexResult instead.")]
public sealed record PluginMarketIndexResult(
bool Success,
IReadOnlyList<PluginMarketPluginInfo> Plugins,
@@ -96,12 +363,39 @@ public sealed record PluginMarketIndexResult(
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage);
[Obsolete("Use PluginCatalogInstallResult instead.")]
public sealed record PluginMarketInstallResult(
bool Success,
string? PluginId,
string? PluginName,
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
{
GridSettingsState Get();
@@ -223,10 +517,17 @@ public interface IPluginManagementSettingsService
bool DeleteInstalledPlugin(string pluginId);
}
public interface IPluginMarketSettingsService
public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
{
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
[Obsolete("Use IPluginCatalogSettingsService instead.")]
public interface IPluginMarketSettingsService : IPluginCatalogSettingsService
{
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
new Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IApplicationInfoService
@@ -252,6 +553,20 @@ public interface ISettingsFacadeService
ILauncherCatalogService LauncherCatalog { get; }
ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; }
IPluginCatalogSettingsService PluginCatalog { get; }
[Obsolete("Use PluginCatalog instead.")]
IPluginMarketSettingsService PluginMarket { get; }
IApplicationInfoService ApplicationInfo { get; }
}
}
namespace LanMountainDesktop.Services.PluginMarket
{
internal enum PluginPackageSourceKind
{
ReleaseAsset = 0,
RawFallback = 1,
WorkspaceLocal = 2
}
}

View File

@@ -870,14 +870,41 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
_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);
}
async Task<PluginMarketIndexResult> IPluginMarketSettingsService.LoadIndexAsync(CancellationToken cancellationToken)
{
return await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
}
public Task<PluginCatalogInstallResult> InstallAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
return InstallCatalogCoreAsync(pluginId, cancellationToken);
}
async Task<PluginMarketInstallResult> IPluginMarketSettingsService.InstallAsync(
string pluginId,
CancellationToken cancellationToken)
{
return await InstallCatalogCoreAsync(pluginId, cancellationToken).ConfigureAwait(false);
}
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)
{
return new PluginMarketIndexResult(
_cachedPlugins.Clear();
return new PluginCatalogIndexResult(
false,
[],
sources,
result.Source?.ToString(),
result.SourceLocation,
result.WarningMessage,
@@ -889,81 +916,189 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
.Select(entry =>
{
_cachedPlugins[entry.Id] = entry;
return new PluginMarketPluginInfo(
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);
return MapCatalogItem(entry);
})
.ToArray();
return new PluginMarketIndexResult(
return new PluginCatalogIndexResult(
true,
plugins,
sources,
result.Source?.ToString(),
result.SourceLocation,
result.WarningMessage,
null);
}
public async Task<PluginMarketInstallResult> InstallAsync(
private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
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)
{
return new PluginMarketInstallResult(
return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
"Plugin runtime is unavailable.");
}
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
{
var load = await LoadIndexAsync(cancellationToken);
var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
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))
{
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)
{
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)
{
if (string.IsNullOrWhiteSpace(entry.DownloadUrl))
{
return [];
}
var sourceKind = entry.HasReleaseDownloadMetadata
? PluginPackageSourceKind.ReleaseAsset
: PluginPackageSourceKind.RawFallback;
return
[
new PluginPackageSourceInfo(
sourceKind,
entry.DownloadUrl,
entry.Sha256,
entry.PackageSizeBytes)
];
}
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()
@@ -1054,6 +1189,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
PluginManagement = _pluginManagementSettingsService;
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
PluginCatalog = _pluginMarketSettingsService;
PluginMarket = _pluginMarketSettingsService;
ApplicationInfo = new ApplicationInfoService();
}
@@ -1086,6 +1222,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IPluginManagementSettingsService PluginManagement { get; }
public IPluginCatalogSettingsService PluginCatalog { get; }
public IPluginMarketSettingsService PluginMarket { get; }
public IApplicationInfoService ApplicationInfo { get; }

View File

@@ -2,10 +2,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Styles.Resources>
<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.Slow">0:0:0.20</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</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.28</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.40</x:TimeSpan>
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
</Styles.Resources>

View 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>

View File

@@ -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:behaviors="using:LanMountainDesktop.Behaviors">
@@ -16,17 +16,17 @@
<Setter Property="Opacity" Value="0" />
<Setter Property="RenderTransform">
<Setter.Value>
<TranslateTransform Y="14" />
<TranslateTransform Y="24" />
</Setter.Value>
</Setter>
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
<Style.Animations>
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
<Animation Duration="0:0:0.65"
FillMode="Both"
Easing="0.22,1,0.36,1">
Easing="0.05, 0.75, 0.10, 1.00">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="0" />
<Setter Property="TranslateTransform.Y" Value="14" />
<Setter Property="TranslateTransform.Y" Value="24" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="1" />
@@ -53,9 +53,9 @@
<Setter Property="MinHeight" Value="34" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" 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.05,0.75,0.10,1.00" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>
@@ -74,8 +74,8 @@
<Style Selector=".settings-scope ComboBox">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" 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.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>
@@ -87,8 +87,8 @@
<Style Selector=".settings-scope ToggleSwitch">
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" 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.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>

View File

@@ -1,7 +1,8 @@
<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">
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
<Style Selector="StackPanel.settings-page-container">
<Setter Property="Spacing" Value="0" />
@@ -9,6 +10,34 @@
<Setter Property="MaxWidth" Value="{DynamicResource SettingsContainerMaxWidth}" />
</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">
<Setter Property="FontSize" Value="30" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -39,10 +68,10 @@
<Transitions>
<BrushTransition Property="Background"
Duration="{StaticResource FluttermotionToken.Duration.Standard}"
Easing="0.22,1,0.36,1" />
Easing="0.05,0.75,0.10,1.00" />
<BoxShadowsTransition Property="BoxShadow"
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
Easing="0.22,1,0.36,1" />
Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>

View File

@@ -5,13 +5,15 @@ namespace LanMountainDesktop.Theme;
public static class FluttermotionToken
{
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160);
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(200);
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(240);
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(320);
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(200);
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(280);
public static readonly TimeSpan Page = 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 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";
}

View File

@@ -1,13 +1,21 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Avalonia.Controls;
using System.ComponentModel;
using LanMountainDesktop.Services;
using FluentIcons.Common;
using CommunityToolkit.Mvvm.ComponentModel;
namespace LanMountainDesktop.ViewModels;
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
{
public string Title { get; set; } = "Widgets";
private string _title = "Widgets";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
@@ -38,20 +46,134 @@ public sealed class ComponentLibraryCategoryViewModel
}
public sealed class ComponentLibraryItemViewModel
: ObservableObject
{
private readonly string _loadingPreviewText;
private readonly string _previewUnavailableText;
private string _displayName;
private ComponentPreviewKey _previewKey;
private ComponentPreviewImageEntry? _previewImageEntry;
private ComponentPreviewImageState _previewState;
private string? _previewErrorMessage;
private string _previewStatusText;
public ComponentLibraryItemViewModel(
string componentId,
string displayName,
Control? previewControl)
ComponentPreviewKey previewKey,
string loadingPreviewText = "Loading preview...",
string previewUnavailableText = "Preview unavailable",
ComponentPreviewImageEntry? previewImageEntry = null)
{
ComponentId = componentId;
DisplayName = displayName;
PreviewControl = previewControl;
_displayName = displayName;
_previewKey = previewKey;
_loadingPreviewText = loadingPreviewText;
_previewUnavailableText = previewUnavailableText;
_previewStatusText = loadingPreviewText;
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
}
public string ComponentId { get; }
public string DisplayName { get; }
public string DisplayName
{
get => _displayName;
set => SetProperty(ref _displayName, value);
}
public Control? PreviewControl { get; }
public ComponentPreviewKey PreviewKey
{
get => _previewKey;
set => SetProperty(ref _previewKey, value);
}
public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry;
public object? PreviewBitmap => _previewImageEntry?.Bitmap;
public ComponentPreviewImageState PreviewState => _previewState;
public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending;
public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null;
public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed;
public string? PreviewErrorMessage => _previewErrorMessage;
public string PreviewStatusText => _previewStatusText;
public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry)
{
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true);
}
private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged)
{
if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry))
{
return;
}
if (_previewImageEntry is not null)
{
_previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged;
}
_previewImageEntry = previewImageEntry;
_previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
_previewErrorMessage = previewImageEntry?.ErrorMessage;
_previewStatusText = _previewState switch
{
ComponentPreviewImageState.Ready => string.Empty,
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
? _previewUnavailableText
: _previewErrorMessage!,
_ => _loadingPreviewText
};
if (_previewImageEntry is not null)
{
_previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged;
}
RaisePreviewDependentProperties();
}
private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
_ = sender;
if (string.IsNullOrWhiteSpace(e.PropertyName) ||
e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or
nameof(ComponentPreviewImageEntry.State) or
nameof(ComponentPreviewImageEntry.ErrorMessage))
{
_previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
_previewErrorMessage = _previewImageEntry?.ErrorMessage;
_previewStatusText = _previewState switch
{
ComponentPreviewImageState.Ready => string.Empty,
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
? _previewUnavailableText
: _previewErrorMessage!,
_ => _loadingPreviewText
};
RaisePreviewDependentProperties();
}
}
private void RaisePreviewDependentProperties()
{
OnPropertyChanged(nameof(PreviewImageEntry));
OnPropertyChanged(nameof(PreviewBitmap));
OnPropertyChanged(nameof(PreviewState));
OnPropertyChanged(nameof(IsPreviewPending));
OnPropertyChanged(nameof(IsPreviewReady));
OnPropertyChanged(nameof(IsPreviewFailed));
OnPropertyChanged(nameof(PreviewErrorMessage));
OnPropertyChanged(nameof(PreviewStatusText));
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
@@ -31,7 +31,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
private bool _isLoadingIcon;
public PluginMarketItemViewModel(
PluginMarketPluginInfo plugin,
PluginCatalogItemInfo plugin,
LocalizationService localizationService,
string languageCode)
{
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
ActionTooltip = L("market.button.install", "Install");
}
public PluginMarketPluginInfo Info { get; }
public PluginCatalogItemInfo Info { get; }
public string PluginId => Info.Id;
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
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; }
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
_readmeService = readmeService;
_primaryActionAsync = primaryActionAsync;
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies);
Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
VersionLabel = L("market.detail.version", "Version");
PublisherLabel = L("market.detail.author", "Author");
ApiVersionLabel = L("market.detail.api_version", "API Version");
@@ -271,7 +275,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public PluginMarketItemViewModel Item { get; }
public ObservableCollection<PluginMarketDependencyInfo> Dependencies { get; }
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
public string DrawerTitle => Item.Name;
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
public async Task InitializeAsync()
{
if (_isInitialized)
@@ -370,6 +378,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly IPluginCatalogSettingsService _pluginCatalog;
private readonly LocalizationService _localizationService;
private readonly AirAppMarketIconService _iconService;
private readonly AirAppMarketReadmeService _readmeService;
@@ -386,6 +395,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
AirAppMarketReadmeService readmeService)
{
_settingsFacade = settingsFacade;
_pluginCatalog = _settingsFacade.PluginCatalog;
_localizationService = localizationService;
_iconService = iconService;
_readmeService = readmeService;
@@ -468,7 +478,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
RefreshInstalledSnapshot();
var result = await _settingsFacade.PluginMarket.LoadIndexAsync();
var result = await _pluginCatalog.LoadCatalogAsync();
if (!result.Success)
{
_hasLoadedMarket = false;
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
item.Name);
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
var result = await _pluginCatalog.InstallAsync(item.PluginId);
if (result.Success)
{
RefreshInstalledSnapshot();

View File

@@ -326,7 +326,9 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
return
[
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", "한국어"))
];
}

View File

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

View File

@@ -14,6 +14,10 @@ public partial class ComponentLibraryWindow : Window
private IComponentLibraryService? _componentLibraryService;
private Func<double, ComponentLibraryCreateContext>? _createContextFactory;
private Func<string, string, string>? _localize;
private Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? _previewKeyResolver;
private Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? _previewEntryResolver;
private Action<ComponentPreviewKey>? _warmPreviewRequested;
private Action<ComponentPreviewKey>? _renderPreviewRequested;
private readonly ComponentLibraryWindowViewModel _viewModel = new();
public ComponentLibraryWindow()
@@ -25,12 +29,20 @@ public partial class ComponentLibraryWindow : Window
public ComponentLibraryWindow(
IComponentLibraryService componentLibraryService,
Func<double, ComponentLibraryCreateContext> createContextFactory,
Func<string, string, string> localize)
Func<string, string, string> localize,
Func<ComponentLibraryComponentEntry, ComponentPreviewKey>? previewKeyResolver = null,
Func<ComponentPreviewKey, ComponentPreviewImageEntry?>? previewEntryResolver = null,
Action<ComponentPreviewKey>? warmPreviewRequested = null,
Action<ComponentPreviewKey>? renderPreviewRequested = null)
: this()
{
_componentLibraryService = componentLibraryService ?? throw new ArgumentNullException(nameof(componentLibraryService));
_createContextFactory = createContextFactory ?? throw new ArgumentNullException(nameof(createContextFactory));
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
_previewKeyResolver = previewKeyResolver;
_previewEntryResolver = previewEntryResolver;
_warmPreviewRequested = warmPreviewRequested;
_renderPreviewRequested = renderPreviewRequested;
Reload();
}
@@ -38,9 +50,7 @@ public partial class ComponentLibraryWindow : Window
public void Reload()
{
if (_componentLibraryService is null ||
_createContextFactory is null ||
_localize is null)
if (_componentLibraryService is null || _localize is null)
{
return;
}
@@ -75,32 +85,26 @@ public partial class ComponentLibraryWindow : Window
private ComponentLibraryItemViewModel CreateComponentItem(ComponentLibraryComponentEntry entry)
{
if (_componentLibraryService is null ||
_createContextFactory is null ||
_localize is null)
var displayName = string.IsNullOrWhiteSpace(entry.DisplayNameLocalizationKey)
? entry.DisplayName
: _localize?.Invoke(entry.DisplayNameLocalizationKey, entry.DisplayName) ?? entry.DisplayName;
var previewKey = ResolvePreviewKey(entry);
var previewEntry = _previewEntryResolver?.Invoke(previewKey);
var item = new ComponentLibraryItemViewModel(
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;
_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);
return item;
}
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -118,6 +122,8 @@ public partial class ComponentLibraryWindow : Window
{
_viewModel.Components.Add(component);
}
RequestPreviewWarmup(selectedCategory.Components);
}
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
@@ -140,6 +146,51 @@ public partial class ComponentLibraryWindow : Window
Hide();
}
public void UpdatePreviewImage(ComponentPreviewImageEntry previewImageEntry)
{
ArgumentNullException.ThrowIfNull(previewImageEntry);
foreach (var category in _viewModel.Categories)
{
foreach (var component in category.Components)
{
if (component.PreviewKey.Equals(previewImageEntry.Key))
{
component.UpdatePreviewImageEntry(previewImageEntry);
}
}
}
}
private ComponentPreviewKey ResolvePreviewKey(ComponentLibraryComponentEntry entry)
{
if (_previewKeyResolver is not null)
{
return _previewKeyResolver(entry);
}
return ComponentPreviewKey.ForComponentType(entry.ComponentId, entry.MinWidthCells, entry.MinHeightCells);
}
private void RequestPreviewWarmup(IEnumerable<ComponentLibraryItemViewModel> components)
{
if (_warmPreviewRequested is null && _renderPreviewRequested is null)
{
return;
}
foreach (var component in components)
{
if (!component.IsPreviewPending)
{
continue;
}
_warmPreviewRequested?.Invoke(component.PreviewKey);
_renderPreviewRequested?.Invoke(component.PreviewKey);
}
}
private Symbol ResolveCategoryIcon(string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Components.ClassScheduleWidget">
<Border x:Name="RootBorder"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View 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>

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

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -428,6 +428,10 @@ public sealed class DesktopComponentRuntimeRegistry
BuiltInComponentIds.DesktopIfengNews,
"component.ifeng_news",
() => new IfengNewsWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopJuyaNews,
"component.juya_news",
() => new JuyaNewsWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"component.bilibili_hot_search",

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -198,7 +198,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
foreach (var visual in _itemVisuals)
{
visual.Host.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F7F8FA"));
visual.Host.Background = Brushes.Transparent;
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}

View 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>

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

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -9,86 +9,124 @@
d:DesignHeight="480"
x:Class="LanMountainDesktop.Views.Components.WhiteboardWidget">
<Border x:Name="RootBorder"
Background="#F1F4F9"
CornerRadius="20"
ClipToBounds="True"
Padding="8">
<Grid RowDefinitions="*,Auto"
RowSpacing="8">
<Border x:Name="CanvasBorder"
Grid.Row="0"
Background="#FFFFFF"
BorderBrush="#24000000"
BorderThickness="1"
CornerRadius="14"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas" />
</Border>
<Grid>
<Border x:Name="RootBorder"
Background="#F1F4F9"
CornerRadius="20"
ClipToBounds="True"
Padding="8">
<Grid RowDefinitions="*,Auto"
RowSpacing="8">
<Border x:Name="CanvasBorder"
Grid.Row="0"
Background="#FFFFFF"
BorderBrush="#24000000"
BorderThickness="1"
CornerRadius="14"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas" />
</Border>
<Border x:Name="ToolbarBorder"
Grid.Row="1"
HorizontalAlignment="Center"
Background="#E6FFFFFF"
BorderBrush="#16000000"
<Border x:Name="ToolbarBorder"
Grid.Row="1"
HorizontalAlignment="Center"
Background="#E6FFFFFF"
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"
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>
CornerRadius="8"
Padding="12">
<StackPanel Spacing="12">
<ColorView x:Name="InkColorPicker"
IsAlphaEnabled="False"
IsColorSpectrumVisible="True"
IsColorPaletteVisible="True"
IsHexInputVisible="True"
ColorChanged="OnColorPickerColorChanged" />
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock Grid.Column="0"
Text="粗细"
VerticalAlignment="Center"
FontSize="12" />
<Slider x:Name="InkThicknessSlider"
Grid.Column="1"
Minimum="1"
Maximum="8"
Value="2.5"
SmallChange="0.5"
LargeChange="1"
ValueChanged="OnInkThicknessSliderValueChanged" />
</Grid>
</StackPanel>
</Border>
</Grid>
</Border>
</Popup>
</Grid>
</UserControl>

View File

@@ -6,6 +6,7 @@ using System.Reflection;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
@@ -38,7 +39,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
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 _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
@@ -66,9 +68,27 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ApplyCellSize(_currentCellSize);
RefreshFromSettings();
ApplyThemeVisual(force: true);
InitializeColorPicker();
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;
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
@@ -97,7 +117,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.IgnorePressure = true;
settings.InkThickness = 2.5f;
settings.InkThickness = _selectedInkThickness;
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
@@ -135,7 +155,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
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);
settings.EraserSize = new Size(eraserSize, eraserSize);
}
@@ -149,7 +168,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
_isNightModeApplied = isNightMode;
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
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.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
RecolorAllStrokes(_currentInkColor);
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()
{
if (_disposed)
@@ -300,12 +340,31 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
if (mode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
}
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()
{
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
@@ -350,7 +409,32 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
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)
@@ -509,14 +593,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
_ = Task.Run(() => _notePersistenceService.SaveNote(
componentId,
placementId,
noteSnapshot,
retentionDays));
try
{
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
}
catch
{
}
}
private async void SchedulePersistedNoteLoad()
@@ -553,7 +636,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
{
ClearAllStrokes();
ApplyNoteSnapshot(noteSnapshot);
RecolorAllStrokes(_currentInkColor);
}
finally
{

View 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

View 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");
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -200,6 +200,7 @@ public partial class MainWindow
{
DesktopEditDragLayer.Width = pageWidth;
DesktopEditDragLayer.Height = pageHeight;
UpdateDesktopEditOverlayViewportSize();
}
DesktopPagesHost.RowDefinitions.Clear();
@@ -486,8 +487,7 @@ public partial class MainWindow
{
return !_isSettingsOpen &&
!_isComponentLibraryOpen &&
!_isDesktopComponentDragActive &&
!_isDesktopComponentResizeActive &&
!HasActiveDesktopEditSession &&
_desktopSurfacePageWidth > 1;
}
@@ -552,7 +552,6 @@ public partial class MainWindow
{
if (node is Control control)
{
// Avoid swiping pages when interacting with desktop components/widgets.
if (control.Classes.Contains("desktop-component") ||
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;
}
@@ -611,7 +634,17 @@ public partial class MainWindow
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;
}
@@ -625,13 +658,23 @@ public partial class MainWindow
}
var typeName = node.GetType().Name;
return typeName.Contains("Button", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
return typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("ScrollBar", StringComparison.OrdinalIgnoreCase) ||
typeName.Contains("NumericUpDown", 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)
{
point = default;

View File

@@ -243,6 +243,15 @@
<Canvas x:Name="DesktopEditDragLayer"
IsHitTestVisible="False" />
<Canvas x:Name="ComponentPreviewStagingHost"
Width="1"
Height="1"
Opacity="0"
ClipToBounds="True"
HorizontalAlignment="Left"
VerticalAlignment="Top"
IsHitTestVisible="False" />
</Grid>
</Border>
@@ -588,6 +597,44 @@
</Border>
</Grid>
</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>
</Window>

View File

@@ -500,6 +500,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
var wasVisible = IsVisible;
var windowState = WindowState.ToString();
SaveAllWhiteboardNotes();
PersistSettings();
_componentEditorWindowService.Close();
if (_detachedComponentLibraryWindow is not null)

View File

@@ -7,7 +7,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.AppearanceSettingsPage"
x:DataType="vm:AppearanceSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<controls:IconText Icon="Color"
Text="{Binding ThemeHeader}"

View File

@@ -7,7 +7,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.ComponentsSettingsPage"
x:DataType="vm:ComponentsSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<controls:IconText Icon="Apps"
Text="{Binding ComponentsHeader}"
Margin="0,0,0,4" />

View File

@@ -7,7 +7,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.GeneralSettingsPage"
x:DataType="vm:GeneralSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<!-- 区域设置分组 -->
<controls:IconText Icon="Globe"

View File

@@ -4,7 +4,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.GeneratedPluginSettingsPage"
x:DataType="vm:PluginGeneratedSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<TextBlock Classes="settings-section-title"
Text="{Binding Title}" />
<TextBlock x:Name="DescriptionTextBlock"

View File

@@ -7,7 +7,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
x:DataType="vm:LauncherSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<Border Classes="settings-section-card">
<Grid ColumnDefinitions="Auto,*,Auto"

View File

@@ -7,7 +7,7 @@
x:Name="Root"
x:DataType="vm:PluginMarketSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
Description="{Binding StatusMessage}">
<ui:SettingsExpander.IconSource>

View File

@@ -8,7 +8,7 @@
x:Name="Root"
x:DataType="vm:PluginsSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<ui:SettingsExpander Header="{Binding RefreshButtonText}"
Description="{Binding StatusMessage}">
<ui:SettingsExpander.IconSource>

View File

@@ -7,7 +7,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacySettingsPage"
x:DataType="vm:PrivacySettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<controls:IconText Icon="Info"
Text="{Binding PrivacyHeader}"
Margin="0,0,0,4" />

View File

@@ -7,7 +7,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.StatusBarSettingsPage"
x:DataType="vm:StatusBarSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<controls:IconText Icon="Apps"
Text="{Binding ComponentsHeader}"
Margin="0,0,0,4" />

View File

@@ -36,7 +36,7 @@
</UserControl.Styles>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<TextBlock Classes="settings-section-title"
Text="{Binding PageTitle}" />
<TextBlock Classes="settings-section-description"

View File

@@ -7,7 +7,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage"
x:DataType="vm:WallpaperSettingsPageViewModel">
<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">

View File

@@ -8,7 +8,7 @@
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage"
x:DataType="vm:WeatherSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container">
<StackPanel Classes="settings-page-container settings-page-animated">
<Border Classes="settings-section-card">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">

View File

@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsWindow"
x:DataType="vm:SettingsWindowViewModel"
Width="1120"
@@ -36,7 +36,8 @@
</Style>
</Window.Styles>
<Grid Classes="settings-scope"
<Grid x:Name="RootGrid"
Classes="settings-scope"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
RowDefinitions="Auto,*">
<Border x:Name="WindowTitleBarHost"
@@ -50,15 +51,14 @@
ColumnSpacing="8"
VerticalAlignment="Center">
<Button x:Name="TogglePaneButton"
Width="40"
Height="32"
Padding="0"
Background="Transparent"
BorderThickness="0"
Classes="pane-toggle-button"
Click="OnTogglePaneButtonClick">
<fi:FluentIcon x:Name="TogglePaneButtonIcon"
Icon="PanelLeftExpand"
IconVariant="Regular" />
<Grid>
<fi:FluentIcon x:Name="TogglePaneButtonIcon"
Icon="Navigation"
IconVariant="Regular"
FontSize="16" />
</Grid>
</Button>
<fi:FluentIcon x:Name="WindowBrandIcon"

View File

@@ -32,7 +32,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
private readonly IHostApplicationLifecycle _hostApplicationLifecycle;
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
private readonly Dictionary<string, Control> _cachedPages = new(StringComparer.OrdinalIgnoreCase);
private readonly bool _useSystemChrome;
private bool _useSystemChrome;
private bool _isResponsiveRefreshPending;
private bool _isRestartPromptVisible;
@@ -152,12 +152,19 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
public void ApplyChromeMode(bool useSystemChrome)
{
if (useSystemChrome || OperatingSystem.IsMacOS())
_useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS();
if (_useSystemChrome)
{
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
ExtendClientAreaTitleBarHeightHint = -1;
SystemDecorations = SystemDecorations.Full;
if (WindowTitleBarHost is { })
{
WindowTitleBarHost.IsVisible = false;
}
return;
}
@@ -165,6 +172,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
ExtendClientAreaTitleBarHeightHint = 48;
if (WindowTitleBarHost is { })
{
WindowTitleBarHost.IsVisible = true;
}
}
public void RefreshShellText()
@@ -563,10 +575,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
{
return;
}
TogglePaneButtonIcon.Icon = RootNavigationView.IsPaneOpen
? FluentIcons.Common.Icon.PanelLeftContract
: FluentIcons.Common.Icon.PanelLeftExpand;
}
private void UpdateChromeMetrics()

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
@@ -12,6 +13,8 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketInstallService : IDisposable
{
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
private readonly PluginRuntimeService _runtime;
private readonly PluginsInstallHelperClient _helperClient = new();
private readonly HttpClient _httpClient;
@@ -38,107 +41,226 @@ internal sealed class AirAppMarketInstallService : IDisposable
{
ArgumentNullException.ThrowIfNull(plugin);
Directory.CreateDirectory(_downloadsDirectory);
var downloadPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
try
if (OperatingSystem.IsWindows())
{
AppLogger.Info(
"PluginMarket",
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
AppLogger.Info(
"PluginMarket",
$"Resolved download url for '{plugin.Id}' to '{resolvedDownloadUrl}'.");
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
var helperPath = ResolveHelperPath();
if (!File.Exists(helperPath))
{
var localCopyResult = await _downloadService.DownloadAsync(
localPackagePath,
downloadPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken);
if (!localCopyResult.Success)
{
return new AirAppMarketInstallResult(false, null, localCopyResult.ErrorMessage);
}
}
else
{
var downloadResult = await _downloadService.DownloadAsync(
resolvedDownloadUrl,
downloadPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken);
if (!downloadResult.Success)
{
return new AirAppMarketInstallResult(false, null, downloadResult.ErrorMessage);
}
}
var actualSize = new FileInfo(downloadPath).Length;
string actualHash;
await using (var hashStream = File.OpenRead(downloadPath))
{
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
AppLogger.Error(
"PluginMarket",
$"SHA-256 verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadUrl='{resolvedDownloadUrl}'; DownloadPath='{downloadPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
File.Delete(downloadPath);
return new AirAppMarketInstallResult(
false,
null,
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}. Source {resolvedDownloadUrl}.");
$"Plugins install helper was not found at '{helperPath}'.");
}
}
Directory.CreateDirectory(_downloadsDirectory);
var sources = plugin.GetPackageSourcesInInstallOrder();
if (sources.Count == 0)
{
return new AirAppMarketInstallResult(
false,
null,
"Plugin does not declare any package sources.");
}
AppLogger.Info(
"PluginMarket",
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'.");
var sourceErrors = new List<string>();
foreach (var source in sources)
{
var attemptResult = await TryInstallFromSourceAsync(plugin, source, cancellationToken).ConfigureAwait(false);
if (attemptResult.Success)
{
return new AirAppMarketInstallResult(true, attemptResult.Manifest, null);
}
if (attemptResult.Fatal)
{
return new AirAppMarketInstallResult(false, null, attemptResult.ErrorMessage);
}
if (!string.IsNullOrWhiteSpace(attemptResult.ErrorMessage))
{
sourceErrors.Add($"{source.SourceKind}: {attemptResult.ErrorMessage}");
}
}
var combinedMessage = sourceErrors.Count == 0
? $"Failed to install plugin '{plugin.Id}' from all available package sources."
: $"Failed to install plugin '{plugin.Id}' from all available package sources. {string.Join(" ", sourceErrors)}";
return new AirAppMarketInstallResult(false, null, combinedMessage);
}
private async Task<AirAppMarketInstallAttemptResult> TryInstallFromSourceAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken = default)
{
var attemptPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp");
try
{
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false);
AppLogger.Warn(
"PluginMarket",
$"Resolved package source for '{plugin.Id}' to '{resolvedDownloadUrl}' using '{source.SourceKind}'.");
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, attemptPath, cancellationToken).ConfigureAwait(false);
if (!acquireResult.Success)
{
TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, acquireResult.ErrorMessage);
}
var verificationResult = await VerifyPackageAsync(plugin, attemptPath, cancellationToken).ConfigureAwait(false);
if (!verificationResult.Success)
{
TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, verificationResult.ErrorMessage);
}
PluginManifest manifest;
if (OperatingSystem.IsWindows())
{
var helperResult = await _helperClient.InstallPackageAsync(
downloadPath,
attemptPath,
_runtime.PluginsDirectory,
cancellationToken);
cancellationToken).ConfigureAwait(false);
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
{
return new AirAppMarketInstallResult(
false,
null,
helperResult.ErrorMessage ?? "Plugins install helper failed.");
var helperMessage = helperResult.ErrorMessage ?? "Plugins install helper failed.";
AppLogger.Error(
"PluginMarket",
$"Windows install helper failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. Message='{helperMessage}'.");
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
}
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
}
else
{
manifest = _runtime.InstallPluginPackage(downloadPath);
manifest = _runtime.InstallPluginPackage(attemptPath);
}
AppLogger.Info(
"PluginMarket",
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{downloadPath}'.");
return new AirAppMarketInstallResult(true, manifest, null);
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{attemptPath}'; SourceKind='{source.SourceKind}'.");
return new AirAppMarketInstallAttemptResult(true, true, manifest, null);
}
catch (OperationCanceledException)
{
AppLogger.Warn(
"PluginMarket",
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.");
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.");
throw;
}
catch (Exception ex)
{
AppLogger.Error(
"PluginMarket",
$"Install failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{downloadPath}'.",
$"Install attempt failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.",
ex);
return new AirAppMarketInstallResult(false, null, ex.Message);
TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, ex.Message);
}
}
private async Task<AirAppMarketAcquisitionResult> AcquirePackageAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
string resolvedDownloadUrl,
string attemptPath,
CancellationToken cancellationToken)
{
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, out var localPackagePath))
{
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
{
AppLogger.Info(
"PluginMarket",
$"Copying workspace package for '{plugin.Id}' from '{localPackagePath}' to '{attemptPath}'.");
}
var localCopyResult = await _downloadService.DownloadAsync(
localPackagePath,
attemptPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!localCopyResult.Success)
{
return new AirAppMarketAcquisitionResult(false, localCopyResult.ErrorMessage);
}
return new AirAppMarketAcquisitionResult(true, null);
}
if (source.SourceKind == PluginPackageSourceKind.WorkspaceLocal)
{
return new AirAppMarketAcquisitionResult(
false,
$"Workspace package source '{source.Url}' could not be resolved to a local file.");
}
var downloadResult = await _downloadService.DownloadAsync(
resolvedDownloadUrl,
attemptPath,
new DownloadOptions(ExpectedSizeBytes: plugin.PackageSizeBytes),
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!downloadResult.Success)
{
return new AirAppMarketAcquisitionResult(false, downloadResult.ErrorMessage);
}
return new AirAppMarketAcquisitionResult(true, null);
}
private async Task<AirAppMarketVerificationResult> VerifyPackageAsync(
AirAppMarketPluginEntry plugin,
string attemptPath,
CancellationToken cancellationToken)
{
var actualSize = new FileInfo(attemptPath).Length;
string actualHash;
await using (var hashStream = File.OpenRead(attemptPath))
{
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken).ConfigureAwait(false);
actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
if (actualSize != plugin.PackageSizeBytes || !string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
AppLogger.Error(
"PluginMarket",
$"Package verification failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; DownloadPath='{attemptPath}'; ExpectedHash='{plugin.Sha256}'; ActualHash='{actualHash}'; ExpectedSize='{plugin.PackageSizeBytes}'; ActualSize='{actualSize}'.");
return new AirAppMarketVerificationResult(
false,
$"Package verification failed. Expected SHA-256 {plugin.Sha256}, actual {actualHash}. Expected size {plugin.PackageSizeBytes}, actual size {actualSize}.");
}
return new AirAppMarketVerificationResult(true, null);
}
private static string ResolveHelperPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Ignore cleanup failures for temporary install artifacts.
}
}
@@ -152,4 +274,18 @@ internal sealed class AirAppMarketInstallService : IDisposable
var invalidChars = Path.GetInvalidFileNameChars();
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
}
private sealed record AirAppMarketInstallAttemptResult(
bool Success,
bool Fatal,
PluginManifest? Manifest,
string? ErrorMessage);
private sealed record AirAppMarketAcquisitionResult(
bool Success,
string? ErrorMessage);
private sealed record AirAppMarketVerificationResult(
bool Success,
string? ErrorMessage);
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,14 +22,46 @@ internal sealed class AirAppMarketReleaseResolverService
{
ArgumentNullException.ThrowIfNull(plugin);
if (!plugin.HasReleaseDownloadMetadata)
var firstSource = plugin.GetPackageSourcesInInstallOrder().FirstOrDefault();
if (firstSource is null)
{
return plugin.DownloadUrl;
}
return await ResolveDownloadUrlAsync(plugin, firstSource, cancellationToken).ConfigureAwait(false);
}
public async Task<string> ResolveDownloadUrlAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
ArgumentNullException.ThrowIfNull(source);
return source.SourceKind switch
{
PluginPackageSourceKind.ReleaseAsset => await ResolveReleaseAssetDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false),
PluginPackageSourceKind.RawFallback => source.Url,
PluginPackageSourceKind.WorkspaceLocal => source.Url,
_ => source.Url
};
}
private async Task<string> ResolveReleaseAssetDownloadUrlAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken)
{
var sourceUrl = source.Url;
if (!plugin.HasReleaseDownloadMetadata)
{
return sourceUrl;
}
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
{
return plugin.DownloadUrl;
return sourceUrl;
}
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
@@ -46,15 +78,15 @@ internal sealed class AirAppMarketReleaseResolverService
try
{
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken);
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken).ConfigureAwait(false);
var asset = release?.Assets.FirstOrDefault(candidate =>
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl;
return asset?.BrowserDownloadUrl ?? releaseDownloadUrl;
}
catch
{
return plugin.DownloadUrl;
return releaseDownloadUrl;
}
}

View File

@@ -1,43 +0,0 @@
# 阑山桌面 产品说明
## 1. 目标人群
- **学生群体**:大学生、研究生、备考人员
- **办公用户**:白领、远程工作者
- **桌面美化爱好者**:追求个性化桌面布局与视觉体验的用户
## 2. 使用场景
| 场景 | 说明 |
| ---- | ---------------------- |
| 学习辅助 | 查看课程表、记录自习时长、获取每日诗词单词 |
| 办公效率 | 查看日历日程、快速访问最近文档、获取新闻资讯 |
| 信息聚合 | 桌面一站式查看天气、日历、热搜、新闻 |
| 个性美化 | 自由定制桌面组件布局、主题、壁纸 |
## 3. 解决方案
**核心方案**:可编排的桌面组件系统 + 插件扩展生态
- **20+ 内置组件**:时钟、天气、日历、新闻、课程表、计时器等
- **网格化布局**:自由拖拽摆放,支持多页桌面
- **主题系统**日夜模式、Monet 取色、玻璃效果
- **插件市场**:支持第三方插件扩展功能
- **跨平台**Windows / Linux / macOS
## 4. 解决的问题
| 痛点 | 解决方案 |
| -------------- | -------------------- |
| 信息分散,需打开多个应用 | 桌面聚合展示天气、日历、新闻等信息 |
| 桌面单调,缺乏个性化 | 丰富的组件和主题自由定制 |
| 学习管理不便 | 课程表、自习监测专为学生设计 |
| 功能单一,需安装多个独立应用 | 一个应用整合考试看板、噪音监测等多种功能 |
| 功能无法满足个性需求 | 插件系统支持无限扩展 |
## 5. 产品进度
- **当前版本**v0.7.0(插件 API 3.0.0
- **开发状态**:功能开发中,预计 1\~2 个月内发布 v1.0 正式版
- **用户统计**:通过 PostHog 收集匿名数据(具体数据需后台查看)

View File

@@ -1,256 +0,0 @@
# 阑山桌面 (LanMountainDesktop) 产品说明文档
**文档版本**: 1.0
**最后更新**: 2026年3月20日
**产品版本**: 1.0.0
**插件 API 基线**: 3.0.0
---
## 一、产品定位
### 1.1 一句话介绍
**阑山桌面是一款可编排的桌面信息与交互空间,让用户能够自由定制个性化桌面,整合信息展示与效率工具于一体。**
### 1.2 核心定位
- **产品类型**: 跨平台桌面环境增强工具
- **技术架构**: 基于 Avalonia UI 的 .NET 跨平台桌面应用
- **支持平台**: Windows、Linux、macOS
- **开发语言**: C# (.NET 10)
---
## 二、目标用户群体
### 2.1 核心用户画像
| 用户群体 | 特征描述 | 核心需求 |
|---------|---------|---------|
| **学生群体** | 大学生、研究生、备考人员 | 课程表管理、自习环境监测、学习计时、每日诗词/单词 |
| **办公用户** | 白领、远程工作者、知识工作者 | 日历日程、天气信息、最近文档、资讯获取 |
| **效率爱好者** | 工具控、桌面美化爱好者 | 高度自定义、插件扩展、个性化布局 |
| **中文用户** | 以中文为母语的用户 | 完整的本地化体验、农历/节假日支持 |
### 2.2 用户场景分析
#### 场景一:学生学习桌面
> 小张是一名大学生,每天需要查看课程表、记录自习时间、查看天气决定穿衣。阑山桌面的课程表组件帮他管理课表,自习监测组件记录学习时长,天气组件提供实时天气信息,让他在学习时无需切换多个应用。
#### 场景二:办公效率桌面
> 李女士是一名产品经理,需要随时查看日程、关注行业资讯、快速访问最近文档。阑山桌面的日历组件展示日程安排,新闻组件聚合央广网/凤凰网资讯,最近文档组件一键打开工作文件,提升工作效率。
#### 场景三:个性化展示桌面
> 小王是一名桌面美化爱好者,喜欢打造独特的桌面环境。阑山桌面提供丰富的组件库和插件系统,支持自定义布局、主题色、壁纸,让他能够打造独一无二的个性化桌面。
---
## 三、使用场景
### 3.1 主要使用场景
| 场景 | 描述 | 核心组件 |
|-----|------|---------|
| **学习辅助** | 课程管理、自习监测、学习计时 | 课程表、自习环境监测、计时器、每日诗词/单词 |
| **信息聚合** | 一站式获取天气、新闻、日历信息 | 天气、新闻、日历、热搜 |
| **效率提升** | 快速访问文档、应用启动、工具使用 | 最近文档、应用启动台、汇率换算、浏览器 |
| **桌面美化** | 个性化桌面布局与视觉呈现 | 时钟、天气、每日名画、主题系统 |
| **音乐控制** | 桌面音乐播放控制 | 音乐控制组件 |
### 3.2 典型使用流程
```
1. 安装阑山桌面 → 2. 选择主题与壁纸 → 3. 添加桌面组件 → 4. 自定义布局
5. 日常使用(查看信息、使用工具)
6. 按需安装插件扩展功能
```
---
## 四、解决方案
### 4.1 产品架构
```
┌─────────────────────────────────────────────────────────────┐
│ 阑山桌面 (LanMountainDesktop) │
├─────────────────────────────────────────────────────────────┤
│ 用户界面层 │ 桌面宿主 │ 组件系统 │ 插件系统 │ 设置中心 │
├─────────────────────────────────────────────────────────────┤
│ 跨平台运行时 (Avalonia UI + .NET 10) │
├─────────────────────────────────────────────────────────────┤
│ Windows │ Linux │ macOS │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 核心功能模块
#### 4.2.1 桌面组件系统
阑山桌面提供丰富的内置组件,涵盖多个类别:
| 类别 | 组件列表 |
|-----|---------|
| **时钟类** | 桌面时钟、世界时钟、天气时钟、模拟时钟 |
| **天气类** | 天气组件、小时天气、多日天气、扩展天气 |
| **日历类** | 月历、农历、节假日日历 |
| **信息类** | 每日诗词、每日名画、每日单词、央广网新闻、凤凰网新闻、B站热搜、百度热搜 |
| **学习类** | 课程表、自习环境监测、录音、自习时段控制、历史数据 |
| **工具类** | 计时器、汇率换算、浏览器、最近文档、可移动存储、音乐控制 |
| **白板类** | 竖向小黑板、横向小黑板 |
#### 4.2.2 插件扩展系统
- **插件 API 基线**: 3.0.0
- **插件格式**: `.laapp` 插件包
- **插件市场**: 官方插件市场 (LanAirApp)
- **开发支持**: 完整的 PluginSdk 和开发文档
#### 4.2.3 主题与个性化
- **主题系统**: 支持日夜模式切换
- **主题色**: 支持 Monet 取色和自定义主题色
- **玻璃效果**: 多层级玻璃视觉效果
- **壁纸系统**: 支持图片壁纸和动态效果
#### 4.2.4 布局系统
- **网格化布局**: 支持多页桌面
- **自由拖拽**: 组件可自由摆放
- **尺寸自适应**: 组件支持多种尺寸规格
### 4.3 技术亮点
| 特性 | 说明 |
|-----|------|
| **跨平台** | 基于 Avalonia UI支持 Windows/Linux/macOS |
| **现代化 UI** | Fluent Design + Material Design 融合 |
| **插件化架构** | 支持第三方插件扩展API 基线 3.0.0 |
| **数据安全** | 本地 SQLite 存储,隐私数据不上传 |
| **性能优化** | 组件懒加载、资源按需加载 |
| **无障碍支持** | 对比度优化、语义化界面 |
---
## 五、解决的问题
### 5.1 用户痛点
| 痛点 | 阑山桌面解决方案 |
|-----|----------------|
| **信息分散** | 整合天气、日历、新闻等信息于桌面 |
| **桌面单调** | 丰富的组件和主题让桌面个性化 |
| **效率低下** | 常用工具和信息一触即达 |
| **学习管理难** | 课程表、自习监测专为学生设计 |
| **功能不足** | 插件系统支持无限扩展 |
### 5.2 竞品差异化
| 对比维度 | 传统桌面工具 | 阑山桌面 |
|---------|-------------|---------|
| **组件丰富度** | 有限组件 | 20+ 内置组件 + 插件扩展 |
| **定制化** | 固定布局 | 自由拖拽、网格化布局 |
| **跨平台** | 单一平台 | Windows/Linux/macOS |
| **插件生态** | 不支持 | 完整插件 SDK 和市场 |
| **本地化** | 一般 | 完整中文本地化 |
---
## 六、用户量与数据统计
### 6.1 数据收集说明
根据隐私政策,阑山桌面收集以下匿名数据用于统计:
-**应用启动事件**: 用于统计日活跃用户
-**设备标识符**: 匿名生成,用于区分用户(不含个人信息)
-**应用版本**: 用于统计版本分布
-**崩溃报告**: 用于提升应用稳定性(可选)
-**使用统计**: 用于功能优化(可选)
### 6.2 隐私承诺
- ❌ 不收集个人身份信息(姓名、邮箱、电话等)
- ❌ 不收集地理位置
- ❌ 不收集文件内容
- ❌ 不出售用户数据
- ❌ 不用于广告目的
### 6.3 当前状态
**当前版本**: 1.0.0
**插件 API 基线**: 3.0.0
**数据收集服务**: PostHog用户分析、Sentry崩溃报告
> **注**: 具体用户量数据需从 PostHog 后台获取,此处未展示具体数字。
---
## 七、产品开发进度
### 7.1 当前开发状态
| 模块 | 状态 | 说明 |
|-----|------|------|
| **核心桌面功能** | ✅ 已完成 | 网格布局、组件系统、主题系统 |
| **内置组件** | ✅ 已完成 | 20+ 组件已上线 |
| **插件系统** | ✅ 已完成 | API 3.0.0 已稳定 |
| **插件市场** | ✅ 已完成 | 官方市场已运营 |
| **多平台支持** | ✅ 已完成 | Windows/Linux/macOS |
| **自动更新** | ✅ 已完成 | 内置更新系统 |
| **应用启动台** | ✅ 已完成 | Windows 开始菜单集成 |
### 7.2 版本里程碑
| 版本 | 目标 | 状态 |
|-----|------|------|
| v1.0.0 | 核心功能完整、插件系统稳定 | ✅ 已发布 |
| v1.x.x | 组件扩展、性能优化 | 🔄 进行中 |
| v2.0.0 | 重大功能升级(规划中) | 📋 规划中 |
### 7.3 近期开发计划
根据 `.trae/specs` 中的规格文档,近期开发任务包括:
1. **设置页面 Fluent 重设计** - 提升设置界面体验
2. **课程表功能增强** - 增加更多课程管理功能
3. **视频壁纸功能移除** - 优化产品定位
### 7.4 生态建设
| 项目 | 状态 | 说明 |
|-----|------|------|
| **LanMountainDesktop** | ✅ 主仓库 | 桌面宿主、插件运行时 |
| **LanAirApp** | ✅ 独立仓库 | 插件市场、开发文档 |
| **SamplePlugin** | ✅ 独立仓库 | 权威示例插件 |
| **PluginSdk** | ✅ 已发布 | 插件开发 SDK |
---
## 八、产品优势总结
### 8.1 核心价值
1. **个性化桌面**: 自由定制组件布局,打造专属桌面空间
2. **信息聚合**: 一站式获取天气、日历、新闻等实用信息
3. **效率提升**: 常用工具触手可及,减少应用切换
4. **学习辅助**: 专为学生群体设计的课程表、自习监测功能
5. **无限扩展**: 插件系统支持功能无限扩展
### 8.2 技术保障
- 跨平台架构,一次开发多端运行
- 现代化 UI 框架,流畅的用户体验
- 严格的隐私保护,数据安全有保障
- 完善的插件生态,功能持续扩展
---
## 九、联系我们
- **GitHub**: https://github.com/wwiinnddyy/LanMountainDesktop
- **Issues**: https://github.com/wwiinnddyy/LanMountainDesktop/issues
- **插件市场**: LanAirApp 官方市场
---
**阑山桌面,让你的桌面更有温度。**

152
README.md
View File

@@ -1,53 +1,133 @@
# LanMountainDesktop
# 阑山桌面 / LanMountainDesktop
`LanMountainDesktop` is the authoritative host repository for the desktop app and the host-side Plugin SDK.
> 你的桌面,不止一面
## Repository Ownership
[![.NET 10](https://img.shields.io/badge/.NET-10-512BD4)](https://dotnet.microsoft.com/)
[![Avalonia UI](https://img.shields.io/badge/Avalonia%20UI-11.2-blue)](https://avaloniaui.net/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
This repository owns:
> [!IMPORTANT]
> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。
>
> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。
- `LanMountainDesktop/`: desktop host app and plugin runtime
- `LanMountainDesktop.PluginSdk/`: canonical plugin API baseline (`4.0.0`)
- `LanMountainDesktop.Shared.Contracts/`: shared host/plugin contract types
- `LanMountainDesktop.Appearance/`: host appearance and radius token generation
- `LanMountainDesktop.Settings.Core/`: host settings primitives
- `LanMountainDesktop.Tests/`: host and SDK tests
## 简介
This repository does not own:
**阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
- plugin market metadata or developer portal content
- official sample plugin release source
- independent ecosystem documentation hub
基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
## Ecosystem Boundaries
![Platform](https://img.shields.io/badge/Windows-✓-0078D4)
![Platform](https://img.shields.io/badge/Linux-✓-FCC624?logo=linux&logoColor=black)
![Platform](https://img.shields.io/badge/macOS-✓-000000?logo=apple)
- Host and SDK source of truth: `LanMountainDesktop` (this repo)
- Plugin market and developer materials: standalone `LanAirApp` repo
- Official sample plugin source of truth: standalone `LanMountainDesktop.SamplePlugin` repo
- `ClassIsland`: reference-only project, not part of build or release flow
## 核心特性
## Plugin SDK v4 Baseline
### 📊 信息聚合
- 课程表、日历、天气、新闻、热搜
- 所有信息一目了然,无需频繁切换窗口
- API baseline: `4.0.0`
- Manifest file: `plugin.json`
- Package extension: `.laapp`
- Entry model: `Initialize(HostBuilderContext, IServiceCollection)`
- Appearance model: `IPluginAppearanceContext`, `PluginAppearanceSnapshot`, `PluginCornerRadiusTokens`, `PluginCornerRadiusPreset`
- Component registration model: `AddPluginDesktopComponent<TControl>(PluginDesktopComponentOptions options)`
### 🎯 效率工具
- 自习环境监测、计时器、知识卡片
- 最近文档、浏览器快捷入口
- 常用工具组件一键触达
## Plugin Package Surfaces
### 🎨 个性化桌面
- 自由布局,随心所欲摆放组件
- 多页桌面,工作学习场景分离
- 主题切换、玻璃效果、圆角风格
- `LanMountainDesktop.PluginSdk`: official plugin SDK package (includes `buildTransitive` default `.laapp` packaging targets)
- `LanMountainDesktop.Shared.Contracts`: shared contract package for host/plugin boundaries
- `LanMountainDesktop.PluginTemplate`: official `dotnet new` template package (`shortName`: `lmd-plugin`)
### 🔌 插件生态
- 通过 `.laapp` 插件扩展功能
- 官方 Plugin SDK 支持自定义组件
- 设置页、组件、集成功能一站式接入
Use `scripts/Pack-PluginPackages.ps1` to generate local-feed packages for CI or workspace integration tests.
## 为谁而设计
## Workspace Market Resolution
| 用户类型 | 典型场景 |
|---------|---------|
| 🎓 学生用户 | 课程表、自习监测、计时、天气和日常信息聚合 |
| 💼 办公用户 | 日历、资讯、最近文档、常用工具入口 |
| 🎨 效率爱好者 | 自由布局、主题切换、插件扩展 |
| 🇨🇳 中文用户 | 本地化界面、农历和节假日等本地语境支持 |
For local market debugging, the host resolves workspace files from the sibling repository path (`..\\LanAirApp`) instead of reading the in-repo mirror folder.
## 快速开始
### 环境要求
- .NET SDK 10
### 构建与运行
```bash
# 还原依赖
dotnet restore
# 构建项目
dotnet build LanMountainDesktop.slnx -c Debug
# 运行桌面宿主
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
### 运行测试
```bash
dotnet test LanMountainDesktop.slnx -c Debug
```
## 插件开发
阑山桌面支持通过 Plugin SDK 开发自定义插件:
```bash
# 安装插件模板
dotnet new install LanMountainDesktop.PluginTemplate
# 创建新插件
dotnet new lmd-plugin -n MyPlugin
```
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
## 项目结构
```
LanMountainDesktop/
├── LanMountainDesktop/ # 桌面宿主应用
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施
├── LanMountainDesktop.Settings.Core/# 设置持久化基础设施
└── LanMountainDesktop.Tests/ # 测试项目
```
## 生态边界
| 项目 | 职责 |
|-----|------|
| **本仓库** | 桌面宿主、插件运行时、Plugin SDK、共享契约 |
| [LanAirApp](https://github.com/yourorg/LanAirApp) | 插件市场元数据、开发者生态材料 |
| [LanMountainDesktop.SamplePlugin](https://github.com/yourorg/LanMountainDesktop.SamplePlugin) | 官方示例插件 |
## 文档索引
- [产品定位](docs/PRODUCT.md) - 产品愿景与目标用户
- [架构说明](docs/ARCHITECTURE.md) - 仓库结构与运行时主线
- [开发指南](docs/DEVELOPMENT.md) - 构建、测试、调试
- [视觉规范](docs/VISUAL_SPEC.md) - 主题、颜色、玻璃层级
- [圆角规范](docs/CORNER_RADIUS_SPEC.md) - 圆角层级与动态规则
- [贡献指南](docs/CONTRIBUTING.md) - PR、spec、文档协作规则
## 技术栈
- **UI 框架**: [Avalonia UI](https://avaloniaui.net/)
- **开发平台**: [.NET 10](https://dotnet.microsoft.com/)
- **支持平台**: Windows 10+, Linux, macOS
## 许可证
[MIT](LICENSE)
See:
- `docs/ECOSYSTEM_BOUNDARIES.md`
- `docs/PLUGIN_SDK_V4_MIGRATION.md`

View File

@@ -0,0 +1,18 @@
{
"widget.display_name": "Radio Station Schedule",
"widget.category": "Info",
"widget.loading": "Loading schedule...",
"widget.retry": "Retry",
"widget.no_schedule": "No schedule data",
"widget.network_error": "Network error",
"settings.title": "VoiceHub Settings",
"settings.description": "Configure radio station schedule data source and display options",
"settings.apiUrl.title": "API URL",
"settings.apiUrl.description": "VoiceHub backend API URL for fetching schedule data",
"settings.showRequester.title": "Show Requester",
"settings.showRequester.description": "Display requester information in the schedule list",
"settings.showVoteCount.title": "Show Vote Count",
"settings.showVoteCount.description": "Display song vote count in the schedule list",
"settings.refreshInterval.title": "Refresh Interval",
"settings.refreshInterval.description": "Time interval for automatic schedule data refresh"
}

View File

@@ -0,0 +1,18 @@
{
"widget.display_name": "广播站排期",
"widget.category": "信息",
"widget.loading": "正在加载排期...",
"widget.retry": "重试",
"widget.no_schedule": "暂无排期数据",
"widget.network_error": "网络错误",
"settings.title": "VoiceHub 设置",
"settings.description": "配置广播站排期数据源和显示选项",
"settings.apiUrl.title": "API 地址",
"settings.apiUrl.description": "VoiceHub 后端 API 地址,用于获取排期数据",
"settings.showRequester.title": "显示点歌人",
"settings.showRequester.description": "在排期列表中显示点歌人信息",
"settings.showVoteCount.title": "显示投票数",
"settings.showVoteCount.description": "在排期列表中显示歌曲投票数",
"settings.refreshInterval.title": "刷新间隔",
"settings.refreshInterval.description": "自动刷新排期数据的时间间隔"
}

View File

@@ -0,0 +1,27 @@
namespace VoiceHubLanDesktop.Models;
/// <summary>
/// 插件设置
/// </summary>
public sealed class PluginSettings
{
/// <summary>
/// API 地址
/// </summary>
public string ApiUrl { get; set; } = "https://voicehub.lao-shui.top/api/songs/public";
/// <summary>
/// 是否显示点歌人
/// </summary>
public bool ShowRequester { get; set; } = true;
/// <summary>
/// 是否显示投票数
/// </summary>
public bool ShowVoteCount { get; set; } = false;
/// <summary>
/// 刷新间隔(分钟)
/// </summary>
public int RefreshIntervalMinutes { get; set; } = 60;
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json.Serialization;
namespace VoiceHubLanDesktop.Models;
/// <summary>
/// 歌曲信息
/// </summary>
public sealed class Song
{
/// <summary>
/// 歌曲标题
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// 艺术家/歌手
/// </summary>
[JsonPropertyName("artist")]
public string Artist { get; set; } = string.Empty;
/// <summary>
/// 点歌人
/// </summary>
[JsonPropertyName("requester")]
public string Requester { get; set; } = string.Empty;
/// <summary>
/// 投票数/热度
/// </summary>
[JsonPropertyName("voteCount")]
public int VoteCount { get; set; }
}
/// <summary>
/// 排期歌曲项目
/// </summary>
public sealed class SongItem
{
/// <summary>
/// 播放日期 (yyyy-MM-dd)
/// </summary>
[JsonPropertyName("playDate")]
public string PlayDate { get; set; } = string.Empty;
/// <summary>
/// 播放序号
/// </summary>
[JsonPropertyName("sequence")]
public int Sequence { get; set; }
/// <summary>
/// 歌曲信息
/// </summary>
[JsonPropertyName("song")]
public Song Song { get; set; } = new();
/// <summary>
/// 获取播放日期
/// </summary>
public DateTime GetPlayDate()
{
if (string.IsNullOrWhiteSpace(PlayDate))
{
return DateTime.MinValue;
}
if (DateTime.TryParseExact(PlayDate, "yyyy-MM-dd", null,
System.Globalization.DateTimeStyles.None, out var result))
{
return result;
}
return DateTime.MinValue;
}
}
/// <summary>
/// 组件状态
/// </summary>
public enum ComponentState
{
/// <summary>
/// 加载中
/// </summary>
Loading,
/// <summary>
/// 正常显示
/// </summary>
Normal,
/// <summary>
/// 网络错误
/// </summary>
NetworkError,
/// <summary>
/// 暂无排期
/// </summary>
NoSchedule
}
/// <summary>
/// 显示数据
/// </summary>
public sealed class DisplayData
{
public ComponentState State { get; set; }
public IReadOnlyList<SongItem> Songs { get; set; } = [];
public DateTime? DisplayDate { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,62 @@
# VoiceHubLanDesktop
VoiceHub 广播站排期插件,用于 LanMountainDesktop 桌面应用。
## 功能特性
- 📻 **排期显示**:展示 VoiceHub 广播站当日排期歌曲
- 🔄 **自动刷新**支持自定义刷新间隔5分钟 ~ 2小时
- ⚙️ **灵活配置**:可自定义 API 地址、显示选项
- 🌐 **多语言支持**:支持中文和英文
## 安装
`.laapp` 包放入 LanMountainDesktop 的插件目录:
```
%LocalAppData%\LanMountainDesktop\Extensions\Plugins\
```
## 配置
在 LanMountainDesktop 设置中找到 "VoiceHub 设置"
| 选项 | 说明 | 默认值 |
|-----|------|--------|
| API 地址 | VoiceHub 后端 API 地址 | `https://voicehub.lao-shui.top/api/songs/public` |
| 显示点歌人 | 是否显示点歌人信息 | 是 |
| 显示投票数 | 是否显示歌曲投票数 | 否 |
| 刷新间隔 | 自动刷新时间间隔 | 1小时 |
## 组件规格
- **最小尺寸**3 × 4 网格
- **缩放模式**:等比例缩放
- **放置位置**:桌面
## 开发
### 构建
```bash
cd VoiceHubLanDesktop
dotnet build
```
### 打包
```bash
dotnet pack
# 或使用脚本
../scripts/Pack-PluginPackages.ps1
```
## 技术栈
- .NET 10
- Avalonia UI 11.3.12
- LanMountainDesktop.PluginSdk 4.0.0
- CommunityToolkit.Mvvm 8.2.1
## 许可证
MIT License

Some files were not shown because too many files have changed in this diff Show More