mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
6 Commits
e1d5a0c6de
...
v0.8.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a671db8b69 | ||
|
|
8c94253f92 | ||
|
|
6849a467d6 | ||
|
|
e69bbf8b19 | ||
|
|
d30af21317 | ||
|
|
8583465a67 |
@@ -62,7 +62,10 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
### UI
|
||||
|
||||
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md` 与 `docs/CORNER_RADIUS_SPEC.md`
|
||||
- **组件圆角**:所有内置与插件组件的根边框必须使用 `{DynamicResource DesignCornerRadiusComponent}` 资源。
|
||||
- **圆角规范 (AI 强制建议)**:
|
||||
- **桌面组件根容器**:必须且仅能使用 `{DynamicResource DesignCornerRadiusComponent}`。
|
||||
- **内部元素**:必须根据嵌套层级使用 `DesignCornerRadiusSm/Md/Lg` 等 Token,严禁硬编码像素值。
|
||||
- **禁止修改系数**:严禁在圆角资源上乘以任何 `scale` 变量,圆角现在由全局样式固定控制。
|
||||
- 设置页相关改动通常同时落在 `Views/`、`ViewModels/`、`Services/` 和 `.trae/specs/`
|
||||
- UI 启动与窗口生命周期主线在 `Program.cs` 和 `App.axaml.cs`
|
||||
|
||||
|
||||
82
CHANGELOG.md
Normal file
82
CHANGELOG.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 更新日志 / Changelog
|
||||
|
||||
所有重要的更改都将记录在此文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增 (Added)
|
||||
- 待发布的新功能
|
||||
|
||||
### 变更 (Changed)
|
||||
- 待发布的变更
|
||||
|
||||
### 修复 (Fixed)
|
||||
- 待发布的修复
|
||||
|
||||
### 移除 (Removed)
|
||||
- 待发布的移除项
|
||||
|
||||
---
|
||||
|
||||
## [0.8.3.1] - 2026-04-08
|
||||
|
||||
### 新增 (Added)
|
||||
- ✨ **快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
|
||||
- 支持创建快捷方式,统一管理应用和文件
|
||||
- 提供单击打开和双击打开两种交互模式
|
||||
- 支持配置是否显示背景
|
||||
- 📝 初始化更新日志文档,为后续版本发布建立基础
|
||||
|
||||
### 变更 (Changed)
|
||||
- 无
|
||||
|
||||
### 修复 (Fixed)
|
||||
- 无
|
||||
|
||||
### 移除 (Removed)
|
||||
- 无
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 版本号规则
|
||||
|
||||
本项目采用语义化版本号 `MAJOR.MINOR.PATCH.BUILD`:
|
||||
|
||||
- **MAJOR (主版本号)**: 不兼容的 API 修改
|
||||
- **MINOR (次版本号)**: 向下兼容的功能性新增
|
||||
- **PATCH (修订号)**: 向下兼容的问题修正
|
||||
- **BUILD (构建号)**: 内部构建版本,用于区分同一 PATCH 版本的不同构建
|
||||
|
||||
### 分类说明
|
||||
|
||||
- **新增 (Added)**: 新功能、新特性
|
||||
- **变更 (Changed)**: 对现有功能的变更
|
||||
- **修复 (Fixed)**: Bug 修复
|
||||
- **移除 (Removed)**: 移除的功能或特性
|
||||
|
||||
### 图例
|
||||
|
||||
- 🎉 **重大更新**: 重要功能或里程碑
|
||||
- ✨ **新功能**: 新增功能特性
|
||||
- 🐛 **Bug修复**: 问题修复
|
||||
- 🔧 **配置**: 配置相关变更
|
||||
- 📝 **文档**: 文档更新
|
||||
- 🎨 **样式**: UI/UX 改进
|
||||
- ♻️ **重构**: 代码重构
|
||||
- ⚡ **性能**: 性能优化
|
||||
- 🔒 **安全**: 安全相关
|
||||
- 🌐 **国际化**: 国际化/本地化
|
||||
|
||||
---
|
||||
|
||||
## 链接
|
||||
|
||||
[Unreleased]: https://github.com/yourorg/LanMountainDesktop/compare/v0.8.3.1...HEAD
|
||||
[0.8.3.1]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1
|
||||
@@ -6,23 +6,48 @@ namespace LanMountainDesktop.Appearance;
|
||||
|
||||
public static class AppearanceCornerRadiusTokenFactory
|
||||
{
|
||||
public static AppearanceCornerRadiusTokens Create(double scale)
|
||||
public static AppearanceCornerRadiusTokens Create(string style)
|
||||
{
|
||||
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
|
||||
return new AppearanceCornerRadiusTokens(
|
||||
Radius(6, normalizedScale),
|
||||
Radius(12, normalizedScale),
|
||||
Radius(14, normalizedScale),
|
||||
Radius(20, normalizedScale),
|
||||
Radius(28, normalizedScale),
|
||||
Radius(32, normalizedScale),
|
||||
Radius(36, normalizedScale),
|
||||
Radius(18, normalizedScale));
|
||||
}
|
||||
|
||||
private static CornerRadius Radius(double value, double scale)
|
||||
{
|
||||
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
|
||||
return new CornerRadius(scaled);
|
||||
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(style);
|
||||
return normalized switch
|
||||
{
|
||||
GlobalAppearanceSettings.CornerRadiusStyleSharp => new AppearanceCornerRadiusTokens(
|
||||
Micro: new CornerRadius(4),
|
||||
Xs: new CornerRadius(8),
|
||||
Sm: new CornerRadius(10),
|
||||
Md: new CornerRadius(14),
|
||||
Lg: new CornerRadius(20),
|
||||
Xl: new CornerRadius(24),
|
||||
Island: new CornerRadius(28),
|
||||
Component: new CornerRadius(20)),
|
||||
GlobalAppearanceSettings.CornerRadiusStyleRounded => new AppearanceCornerRadiusTokens(
|
||||
Micro: new CornerRadius(8),
|
||||
Xs: new CornerRadius(14),
|
||||
Sm: new CornerRadius(16),
|
||||
Md: new CornerRadius(24),
|
||||
Lg: new CornerRadius(32),
|
||||
Xl: new CornerRadius(36),
|
||||
Island: new CornerRadius(40),
|
||||
Component: new CornerRadius(28)),
|
||||
GlobalAppearanceSettings.CornerRadiusStyleOpen => new AppearanceCornerRadiusTokens(
|
||||
Micro: new CornerRadius(10),
|
||||
Xs: new CornerRadius(16),
|
||||
Sm: new CornerRadius(20),
|
||||
Md: new CornerRadius(28),
|
||||
Lg: new CornerRadius(36),
|
||||
Xl: new CornerRadius(40),
|
||||
Island: new CornerRadius(44),
|
||||
Component: new CornerRadius(32)),
|
||||
// Balanced (default)
|
||||
_ => new AppearanceCornerRadiusTokens(
|
||||
Micro: new CornerRadius(6),
|
||||
Xs: new CornerRadius(12),
|
||||
Sm: new CornerRadius(14),
|
||||
Md: new CornerRadius(20),
|
||||
Lg: new CornerRadius(28),
|
||||
Xl: new CornerRadius(32),
|
||||
Island: new CornerRadius(36),
|
||||
Component: new CornerRadius(24))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,5 @@ public sealed record ComponentChromeContext(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
double CellSize,
|
||||
double GlobalCornerRadiusScale,
|
||||
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||
SettingsScope Scope = SettingsScope.App);
|
||||
|
||||
@@ -9,7 +9,6 @@ public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
||||
|
||||
Snapshot = snapshot with
|
||||
{
|
||||
GlobalCornerRadiusScale = Math.Max(0d, snapshot.GlobalCornerRadiusScale),
|
||||
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
|
||||
? "Unknown"
|
||||
: snapshot.ThemeVariant.Trim()
|
||||
@@ -20,13 +19,15 @@ public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
||||
|
||||
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
||||
{
|
||||
var scale = Snapshot.GlobalCornerRadiusScale;
|
||||
var scaled = Math.Max(0d, baseRadius) * scale;
|
||||
var scaledMin = minimum.HasValue ? minimum.Value * scale : scaled;
|
||||
var scaledMax = maximum.HasValue ? maximum.Value * scale : scaled;
|
||||
return minimum.HasValue || maximum.HasValue
|
||||
? Math.Clamp(scaled, scaledMin, scaledMax)
|
||||
: scaled;
|
||||
var value = Math.Max(0d, baseRadius);
|
||||
if (!minimum.HasValue && !maximum.HasValue)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var clampedMin = minimum ?? value;
|
||||
var clampedMax = maximum ?? value;
|
||||
return Math.Clamp(value, clampedMin, clampedMax);
|
||||
}
|
||||
|
||||
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginAppearanceSnapshot(
|
||||
double GlobalCornerRadiusScale,
|
||||
PluginCornerRadiusTokens CornerRadiusTokens,
|
||||
string ThemeVariant);
|
||||
|
||||
@@ -52,8 +52,6 @@ public sealed class PluginDesktopComponentContext
|
||||
|
||||
public IPluginAppearanceContext Appearance { get; }
|
||||
|
||||
public double GlobalCornerRadiusScale => Appearance.Snapshot.GlobalCornerRadiusScale;
|
||||
|
||||
public PluginCornerRadiusTokens CornerRadiusTokens => Appearance.Snapshot.CornerRadiusTokens;
|
||||
|
||||
public IPluginSettingsService? PluginSettings { get; }
|
||||
|
||||
@@ -2,17 +2,69 @@ namespace LanMountainDesktop.Settings.Core;
|
||||
|
||||
public static class GlobalAppearanceSettings
|
||||
{
|
||||
public const string CornerRadiusStyleSharp = "Sharp";
|
||||
public const string CornerRadiusStyleBalanced = "Balanced";
|
||||
public const string CornerRadiusStyleRounded = "Rounded";
|
||||
public const string CornerRadiusStyleOpen = "Open";
|
||||
public const string DefaultCornerRadiusStyle = CornerRadiusStyleBalanced;
|
||||
|
||||
/// <summary>
|
||||
/// Kept for backward compatibility during settings migration.
|
||||
/// New code should not reference this constant.
|
||||
/// </summary>
|
||||
public const double DefaultCornerRadiusScale = 1.0;
|
||||
public const double MinimumCornerRadiusScale = 0.0;
|
||||
public const double MaximumCornerRadiusScale = 2.50;
|
||||
|
||||
public static double NormalizeCornerRadiusScale(double value)
|
||||
public static string NormalizeCornerRadiusStyle(string? value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return DefaultCornerRadiusScale;
|
||||
return DefaultCornerRadiusStyle;
|
||||
}
|
||||
|
||||
return Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
|
||||
var trimmed = value.Trim();
|
||||
if (string.Equals(trimmed, CornerRadiusStyleSharp, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CornerRadiusStyleSharp;
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, CornerRadiusStyleBalanced, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CornerRadiusStyleBalanced;
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, CornerRadiusStyleRounded, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CornerRadiusStyleRounded;
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, CornerRadiusStyleOpen, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CornerRadiusStyleOpen;
|
||||
}
|
||||
|
||||
return DefaultCornerRadiusStyle;
|
||||
}
|
||||
|
||||
public static readonly IReadOnlyList<string> AllCornerRadiusStyles =
|
||||
[
|
||||
CornerRadiusStyleSharp,
|
||||
CornerRadiusStyleBalanced,
|
||||
CornerRadiusStyleRounded,
|
||||
CornerRadiusStyleOpen
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Backward compatibility: map previous scale values to the closest style.
|
||||
/// </summary>
|
||||
public static string MigrateScaleToStyle(double scale)
|
||||
{
|
||||
return scale switch
|
||||
{
|
||||
<= 0.60 => CornerRadiusStyleSharp,
|
||||
<= 1.20 => CornerRadiusStyleBalanced,
|
||||
<= 1.70 => CornerRadiusStyleRounded,
|
||||
_ => CornerRadiusStyleOpen
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,19 @@ namespace LanMountainDesktop.Tests;
|
||||
public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(80d, 0d)]
|
||||
[InlineData(120d, 1d)]
|
||||
[InlineData(160d, 2.5d)]
|
||||
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, double globalScale)
|
||||
[InlineData(80d, "Sharp")]
|
||||
[InlineData(120d, "Balanced")]
|
||||
[InlineData(160d, "Rounded")]
|
||||
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, string style)
|
||||
{
|
||||
var registry = new DesktopComponentRuntimeRegistry(
|
||||
ComponentRegistry.CreateDefault(),
|
||||
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
|
||||
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Component.TopLeft;
|
||||
var expected = AppearanceCornerRadiusTokenFactory.Create(style).Component.TopLeft;
|
||||
|
||||
foreach (var descriptor in registry.GetDesktopComponents())
|
||||
{
|
||||
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, globalScale));
|
||||
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, style));
|
||||
Assert.Equal(expected, resolved, 3);
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,12 @@ public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
|
||||
private static ComponentChromeContext CreateChromeContext(
|
||||
string componentId,
|
||||
double cellSize,
|
||||
double globalScale)
|
||||
string style)
|
||||
{
|
||||
return new ComponentChromeContext(
|
||||
componentId,
|
||||
null,
|
||||
cellSize,
|
||||
globalScale,
|
||||
AppearanceCornerRadiusTokenFactory.Create(globalScale));
|
||||
AppearanceCornerRadiusTokenFactory.Create(style));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class CornerRadiusScaleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(-1d, 0d)]
|
||||
[InlineData(0d, 0d)]
|
||||
[InlineData(0.33d, 0.33d)]
|
||||
[InlineData(1.234d, 1.234d)]
|
||||
[InlineData(2.5d, 2.5d)]
|
||||
[InlineData(3d, 2.5d)]
|
||||
public void NormalizeCornerRadiusScale_ClampsWithoutSnapping(double input, double expected)
|
||||
{
|
||||
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusScale(input), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeCornerRadiusScale_UsesDefaultForInvalidValues()
|
||||
{
|
||||
Assert.Equal(
|
||||
GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.NaN),
|
||||
3);
|
||||
Assert.Equal(
|
||||
GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.PositiveInfinity),
|
||||
3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginDesktopComponentContext_AllowsZeroRadiusScaling()
|
||||
{
|
||||
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: 0d,
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(12),
|
||||
new CornerRadius(14),
|
||||
new CornerRadius(20),
|
||||
new CornerRadius(28),
|
||||
new CornerRadius(32),
|
||||
new CornerRadius(36),
|
||||
new CornerRadius(8))),
|
||||
ThemeVariant: "Unknown"));
|
||||
|
||||
var context = new PluginDesktopComponentContext(
|
||||
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
|
||||
"C:\\Plugins\\plugin.id",
|
||||
"C:\\Data\\plugin.id",
|
||||
new NullServiceProvider(),
|
||||
new Dictionary<string, object?>(),
|
||||
"component-1",
|
||||
null,
|
||||
96d,
|
||||
appearanceContext);
|
||||
|
||||
Assert.Equal(0d, context.GlobalCornerRadiusScale, 3);
|
||||
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d), 3);
|
||||
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginAppearanceContext_ResolveCornerRadius_DoesNotDoubleScalePresetTokens()
|
||||
{
|
||||
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: 2d,
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(
|
||||
Micro: 12d,
|
||||
Xs: 20d,
|
||||
Sm: 28d,
|
||||
Md: 36d,
|
||||
Lg: 48d,
|
||||
Xl: 60d,
|
||||
Island: 72d,
|
||||
Component: 16d),
|
||||
ThemeVariant: "Light"));
|
||||
|
||||
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
|
||||
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 40d), 3);
|
||||
Assert.Equal(36d, context.ResolveScaledCornerRadius(18d), 3);
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
}
|
||||
71
LanMountainDesktop.Tests/CornerRadiusStyleTests.cs
Normal file
71
LanMountainDesktop.Tests/CornerRadiusStyleTests.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class CornerRadiusStyleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Sharp", "Sharp")]
|
||||
[InlineData("Balanced", "Balanced")]
|
||||
[InlineData("Rounded", "Rounded")]
|
||||
[InlineData("Open", "Open")]
|
||||
[InlineData("Unknown", "Balanced")]
|
||||
[InlineData(null, "Balanced")]
|
||||
public void NormalizeCornerRadiusStyle_ReturnsValidStyleOrDefault(string? input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusStyle(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginAppearanceContext_ResolveCornerRadius_ReturnsFixedTokenValues()
|
||||
{
|
||||
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(
|
||||
Micro: 6d,
|
||||
Xs: 12d,
|
||||
Sm: 14d,
|
||||
Md: 20d,
|
||||
Lg: 28d,
|
||||
Xl: 32d,
|
||||
Island: 36d,
|
||||
Component: 24d),
|
||||
ThemeVariant: "Light"));
|
||||
|
||||
// Preset resolution should return fixed values from tokens regardless of any legacy scale
|
||||
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
|
||||
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 15d), 3);
|
||||
Assert.Equal(20d, context.ResolveScaledCornerRadius(18d), 3);
|
||||
Assert.Equal(24d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Component), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginDesktopComponentContext_ProvidesDirectTokenAccess()
|
||||
{
|
||||
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
|
||||
ThemeVariant: "Dark"));
|
||||
|
||||
var context = new PluginDesktopComponentContext(
|
||||
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
|
||||
"C:\\Plugins\\plugin.id",
|
||||
"C:\\Data\\plugin.id",
|
||||
new NullServiceProvider(),
|
||||
new Dictionary<string, object?>(),
|
||||
"component-1",
|
||||
null,
|
||||
96d,
|
||||
appearanceContext);
|
||||
|
||||
Assert.Equal(24d, context.ResolveScaledCornerRadius(12d), 3);
|
||||
Assert.Equal(24d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Tests;
|
||||
public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
|
||||
{
|
||||
[Fact]
|
||||
public void LegacyCellSizeResolver_AppliesGlobalCornerRadiusScale()
|
||||
public void LegacyCellSizeResolver_ReturnsUnscaledFixedValue()
|
||||
{
|
||||
var registration = new DesktopComponentRuntimeRegistration(
|
||||
componentId: "test.component",
|
||||
@@ -19,41 +19,42 @@ public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.30, 10, 40));
|
||||
|
||||
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
|
||||
var resolved = resolver(CreateChromeContext(cellSize: 120, globalScale: 2.0));
|
||||
// Previously: (120 * 0.30) * 2.0 = 72.0
|
||||
// Now: (120 * 0.30) = 36.0 (No scale applied automatically by the wrapper)
|
||||
var resolved = resolver(CreateChromeContext(cellSize: 120));
|
||||
|
||||
Assert.Equal(72.0, resolved, 3);
|
||||
Assert.Equal(36.0, resolved, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChromeContextResolver_IsNotDoubleScaledByRegistrationWrapper()
|
||||
public void ChromeContextResolver_UsesTokenValue()
|
||||
{
|
||||
var registration = new DesktopComponentRuntimeRegistration(
|
||||
componentId: "test.component",
|
||||
displayNameLocalizationKey: null,
|
||||
controlFactory: _ => new Border(),
|
||||
cornerRadiusResolver: chromeContext => chromeContext.CellSize + chromeContext.GlobalCornerRadiusScale);
|
||||
cornerRadiusResolver: chromeContext => chromeContext.CornerRadiusTokens.Component.TopLeft);
|
||||
|
||||
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
|
||||
var resolved = resolver(CreateChromeContext(cellSize: 50, globalScale: 2.5));
|
||||
var resolved = resolver(CreateChromeContext(cellSize: 50));
|
||||
|
||||
Assert.Equal(52.5, resolved, 3);
|
||||
Assert.Equal(24.0, resolved, 3);
|
||||
}
|
||||
|
||||
private static ComponentChromeContext CreateChromeContext(double cellSize, double globalScale)
|
||||
private static ComponentChromeContext CreateChromeContext(double cellSize)
|
||||
{
|
||||
return new ComponentChromeContext(
|
||||
ComponentId: "test.component",
|
||||
PlacementId: null,
|
||||
CellSize: cellSize,
|
||||
GlobalCornerRadiusScale: globalScale,
|
||||
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(12),
|
||||
new CornerRadius(14),
|
||||
new CornerRadius(20),
|
||||
new CornerRadius(28),
|
||||
new CornerRadius(32),
|
||||
new CornerRadius(36),
|
||||
new CornerRadius(8)));
|
||||
Micro: new CornerRadius(6),
|
||||
Xs: new CornerRadius(12),
|
||||
Sm: new CornerRadius(14),
|
||||
Md: new CornerRadius(20),
|
||||
Lg: new CornerRadius(28),
|
||||
Xl: new CornerRadius(32),
|
||||
Island: new CornerRadius(36),
|
||||
Component: new CornerRadius(24)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,26 +48,27 @@ public sealed class InfoRecommendationHostCornerRadiusTests
|
||||
registry.TryGetDescriptor(componentId, out var descriptor),
|
||||
$"Missing runtime registration for '{componentId}'.");
|
||||
|
||||
var zero = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 0d));
|
||||
var unit = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 1d));
|
||||
var max = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 2.5d));
|
||||
var sharp = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Sharp"));
|
||||
var balanced = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Balanced"));
|
||||
var rounded = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Rounded"));
|
||||
var open = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Open"));
|
||||
|
||||
Assert.Equal(0d, zero, 3);
|
||||
Assert.Equal(18d, unit, 3);
|
||||
Assert.Equal(45d, max, 3);
|
||||
Assert.True(zero <= unit && unit <= max);
|
||||
// All info widgets should resolve to the Component token in the new system
|
||||
Assert.Equal(20d, sharp, 3);
|
||||
Assert.Equal(24d, balanced, 3);
|
||||
Assert.Equal(28d, rounded, 3);
|
||||
Assert.Equal(32d, open, 3);
|
||||
}
|
||||
|
||||
private static ComponentChromeContext CreateChromeContext(
|
||||
string componentId,
|
||||
double cellSize,
|
||||
double globalScale)
|
||||
string style)
|
||||
{
|
||||
return new ComponentChromeContext(
|
||||
componentId,
|
||||
null,
|
||||
cellSize,
|
||||
globalScale,
|
||||
AppearanceCornerRadiusTokenFactory.Create(globalScale));
|
||||
AppearanceCornerRadiusTokenFactory.Create(style));
|
||||
}
|
||||
}
|
||||
|
||||
113
LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs
Normal file
113
LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class StudyAnalyticsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SnapshotUpdated_UsesUiPublishThrottle()
|
||||
{
|
||||
using var recorder = new FakeAudioRecorderService();
|
||||
using var service = new StudyAnalyticsService(recorder);
|
||||
service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
|
||||
|
||||
var updateCount = 0;
|
||||
service.SnapshotUpdated += (_, _) => Interlocked.Increment(ref updateCount);
|
||||
|
||||
Assert.True(service.StartOrResumeMonitoring());
|
||||
Thread.Sleep(280);
|
||||
Assert.True(service.PauseMonitoring());
|
||||
|
||||
var totalUpdates = Volatile.Read(ref updateCount);
|
||||
Assert.InRange(totalUpdates, 2, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_ReusesRealtimeBufferSnapshot_WhenNoNewFramesArrive()
|
||||
{
|
||||
using var recorder = new FakeAudioRecorderService();
|
||||
using var service = new StudyAnalyticsService(recorder);
|
||||
service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
|
||||
|
||||
using var firstUpdate = new ManualResetEventSlim(false);
|
||||
service.SnapshotUpdated += (_, args) =>
|
||||
{
|
||||
if (args.Snapshot.RealtimeBuffer.Count > 0)
|
||||
{
|
||||
firstUpdate.Set();
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(service.StartOrResumeMonitoring());
|
||||
Assert.True(firstUpdate.Wait(TimeSpan.FromSeconds(2)));
|
||||
Assert.True(service.PauseMonitoring());
|
||||
|
||||
var firstSnapshot = service.GetSnapshot();
|
||||
var secondSnapshot = service.GetSnapshot();
|
||||
|
||||
Assert.NotEmpty(firstSnapshot.RealtimeBuffer);
|
||||
Assert.Same(firstSnapshot.RealtimeBuffer, secondSnapshot.RealtimeBuffer);
|
||||
}
|
||||
|
||||
private sealed class FakeAudioRecorderService : IAudioRecorderService
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Ready;
|
||||
|
||||
public AudioRecorderSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return new AudioRecorderSnapshot(
|
||||
State: _state,
|
||||
Duration: TimeSpan.Zero,
|
||||
InputLevel: _state == AudioRecorderRuntimeState.Recording ? 0.55 : 0,
|
||||
LastSavedFilePath: string.Empty,
|
||||
LastError: string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public bool StartOrResume()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Recording;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Pause()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Paused;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public string? StopAndSave(string? outputPath = null)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Ready;
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
|
||||
public void Discard()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Ready;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -664,7 +664,7 @@ public partial class App : Application
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
|
||||
@@ -46,4 +46,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
||||
public const string DesktopFileManager = "DesktopFileManager";
|
||||
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
||||
public const string DesktopShortcut = "DesktopShortcut";
|
||||
}
|
||||
|
||||
@@ -420,6 +420,16 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
"快捷方式",
|
||||
"App",
|
||||
"File",
|
||||
MinWidthCells: 1,
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free)
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||
|
||||
public string CornerRadiusStyle { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
|
||||
|
||||
public string ThemeColorMode { get; set; } = "default_neutral";
|
||||
|
||||
public string SystemMaterialMode { get; set; } = "none";
|
||||
|
||||
@@ -123,6 +123,25 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shortcut Component Settings (快捷方式组件设置)
|
||||
|
||||
/// <summary>
|
||||
/// 快捷方式目标路径
|
||||
/// </summary>
|
||||
public string? ShortcutTargetPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 点击模式:Single(单击打开) 或 Double(双击打开)
|
||||
/// </summary>
|
||||
public string ShortcutClickMode { get; set; } = "Double";
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示背景
|
||||
/// </summary>
|
||||
public bool ShortcutShowBackground { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
|
||||
@@ -37,6 +37,7 @@ public enum StudyDataMode
|
||||
|
||||
public sealed record StudyAnalyticsConfig(
|
||||
int FrameMs = 50,
|
||||
int UiPublishIntervalMs = 125,
|
||||
int SliceSec = 30,
|
||||
double ScoreThresholdDbfs = -50,
|
||||
int SegmentMergeGapMs = 500,
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed record AppearanceThemeSnapshot(
|
||||
string ThemeColorMode,
|
||||
string? UserThemeColor,
|
||||
string? SelectedWallpaperSeed,
|
||||
double GlobalCornerRadiusScale,
|
||||
string CornerRadiusStyle,
|
||||
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||
string ResolvedSeedSource,
|
||||
MonetPalette MonetPalette,
|
||||
@@ -551,7 +551,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
if (!refreshAll &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) &&
|
||||
!(respondsToThemeColor &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
|
||||
!(respondsToWallpaper &&
|
||||
@@ -573,8 +573,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
bool queueWallpaperPaletteBuild)
|
||||
{
|
||||
var availableModes = _windowMaterialService.GetAvailableModes();
|
||||
var globalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(themeState.GlobalCornerRadiusScale);
|
||||
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(globalCornerRadiusScale);
|
||||
var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(themeState.CornerRadiusStyle);
|
||||
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(cornerRadiusStyle);
|
||||
MonetPalette palette;
|
||||
IReadOnlyList<Color> wallpaperSeedCandidates;
|
||||
Color effectiveSeedColor;
|
||||
@@ -614,7 +614,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
themeColorMode,
|
||||
themeState.ThemeColor,
|
||||
selectedWallpaperSeed,
|
||||
globalCornerRadiusScale,
|
||||
cornerRadiusStyle,
|
||||
cornerRadiusTokens,
|
||||
resolvedSeedSource,
|
||||
palette,
|
||||
|
||||
@@ -272,7 +272,12 @@ public static class DesktopComponentEditorRegistryFactory
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
context => new NotificationBoxComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d)
|
||||
preferredHeight: 520d),
|
||||
[BuiltInComponentIds.DesktopShortcut] = new(
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
context => new ShortcutComponentEditor(context),
|
||||
preferredWidth: 420d,
|
||||
preferredHeight: 400d)
|
||||
};
|
||||
|
||||
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
||||
|
||||
@@ -129,7 +129,6 @@ public static class DesktopComponentRegistryFactory
|
||||
settingsService);
|
||||
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||
var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens),
|
||||
ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light"));
|
||||
var pluginContext = new PluginDesktopComponentContext(
|
||||
@@ -157,7 +156,6 @@ public static class DesktopComponentRegistryFactory
|
||||
private static IPluginAppearanceContext CreatePluginAppearanceContext(ComponentChromeContext chromeContext)
|
||||
{
|
||||
return new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: chromeContext.GlobalCornerRadiusScale,
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(chromeContext.CornerRadiusTokens),
|
||||
ThemeVariant: "Unknown"));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ public sealed record ComponentLibraryCategoryEntry(
|
||||
|
||||
public sealed record ComponentLibraryCreateContext(
|
||||
double CellSize,
|
||||
double GlobalCornerRadiusScale,
|
||||
TimeZoneService TimeZoneService,
|
||||
IWeatherInfoService WeatherInfoService,
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed record ThemeAppearanceSettingsState(
|
||||
bool IsNightMode,
|
||||
string? ThemeColor,
|
||||
bool UseSystemChrome,
|
||||
double GlobalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
string CornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle,
|
||||
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
|
||||
string? SelectedWallpaperSeed = null);
|
||||
|
||||
@@ -254,11 +254,19 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
public ThemeAppearanceSettingsState Get()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(snapshot.CornerRadiusStyle);
|
||||
if (string.Equals(cornerRadiusStyle, GlobalAppearanceSettings.DefaultCornerRadiusStyle, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.IsNullOrWhiteSpace(snapshot.CornerRadiusStyle) &&
|
||||
Math.Abs(snapshot.GlobalCornerRadiusScale - GlobalAppearanceSettings.DefaultCornerRadiusScale) > 0.01)
|
||||
{
|
||||
cornerRadiusStyle = GlobalAppearanceSettings.MigrateScaleToStyle(snapshot.GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
return new ThemeAppearanceSettingsState(
|
||||
snapshot.IsNightMode ?? false,
|
||||
snapshot.ThemeColor,
|
||||
snapshot.UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale),
|
||||
cornerRadiusStyle,
|
||||
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
|
||||
snapshot.SelectedWallpaperSeed);
|
||||
@@ -269,7 +277,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
var snapshot = _settingsService.Load();
|
||||
var changedKeys = new List<string>();
|
||||
var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor;
|
||||
var normalizedCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(state.GlobalCornerRadiusScale);
|
||||
var normalizedCornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(state.CornerRadiusStyle);
|
||||
var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor);
|
||||
var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode);
|
||||
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
|
||||
@@ -294,10 +302,10 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome));
|
||||
}
|
||||
|
||||
if (Math.Abs(GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale) - normalizedCornerRadiusScale) > 0.0001d)
|
||||
if (!string.Equals(GlobalAppearanceSettings.NormalizeCornerRadiusStyle(snapshot.CornerRadiusStyle), normalizedCornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
snapshot.GlobalCornerRadiusScale = normalizedCornerRadiusScale;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale));
|
||||
snapshot.CornerRadiusStyle = normalizedCornerRadiusStyle;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.CornerRadiusStyle));
|
||||
}
|
||||
|
||||
if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -12,8 +12,13 @@ internal readonly record struct NoisePipelineTickResult(
|
||||
internal sealed class NoiseFramePipeline
|
||||
{
|
||||
private StudyAnalyticsConfig _config;
|
||||
private readonly Queue<NoiseRealtimePoint> _realtimeBuffer = new();
|
||||
private readonly List<NoiseRealtimePoint> _slicePoints = [];
|
||||
private NoiseRealtimePoint[] _realtimeBuffer;
|
||||
private IReadOnlyList<NoiseRealtimePoint> _realtimeSnapshot = Array.Empty<NoiseRealtimePoint>();
|
||||
private int _realtimeBufferStart;
|
||||
private int _realtimeBufferCount;
|
||||
private int _realtimeBufferVersion;
|
||||
private int _realtimeSnapshotVersion = -1;
|
||||
|
||||
private DateTimeOffset _sliceStartAt;
|
||||
private DateTimeOffset _lastFrameAt;
|
||||
@@ -28,18 +33,29 @@ internal sealed class NoiseFramePipeline
|
||||
public NoiseFramePipeline(StudyAnalyticsConfig config)
|
||||
{
|
||||
_config = NormalizeConfig(config);
|
||||
_realtimeBuffer = new NoiseRealtimePoint[_config.RealtimeBufferCapacity];
|
||||
}
|
||||
|
||||
public void UpdateConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
_config = NormalizeConfig(config);
|
||||
var normalized = NormalizeConfig(config);
|
||||
if (normalized.RealtimeBufferCapacity != _config.RealtimeBufferCapacity)
|
||||
{
|
||||
_realtimeBuffer = new NoiseRealtimePoint[normalized.RealtimeBufferCapacity];
|
||||
}
|
||||
|
||||
_config = normalized;
|
||||
Reset();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_realtimeBuffer.Clear();
|
||||
_slicePoints.Clear();
|
||||
_realtimeBufferStart = 0;
|
||||
_realtimeBufferCount = 0;
|
||||
_realtimeBufferVersion++;
|
||||
_realtimeSnapshot = Array.Empty<NoiseRealtimePoint>();
|
||||
_realtimeSnapshotVersion = -1;
|
||||
_sliceStartAt = default;
|
||||
_lastFrameAt = default;
|
||||
_lastOverThresholdAt = default;
|
||||
@@ -52,7 +68,27 @@ internal sealed class NoiseFramePipeline
|
||||
|
||||
public IReadOnlyList<NoiseRealtimePoint> GetRealtimeBufferSnapshot()
|
||||
{
|
||||
return _realtimeBuffer.ToArray();
|
||||
if (_realtimeBufferCount == 0)
|
||||
{
|
||||
return Array.Empty<NoiseRealtimePoint>();
|
||||
}
|
||||
|
||||
if (_realtimeSnapshotVersion == _realtimeBufferVersion)
|
||||
{
|
||||
return _realtimeSnapshot;
|
||||
}
|
||||
|
||||
var snapshot = new NoiseRealtimePoint[_realtimeBufferCount];
|
||||
var firstSegmentLength = Math.Min(_realtimeBufferCount, _realtimeBuffer.Length - _realtimeBufferStart);
|
||||
Array.Copy(_realtimeBuffer, _realtimeBufferStart, snapshot, 0, firstSegmentLength);
|
||||
if (firstSegmentLength < _realtimeBufferCount)
|
||||
{
|
||||
Array.Copy(_realtimeBuffer, 0, snapshot, firstSegmentLength, _realtimeBufferCount - firstSegmentLength);
|
||||
}
|
||||
|
||||
_realtimeSnapshot = snapshot;
|
||||
_realtimeSnapshotVersion = _realtimeBufferVersion;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak)
|
||||
@@ -114,12 +150,7 @@ internal sealed class NoiseFramePipeline
|
||||
peak,
|
||||
isOverThreshold);
|
||||
_slicePoints.Add(point);
|
||||
_realtimeBuffer.Enqueue(point);
|
||||
|
||||
while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity)
|
||||
{
|
||||
_realtimeBuffer.Dequeue();
|
||||
}
|
||||
AddRealtimePoint(point);
|
||||
|
||||
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
|
||||
if (elapsedSeconds + 1e-6 < _config.SliceSec)
|
||||
@@ -132,6 +163,29 @@ internal sealed class NoiseFramePipeline
|
||||
return new NoisePipelineTickResult(point, slice);
|
||||
}
|
||||
|
||||
private void AddRealtimePoint(NoiseRealtimePoint point)
|
||||
{
|
||||
if (_realtimeBuffer.Length == 0)
|
||||
{
|
||||
_realtimeBuffer = new NoiseRealtimePoint[Math.Max(1, _config.RealtimeBufferCapacity)];
|
||||
}
|
||||
|
||||
if (_realtimeBufferCount < _realtimeBuffer.Length)
|
||||
{
|
||||
var writeIndex = (_realtimeBufferStart + _realtimeBufferCount) % _realtimeBuffer.Length;
|
||||
_realtimeBuffer[writeIndex] = point;
|
||||
_realtimeBufferCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_realtimeBuffer[_realtimeBufferStart] = point;
|
||||
_realtimeBufferStart = (_realtimeBufferStart + 1) % _realtimeBuffer.Length;
|
||||
}
|
||||
|
||||
_realtimeBufferVersion++;
|
||||
_realtimeSnapshotVersion = -1;
|
||||
}
|
||||
|
||||
private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt)
|
||||
{
|
||||
var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
|
||||
@@ -247,6 +301,7 @@ internal sealed class NoiseFramePipeline
|
||||
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||
var uiPublishIntervalMs = Math.Clamp(config.UiPublishIntervalMs, 50, 500);
|
||||
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||
@@ -259,6 +314,7 @@ internal sealed class NoiseFramePipeline
|
||||
return config with
|
||||
{
|
||||
FrameMs = frameMs,
|
||||
UiPublishIntervalMs = uiPublishIntervalMs,
|
||||
SliceSec = sliceSec,
|
||||
ScoreThresholdDbfs = threshold,
|
||||
SegmentMergeGapMs = mergeGapMs,
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
private readonly List<StudySessionReport> _sessionHistory = [];
|
||||
private string? _selectedSessionReportId;
|
||||
private string _lastError = string.Empty;
|
||||
private DateTimeOffset _lastUiPublishedAt;
|
||||
private bool _disposed;
|
||||
|
||||
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
|
||||
@@ -102,6 +103,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
ThrowIfDisposedLocked();
|
||||
_config = NormalizeConfig(config);
|
||||
_pipeline.UpdateConfig(_config);
|
||||
_lastUiPublishedAt = default;
|
||||
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
StartTimerLocked();
|
||||
@@ -546,7 +548,11 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
|
||||
_lastError = string.Empty;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(now);
|
||||
if (ShouldPublishRealtimeSnapshotLocked(now, closedSlice is not null))
|
||||
{
|
||||
snapshot = BuildSnapshotLocked(now);
|
||||
_lastUiPublishedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,6 +605,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
|
||||
private void StartTimerLocked()
|
||||
{
|
||||
_lastUiPublishedAt = default;
|
||||
_samplingTimer.Change(
|
||||
dueTime: TimeSpan.Zero,
|
||||
period: TimeSpan.FromMilliseconds(_config.FrameMs));
|
||||
@@ -673,6 +680,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||
var uiPublishIntervalMs = Math.Clamp(config.UiPublishIntervalMs, 50, 500);
|
||||
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||
@@ -685,6 +693,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
return config with
|
||||
{
|
||||
FrameMs = frameMs,
|
||||
UiPublishIntervalMs = uiPublishIntervalMs,
|
||||
SliceSec = sliceSec,
|
||||
ScoreThresholdDbfs = threshold,
|
||||
SegmentMergeGapMs = mergeGapMs,
|
||||
@@ -696,6 +705,16 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
};
|
||||
}
|
||||
|
||||
private bool ShouldPublishRealtimeSnapshotLocked(DateTimeOffset now, bool hasClosedSlice)
|
||||
{
|
||||
if (hasClosedSlice || _lastUiPublishedAt == default)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return (now - _lastUiPublishedAt).TotalMilliseconds >= _config.UiPublishIntervalMs;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposedLocked()
|
||||
{
|
||||
if (_disposed)
|
||||
|
||||
@@ -222,10 +222,37 @@
|
||||
</Style>
|
||||
|
||||
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||
<Style Selector="Border.glass-panel" />
|
||||
<Style Selector="Border.glass-strong" />
|
||||
<Style Selector="Border.glass-island" />
|
||||
<Style Selector="Border.mica-strong" />
|
||||
<Style Selector="Border.glass-overlay" />
|
||||
<Style Selector="Border.glass-panel">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.2" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-island">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.mica-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-overlay">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
public string Greeting { get; } = "A modern desktop shell powered by FluentAvalonia.";
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenDesignSpec(string? fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return;
|
||||
|
||||
var fullPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "docs", fileName);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
// Try relative to project root in dev
|
||||
fullPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "docs", fileName));
|
||||
}
|
||||
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = fullPath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,10 +614,10 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
private string _systemMaterialLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusLabel = string.Empty;
|
||||
private string _cornerRadiusStyleLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusDescription = string.Empty;
|
||||
private string _cornerRadiusStyleDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeHeader = string.Empty;
|
||||
@@ -701,6 +701,15 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public IBrush NeutralDarkPreviewBrush => NeutralDarkBrushValue;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption? _selectedCornerRadiusStyle;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var theme = _settingsFacade.Theme.Get();
|
||||
@@ -740,29 +749,14 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
partial void OnGlobalCornerRadiusScaleChanged(double value)
|
||||
partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
|
||||
if (Math.Abs(normalized - value) > 0.0001d)
|
||||
{
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
GlobalCornerRadiusScale = normalized;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
CornerRadiusStyle = value.Value;
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
@@ -830,8 +824,12 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
|
||||
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
|
||||
SystemMaterialLabel = L("settings.appearance.system_material_label", "System material");
|
||||
GlobalCornerRadiusLabel = L("settings.appearance.corner_radius.label", "Global corner radius");
|
||||
GlobalCornerRadiusDescription = L("settings.appearance.corner_radius.description", "Adjust the shared radius scale used by cards, panels, and component containers.");
|
||||
CornerRadiusStyleLabel = L("settings.appearance.corner_radius.label", "Global corner radius style");
|
||||
CornerRadiusStyleDescription = L("settings.appearance.corner_radius.description", "Select a fixed corner radius style inspired by Xiaomi HyperOS.");
|
||||
|
||||
CornerRadiusStyleOptions = GlobalAppearanceSettings.AllCornerRadiusStyles
|
||||
.Select(style => new SelectionOption(style, L($"settings.appearance.corner_radius.style_{style.ToLower()}", style)))
|
||||
.ToList();
|
||||
ThemeSourceNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral");
|
||||
ThemeSourceUserColorText = L("settings.appearance.theme_color_mode.user", "User theme color Monet");
|
||||
ThemeSourceWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet");
|
||||
@@ -876,7 +874,10 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
IsNightMode = theme.IsNightMode;
|
||||
ThemeColor = theme.ThemeColor ?? string.Empty;
|
||||
UseSystemChrome = theme.UseSystemChrome;
|
||||
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
|
||||
CornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(theme.CornerRadiusStyle);
|
||||
SelectedCornerRadiusStyle = CornerRadiusStyleOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, CornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
|
||||
?? CornerRadiusStyleOptions.FirstOrDefault(o => o.Value == GlobalAppearanceSettings.DefaultCornerRadiusStyle);
|
||||
_selectedWallpaperSeed = theme.SelectedWallpaperSeed;
|
||||
SyncCustomSeedPickerWithSavedThemeColor();
|
||||
|
||||
@@ -926,7 +927,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
IsNightMode,
|
||||
themeColor,
|
||||
UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
|
||||
themeColorMode,
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
|
||||
_selectedWallpaperSeed);
|
||||
@@ -1070,20 +1071,22 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
private string _spacingPresetLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _globalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||
private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
|
||||
|
||||
public double GlobalCornerRadiusMinimum => GlobalAppearanceSettings.MinimumCornerRadiusScale;
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = [];
|
||||
|
||||
public double GlobalCornerRadiusMaximum => GlobalAppearanceSettings.MaximumCornerRadiusScale;
|
||||
[ObservableProperty]
|
||||
private SelectionOption? _selectedCornerRadiusStyle;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _componentRadiusHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusLabel = string.Empty;
|
||||
private string _cornerRadiusStyleLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusDescription = string.Empty;
|
||||
private string _cornerRadiusStyleDescription = string.Empty;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
@@ -1096,7 +1099,10 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
?? SpacingPresets[1];
|
||||
|
||||
var theme = _settingsFacade.Theme.Get();
|
||||
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
|
||||
CornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(theme.CornerRadiusStyle);
|
||||
SelectedCornerRadiusStyle = CornerRadiusStyleOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, CornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
|
||||
?? CornerRadiusStyleOptions.FirstOrDefault(o => o.Value == GlobalAppearanceSettings.DefaultCornerRadiusStyle);
|
||||
}
|
||||
|
||||
partial void OnShortSideCellsChanged(int value)
|
||||
@@ -1129,29 +1135,14 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
SaveGrid();
|
||||
}
|
||||
|
||||
partial void OnGlobalCornerRadiusScaleChanged(double value)
|
||||
partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
|
||||
if (Math.Abs(normalized - value) > 0.0001d)
|
||||
{
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
GlobalCornerRadiusScale = normalized;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
CornerRadiusStyle = value.Value;
|
||||
SaveComponentCornerRadius();
|
||||
}
|
||||
|
||||
@@ -1170,7 +1161,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
theme.IsNightMode,
|
||||
theme.ThemeColor,
|
||||
theme.UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
|
||||
theme.ThemeColorMode,
|
||||
theme.SystemMaterialMode,
|
||||
theme.SelectedWallpaperSeed));
|
||||
@@ -1194,10 +1185,14 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
EdgeInsetPercentLabel = L("settings.components.edge_inset_label", "Screen Inset");
|
||||
SpacingPresetLabel = L("settings.components.spacing_label", "Component Spacing");
|
||||
ComponentRadiusHeader = L("settings.components.corner_radius.header", "Corner Design");
|
||||
GlobalCornerRadiusLabel = L("settings.components.corner_radius.label", "Component Corner Radius");
|
||||
GlobalCornerRadiusDescription = L(
|
||||
CornerRadiusStyleLabel = L("settings.components.corner_radius.label", "Component Corner Radius Style");
|
||||
CornerRadiusStyleDescription = L(
|
||||
"settings.components.corner_radius.description",
|
||||
"Adjust the shared corner radius from a square edge to a capsule-like shape, and expand the internal safe area with it.");
|
||||
"Select a fixed corner radius style (inspired by Xiaomi HyperOS) to ensure consistency across all components.");
|
||||
|
||||
CornerRadiusStyleOptions = GlobalAppearanceSettings.AllCornerRadiusStyles
|
||||
.Select(style => new SelectionOption(style, L($"settings.appearance.corner_radius.style_{style.ToLower()}", style)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
|
||||
87
LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs
Normal file
87
LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed partial class ShortcutEditorViewModel : ViewModelBase
|
||||
{
|
||||
private readonly DesktopComponentEditorContext? _context;
|
||||
private bool _isInitializing;
|
||||
|
||||
public ShortcutEditorViewModel(DesktopComponentEditorContext? context)
|
||||
{
|
||||
_context = context;
|
||||
|
||||
ClickModeOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("Double", "双击打开"),
|
||||
new("Single", "单击打开")
|
||||
};
|
||||
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>()
|
||||
?? new ComponentSettingsSnapshot();
|
||||
|
||||
_isInitializing = true;
|
||||
|
||||
TargetPath = snapshot.ShortcutTargetPath ?? string.Empty;
|
||||
SelectedClickMode = ClickModeOptions.FirstOrDefault(o => o.Value == snapshot.ShortcutClickMode)
|
||||
?? ClickModeOptions[0];
|
||||
ShowBackground = snapshot.ShortcutShowBackground;
|
||||
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
if (_isInitializing || _context == null) return;
|
||||
|
||||
var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
|
||||
snapshot.ShortcutTargetPath = string.IsNullOrWhiteSpace(TargetPath) ? null : TargetPath;
|
||||
snapshot.ShortcutClickMode = SelectedClickMode?.Value ?? "Double";
|
||||
snapshot.ShortcutShowBackground = ShowBackground;
|
||||
|
||||
_context.ComponentSettingsAccessor.SaveSnapshot(snapshot);
|
||||
|
||||
_context.HostContext.RequestRefresh();
|
||||
}
|
||||
|
||||
[ObservableProperty] private string _descriptionText = "配置此快捷方式组件的目标路径和打开方式。这些设置仅作用于当前组件实例。";
|
||||
[ObservableProperty] private string _targetPathLabel = "目标路径";
|
||||
[ObservableProperty] private string _targetPathPlaceholder = "未选择目标";
|
||||
[ObservableProperty] private string _browseButtonText = "浏览...";
|
||||
[ObservableProperty] private string _clearButtonText = "清除";
|
||||
[ObservableProperty] private string _clickModeLabel = "打开方式";
|
||||
[ObservableProperty] private string _backgroundLabel = "显示背景";
|
||||
[ObservableProperty] private string _backgroundDescription = "关闭后组件背景将变为透明。";
|
||||
|
||||
[ObservableProperty] private string _targetPath = string.Empty;
|
||||
[ObservableProperty] private SelectionOption? _selectedClickMode;
|
||||
[ObservableProperty] private bool _showBackground = true;
|
||||
|
||||
public ObservableCollection<SelectionOption> ClickModeOptions { get; }
|
||||
|
||||
public void SetTargetPath(string? path)
|
||||
{
|
||||
TargetPath = path ?? string.Empty;
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
public void ClearTargetPath()
|
||||
{
|
||||
TargetPath = string.Empty;
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
partial void OnSelectedClickModeChanged(SelectionOption? value) => SaveSettings();
|
||||
partial void OnShowBackgroundChanged(bool value) => SaveSettings();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor"
|
||||
x:DataType="vm:ShortcutEditorViewModel">
|
||||
|
||||
<StackPanel Spacing="16">
|
||||
<!-- 说明卡片 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<TextBlock Text="{Binding DescriptionText}"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- 目标路径 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding TargetPathLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Text="{Binding TargetPath}"
|
||||
IsReadOnly="True"
|
||||
Watermark="{Binding TargetPathPlaceholder}"
|
||||
Grid.Column="0" />
|
||||
<Button Content="{Binding BrowseButtonText}"
|
||||
Click="OnBrowseClick"
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0" />
|
||||
</Grid>
|
||||
<Button Content="{Binding ClearButtonText}"
|
||||
Click="OnClearClick"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 打开方式 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding ClickModeLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox ItemsSource="{Binding ClickModeOptions}"
|
||||
SelectedItem="{Binding SelectedClickMode}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 背景设置 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding BackgroundLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock Text="{Binding BackgroundDescription}"
|
||||
Classes="component-editor-secondary-text" />
|
||||
<CheckBox IsChecked="{Binding ShowBackground}"
|
||||
Content="{Binding BackgroundLabel}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class ShortcutComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
private ShortcutEditorViewModel? _viewModel;
|
||||
|
||||
public ShortcutComponentEditor()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public ShortcutComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = new ShortcutEditorViewModel(context);
|
||||
DataContext = _viewModel;
|
||||
}
|
||||
|
||||
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel?.StorageProvider is not { } storageProvider)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new FilePickerOpenOptions
|
||||
{
|
||||
Title = "选择目标文件",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("可执行文件")
|
||||
{
|
||||
Patterns = ["*.exe", "*.lnk", "*.bat", "*.cmd"]
|
||||
},
|
||||
new FilePickerFileType("所有文件")
|
||||
{
|
||||
Patterns = ["*.*"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var files = await storageProvider.OpenFilePickerAsync(options);
|
||||
var localPath = files.FirstOrDefault()?.TryGetLocalPath();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(localPath))
|
||||
{
|
||||
var folderOptions = new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "选择目标文件夹",
|
||||
AllowMultiple = false
|
||||
};
|
||||
|
||||
var folders = await storageProvider.OpenFolderPickerAsync(folderOptions);
|
||||
localPath = folders.FirstOrDefault()?.TryGetLocalPath();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(localPath))
|
||||
{
|
||||
_viewModel?.SetTargetPath(localPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClearClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_viewModel?.ClearTargetPath();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -552,7 +552,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
Width = 160,
|
||||
Height = 90,
|
||||
CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16, 8, 22),
|
||||
CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16, 8, 22),
|
||||
ClipToBounds = true,
|
||||
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
|
||||
IsHitTestVisible = false
|
||||
@@ -647,8 +647,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
News1ImageHost.Height = imageHeight;
|
||||
News2ImageHost.Width = imageWidth;
|
||||
News2ImageHost.Height = imageHeight;
|
||||
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
|
||||
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
|
||||
News1ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
News2ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
|
||||
@@ -691,7 +691,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
row.ImageHost.Width = imageWidth;
|
||||
row.ImageHost.Height = imageHeight;
|
||||
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
|
||||
row.ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
|
||||
row.TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
|
||||
@@ -3,13 +3,14 @@ using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal static class ComponentChromeCornerRadiusHelper
|
||||
{
|
||||
public static double ResolveMainRectangleRadiusValue(ComponentChromeContext? chromeContext = null, double fallback = 18d)
|
||||
public static double ResolveMainRectangleRadiusValue(ComponentChromeContext? chromeContext = null, double fallback = 24d)
|
||||
{
|
||||
if (chromeContext is not null)
|
||||
{
|
||||
@@ -20,7 +21,7 @@ internal static class ComponentChromeCornerRadiusHelper
|
||||
var resolved = snapshot.CornerRadiusTokens.Component.TopLeft;
|
||||
return double.IsFinite(resolved)
|
||||
? Math.Max(0d, resolved)
|
||||
: Math.Max(0d, fallback * ResolveScale(chromeContext));
|
||||
: Math.Max(0d, fallback);
|
||||
}
|
||||
|
||||
public static CornerRadius ResolveMainRectangleRadius(ComponentChromeContext? chromeContext = null, double fallback = 24d)
|
||||
@@ -28,24 +29,6 @@ internal static class ComponentChromeCornerRadiusHelper
|
||||
return new CornerRadius(ResolveMainRectangleRadiusValue(chromeContext, fallback));
|
||||
}
|
||||
|
||||
public static double ResolveScale(ComponentChromeContext? chromeContext = null)
|
||||
{
|
||||
if (chromeContext is not null)
|
||||
{
|
||||
return Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
return Math.Max(
|
||||
GlobalAppearanceSettings.MinimumCornerRadiusScale,
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
public static CornerRadius Scale(double baseRadius, double min, double max, ComponentChromeContext? chromeContext = null)
|
||||
{
|
||||
var scale = ResolveScale(chromeContext);
|
||||
return new CornerRadius(Math.Clamp(baseRadius * scale, min * scale, max * scale));
|
||||
}
|
||||
|
||||
public static void Apply(CornerRadius radius, params Border?[] chromeLayers)
|
||||
{
|
||||
foreach (var chromeLayer in chromeLayers)
|
||||
@@ -67,28 +50,57 @@ internal static class ComponentChromeCornerRadiusHelper
|
||||
: new CornerRadius(fallback);
|
||||
}
|
||||
|
||||
public static double ScaleValue(double value, ComponentChromeContext? chromeContext = null)
|
||||
public static double SafeValue(double value, double min, double max, ComponentChromeContext? context = null)
|
||||
{
|
||||
return value * ResolveScale(chromeContext);
|
||||
_ = context;
|
||||
return Math.Clamp(value, min, max);
|
||||
}
|
||||
|
||||
public static double ResolveContentSafetyScale(
|
||||
ComponentChromeContext? chromeContext = null,
|
||||
double responsiveness = 0.45d)
|
||||
public static double Scale(double value, double min, double max, ComponentChromeContext? context = null)
|
||||
{
|
||||
var scale = ResolveScale(chromeContext);
|
||||
var normalizedResponsiveness = Math.Clamp(responsiveness, 0d, 1d);
|
||||
return 1d + ((scale - 1d) * normalizedResponsiveness);
|
||||
_ = context;
|
||||
return Math.Clamp(value, min, max);
|
||||
}
|
||||
|
||||
public static double SafeValue(
|
||||
double baseValue,
|
||||
double min,
|
||||
double max,
|
||||
ComponentChromeContext? chromeContext = null,
|
||||
double responsiveness = 0.45d)
|
||||
public static CornerRadius SafeRadius(double value, double min, double max, ComponentChromeContext? context = null)
|
||||
{
|
||||
var safetyScale = ResolveContentSafetyScale(chromeContext, responsiveness);
|
||||
return Math.Clamp(baseValue * safetyScale, min * safetyScale, max * safetyScale);
|
||||
_ = context;
|
||||
return new CornerRadius(Math.Clamp(value, min, max));
|
||||
}
|
||||
|
||||
public static CornerRadius ScaleRadius(double value, double min, double max, ComponentChromeContext? context = null)
|
||||
{
|
||||
_ = context;
|
||||
return new CornerRadius(Math.Clamp(value, min, max));
|
||||
}
|
||||
|
||||
public static double Mini(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Micro.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusMicro", 6).TopLeft;
|
||||
}
|
||||
|
||||
public static double Micro(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Micro.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusMicro", 6).TopLeft;
|
||||
}
|
||||
|
||||
public static double Small(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Sm.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusSm", 14).TopLeft;
|
||||
}
|
||||
|
||||
public static double Medium(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Md.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusMd", 20).TopLeft;
|
||||
}
|
||||
|
||||
public static double Large(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Lg.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusLg", 28).TopLeft;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ public sealed class DesktopComponentRuntimeRegistration
|
||||
_ => controlFactory(),
|
||||
cornerRadiusResolver is null
|
||||
? null
|
||||
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) *
|
||||
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
|
||||
: chromeContext => cornerRadiusResolver(chromeContext.CellSize))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -55,8 +54,7 @@ public sealed class DesktopComponentRuntimeRegistration
|
||||
controlFactory,
|
||||
cornerRadiusResolver is null
|
||||
? null
|
||||
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) *
|
||||
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
|
||||
: chromeContext => cornerRadiusResolver(chromeContext.CellSize))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -131,7 +129,6 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
Definition.Id,
|
||||
placementId,
|
||||
cellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
appearanceSnapshot.CornerRadiusTokens);
|
||||
var control = _controlFactory(new DesktopComponentControlFactoryContext(
|
||||
Definition,
|
||||
@@ -226,8 +223,7 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
Definition.Id,
|
||||
null,
|
||||
Math.Max(1, cellSize),
|
||||
1d,
|
||||
AppearanceCornerRadiusTokenFactory.Create(1d)));
|
||||
AppearanceCornerRadiusTokenFactory.Create(GlobalAppearanceSettings.DefaultCornerRadiusStyle)));
|
||||
}
|
||||
|
||||
private static void ApplySettingsDependencies(
|
||||
@@ -483,7 +479,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"component.notification_box",
|
||||
() => new NotificationBoxWidget())
|
||||
() => new NotificationBoxWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
"component.shortcut",
|
||||
() => new ShortcutWidget())
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -730,7 +730,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
|
||||
|
||||
_imageHost.Width = imageWidth;
|
||||
_imageHost.Height = imageHeight;
|
||||
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
|
||||
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(imageHeight * 0.15, 8, 16);
|
||||
|
||||
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
|
||||
_titleTextBlock.MaxWidth = textWidth;
|
||||
|
||||
46
LanMountainDesktop/Views/Components/ShortcutWidget.axaml
Normal file
46
LanMountainDesktop/Views/Components/ShortcutWidget.axaml
Normal file
@@ -0,0 +1,46 @@
|
||||
<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"
|
||||
d:DesignWidth="96"
|
||||
d:DesignHeight="96"
|
||||
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
x:Name="ContentGrid">
|
||||
|
||||
<Border x:Name="IconHost"
|
||||
Grid.Row="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<Panel>
|
||||
<Image x:Name="IconImage"
|
||||
Stretch="Uniform"
|
||||
IsVisible="False" />
|
||||
<ContentControl x:Name="SymbolIconHost"
|
||||
IsVisible="False" />
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="NameTextBlock"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
TextWrapping="Wrap"
|
||||
Margin="4,0,4,4"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
396
LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
Normal file
396
LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using FluentIcons.Avalonia;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IComponentSettingsContextAware, IDisposable
|
||||
{
|
||||
private string _componentId = BuiltInComponentIds.DesktopShortcut;
|
||||
private string _placementId = string.Empty;
|
||||
private string? _targetPath;
|
||||
private string _clickMode = "Double";
|
||||
private bool _showBackground = true;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isDisposed;
|
||||
|
||||
private const double TapMovementThreshold = 10;
|
||||
private const long TapTimeThresholdMs = 500;
|
||||
|
||||
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
|
||||
|
||||
private record PointerGestureState(
|
||||
Point StartPosition,
|
||||
long StartTime
|
||||
);
|
||||
|
||||
public ShortcutWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
DoubleTapped += OnDoubleTapped;
|
||||
UpdateDisplay();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopShortcut
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||
{
|
||||
var snapshot = context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
ApplySettings(snapshot);
|
||||
}
|
||||
|
||||
public void ApplySettings(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
_targetPath = snapshot.ShortcutTargetPath;
|
||||
_clickMode = string.Equals(snapshot.ShortcutClickMode, "Single", StringComparison.OrdinalIgnoreCase)
|
||||
? "Single"
|
||||
: "Double";
|
||||
_showBackground = snapshot.ShortcutShowBackground;
|
||||
UpdateDisplay();
|
||||
ApplyChrome();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = cellSize;
|
||||
|
||||
// 图标大小:从 cellSize 的 50% 计算,最小 24px,最大 128px
|
||||
var iconSize = Math.Clamp(cellSize * 0.5, 24, 128);
|
||||
IconImage.Width = iconSize;
|
||||
IconImage.Height = iconSize;
|
||||
|
||||
// 字体大小:从 cellSize 的 18% 计算,最小 10px,最大 24px
|
||||
var fontSize = Math.Clamp(cellSize * 0.18, 10, 24);
|
||||
NameTextBlock.FontSize = fontSize;
|
||||
|
||||
// 更新符号图标的大小(如果当前显示的是符号图标)
|
||||
if (SymbolIconHost.Content is SymbolIcon symbolIcon)
|
||||
{
|
||||
symbolIcon.FontSize = iconSize;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDisplay()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||
{
|
||||
ShowEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var name = GetDisplayName(_targetPath);
|
||||
NameTextBlock.Text = name;
|
||||
// 文字颜色由 XAML 中的 DynamicResource 自动适配主题
|
||||
|
||||
LoadIcon(_targetPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ShowEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowEmptyState()
|
||||
{
|
||||
NameTextBlock.Text = "添加快捷方式";
|
||||
// 使用次要文字颜色(由主题自动适配)
|
||||
NameTextBlock.Foreground = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
|
||||
|
||||
var iconBrush = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
|
||||
|
||||
// 隐藏图片图标,显示符号图标
|
||||
IconImage.IsVisible = false;
|
||||
IconImage.Source = null;
|
||||
|
||||
// 计算图标大小
|
||||
var iconSize = Math.Clamp(_currentCellSize * 0.5, 24, 128);
|
||||
|
||||
var iconHostContent = new SymbolIcon
|
||||
{
|
||||
Symbol = FluentIcons.Common.Symbol.Add,
|
||||
FontSize = iconSize,
|
||||
Foreground = iconBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
SymbolIconHost.Content = iconHostContent;
|
||||
SymbolIconHost.IsVisible = true;
|
||||
}
|
||||
|
||||
private static string GetDisplayName(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return "快捷方式";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
return Path.GetFileName(path.TrimEnd('\\', '/'));
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||
return string.IsNullOrWhiteSpace(fileName) ? path : fileName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadIcon(string path)
|
||||
{
|
||||
byte[]? pngBytes = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
pngBytes = WindowsIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (File.Exists(path))
|
||||
{
|
||||
pngBytes = WindowsIconService.TryGetIconPngBytes(path);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
pngBytes = LinuxIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (File.Exists(path))
|
||||
{
|
||||
pngBytes = LinuxIconService.TryGetIconPngBytes(path);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
pngBytes = MacIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (File.Exists(path))
|
||||
{
|
||||
pngBytes = MacIconService.TryGetIconPngBytes(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pngBytes = null;
|
||||
}
|
||||
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(pngBytes);
|
||||
IconImage.Source = new Bitmap(stream);
|
||||
IconImage.IsVisible = true;
|
||||
SymbolIconHost.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
LoadFallbackIcon(path);
|
||||
}
|
||||
|
||||
private void LoadFallbackIcon(string path)
|
||||
{
|
||||
var symbol = Directory.Exists(path)
|
||||
? FluentIcons.Common.Symbol.Folder
|
||||
: FluentIcons.Common.Symbol.Document;
|
||||
|
||||
// 使用强调色(由主题自动适配)
|
||||
var iconBrush = this.FindResource("AdaptiveAccentBrush") as IBrush;
|
||||
|
||||
// 隐藏图片图标,显示符号图标
|
||||
IconImage.IsVisible = false;
|
||||
IconImage.Source = null;
|
||||
|
||||
// 计算图标大小
|
||||
var iconSize = Math.Clamp(_currentCellSize * 0.5, 24, 128);
|
||||
|
||||
var iconHostContent = new SymbolIcon
|
||||
{
|
||||
Symbol = symbol,
|
||||
FontSize = iconSize,
|
||||
Foreground = iconBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
SymbolIconHost.Content = iconHostContent;
|
||||
SymbolIconHost.IsVisible = true;
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (!_showBackground)
|
||||
{
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复默认的实心背景样式
|
||||
RootBorder.Background = this.FindResource("AdaptiveSurfaceRaisedBrush") as IBrush ?? Brushes.Transparent;
|
||||
RootBorder.BorderBrush = this.FindResource("AdaptiveButtonBorderBrush") as IBrush ?? Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(1);
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointer = e.GetCurrentPoint(this);
|
||||
var pointerId = e.Pointer.Id;
|
||||
var position = pointer.Position;
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
_gestureStates[pointerId] = new PointerGestureState(position, timestamp);
|
||||
e.Pointer.Capture(this);
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.TryGetValue(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(this);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
if (distance > TapMovementThreshold)
|
||||
{
|
||||
_gestureStates.Remove(pointerId);
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.Remove(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Pointer.Capture(null);
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(this);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
|
||||
|
||||
if (distance > TapMovementThreshold || elapsed > TapTimeThresholdMs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_clickMode == "Single")
|
||||
{
|
||||
OpenTarget();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_clickMode == "Double")
|
||||
{
|
||||
OpenTarget();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenTarget()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(_targetPath)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
Process.Start("xdg-open", _targetPath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
Process.Start("open", _targetPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("ShortcutWidget", $"Failed to open target: {_targetPath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
_gestureStates.Clear();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -638,7 +638,7 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
foreach (var visual in _itemVisuals)
|
||||
{
|
||||
visual.Host.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(10 * softScale, 6, 14);
|
||||
visual.Host.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(10 * softScale, 6, 14);
|
||||
visual.Host.Padding = new Thickness(rowPaddingHorizontal, rowPaddingVertical);
|
||||
visual.RowGrid.ColumnSpacing = Math.Clamp(8 * softScale, 4, 12);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
@@ -20,10 +20,24 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
|
||||
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private Point[]? _pointBuffer;
|
||||
private StreamGeometry? _lineGeometry;
|
||||
private StreamGeometry? _fillGeometry;
|
||||
private Rect _cachedPlot;
|
||||
private bool _geometryDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points)
|
||||
{
|
||||
_points = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextSignature = ComputeSeriesSignature(nextPoints);
|
||||
if (ReferenceEquals(_points, nextPoints) && _lastSeriesSignature == nextSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_points = nextPoints;
|
||||
_lastSeriesSignature = nextSignature;
|
||||
_geometryDirty = true;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
@@ -34,11 +48,18 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
_pointBuffer = null;
|
||||
}
|
||||
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
_geometryDirty = true;
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
ReleasePointBuffer();
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
_geometryDirty = true;
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
@@ -64,16 +85,14 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
return;
|
||||
}
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
|
||||
var pointCount = BuildPlotPoints(plot, maxSamples);
|
||||
if (pointCount < 2 || _pointBuffer is null)
|
||||
EnsureGeometry(plot);
|
||||
if (_lineGeometry is null || _fillGeometry is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var span = _pointBuffer.AsSpan(0, pointCount);
|
||||
DrawAreaFill(context, plot.Bottom, span);
|
||||
DrawLine(context, span);
|
||||
context.DrawGeometry(FillBrush, pen: null, _fillGeometry);
|
||||
context.DrawGeometry(brush: null, pen: LinePen, _lineGeometry);
|
||||
}
|
||||
|
||||
private static void DrawGrid(DrawingContext context, Rect plot)
|
||||
@@ -97,42 +116,56 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
|
||||
}
|
||||
|
||||
private void DrawLine(DrawingContext context, ReadOnlySpan<Point> points)
|
||||
private void EnsureGeometry(Rect plot)
|
||||
{
|
||||
var geometry = new StreamGeometry();
|
||||
using (var builder = geometry.Open())
|
||||
if (!_geometryDirty && _cachedPlot == plot)
|
||||
{
|
||||
builder.BeginFigure(points[0], false);
|
||||
for (var i = 1; i < points.Length; i++)
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedPlot = plot;
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
|
||||
var pointCount = BuildPlotPoints(plot, maxSamples);
|
||||
if (pointCount < 2 || _pointBuffer is null)
|
||||
{
|
||||
_geometryDirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var lineGeometry = new StreamGeometry();
|
||||
using (var builder = lineGeometry.Open())
|
||||
{
|
||||
builder.BeginFigure(_pointBuffer[0], false);
|
||||
for (var i = 1; i < pointCount; i++)
|
||||
{
|
||||
builder.LineTo(points[i]);
|
||||
builder.LineTo(_pointBuffer[i]);
|
||||
}
|
||||
}
|
||||
|
||||
context.DrawGeometry(brush: null, pen: LinePen, geometry);
|
||||
}
|
||||
|
||||
private void DrawAreaFill(DrawingContext context, double baselineY, ReadOnlySpan<Point> points)
|
||||
{
|
||||
var geometry = new StreamGeometry();
|
||||
using (var builder = geometry.Open())
|
||||
var fillGeometry = new StreamGeometry();
|
||||
using (var builder = fillGeometry.Open())
|
||||
{
|
||||
var first = points[0];
|
||||
builder.BeginFigure(new Point(first.X, baselineY), true);
|
||||
var first = _pointBuffer[0];
|
||||
builder.BeginFigure(new Point(first.X, plot.Bottom), true);
|
||||
builder.LineTo(first);
|
||||
|
||||
for (var i = 1; i < points.Length; i++)
|
||||
for (var i = 1; i < pointCount; i++)
|
||||
{
|
||||
builder.LineTo(points[i]);
|
||||
builder.LineTo(_pointBuffer[i]);
|
||||
}
|
||||
|
||||
var last = points[^1];
|
||||
builder.LineTo(new Point(last.X, baselineY));
|
||||
builder.LineTo(new Point(first.X, baselineY));
|
||||
var last = _pointBuffer[pointCount - 1];
|
||||
builder.LineTo(new Point(last.X, plot.Bottom));
|
||||
builder.LineTo(new Point(first.X, plot.Bottom));
|
||||
builder.EndFigure(true);
|
||||
}
|
||||
|
||||
context.DrawGeometry(FillBrush, pen: null, geometry);
|
||||
_lineGeometry = lineGeometry;
|
||||
_fillGeometry = fillGeometry;
|
||||
_geometryDirty = false;
|
||||
}
|
||||
|
||||
private int BuildPlotPoints(Rect plot, int maxSamples)
|
||||
@@ -295,4 +328,20 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
_pointBuffer = null;
|
||||
}
|
||||
|
||||
private static int ComputeSeriesSignature(IReadOnlyList<NoiseRealtimePoint> points)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var first = points[0];
|
||||
var last = points[^1];
|
||||
return HashCode.Combine(
|
||||
points.Count,
|
||||
first.Timestamp.UtcTicks,
|
||||
last.Timestamp.UtcTicks,
|
||||
Math.Round(last.DisplayDb, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
{
|
||||
private readonly record struct SampledPoint(double X, double Y, NoiseDistributionLevel Level);
|
||||
|
||||
private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96"));
|
||||
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
|
||||
private static readonly Pen GridPen = new(GridBrush, 1);
|
||||
@@ -18,14 +20,35 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
private static readonly IBrush NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
|
||||
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
|
||||
private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444"));
|
||||
private static readonly byte[] CloudAlphas = [44, 58, 72, 86];
|
||||
private static readonly byte[] GlowAlphas = [26, 36];
|
||||
private static readonly IBrush[][] CloudBrushes = CreateBrushTable(CloudAlphas);
|
||||
private static readonly IBrush[][] GlowBrushes = CreateBrushTable(GlowAlphas);
|
||||
|
||||
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private SampledPoint[] _sampledPoints = Array.Empty<SampledPoint>();
|
||||
private int _sampledPointCount;
|
||||
private double _baselineDb = 45;
|
||||
private Rect _cachedPlot;
|
||||
private bool _sampleCacheDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
|
||||
{
|
||||
_points = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
_baselineDb = Math.Clamp(baselineDb, 20, 85);
|
||||
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextBaselineDb = Math.Clamp(baselineDb, 20, 85);
|
||||
var nextSignature = ComputeSeriesSignature(nextPoints, nextBaselineDb);
|
||||
if (ReferenceEquals(_points, nextPoints) &&
|
||||
Math.Abs(_baselineDb - nextBaselineDb) < 0.001 &&
|
||||
_lastSeriesSignature == nextSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_points = nextPoints;
|
||||
_baselineDb = nextBaselineDb;
|
||||
_lastSeriesSignature = nextSignature;
|
||||
_sampleCacheDirty = true;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
@@ -52,45 +75,34 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureSampleCache(plot);
|
||||
if (_sampledPointCount < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DrawElectronCloud(context, plot);
|
||||
}
|
||||
|
||||
private void DrawElectronCloud(DrawingContext context, Rect plot)
|
||||
{
|
||||
var start = _points[0].Timestamp;
|
||||
var end = _points[^1].Timestamp;
|
||||
var totalTicks = Math.Max(1, (end - start).Ticks);
|
||||
|
||||
var pointCount = _points.Count;
|
||||
var cloudLayers = 8;
|
||||
var cloudLayers = CloudAlphas.Length;
|
||||
var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12);
|
||||
|
||||
var sortedPoints = new List<(double X, double Y, NoiseDistributionLevel Level)>();
|
||||
for (var i = 0; i < pointCount; i++)
|
||||
{
|
||||
var point = _points[i];
|
||||
var x = MapX(plot, point.Timestamp, start, totalTicks);
|
||||
var y = MapYContinuous(plot, point.DisplayDb);
|
||||
var level = ResolveLevel(point.DisplayDb, _baselineDb);
|
||||
sortedPoints.Add((x, y, level));
|
||||
}
|
||||
|
||||
sortedPoints.Sort((a, b) => a.X.CompareTo(b.X));
|
||||
|
||||
for (var layer = cloudLayers - 1; layer >= 0; layer--)
|
||||
{
|
||||
var layerRatio = (double)layer / (cloudLayers - 1);
|
||||
var layerRatio = cloudLayers == 1 ? 0d : layer / (double)(cloudLayers - 1);
|
||||
var layerRadius = baseRadius * (1.2 + layerRatio * 0.8);
|
||||
var layerAlpha = (byte)(40 + layerRatio * 25);
|
||||
var layerBrushes = CloudBrushes[layer];
|
||||
|
||||
foreach (var pt in sortedPoints)
|
||||
for (var i = 0; i < _sampledPointCount; i++)
|
||||
{
|
||||
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
|
||||
var pt = _sampledPoints[i];
|
||||
var jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3;
|
||||
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
|
||||
|
||||
|
||||
context.DrawEllipse(
|
||||
brush,
|
||||
layerBrushes[(int)pt.Level],
|
||||
pen: null,
|
||||
center: new Point(pt.X + jitterX, pt.Y + jitterY),
|
||||
radiusX: layerRadius,
|
||||
@@ -98,18 +110,17 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
}
|
||||
}
|
||||
|
||||
var glowLayers = 5;
|
||||
var glowLayers = GlowAlphas.Length;
|
||||
for (var layer = glowLayers - 1; layer >= 0; layer--)
|
||||
{
|
||||
var layerRatio = (double)layer / (glowLayers - 1);
|
||||
var layerRatio = glowLayers == 1 ? 0d : layer / (double)(glowLayers - 1);
|
||||
var layerRadius = baseRadius * (0.8 + layerRatio * 0.6);
|
||||
var layerAlpha = (byte)(20 + layerRatio * 15);
|
||||
|
||||
foreach (var pt in sortedPoints)
|
||||
var layerBrushes = GlowBrushes[layer];
|
||||
for (var i = 0; i < _sampledPointCount; i++)
|
||||
{
|
||||
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
|
||||
var pt = _sampledPoints[i];
|
||||
context.DrawEllipse(
|
||||
brush,
|
||||
layerBrushes[(int)pt.Level],
|
||||
pen: null,
|
||||
center: new Point(pt.X, pt.Y),
|
||||
radiusX: layerRadius,
|
||||
@@ -117,34 +128,42 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
}
|
||||
}
|
||||
|
||||
var latest = _points[^1];
|
||||
var latestX = MapX(plot, latest.Timestamp, start, totalTicks);
|
||||
var latestY = MapYContinuous(plot, latest.DisplayDb);
|
||||
var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb);
|
||||
|
||||
var latest = _sampledPoints[_sampledPointCount - 1];
|
||||
for (var i = 3; i >= 0; i--)
|
||||
{
|
||||
var radius = baseRadius * (1.5 + i * 0.8);
|
||||
var alpha = (byte)(30 - i * 6);
|
||||
var glowBrush = GetLevelBrushWithAlpha(latestLevel, alpha);
|
||||
context.DrawEllipse(glowBrush, null, new Point(latestX, latestY), radius, radius * 0.6);
|
||||
var glowBrush = GetAlphaBrush(latest.Level, alpha);
|
||||
context.DrawEllipse(glowBrush, null, new Point(latest.X, latest.Y), radius, radius * 0.6);
|
||||
}
|
||||
|
||||
context.DrawEllipse(
|
||||
GetLevelBrush(latestLevel),
|
||||
GetLevelBrush(latest.Level),
|
||||
new Pen(Brushes.White, 1.5),
|
||||
new Point(latestX, latestY),
|
||||
new Point(latest.X, latest.Y),
|
||||
baseRadius + 1,
|
||||
baseRadius * 0.7 + 1);
|
||||
|
||||
context.DrawEllipse(
|
||||
Brushes.White,
|
||||
null,
|
||||
new Point(latestX, latestY),
|
||||
new Point(latest.X, latest.Y),
|
||||
2,
|
||||
2);
|
||||
}
|
||||
|
||||
private void EnsureSampleCache(Rect plot)
|
||||
{
|
||||
if (!_sampleCacheDirty && _cachedPlot == plot)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedPlot = plot;
|
||||
_sampledPointCount = BuildSampledPoints(plot);
|
||||
_sampleCacheDirty = false;
|
||||
}
|
||||
|
||||
private static void DrawGrid(DrawingContext context, Rect plot)
|
||||
{
|
||||
const int verticalDivisions = 4;
|
||||
@@ -176,7 +195,10 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
var minDb = _baselineDb - 5;
|
||||
var maxDb = _baselineDb + 25;
|
||||
var dbRange = maxDb - minDb;
|
||||
if (dbRange <= 0) dbRange = 30;
|
||||
if (dbRange <= 0)
|
||||
{
|
||||
dbRange = 30;
|
||||
}
|
||||
|
||||
var normalizedDb = (displayDb - minDb) / dbRange;
|
||||
normalizedDb = Math.Clamp(normalizedDb, 0, 1);
|
||||
@@ -243,6 +265,106 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
_ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA))
|
||||
};
|
||||
}
|
||||
|
||||
private int BuildSampledPoints(Rect plot)
|
||||
{
|
||||
if (_points.Count < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Ceiling(plot.Width / 2d), 48, 144);
|
||||
var targetCount = Math.Min(_points.Count, maxSamples);
|
||||
if (_sampledPoints.Length < targetCount)
|
||||
{
|
||||
_sampledPoints = new SampledPoint[targetCount];
|
||||
}
|
||||
|
||||
var start = _points[0].Timestamp;
|
||||
var end = _points[^1].Timestamp;
|
||||
var totalTicks = Math.Max(1, (end - start).Ticks);
|
||||
var step = _points.Count <= targetCount
|
||||
? 1d
|
||||
: (_points.Count - 1d) / Math.Max(1d, targetCount - 1d);
|
||||
|
||||
var outputIndex = 0;
|
||||
var lastSourceIndex = -1;
|
||||
for (var i = 0; i < targetCount; i++)
|
||||
{
|
||||
var sourceIndex = i == targetCount - 1
|
||||
? _points.Count - 1
|
||||
: (int)Math.Round(i * step);
|
||||
sourceIndex = Math.Clamp(sourceIndex, 0, _points.Count - 1);
|
||||
if (sourceIndex == lastSourceIndex)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var point = _points[sourceIndex];
|
||||
_sampledPoints[outputIndex++] = new SampledPoint(
|
||||
MapX(plot, point.Timestamp, start, totalTicks),
|
||||
MapYContinuous(plot, point.DisplayDb),
|
||||
ResolveLevel(point.DisplayDb, _baselineDb));
|
||||
lastSourceIndex = sourceIndex;
|
||||
}
|
||||
|
||||
return outputIndex;
|
||||
}
|
||||
|
||||
private static int ComputeSeriesSignature(IReadOnlyList<NoiseRealtimePoint> points, double baselineDb)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
{
|
||||
return HashCode.Combine(0, baselineDb);
|
||||
}
|
||||
|
||||
var first = points[0];
|
||||
var last = points[^1];
|
||||
return HashCode.Combine(
|
||||
points.Count,
|
||||
first.Timestamp.UtcTicks,
|
||||
last.Timestamp.UtcTicks,
|
||||
Math.Round(last.DisplayDb, 2),
|
||||
Math.Round(baselineDb, 2));
|
||||
}
|
||||
|
||||
private static IBrush[][] CreateBrushTable(IReadOnlyList<byte> alphas)
|
||||
{
|
||||
var table = new IBrush[alphas.Count][];
|
||||
for (var i = 0; i < alphas.Count; i++)
|
||||
{
|
||||
table[i] =
|
||||
[
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Quiet, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Normal, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Noisy, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Extreme, alphas[i])
|
||||
];
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static IBrush GetAlphaBrush(NoiseDistributionLevel level, byte alpha)
|
||||
{
|
||||
for (var i = 0; i < CloudAlphas.Length; i++)
|
||||
{
|
||||
if (CloudAlphas[i] == alpha)
|
||||
{
|
||||
return CloudBrushes[i][(int)level];
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < GlowAlphas.Length; i++)
|
||||
{
|
||||
if (GlowAlphas[i] == alpha)
|
||||
{
|
||||
return GlowBrushes[i][(int)level];
|
||||
}
|
||||
}
|
||||
|
||||
return GetLevelBrushWithAlpha(level, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
public enum NoiseDistributionLevel
|
||||
|
||||
@@ -39,21 +39,22 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
|
||||
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||
|
||||
private readonly object _snapshotSync = new();
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private StudyAnalyticsSnapshot? _pendingSnapshot;
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _dispatchQueued;
|
||||
private bool _hasPendingSnapshot;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isDisposed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isSubscribed;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
private IDisposable? _monitoringLease;
|
||||
@@ -71,7 +72,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_uiTimer.Tick += OnUiTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
@@ -80,7 +80,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyDefaultXAxisLabels();
|
||||
ApplyLocalizedAxisLabels();
|
||||
RefreshVisual();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -94,24 +94,28 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
|
||||
|
||||
if (isOnActivePage && !wasOnActivePage)
|
||||
{
|
||||
RefreshVisual();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
UpdateTimerState();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
|
||||
if (!_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
RefreshVisual();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
@@ -119,7 +123,12 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
_isAttached = false;
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_uiTimer.Stop();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -130,27 +139,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnUiTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_uiTimer.IsEnabled)
|
||||
{
|
||||
_uiTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_uiTimer.Stop();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
@@ -172,7 +161,52 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
_monitoringLease = null;
|
||||
}
|
||||
|
||||
private void RefreshVisual()
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
QueueSnapshotForRender(e.Snapshot);
|
||||
}
|
||||
|
||||
private void QueueSnapshotForRender(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_pendingSnapshot = snapshot;
|
||||
_hasPendingSnapshot = true;
|
||||
if (_dispatchQueued)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dispatchQueued = true;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(ProcessPendingSnapshot, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void ProcessPendingSnapshot()
|
||||
{
|
||||
StudyAnalyticsSnapshot? snapshot = null;
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_dispatchQueued = false;
|
||||
if (_hasPendingSnapshot)
|
||||
{
|
||||
snapshot = _pendingSnapshot;
|
||||
_pendingSnapshot = null;
|
||||
_hasPendingSnapshot = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isAttached || !_isOnActivePage || snapshot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(snapshot);
|
||||
}
|
||||
|
||||
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
@@ -189,8 +223,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
var isSessionView = isSessionRunning || isSessionReport;
|
||||
@@ -634,13 +666,17 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
_uiTimer.Stop();
|
||||
_uiTimer.Tick -= OnUiTimerTick;
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
private string _placementId = string.Empty;
|
||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||
private bool _isApplyingPersistedSnapshot;
|
||||
private bool? _lastBitmapCacheEnabled;
|
||||
private int _lastBitmapCacheSize;
|
||||
private bool _noteDirty;
|
||||
private int _noteLoadRevision;
|
||||
private bool _disposed;
|
||||
@@ -119,11 +121,10 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
settings.IgnorePressure = true;
|
||||
settings.InkThickness = _selectedInkThickness;
|
||||
settings.EraserSize = new Size(20, 20);
|
||||
settings.IsBitmapCacheEnabled = true;
|
||||
settings.MaxBitmapCacheSize = 2048;
|
||||
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
||||
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
||||
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -157,6 +158,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
||||
settings.EraserSize = new Size(eraserSize, eraserSize);
|
||||
UpdateInkCanvasCacheSettings(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void ApplyThemeVisual(bool force)
|
||||
@@ -711,8 +713,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
||||
}
|
||||
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||
}
|
||||
|
||||
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
||||
@@ -765,9 +766,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
}
|
||||
}
|
||||
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||
}
|
||||
|
||||
private bool HasValidPersistenceContext()
|
||||
@@ -785,4 +784,47 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
|
||||
return Array.Empty<InkStylusPoint>();
|
||||
}
|
||||
|
||||
private void UpdateInkCanvasCacheSettings(bool forceRefresh)
|
||||
{
|
||||
var renderScaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1d;
|
||||
var widthPx = Math.Max(1d, CanvasBorder.Bounds.Width * renderScaling);
|
||||
var heightPx = Math.Max(1d, CanvasBorder.Bounds.Height * renderScaling);
|
||||
var longestSide = Math.Max(widthPx, heightPx);
|
||||
var area = widthPx * heightPx;
|
||||
|
||||
var cacheEnabled = longestSide <= 1536d && area <= 1_400_000d;
|
||||
var cacheSize = (int)Math.Clamp(Math.Ceiling(longestSide), 384d, 1536d);
|
||||
if (!forceRefresh &&
|
||||
_lastBitmapCacheEnabled == cacheEnabled &&
|
||||
_lastBitmapCacheSize == cacheSize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastBitmapCacheEnabled = cacheEnabled;
|
||||
_lastBitmapCacheSize = cacheSize;
|
||||
|
||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||
settings.IsBitmapCacheEnabled = cacheEnabled;
|
||||
settings.MaxBitmapCacheSize = cacheSize;
|
||||
|
||||
try
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(cacheEnabled);
|
||||
if (cacheEnabled)
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
||||
}
|
||||
else
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep drawing available even if the underlying cache backend rejects the cache update.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ public partial class MainWindow
|
||||
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)}");
|
||||
$"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.CornerRadiusStyle}|Accent={FormatSignatureColor(appearance.AccentColor)}");
|
||||
}
|
||||
|
||||
private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells)
|
||||
|
||||
@@ -1548,7 +1548,6 @@ public partial class MainWindow
|
||||
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||
return new ComponentLibraryCreateContext(
|
||||
cellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
@@ -2552,12 +2551,10 @@ public partial class MainWindow
|
||||
componentId,
|
||||
null,
|
||||
_currentDesktopCellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
appearanceSnapshot.CornerRadiusTokens));
|
||||
}
|
||||
|
||||
var scale = Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, appearanceSnapshot.GlobalCornerRadiusScale);
|
||||
return Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18) * scale;
|
||||
return Math.Max(0d, appearanceSnapshot.CornerRadiusTokens.Component.TopLeft);
|
||||
}
|
||||
|
||||
private Thickness GetDesktopComponentVisualInset(int widthCells, int heightCells)
|
||||
@@ -2809,7 +2806,6 @@ public partial class MainWindow
|
||||
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||
var createContext = new ComponentLibraryCreateContext(
|
||||
cellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
|
||||
@@ -51,6 +51,7 @@ public partial class MainWindow
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateVersion), StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -611,7 +612,7 @@ public partial class MainWindow
|
||||
SystemMaterialMode = latestThemeState.SystemMaterialMode,
|
||||
SelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed,
|
||||
UseSystemChrome = latestThemeState.UseSystemChrome,
|
||||
GlobalCornerRadiusScale = latestThemeState.GlobalCornerRadiusScale,
|
||||
CornerRadiusStyle = latestThemeState.CornerRadiusStyle,
|
||||
WallpaperPath = latestWallpaperState.WallpaperPath,
|
||||
WallpaperType = latestWallpaperState.Type,
|
||||
WallpaperColor = string.Equals(latestWallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
@@ -508,38 +508,34 @@
|
||||
</Grid.RenderTransform>
|
||||
<StackPanel Spacing="8">
|
||||
<Button x:Name="TaskbarPowerBackButton"
|
||||
Padding="4,6"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
HorizontalAlignment="Left"
|
||||
Click="OnPowerMenuBackClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Classes="icon-s"
|
||||
Symbol="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="ArrowLeft" />
|
||||
<TextBlock x:Name="TaskbarPowerBackTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Back" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
|
||||
Margin="2,6,0,0"
|
||||
Text="Power" />
|
||||
|
||||
<Border Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}"
|
||||
Margin="0,4" />
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
|
||||
<Button x:Name="PowerShutdownButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerShutdownClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Power" />
|
||||
<TextBlock x:Name="PowerShutdownTextBlock"
|
||||
@@ -553,7 +549,7 @@
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerRestartClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Refresh" />
|
||||
<TextBlock x:Name="PowerRestartTextBlock"
|
||||
@@ -567,7 +563,7 @@
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLogoutClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="ExitToApp" />
|
||||
<TextBlock x:Name="PowerLogoutTextBlock"
|
||||
@@ -581,7 +577,7 @@
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerSleepClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="WeatherNight" />
|
||||
<TextBlock x:Name="PowerSleepTextBlock"
|
||||
@@ -595,7 +591,7 @@
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLockClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Lock" />
|
||||
<TextBlock x:Name="PowerLockTextBlock"
|
||||
|
||||
@@ -73,28 +73,27 @@
|
||||
Text="{Binding ComponentRadiusHeader}"
|
||||
Margin="0,12,0,4" />
|
||||
|
||||
<ui:SettingsExpander Header="{Binding GlobalCornerRadiusLabel}"
|
||||
Description="{Binding GlobalCornerRadiusDescription}">
|
||||
<ui:SettingsExpander Header="{Binding CornerRadiusStyleLabel}"
|
||||
Description="{Binding CornerRadiusStyleDescription}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ShapeOrganic" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="16">
|
||||
<TextBlock Text="{Binding GlobalCornerRadiusLabel}"
|
||||
VerticalAlignment="Center" />
|
||||
<Slider Grid.Column="1"
|
||||
Minimum="{Binding GlobalCornerRadiusMinimum}"
|
||||
Maximum="{Binding GlobalCornerRadiusMaximum}"
|
||||
SmallChange="0.01"
|
||||
LargeChange="0.1"
|
||||
Value="{Binding GlobalCornerRadiusScale}" />
|
||||
<TextBlock Grid.Column="2"
|
||||
Width="56"
|
||||
Text="{Binding GlobalCornerRadiusScale, StringFormat={}{0:F2}x}"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ComboBox Width="200"
|
||||
ItemsSource="{Binding CornerRadiusStyleOptions}"
|
||||
SelectedItem="{Binding SelectedCornerRadiusStyle}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Classes="AppBarButton" ToolTip.Tip="View Corner Radius Specification" Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).OpenDesignSpecCommand}" CommandParameter="CORNER_RADIUS_SPEC.md">
|
||||
<fi:SymbolIcon Symbol="QuestionCircle" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -339,8 +339,7 @@ public sealed class PluginLoader
|
||||
private static PluginAppearanceSnapshot BuildAppearanceSnapshot(IServiceProvider? hostServices)
|
||||
{
|
||||
var defaultSnapshot = new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: 1d,
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 18),
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
|
||||
ThemeVariant: "Unknown");
|
||||
|
||||
if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService)
|
||||
@@ -352,7 +351,6 @@ public sealed class PluginLoader
|
||||
{
|
||||
var hostSnapshot = appearanceThemeService.GetCurrent();
|
||||
return new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: Math.Max(0d, hostSnapshot.GlobalCornerRadiusScale),
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(hostSnapshot.CornerRadiusTokens),
|
||||
ThemeVariant: hostSnapshot.IsNightMode ? "Dark" : "Light");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 阑山桌面 / LanMountainDesktop
|
||||
# 阑山桌面LanMountainDesktop
|
||||
|
||||
> 你的桌面,不止一面
|
||||
|
||||
|
||||
@@ -1,39 +1,59 @@
|
||||
# 圆角设计规范
|
||||
# 圆角设计规范 (LanMountain Desktop Corner Radius Spec)
|
||||
|
||||
## 中文
|
||||
## 核心理念 (Core Philosophy)
|
||||
|
||||
本规范用于统一阑山桌面不同层级容器和控件的圆角尺度。
|
||||
为了确保桌面组件在不同尺寸、缩放比例下都能保持视觉一致性和美感,阑山桌面采用了 **固定圆角风格预设 (Fixed Corner Radius Styles)**,全面参考小米澎湃OS (Xiaomi HyperOS) 的设计语言。
|
||||
|
||||
### 基础层级
|
||||
所有的组件和容器必须使用统一的资源键,禁止在 XAML 或代码中使用硬编码的像素值。
|
||||
|
||||
- Level 1:12px,小元素和图标容器
|
||||
- Level 2:16px,小型色块和紧凑控件
|
||||
- Level 3:20px,普通按钮
|
||||
- Level 4:24px,输入面板和小型容器
|
||||
- Component:18px,桌面组件的标准圆角(默认值)
|
||||
- Level 5:28px,普通玻璃面板
|
||||
- Level 6:32px,强化容器
|
||||
- Level 7:36px,大容器、窗口、任务栏
|
||||
## 预设风格 (Preset Styles)
|
||||
|
||||
### 使用建议
|
||||
用户可以在设置中选择以下四种风格之一。系统会自动根据选中的风格动态映射全局圆角 Token。
|
||||
|
||||
- 同层级元素保持相同圆角。
|
||||
- 大容器的圆角大于内部子面板。
|
||||
- 动态尺寸组件可按 `cellSize` 计算圆角,但仍要落在统一范围内。
|
||||
| 风格 (ID) | 名称 (Local) | 组件圆角 (Component) | 设计语义 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Sharp** | 锐利 | 20px | 紧凑、精确、利落 |
|
||||
| **Balanced** | 平衡 | 24px | **默认值**。和谐、自然、普适 |
|
||||
| **Rounded** | 圆润 | 28px | 保守、柔和、亲切 |
|
||||
| **Open** | 开放 | 32px | 现代、沉浸、夸张 |
|
||||
|
||||
### 动态圆角建议
|
||||
## Token 阶梯映射 (Token Step Mapping)
|
||||
|
||||
```csharp
|
||||
var cornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
|
||||
```
|
||||
每个风格都定义了一套完整的圆角阶梯,以确保在大容器包裹小元素时满足 **圆角嵌套一致性 (Nesting Consistency)**。
|
||||
|
||||
## English
|
||||
| Token | Sharp | Balanced | Rounded | Open | 典型场景 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **Micro** | 4px | 6px | 8px | 10px | 小图标容器、角标 (Badge) |
|
||||
| **Xs** | 8px | 12px | 14px | 16px | 小标签 (Tag)、输入框 |
|
||||
| **Sm** | 10px | 14px | 16px | 20px | 普通按钮、搜索栏、复选框 |
|
||||
| **Md** | 14px | 20px | 24px | 28px | 悬浮菜单、小提示框、子卡片 |
|
||||
| **Lg** | 20px | 28px | 32px | 36px | 普通面板、对话框内容区 |
|
||||
| **Xl** | 24px | 32px | 36px | 40px | 大尺寸容器、设置中心页面 |
|
||||
| **Island** | 28px | 36px | 40px | 44px | 任务栏、全局大悬浮容器 |
|
||||
| **Component** | **20px** | **24px** | **28px** | **32px** | **所有桌面组件 (Widget) 的主边框** |
|
||||
|
||||
This specification keeps corner radius usage consistent across containers and controls.
|
||||
## 开发准则 (Implementation Rules)
|
||||
|
||||
### Reference levels
|
||||
> [!IMPORTANT]
|
||||
> **1. 桌面组件强制约束**:
|
||||
> 所有桌面组件(Widget / Desktop Component)的根容器边框必须使用 `{DynamicResource DesignCornerRadiusComponent}`。严禁对其进行任何比例运算或系数乘积(如 `* scale`),必须保持固定。
|
||||
|
||||
- 12px for small elements
|
||||
- 20px for common buttons
|
||||
- 28px for normal glass panels
|
||||
- 36px for large containers and windows
|
||||
> [!TIP]
|
||||
> **2. 圆角嵌套规则**:
|
||||
> 当一个容器包裹另一个元素时,外层圆角应比内层圆角大一个阶梯。例如:
|
||||
> - 外部使用 `DesignCornerRadiusLg`
|
||||
> - 内部紧贴边缘的内容应使用 `DesignCornerRadiusMd`
|
||||
> 这样可以保证两条圆弧的圆心趋于重合,视觉重心更稳固。
|
||||
|
||||
> [!CAUTION]
|
||||
> **3. 禁止硬编码 (No Hardcoding)**:
|
||||
> 禁止写死数字(如 `CornerRadius="24"`)或私有资源。如果现有 Token 无法满足需求,应优先考虑使用 `SafeValue` 辅助方法封装,但必须声明理由。
|
||||
|
||||
## 常用资源键 (Common Resource Keys)
|
||||
|
||||
- `DesignCornerRadiusComponent` (最常用)
|
||||
- `DesignCornerRadiusMicro`
|
||||
- `DesignCornerRadiusSm`
|
||||
- `DesignCornerRadiusMd`
|
||||
- `DesignCornerRadiusLg`
|
||||
- `DesignCornerRadiusXl`
|
||||
|
||||
62
docs/TYPOGRAPHY_SPEC.md
Normal file
62
docs/TYPOGRAPHY_SPEC.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 字体排版设计规范 (Typography Specification)
|
||||
|
||||
## 中文
|
||||
|
||||
本规范用于统一阑山桌面各组件(Widget)及页面的字体样式,解决目前组件间字体不协调、厚度不一的问题。通过引入标准化的设计 Token,确保在不同 DPI 和设备上呈现一致的高级感(Premium Look)。
|
||||
|
||||
### 1. 字体家族 (Font Family)
|
||||
|
||||
- **默认字体**:优先使用内置的 `MiSans VF` (Variable Font)。
|
||||
- **回退顺序**:`MiSans VF` -> `MiSans` -> `Microsoft YaHei` -> `Sans-serif`。
|
||||
|
||||
### 2. 字重标准 (Font Weights)
|
||||
|
||||
为了达到“不粗不细”的协调感,我们采用 `Medium (500)` 作为默认正文字重,以应对复杂的背景环境。
|
||||
|
||||
| 角色 | Token | MiSans 权重 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Caption/Secondary** | `DesignFontWeightCaption` | `Normal (400)` | 用于不重要的补充说明信息 |
|
||||
| **Body (Default)** | `DesignFontWeightBody` | `Medium (500)` | **核心全局字重**,用于所有常规正文 |
|
||||
| **Title/Header** | `DesignFontWeightTitle` | `SemiBold (600)` | 用于卡片标题、分类标题 |
|
||||
| **Display (Large)** | `DesignFontWeightDisplay` | `SemiBold (600)` | 用于超大号文本(如温度数字) |
|
||||
|
||||
> **注意**:除非极特殊艺术需求,应避免使用 `Thin`, `ExtraLight`, `Light` 或 `Bold (700)`, `Heavy`。
|
||||
|
||||
### 3. 字号标准 (Font Sizes)
|
||||
|
||||
| 角色 | Token | 数值 (px) | 典型应用场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Caption** | `DesignFontSizeCaption` | 12 | 底部说明、状态提示 |
|
||||
| **BodySmall** | `DesignFontSizeBodySmall` | 13 | 设置项描述、次要标签 |
|
||||
| **Body** | `DesignFontSizeBody` | 14 | 标准文本、正文内容 |
|
||||
| **BodyLarge** | `DesignFontSizeBodyLarge` | 16 | 加大正文、菜单项 |
|
||||
| **Subtitle** | `DesignFontSizeSubtitle` | 18 | 小节标题、大按钮文字 |
|
||||
| **Title** | `DesignFontSizeTitle` | 24 | 组件标题、大卡片标题 |
|
||||
| **Headline** | `DesignFontSizeHeadline` | 32 | 重要数据指标 |
|
||||
| **Display** | `DesignFontSizeDisplay` | 48 | 天气温度、时间分钟 |
|
||||
| **DisplayLarge** | `DesignFontSizeDisplayLarge` | 54 | 诗词正文、欢迎语 |
|
||||
|
||||
### 4. 行高标准 (Line Heights)
|
||||
|
||||
统一行高可以增强视觉节奏感。
|
||||
|
||||
| Token | 数值 (倍率) | 应用场景 |
|
||||
| --- | --- | --- |
|
||||
| `DesignLineHeightStandard` | 1.2 | 单行标签、紧凑卡片 |
|
||||
| `DesignLineHeightLoose` | 1.5 | 多行诗词、新闻摘要、说明文档 |
|
||||
|
||||
### 5. 使用规范
|
||||
|
||||
1. **禁止硬编码**:严禁在 `.axaml` 中直接写入 `FontSize="18"` 或 `FontWeight="Bold"`。
|
||||
2. **动态资源绑定**:始终使用 `{DynamicResource DesignFontSize...}` 进行绑定。
|
||||
3. **全局样式继承**:`App.axaml` 已经设置了 `TextBlock` 的默认 `FontWeight` 为 `Medium`,除非是 `Caption` 或 `Title`,否则无需重复声明。
|
||||
|
||||
---
|
||||
|
||||
## English (Summary)
|
||||
|
||||
- **Default Font**: MiSans VF.
|
||||
- **Base Weight**: `Medium (500)` for better readability on glass/dark backgrounds.
|
||||
- **Header Weight**: `SemiBold (600)` for a modern premium feel.
|
||||
- **Line Height**: Standardized to 1.2x and 1.5x.
|
||||
- **Tokens**: All components must use `DesignFontSize...` and `DesignFontWeight...` resource keys.
|
||||
@@ -25,6 +25,12 @@
|
||||
- `glass-strong`:主要大容器
|
||||
- `glass-panel`:子区域、小面板、卡片
|
||||
|
||||
### 形状与圆角 (Shape & Corner Radius)
|
||||
|
||||
- **全局统一**:所有 UI 元素的圆角必须遵循 [圆角设计规范](file:///c:/Users/USER154971/Documents/GitHub/LanMountainDesktop/docs/CORNER_RADIUS_SPEC.md)。
|
||||
- **禁止硬编码**:严禁在资源库以外的地方硬编码 `CornerRadius` 数值。
|
||||
- **动态适配**:桌面组件必须使用 `DesignCornerRadiusComponent` 动态资源,以支持用户在设置中全局切换“锐利/平衡/圆润/开放”风格。
|
||||
|
||||
### 可访问性
|
||||
|
||||
- 正文对比度目标不低于 `4.5:1`
|
||||
|
||||
Reference in New Issue
Block a user