Compare commits

...

6 Commits

Author SHA1 Message Date
lincube
a671db8b69 更新 README.md 2026-04-08 23:32:39 +08:00
lincube
8c94253f92 fix.快捷方式组件的透明问题修复。顺便修了一下电源菜单。 2026-04-08 17:39:19 +08:00
lincube
6849a467d6 fead.快捷方式组件。fix.优化了噪音检测组件与白板组件的性能 2026-04-08 16:22:32 +08:00
lincube
e69bbf8b19 feat.加入快捷方式组件 2026-04-08 02:09:17 +08:00
lincube
d30af21317 docs.加入changelog 2026-04-08 01:45:26 +08:00
lincube
8583465a67 fead.圆角,终于统一 2026-04-08 00:55:10 +08:00
55 changed files with 1925 additions and 497 deletions

View File

@@ -62,7 +62,10 @@ dotnet test LanMountainDesktop.slnx -c Debug
### UI ### UI
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md` - 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md`
- **组件圆角**:所有内置与插件组件的根边框必须使用 `{DynamicResource DesignCornerRadiusComponent}` 资源。 - **圆角规范 (AI 强制建议)**
- **桌面组件根容器**:必须且仅能使用 `{DynamicResource DesignCornerRadiusComponent}`
- **内部元素**:必须根据嵌套层级使用 `DesignCornerRadiusSm/Md/Lg` 等 Token严禁硬编码像素值。
- **禁止修改系数**:严禁在圆角资源上乘以任何 `scale` 变量,圆角现在由全局样式固定控制。
- 设置页相关改动通常同时落在 `Views/``ViewModels/``Services/``.trae/specs/` - 设置页相关改动通常同时落在 `Views/``ViewModels/``Services/``.trae/specs/`
- UI 启动与窗口生命周期主线在 `Program.cs``App.axaml.cs` - UI 启动与窗口生命周期主线在 `Program.cs``App.axaml.cs`

82
CHANGELOG.md Normal file
View 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

View File

@@ -6,23 +6,48 @@ namespace LanMountainDesktop.Appearance;
public static class AppearanceCornerRadiusTokenFactory public static class AppearanceCornerRadiusTokenFactory
{ {
public static AppearanceCornerRadiusTokens Create(double scale) public static AppearanceCornerRadiusTokens Create(string style)
{ {
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale); var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(style);
return new AppearanceCornerRadiusTokens( return normalized switch
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; GlobalAppearanceSettings.CornerRadiusStyleSharp => new AppearanceCornerRadiusTokens(
return new CornerRadius(scaled); 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))
};
} }
} }

View File

@@ -7,6 +7,5 @@ public sealed record ComponentChromeContext(
string ComponentId, string ComponentId,
string? PlacementId, string? PlacementId,
double CellSize, double CellSize,
double GlobalCornerRadiusScale,
AppearanceCornerRadiusTokens CornerRadiusTokens, AppearanceCornerRadiusTokens CornerRadiusTokens,
SettingsScope Scope = SettingsScope.App); SettingsScope Scope = SettingsScope.App);

View File

@@ -9,7 +9,6 @@ public sealed class PluginAppearanceContext : IPluginAppearanceContext
Snapshot = snapshot with Snapshot = snapshot with
{ {
GlobalCornerRadiusScale = Math.Max(0d, snapshot.GlobalCornerRadiusScale),
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant) ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
? "Unknown" ? "Unknown"
: snapshot.ThemeVariant.Trim() : snapshot.ThemeVariant.Trim()
@@ -20,13 +19,15 @@ public sealed class PluginAppearanceContext : IPluginAppearanceContext
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null) public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
{ {
var scale = Snapshot.GlobalCornerRadiusScale; var value = Math.Max(0d, baseRadius);
var scaled = Math.Max(0d, baseRadius) * scale; if (!minimum.HasValue && !maximum.HasValue)
var scaledMin = minimum.HasValue ? minimum.Value * scale : scaled; {
var scaledMax = maximum.HasValue ? maximum.Value * scale : scaled; return value;
return minimum.HasValue || maximum.HasValue }
? Math.Clamp(scaled, scaledMin, scaledMax)
: scaled; 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) public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)

View File

@@ -1,6 +1,5 @@
namespace LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.PluginSdk;
public sealed record PluginAppearanceSnapshot( public sealed record PluginAppearanceSnapshot(
double GlobalCornerRadiusScale,
PluginCornerRadiusTokens CornerRadiusTokens, PluginCornerRadiusTokens CornerRadiusTokens,
string ThemeVariant); string ThemeVariant);

View File

@@ -52,8 +52,6 @@ public sealed class PluginDesktopComponentContext
public IPluginAppearanceContext Appearance { get; } public IPluginAppearanceContext Appearance { get; }
public double GlobalCornerRadiusScale => Appearance.Snapshot.GlobalCornerRadiusScale;
public PluginCornerRadiusTokens CornerRadiusTokens => Appearance.Snapshot.CornerRadiusTokens; public PluginCornerRadiusTokens CornerRadiusTokens => Appearance.Snapshot.CornerRadiusTokens;
public IPluginSettingsService? PluginSettings { get; } public IPluginSettingsService? PluginSettings { get; }

View File

@@ -2,17 +2,69 @@ namespace LanMountainDesktop.Settings.Core;
public static class GlobalAppearanceSettings 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 DefaultCornerRadiusScale = 1.0;
public const double MinimumCornerRadiusScale = 0.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
};
} }
} }

View File

@@ -11,19 +11,19 @@ namespace LanMountainDesktop.Tests;
public sealed class BuiltInDesktopHostCornerRadiusBaselineTests public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
{ {
[Theory] [Theory]
[InlineData(80d, 0d)] [InlineData(80d, "Sharp")]
[InlineData(120d, 1d)] [InlineData(120d, "Balanced")]
[InlineData(160d, 2.5d)] [InlineData(160d, "Rounded")]
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, double globalScale) public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, string style)
{ {
var registry = new DesktopComponentRuntimeRegistry( var registry = new DesktopComponentRuntimeRegistry(
ComponentRegistry.CreateDefault(), ComponentRegistry.CreateDefault(),
DesktopComponentRuntimeRegistry.GetDefaultRegistrations()); DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Component.TopLeft; var expected = AppearanceCornerRadiusTokenFactory.Create(style).Component.TopLeft;
foreach (var descriptor in registry.GetDesktopComponents()) 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); Assert.Equal(expected, resolved, 3);
} }
} }
@@ -31,13 +31,12 @@ public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
private static ComponentChromeContext CreateChromeContext( private static ComponentChromeContext CreateChromeContext(
string componentId, string componentId,
double cellSize, double cellSize,
double globalScale) string style)
{ {
return new ComponentChromeContext( return new ComponentChromeContext(
componentId, componentId,
null, null,
cellSize, cellSize,
globalScale, AppearanceCornerRadiusTokenFactory.Create(style));
AppearanceCornerRadiusTokenFactory.Create(globalScale));
} }
} }

View File

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

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

View File

@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Tests;
public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
{ {
[Fact] [Fact]
public void LegacyCellSizeResolver_AppliesGlobalCornerRadiusScale() public void LegacyCellSizeResolver_ReturnsUnscaledFixedValue()
{ {
var registration = new DesktopComponentRuntimeRegistration( var registration = new DesktopComponentRuntimeRegistration(
componentId: "test.component", componentId: "test.component",
@@ -19,41 +19,42 @@ public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.30, 10, 40)); cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.30, 10, 40));
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver); 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] [Fact]
public void ChromeContextResolver_IsNotDoubleScaledByRegistrationWrapper() public void ChromeContextResolver_UsesTokenValue()
{ {
var registration = new DesktopComponentRuntimeRegistration( var registration = new DesktopComponentRuntimeRegistration(
componentId: "test.component", componentId: "test.component",
displayNameLocalizationKey: null, displayNameLocalizationKey: null,
controlFactory: _ => new Border(), 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 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( return new ComponentChromeContext(
ComponentId: "test.component", ComponentId: "test.component",
PlacementId: null, PlacementId: null,
CellSize: cellSize, CellSize: cellSize,
GlobalCornerRadiusScale: globalScale,
CornerRadiusTokens: new AppearanceCornerRadiusTokens( CornerRadiusTokens: new AppearanceCornerRadiusTokens(
new CornerRadius(6), Micro: new CornerRadius(6),
new CornerRadius(12), Xs: new CornerRadius(12),
new CornerRadius(14), Sm: new CornerRadius(14),
new CornerRadius(20), Md: new CornerRadius(20),
new CornerRadius(28), Lg: new CornerRadius(28),
new CornerRadius(32), Xl: new CornerRadius(32),
new CornerRadius(36), Island: new CornerRadius(36),
new CornerRadius(8))); Component: new CornerRadius(24)));
} }
} }

View File

@@ -48,26 +48,27 @@ public sealed class InfoRecommendationHostCornerRadiusTests
registry.TryGetDescriptor(componentId, out var descriptor), registry.TryGetDescriptor(componentId, out var descriptor),
$"Missing runtime registration for '{componentId}'."); $"Missing runtime registration for '{componentId}'.");
var zero = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 0d)); var sharp = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Sharp"));
var unit = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 1d)); var balanced = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Balanced"));
var max = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 2.5d)); var rounded = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Rounded"));
var open = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Open"));
Assert.Equal(0d, zero, 3); // All info widgets should resolve to the Component token in the new system
Assert.Equal(18d, unit, 3); Assert.Equal(20d, sharp, 3);
Assert.Equal(45d, max, 3); Assert.Equal(24d, balanced, 3);
Assert.True(zero <= unit && unit <= max); Assert.Equal(28d, rounded, 3);
Assert.Equal(32d, open, 3);
} }
private static ComponentChromeContext CreateChromeContext( private static ComponentChromeContext CreateChromeContext(
string componentId, string componentId,
double cellSize, double cellSize,
double globalScale) string style)
{ {
return new ComponentChromeContext( return new ComponentChromeContext(
componentId, componentId,
null, null,
cellSize, cellSize,
globalScale, AppearanceCornerRadiusTokenFactory.Create(style));
AppearanceCornerRadiusTokenFactory.Create(globalScale));
} }
} }

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

View File

@@ -664,7 +664,7 @@ public partial class App : Application
refreshAll || refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), 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) && (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) || changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&

View File

@@ -46,4 +46,5 @@ public static class BuiltInComponentIds
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub"; public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
public const string DesktopFileManager = "DesktopFileManager"; public const string DesktopFileManager = "DesktopFileManager";
public const string DesktopNotificationBox = "DesktopNotificationBox"; public const string DesktopNotificationBox = "DesktopNotificationBox";
public const string DesktopShortcut = "DesktopShortcut";
} }

View File

@@ -420,6 +420,16 @@ public sealed class ComponentRegistry
MinHeightCells: 2, MinHeightCells: 2,
AllowStatusBarPlacement: false, AllowStatusBarPlacement: false,
AllowDesktopPlacement: true, AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopShortcut,
"快捷方式",
"App",
"File",
MinWidthCells: 1,
MinHeightCells: 1,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free) ResizeMode: DesktopComponentResizeMode.Free)
}; };

View File

@@ -19,6 +19,8 @@ public sealed class AppSettingsSnapshot
public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale; public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale;
public string CornerRadiusStyle { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
public string ThemeColorMode { get; set; } = "default_neutral"; public string ThemeColorMode { get; set; } = "default_neutral";
public string SystemMaterialMode { get; set; } = "none"; public string SystemMaterialMode { get; set; } = "none";

View File

@@ -123,6 +123,25 @@ public sealed class ComponentSettingsSnapshot
#endregion #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() public ComponentSettingsSnapshot Clone()
{ {
var clone = (ComponentSettingsSnapshot)MemberwiseClone(); var clone = (ComponentSettingsSnapshot)MemberwiseClone();

View File

@@ -37,6 +37,7 @@ public enum StudyDataMode
public sealed record StudyAnalyticsConfig( public sealed record StudyAnalyticsConfig(
int FrameMs = 50, int FrameMs = 50,
int UiPublishIntervalMs = 125,
int SliceSec = 30, int SliceSec = 30,
double ScoreThresholdDbfs = -50, double ScoreThresholdDbfs = -50,
int SegmentMergeGapMs = 500, int SegmentMergeGapMs = 500,

View File

@@ -44,7 +44,7 @@ public sealed record AppearanceThemeSnapshot(
string ThemeColorMode, string ThemeColorMode,
string? UserThemeColor, string? UserThemeColor,
string? SelectedWallpaperSeed, string? SelectedWallpaperSeed,
double GlobalCornerRadiusScale, string CornerRadiusStyle,
AppearanceCornerRadiusTokens CornerRadiusTokens, AppearanceCornerRadiusTokens CornerRadiusTokens,
string ResolvedSeedSource, string ResolvedSeedSource,
MonetPalette MonetPalette, MonetPalette MonetPalette,
@@ -551,7 +551,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
if (!refreshAll && if (!refreshAll &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) && !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) && !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) && !changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) &&
!(respondsToThemeColor && !(respondsToThemeColor &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) && changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
!(respondsToWallpaper && !(respondsToWallpaper &&
@@ -573,8 +573,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
bool queueWallpaperPaletteBuild) bool queueWallpaperPaletteBuild)
{ {
var availableModes = _windowMaterialService.GetAvailableModes(); var availableModes = _windowMaterialService.GetAvailableModes();
var globalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(themeState.GlobalCornerRadiusScale); var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(themeState.CornerRadiusStyle);
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(globalCornerRadiusScale); var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(cornerRadiusStyle);
MonetPalette palette; MonetPalette palette;
IReadOnlyList<Color> wallpaperSeedCandidates; IReadOnlyList<Color> wallpaperSeedCandidates;
Color effectiveSeedColor; Color effectiveSeedColor;
@@ -614,7 +614,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
themeColorMode, themeColorMode,
themeState.ThemeColor, themeState.ThemeColor,
selectedWallpaperSeed, selectedWallpaperSeed,
globalCornerRadiusScale, cornerRadiusStyle,
cornerRadiusTokens, cornerRadiusTokens,
resolvedSeedSource, resolvedSeedSource,
palette, palette,

View File

@@ -272,7 +272,12 @@ public static class DesktopComponentEditorRegistryFactory
BuiltInComponentIds.DesktopNotificationBox, BuiltInComponentIds.DesktopNotificationBox,
context => new NotificationBoxComponentEditor(context), context => new NotificationBoxComponentEditor(context),
preferredWidth: 480d, 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)) foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))

View File

@@ -129,7 +129,6 @@ public static class DesktopComponentRegistryFactory
settingsService); settingsService);
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent(); var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot( var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: appearanceSnapshot.GlobalCornerRadiusScale,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens), CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens),
ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light")); ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light"));
var pluginContext = new PluginDesktopComponentContext( var pluginContext = new PluginDesktopComponentContext(
@@ -157,7 +156,6 @@ public static class DesktopComponentRegistryFactory
private static IPluginAppearanceContext CreatePluginAppearanceContext(ComponentChromeContext chromeContext) private static IPluginAppearanceContext CreatePluginAppearanceContext(ComponentChromeContext chromeContext)
{ {
return new PluginAppearanceContext(new PluginAppearanceSnapshot( return new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: chromeContext.GlobalCornerRadiusScale,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(chromeContext.CornerRadiusTokens), CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(chromeContext.CornerRadiusTokens),
ThemeVariant: "Unknown")); ThemeVariant: "Unknown"));
} }

View File

@@ -20,7 +20,6 @@ public sealed record ComponentLibraryCategoryEntry(
public sealed record ComponentLibraryCreateContext( public sealed record ComponentLibraryCreateContext(
double CellSize, double CellSize,
double GlobalCornerRadiusScale,
TimeZoneService TimeZoneService, TimeZoneService TimeZoneService,
IWeatherInfoService WeatherInfoService, IWeatherInfoService WeatherInfoService,
IRecommendationInfoService RecommendationInfoService, IRecommendationInfoService RecommendationInfoService,

View File

@@ -30,7 +30,7 @@ public sealed record ThemeAppearanceSettingsState(
bool IsNightMode, bool IsNightMode,
string? ThemeColor, string? ThemeColor,
bool UseSystemChrome, bool UseSystemChrome,
double GlobalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale, string CornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle,
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral, string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone, string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
string? SelectedWallpaperSeed = null); string? SelectedWallpaperSeed = null);

View File

@@ -254,11 +254,19 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
public ThemeAppearanceSettingsState Get() public ThemeAppearanceSettingsState Get()
{ {
var snapshot = _settingsService.Load(); 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( return new ThemeAppearanceSettingsState(
snapshot.IsNightMode ?? false, snapshot.IsNightMode ?? false,
snapshot.ThemeColor, snapshot.ThemeColor,
snapshot.UseSystemChrome, snapshot.UseSystemChrome,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale), cornerRadiusStyle,
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor), ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode), ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
snapshot.SelectedWallpaperSeed); snapshot.SelectedWallpaperSeed);
@@ -269,7 +277,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
var snapshot = _settingsService.Load(); var snapshot = _settingsService.Load();
var changedKeys = new List<string>(); var changedKeys = new List<string>();
var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor; 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 normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor);
var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode); var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode);
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed) var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
@@ -294,10 +302,10 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome)); 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; snapshot.CornerRadiusStyle = normalizedCornerRadiusStyle;
changedKeys.Add(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale)); changedKeys.Add(nameof(AppSettingsSnapshot.CornerRadiusStyle));
} }
if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase))

View File

@@ -12,8 +12,13 @@ internal readonly record struct NoisePipelineTickResult(
internal sealed class NoiseFramePipeline internal sealed class NoiseFramePipeline
{ {
private StudyAnalyticsConfig _config; private StudyAnalyticsConfig _config;
private readonly Queue<NoiseRealtimePoint> _realtimeBuffer = new();
private readonly List<NoiseRealtimePoint> _slicePoints = []; 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 _sliceStartAt;
private DateTimeOffset _lastFrameAt; private DateTimeOffset _lastFrameAt;
@@ -28,18 +33,29 @@ internal sealed class NoiseFramePipeline
public NoiseFramePipeline(StudyAnalyticsConfig config) public NoiseFramePipeline(StudyAnalyticsConfig config)
{ {
_config = NormalizeConfig(config); _config = NormalizeConfig(config);
_realtimeBuffer = new NoiseRealtimePoint[_config.RealtimeBufferCapacity];
} }
public void UpdateConfig(StudyAnalyticsConfig config) 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(); Reset();
} }
public void Reset() public void Reset()
{ {
_realtimeBuffer.Clear();
_slicePoints.Clear(); _slicePoints.Clear();
_realtimeBufferStart = 0;
_realtimeBufferCount = 0;
_realtimeBufferVersion++;
_realtimeSnapshot = Array.Empty<NoiseRealtimePoint>();
_realtimeSnapshotVersion = -1;
_sliceStartAt = default; _sliceStartAt = default;
_lastFrameAt = default; _lastFrameAt = default;
_lastOverThresholdAt = default; _lastOverThresholdAt = default;
@@ -52,7 +68,27 @@ internal sealed class NoiseFramePipeline
public IReadOnlyList<NoiseRealtimePoint> GetRealtimeBufferSnapshot() 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) public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak)
@@ -114,12 +150,7 @@ internal sealed class NoiseFramePipeline
peak, peak,
isOverThreshold); isOverThreshold);
_slicePoints.Add(point); _slicePoints.Add(point);
_realtimeBuffer.Enqueue(point); AddRealtimePoint(point);
while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity)
{
_realtimeBuffer.Dequeue();
}
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds; var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
if (elapsedSeconds + 1e-6 < _config.SliceSec) if (elapsedSeconds + 1e-6 < _config.SliceSec)
@@ -132,6 +163,29 @@ internal sealed class NoiseFramePipeline
return new NoisePipelineTickResult(point, slice); 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) private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt)
{ {
var sampledDurationMs = _slicePoints.Count * _config.FrameMs; var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
@@ -247,6 +301,7 @@ internal sealed class NoiseFramePipeline
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config) private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
{ {
var frameMs = Math.Clamp(config.FrameMs, 20, 250); 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 sliceSec = Math.Clamp(config.SliceSec, 5, 600);
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5); var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000); var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
@@ -259,6 +314,7 @@ internal sealed class NoiseFramePipeline
return config with return config with
{ {
FrameMs = frameMs, FrameMs = frameMs,
UiPublishIntervalMs = uiPublishIntervalMs,
SliceSec = sliceSec, SliceSec = sliceSec,
ScoreThresholdDbfs = threshold, ScoreThresholdDbfs = threshold,
SegmentMergeGapMs = mergeGapMs, SegmentMergeGapMs = mergeGapMs,

View File

@@ -46,6 +46,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
private readonly List<StudySessionReport> _sessionHistory = []; private readonly List<StudySessionReport> _sessionHistory = [];
private string? _selectedSessionReportId; private string? _selectedSessionReportId;
private string _lastError = string.Empty; private string _lastError = string.Empty;
private DateTimeOffset _lastUiPublishedAt;
private bool _disposed; private bool _disposed;
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null) public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
@@ -102,6 +103,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
ThrowIfDisposedLocked(); ThrowIfDisposedLocked();
_config = NormalizeConfig(config); _config = NormalizeConfig(config);
_pipeline.UpdateConfig(_config); _pipeline.UpdateConfig(_config);
_lastUiPublishedAt = default;
if (_state == StudyAnalyticsRuntimeState.Running) if (_state == StudyAnalyticsRuntimeState.Running)
{ {
StartTimerLocked(); StartTimerLocked();
@@ -546,7 +548,11 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_lastError = string.Empty; _lastError = string.Empty;
UpdateDataModeLocked(); UpdateDataModeLocked();
if (ShouldPublishRealtimeSnapshotLocked(now, closedSlice is not null))
{
snapshot = BuildSnapshotLocked(now); snapshot = BuildSnapshotLocked(now);
_lastUiPublishedAt = now;
}
} }
} }
@@ -599,6 +605,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
private void StartTimerLocked() private void StartTimerLocked()
{ {
_lastUiPublishedAt = default;
_samplingTimer.Change( _samplingTimer.Change(
dueTime: TimeSpan.Zero, dueTime: TimeSpan.Zero,
period: TimeSpan.FromMilliseconds(_config.FrameMs)); period: TimeSpan.FromMilliseconds(_config.FrameMs));
@@ -673,6 +680,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config) private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
{ {
var frameMs = Math.Clamp(config.FrameMs, 20, 250); 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 sliceSec = Math.Clamp(config.SliceSec, 5, 600);
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5); var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000); var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
@@ -685,6 +693,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
return config with return config with
{ {
FrameMs = frameMs, FrameMs = frameMs,
UiPublishIntervalMs = uiPublishIntervalMs,
SliceSec = sliceSec, SliceSec = sliceSec,
ScoreThresholdDbfs = threshold, ScoreThresholdDbfs = threshold,
SegmentMergeGapMs = mergeGapMs, 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() private void ThrowIfDisposedLocked()
{ {
if (_disposed) if (_disposed)

View File

@@ -222,10 +222,37 @@
</Style> </Style>
<!-- 向后兼容的旧样式类(已弃用) --> <!-- 向后兼容的旧样式类(已弃用) -->
<Style Selector="Border.glass-panel" /> <Style Selector="Border.glass-panel">
<Style Selector="Border.glass-strong" /> <Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
<Style Selector="Border.glass-island" /> <Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Style Selector="Border.mica-strong" /> <Setter Property="BorderThickness" Value="1.2" />
<Style Selector="Border.glass-overlay" /> <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> </Styles>

View File

@@ -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 partial class MainWindowViewModel : ViewModelBase
{ {
public string Greeting { get; } = "A modern desktop shell powered by FluentAvalonia."; 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
});
}
}
} }

View File

@@ -614,10 +614,10 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
private string _systemMaterialLabel = string.Empty; private string _systemMaterialLabel = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _globalCornerRadiusLabel = string.Empty; private string _cornerRadiusStyleLabel = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _globalCornerRadiusDescription = string.Empty; private string _cornerRadiusStyleDescription = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _themeHeader = string.Empty; private string _themeHeader = string.Empty;
@@ -701,6 +701,15 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
public IBrush NeutralDarkPreviewBrush => NeutralDarkBrushValue; public IBrush NeutralDarkPreviewBrush => NeutralDarkBrushValue;
[ObservableProperty]
private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
[ObservableProperty]
private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = [];
[ObservableProperty]
private SelectionOption? _selectedCornerRadiusStyle;
public void Load() public void Load()
{ {
var theme = _settingsFacade.Theme.Get(); var theme = _settingsFacade.Theme.Get();
@@ -740,29 +749,14 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
PersistCurrentState(restartRequired: false); PersistCurrentState(restartRequired: false);
} }
partial void OnGlobalCornerRadiusScaleChanged(double value) partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value)
{ {
if (_isInitializing) if (_isInitializing || value is null)
{ {
return; return;
} }
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value); CornerRadiusStyle = value.Value;
if (Math.Abs(normalized - value) > 0.0001d)
{
_isInitializing = true;
try
{
GlobalCornerRadiusScale = normalized;
}
finally
{
_isInitializing = false;
}
return;
}
PersistCurrentState(restartRequired: false); PersistCurrentState(restartRequired: false);
} }
@@ -830,8 +824,12 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color"); ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source"); ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
SystemMaterialLabel = L("settings.appearance.system_material_label", "System material"); SystemMaterialLabel = L("settings.appearance.system_material_label", "System material");
GlobalCornerRadiusLabel = L("settings.appearance.corner_radius.label", "Global corner radius"); CornerRadiusStyleLabel = L("settings.appearance.corner_radius.label", "Global corner radius style");
GlobalCornerRadiusDescription = L("settings.appearance.corner_radius.description", "Adjust the shared radius scale used by cards, panels, and component containers."); 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"); ThemeSourceNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral");
ThemeSourceUserColorText = L("settings.appearance.theme_color_mode.user", "User theme color Monet"); ThemeSourceUserColorText = L("settings.appearance.theme_color_mode.user", "User theme color Monet");
ThemeSourceWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet"); ThemeSourceWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet");
@@ -876,7 +874,10 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
IsNightMode = theme.IsNightMode; IsNightMode = theme.IsNightMode;
ThemeColor = theme.ThemeColor ?? string.Empty; ThemeColor = theme.ThemeColor ?? string.Empty;
UseSystemChrome = theme.UseSystemChrome; 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; _selectedWallpaperSeed = theme.SelectedWallpaperSeed;
SyncCustomSeedPickerWithSavedThemeColor(); SyncCustomSeedPickerWithSavedThemeColor();
@@ -926,7 +927,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
IsNightMode, IsNightMode,
themeColor, themeColor,
UseSystemChrome, UseSystemChrome,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale), GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
themeColorMode, themeColorMode,
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value), ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
_selectedWallpaperSeed); _selectedWallpaperSeed);
@@ -1070,20 +1071,22 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
private string _spacingPresetLabel = string.Empty; private string _spacingPresetLabel = string.Empty;
[ObservableProperty] [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] [ObservableProperty]
private string _componentRadiusHeader = string.Empty; private string _componentRadiusHeader = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _globalCornerRadiusLabel = string.Empty; private string _cornerRadiusStyleLabel = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _globalCornerRadiusDescription = string.Empty; private string _cornerRadiusStyleDescription = string.Empty;
public void Load() public void Load()
{ {
@@ -1096,7 +1099,10 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
?? SpacingPresets[1]; ?? SpacingPresets[1];
var theme = _settingsFacade.Theme.Get(); 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) partial void OnShortSideCellsChanged(int value)
@@ -1129,29 +1135,14 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
SaveGrid(); SaveGrid();
} }
partial void OnGlobalCornerRadiusScaleChanged(double value) partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value)
{ {
if (_isInitializing) if (_isInitializing || value is null)
{ {
return; return;
} }
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value); CornerRadiusStyle = value.Value;
if (Math.Abs(normalized - value) > 0.0001d)
{
_isInitializing = true;
try
{
GlobalCornerRadiusScale = normalized;
}
finally
{
_isInitializing = false;
}
return;
}
SaveComponentCornerRadius(); SaveComponentCornerRadius();
} }
@@ -1170,7 +1161,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
theme.IsNightMode, theme.IsNightMode,
theme.ThemeColor, theme.ThemeColor,
theme.UseSystemChrome, theme.UseSystemChrome,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale), GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
theme.ThemeColorMode, theme.ThemeColorMode,
theme.SystemMaterialMode, theme.SystemMaterialMode,
theme.SelectedWallpaperSeed)); theme.SelectedWallpaperSeed));
@@ -1194,10 +1185,14 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
EdgeInsetPercentLabel = L("settings.components.edge_inset_label", "Screen Inset"); EdgeInsetPercentLabel = L("settings.components.edge_inset_label", "Screen Inset");
SpacingPresetLabel = L("settings.components.spacing_label", "Component Spacing"); SpacingPresetLabel = L("settings.components.spacing_label", "Component Spacing");
ComponentRadiusHeader = L("settings.components.corner_radius.header", "Corner Design"); ComponentRadiusHeader = L("settings.components.corner_radius.header", "Corner Design");
GlobalCornerRadiusLabel = L("settings.components.corner_radius.label", "Component Corner Radius"); CornerRadiusStyleLabel = L("settings.components.corner_radius.label", "Component Corner Radius Style");
GlobalCornerRadiusDescription = L( CornerRadiusStyleDescription = L(
"settings.components.corner_radius.description", "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) private string L(string key, string fallback)

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@@ -552,7 +552,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
{ {
Width = 160, Width = 160,
Height = 90, Height = 90,
CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16, 8, 22), CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16, 8, 22),
ClipToBounds = true, ClipToBounds = true,
Background = new SolidColorBrush(Color.Parse("#E6E6E6")), Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
IsHitTestVisible = false IsHitTestVisible = false
@@ -647,8 +647,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
News1ImageHost.Height = imageHeight; News1ImageHost.Height = imageHeight;
News2ImageHost.Width = imageWidth; News2ImageHost.Width = imageWidth;
News2ImageHost.Height = imageHeight; News2ImageHost.Height = imageHeight;
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22); News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22); News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
News1ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6")); News1ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
News2ImageHost.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.Width = imageWidth;
row.ImageHost.Height = imageHeight; 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.ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
row.TitleTextBlock.MaxWidth = availableTextWidth; row.TitleTextBlock.MaxWidth = availableTextWidth;

View File

@@ -3,13 +3,14 @@ using Avalonia.Controls;
using Avalonia.Media; using Avalonia.Media;
using LanMountainDesktop.Host.Abstractions; using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core; using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Views.Components; namespace LanMountainDesktop.Views.Components;
internal static class ComponentChromeCornerRadiusHelper 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) if (chromeContext is not null)
{ {
@@ -20,7 +21,7 @@ internal static class ComponentChromeCornerRadiusHelper
var resolved = snapshot.CornerRadiusTokens.Component.TopLeft; var resolved = snapshot.CornerRadiusTokens.Component.TopLeft;
return double.IsFinite(resolved) return double.IsFinite(resolved)
? Math.Max(0d, 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) public static CornerRadius ResolveMainRectangleRadius(ComponentChromeContext? chromeContext = null, double fallback = 24d)
@@ -28,24 +29,6 @@ internal static class ComponentChromeCornerRadiusHelper
return new CornerRadius(ResolveMainRectangleRadiusValue(chromeContext, fallback)); 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) public static void Apply(CornerRadius radius, params Border?[] chromeLayers)
{ {
foreach (var chromeLayer in chromeLayers) foreach (var chromeLayer in chromeLayers)
@@ -67,28 +50,57 @@ internal static class ComponentChromeCornerRadiusHelper
: new CornerRadius(fallback); : 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( public static double Scale(double value, double min, double max, ComponentChromeContext? context = null)
ComponentChromeContext? chromeContext = null,
double responsiveness = 0.45d)
{ {
var scale = ResolveScale(chromeContext); _ = context;
var normalizedResponsiveness = Math.Clamp(responsiveness, 0d, 1d); return Math.Clamp(value, min, max);
return 1d + ((scale - 1d) * normalizedResponsiveness);
} }
public static double SafeValue( public static CornerRadius SafeRadius(double value, double min, double max, ComponentChromeContext? context = null)
double baseValue,
double min,
double max,
ComponentChromeContext? chromeContext = null,
double responsiveness = 0.45d)
{ {
var safetyScale = ResolveContentSafetyScale(chromeContext, responsiveness); _ = context;
return Math.Clamp(baseValue * safetyScale, min * safetyScale, max * safetyScale); 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;
} }
} }

View File

@@ -39,8 +39,7 @@ public sealed class DesktopComponentRuntimeRegistration
_ => controlFactory(), _ => controlFactory(),
cornerRadiusResolver is null cornerRadiusResolver is null
? null ? null
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) * : chromeContext => cornerRadiusResolver(chromeContext.CellSize))
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
{ {
} }
@@ -55,8 +54,7 @@ public sealed class DesktopComponentRuntimeRegistration
controlFactory, controlFactory,
cornerRadiusResolver is null cornerRadiusResolver is null
? null ? null
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) * : chromeContext => cornerRadiusResolver(chromeContext.CellSize))
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
{ {
} }
@@ -131,7 +129,6 @@ public sealed class DesktopComponentRuntimeDescriptor
Definition.Id, Definition.Id,
placementId, placementId,
cellSize, cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
appearanceSnapshot.CornerRadiusTokens); appearanceSnapshot.CornerRadiusTokens);
var control = _controlFactory(new DesktopComponentControlFactoryContext( var control = _controlFactory(new DesktopComponentControlFactoryContext(
Definition, Definition,
@@ -226,8 +223,7 @@ public sealed class DesktopComponentRuntimeDescriptor
Definition.Id, Definition.Id,
null, null,
Math.Max(1, cellSize), Math.Max(1, cellSize),
1d, AppearanceCornerRadiusTokenFactory.Create(GlobalAppearanceSettings.DefaultCornerRadiusStyle)));
AppearanceCornerRadiusTokenFactory.Create(1d)));
} }
private static void ApplySettingsDependencies( private static void ApplySettingsDependencies(
@@ -483,7 +479,11 @@ public sealed class DesktopComponentRuntimeRegistry
new DesktopComponentRuntimeRegistration( new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopNotificationBox, BuiltInComponentIds.DesktopNotificationBox,
"component.notification_box", "component.notification_box",
() => new NotificationBoxWidget()) () => new NotificationBoxWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopShortcut,
"component.shortcut",
() => new ShortcutWidget())
]; ];
} }

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@@ -730,7 +730,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
_imageHost.Width = imageWidth; _imageHost.Width = imageWidth;
_imageHost.Height = imageHeight; _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); var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
_titleTextBlock.MaxWidth = textWidth; _titleTextBlock.MaxWidth = textWidth;

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

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

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@@ -638,7 +638,7 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I
foreach (var visual in _itemVisuals) 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.Host.Padding = new Thickness(rowPaddingHorizontal, rowPaddingVertical);
visual.RowGrid.ColumnSpacing = Math.Clamp(8 * softScale, 4, 12); visual.RowGrid.ColumnSpacing = Math.Clamp(8 * softScale, 4, 12);

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Buffers; using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using Avalonia; using Avalonia;
@@ -20,10 +20,24 @@ public sealed class StudyNoiseCurveChartControl : Control
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>(); private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
private Point[]? _pointBuffer; 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) 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(); InvalidateVisual();
} }
@@ -34,11 +48,18 @@ public sealed class StudyNoiseCurveChartControl : Control
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false); ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
_pointBuffer = null; _pointBuffer = null;
} }
_lineGeometry = null;
_fillGeometry = null;
_geometryDirty = true;
} }
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{ {
ReleasePointBuffer(); ReleasePointBuffer();
_lineGeometry = null;
_fillGeometry = null;
_geometryDirty = true;
base.OnDetachedFromVisualTree(e); base.OnDetachedFromVisualTree(e);
} }
@@ -64,16 +85,14 @@ public sealed class StudyNoiseCurveChartControl : Control
return; return;
} }
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360); EnsureGeometry(plot);
var pointCount = BuildPlotPoints(plot, maxSamples); if (_lineGeometry is null || _fillGeometry is null)
if (pointCount < 2 || _pointBuffer is null)
{ {
return; return;
} }
var span = _pointBuffer.AsSpan(0, pointCount); context.DrawGeometry(FillBrush, pen: null, _fillGeometry);
DrawAreaFill(context, plot.Bottom, span); context.DrawGeometry(brush: null, pen: LinePen, _lineGeometry);
DrawLine(context, span);
} }
private static void DrawGrid(DrawingContext context, Rect plot) 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)); 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(); if (!_geometryDirty && _cachedPlot == plot)
using (var builder = geometry.Open())
{ {
builder.BeginFigure(points[0], false); return;
for (var i = 1; i < points.Length; i++) }
_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)
{ {
builder.LineTo(points[i]); _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(_pointBuffer[i]);
} }
} }
context.DrawGeometry(brush: null, pen: LinePen, geometry); var fillGeometry = new StreamGeometry();
} using (var builder = fillGeometry.Open())
private void DrawAreaFill(DrawingContext context, double baselineY, ReadOnlySpan<Point> points)
{ {
var geometry = new StreamGeometry(); var first = _pointBuffer[0];
using (var builder = geometry.Open()) builder.BeginFigure(new Point(first.X, plot.Bottom), true);
{
var first = points[0];
builder.BeginFigure(new Point(first.X, baselineY), true);
builder.LineTo(first); 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]; var last = _pointBuffer[pointCount - 1];
builder.LineTo(new Point(last.X, baselineY)); builder.LineTo(new Point(last.X, plot.Bottom));
builder.LineTo(new Point(first.X, baselineY)); builder.LineTo(new Point(first.X, plot.Bottom));
builder.EndFigure(true); builder.EndFigure(true);
} }
context.DrawGeometry(FillBrush, pen: null, geometry); _lineGeometry = lineGeometry;
_fillGeometry = fillGeometry;
_geometryDirty = false;
} }
private int BuildPlotPoints(Rect plot, int maxSamples) private int BuildPlotPoints(Rect plot, int maxSamples)
@@ -295,4 +328,20 @@ public sealed class StudyNoiseCurveChartControl : Control
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false); ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
_pointBuffer = null; _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));
}
} }

View File

@@ -9,6 +9,8 @@ namespace LanMountainDesktop.Views.Components;
public sealed class StudyNoiseDistributionScatterChartControl : Control 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 GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96"));
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1")); private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
private static readonly Pen GridPen = new(GridBrush, 1); 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 NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B")); private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444")); 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 IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
private SampledPoint[] _sampledPoints = Array.Empty<SampledPoint>();
private int _sampledPointCount;
private double _baselineDb = 45; private double _baselineDb = 45;
private Rect _cachedPlot;
private bool _sampleCacheDirty = true;
private int _lastSeriesSignature;
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb) public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
{ {
_points = points ?? Array.Empty<NoiseRealtimePoint>(); var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
_baselineDb = Math.Clamp(baselineDb, 20, 85); 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(); InvalidateVisual();
} }
@@ -52,45 +75,34 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
return; return;
} }
EnsureSampleCache(plot);
if (_sampledPointCount < 2)
{
return;
}
DrawElectronCloud(context, plot); DrawElectronCloud(context, plot);
} }
private void DrawElectronCloud(DrawingContext context, Rect plot) private void DrawElectronCloud(DrawingContext context, Rect plot)
{ {
var start = _points[0].Timestamp; var cloudLayers = CloudAlphas.Length;
var end = _points[^1].Timestamp;
var totalTicks = Math.Max(1, (end - start).Ticks);
var pointCount = _points.Count;
var cloudLayers = 8;
var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12); 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--) 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 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 jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3;
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3; var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
context.DrawEllipse( context.DrawEllipse(
brush, layerBrushes[(int)pt.Level],
pen: null, pen: null,
center: new Point(pt.X + jitterX, pt.Y + jitterY), center: new Point(pt.X + jitterX, pt.Y + jitterY),
radiusX: layerRadius, 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--) 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 layerRadius = baseRadius * (0.8 + layerRatio * 0.6);
var layerAlpha = (byte)(20 + layerRatio * 15); var layerBrushes = GlowBrushes[layer];
for (var i = 0; i < _sampledPointCount; i++)
foreach (var pt in sortedPoints)
{ {
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha); var pt = _sampledPoints[i];
context.DrawEllipse( context.DrawEllipse(
brush, layerBrushes[(int)pt.Level],
pen: null, pen: null,
center: new Point(pt.X, pt.Y), center: new Point(pt.X, pt.Y),
radiusX: layerRadius, radiusX: layerRadius,
@@ -117,34 +128,42 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
} }
} }
var latest = _points[^1]; var latest = _sampledPoints[_sampledPointCount - 1];
var latestX = MapX(plot, latest.Timestamp, start, totalTicks);
var latestY = MapYContinuous(plot, latest.DisplayDb);
var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb);
for (var i = 3; i >= 0; i--) for (var i = 3; i >= 0; i--)
{ {
var radius = baseRadius * (1.5 + i * 0.8); var radius = baseRadius * (1.5 + i * 0.8);
var alpha = (byte)(30 - i * 6); var alpha = (byte)(30 - i * 6);
var glowBrush = GetLevelBrushWithAlpha(latestLevel, alpha); var glowBrush = GetAlphaBrush(latest.Level, alpha);
context.DrawEllipse(glowBrush, null, new Point(latestX, latestY), radius, radius * 0.6); context.DrawEllipse(glowBrush, null, new Point(latest.X, latest.Y), radius, radius * 0.6);
} }
context.DrawEllipse( context.DrawEllipse(
GetLevelBrush(latestLevel), GetLevelBrush(latest.Level),
new Pen(Brushes.White, 1.5), new Pen(Brushes.White, 1.5),
new Point(latestX, latestY), new Point(latest.X, latest.Y),
baseRadius + 1, baseRadius + 1,
baseRadius * 0.7 + 1); baseRadius * 0.7 + 1);
context.DrawEllipse( context.DrawEllipse(
Brushes.White, Brushes.White,
null, null,
new Point(latestX, latestY), new Point(latest.X, latest.Y),
2, 2,
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) private static void DrawGrid(DrawingContext context, Rect plot)
{ {
const int verticalDivisions = 4; const int verticalDivisions = 4;
@@ -176,7 +195,10 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
var minDb = _baselineDb - 5; var minDb = _baselineDb - 5;
var maxDb = _baselineDb + 25; var maxDb = _baselineDb + 25;
var dbRange = maxDb - minDb; var dbRange = maxDb - minDb;
if (dbRange <= 0) dbRange = 30; if (dbRange <= 0)
{
dbRange = 30;
}
var normalizedDb = (displayDb - minDb) / dbRange; var normalizedDb = (displayDb - minDb) / dbRange;
normalizedDb = Math.Clamp(normalizedDb, 0, 1); normalizedDb = Math.Clamp(normalizedDb, 0, 1);
@@ -243,6 +265,106 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
_ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA)) _ => 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 public enum NoiseDistributionLevel

View File

@@ -39,21 +39,22 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220"); private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA"); private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
private readonly object _snapshotSync = new();
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault(); private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault(); private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings; private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new(); private readonly LocalizationService _localizationService = new();
private readonly DispatcherTimer _uiTimer = new()
{
Interval = TimeSpan.FromMilliseconds(100)
};
private double _currentCellSize = 48; private double _currentCellSize = 48;
private StudyAnalyticsSnapshot? _pendingSnapshot;
private string _languageCode = "zh-CN"; private string _languageCode = "zh-CN";
private bool _dispatchQueued;
private bool _hasPendingSnapshot;
private bool _isAttached; private bool _isAttached;
private bool _isOnActivePage = true; private bool _isOnActivePage = true;
private bool _isDisposed; private bool _isDisposed;
private bool _isCompactMode; private bool _isCompactMode;
private bool _isSubscribed;
private bool _isUltraCompactMode; private bool _isUltraCompactMode;
private bool _studyEnabled = true; private bool _studyEnabled = true;
private IDisposable? _monitoringLease; private IDisposable? _monitoringLease;
@@ -71,7 +72,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
{ {
InitializeComponent(); InitializeComponent();
_uiTimer.Tick += OnUiTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree; AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged; SizeChanged += OnSizeChanged;
@@ -80,7 +80,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
ApplyCellSize(_currentCellSize); ApplyCellSize(_currentCellSize);
ApplyDefaultXAxisLabels(); ApplyDefaultXAxisLabels();
ApplyLocalizedAxisLabels(); ApplyLocalizedAxisLabels();
RefreshVisual(); QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
} }
public void ApplyCellSize(double cellSize) public void ApplyCellSize(double cellSize)
@@ -99,19 +99,23 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
if (isOnActivePage && !wasOnActivePage) if (isOnActivePage && !wasOnActivePage)
{ {
RefreshVisual(); QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
} }
UpdateTimerState();
} }
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{ {
_isAttached = true; _isAttached = true;
ReloadLanguageCode(); ReloadLanguageCode();
if (!_isSubscribed)
{
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
_isSubscribed = true;
}
UpdateMonitoringLeaseState(); UpdateMonitoringLeaseState();
UpdateTimerState(); QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
RefreshVisual();
} }
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
@@ -119,7 +123,12 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
_isAttached = false; _isAttached = false;
_monitoringLease?.Dispose(); _monitoringLease?.Dispose();
_monitoringLease = null; _monitoringLease = null;
_uiTimer.Stop();
if (_isSubscribed)
{
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
_isSubscribed = false;
}
} }
private void OnSizeChanged(object? sender, SizeChangedEventArgs e) private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
@@ -130,27 +139,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
private void OnActualThemeVariantChanged(object? sender, EventArgs e) private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{ {
RefreshVisual(); QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
}
private void OnUiTimerTick(object? sender, EventArgs e)
{
RefreshVisual();
}
private void UpdateTimerState()
{
if (_isAttached && _isOnActivePage)
{
if (!_uiTimer.IsEnabled)
{
_uiTimer.Start();
}
return;
}
_uiTimer.Stop();
} }
private void UpdateMonitoringLeaseState() private void UpdateMonitoringLeaseState()
@@ -172,7 +161,52 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
_monitoringLease = null; _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(); var panelColor = ResolvePanelBackgroundColor();
ApplyTypographyByBackground(panelColor); ApplyTypographyByBackground(panelColor);
@@ -189,8 +223,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
return; return;
} }
var snapshot = _studyAnalyticsService.GetSnapshot();
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running; var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null; var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
var isSessionView = isSessionRunning || isSessionReport; var isSessionView = isSessionRunning || isSessionReport;
@@ -634,13 +666,17 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
_isDisposed = true; _isDisposed = true;
_uiTimer.Stop();
_uiTimer.Tick -= OnUiTimerTick;
AttachedToVisualTree -= OnAttachedToVisualTree; AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree; DetachedFromVisualTree -= OnDetachedFromVisualTree;
SizeChanged -= OnSizeChanged; SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged; ActualThemeVariantChanged -= OnActualThemeVariantChanged;
if (_isSubscribed)
{
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
_isSubscribed = false;
}
_monitoringLease?.Dispose(); _monitoringLease?.Dispose();
_monitoringLease = null; _monitoringLease = null;
} }

View File

@@ -45,6 +45,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private string _placementId = string.Empty; private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays; private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
private bool _isApplyingPersistedSnapshot; private bool _isApplyingPersistedSnapshot;
private bool? _lastBitmapCacheEnabled;
private int _lastBitmapCacheSize;
private bool _noteDirty; private bool _noteDirty;
private int _noteLoadRevision; private int _noteLoadRevision;
private bool _disposed; private bool _disposed;
@@ -119,11 +121,10 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
settings.IgnorePressure = true; settings.IgnorePressure = true;
settings.InkThickness = _selectedInkThickness; settings.InkThickness = _selectedInkThickness;
settings.EraserSize = new Size(20, 20); settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected; InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased += OnInkCanvasPointerReleased; InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost; InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
UpdateInkCanvasCacheSettings(forceRefresh: true);
} }
public void ApplyCellSize(double cellSize) public void ApplyCellSize(double cellSize)
@@ -157,6 +158,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings; var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44); var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
settings.EraserSize = new Size(eraserSize, eraserSize); settings.EraserSize = new Size(eraserSize, eraserSize);
UpdateInkCanvasCacheSettings(forceRefresh: false);
} }
private void ApplyThemeVisual(bool force) private void ApplyThemeVisual(bool force)
@@ -711,8 +713,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke); InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
} }
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache(); UpdateInkCanvasCacheSettings(forceRefresh: true);
InkCanvas.InvalidateVisual();
} }
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point) private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
@@ -765,9 +766,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
} }
} }
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false); UpdateInkCanvasCacheSettings(forceRefresh: true);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
} }
private bool HasValidPersistenceContext() private bool HasValidPersistenceContext()
@@ -785,4 +784,47 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return Array.Empty<InkStylusPoint>(); 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.
}
}
} }

View File

@@ -295,7 +295,7 @@ public partial class MainWindow
var renderScale = RenderScaling > 0 ? RenderScaling : 1d; var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
return string.Create( return string.Create(
CultureInfo.InvariantCulture, 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) private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells)

View File

@@ -1548,7 +1548,6 @@ public partial class MainWindow
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent(); var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
return new ComponentLibraryCreateContext( return new ComponentLibraryCreateContext(
cellSize, cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
_timeZoneService, _timeZoneService,
_weatherDataService, _weatherDataService,
_recommendationInfoService, _recommendationInfoService,
@@ -2552,12 +2551,10 @@ public partial class MainWindow
componentId, componentId,
null, null,
_currentDesktopCellSize, _currentDesktopCellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
appearanceSnapshot.CornerRadiusTokens)); appearanceSnapshot.CornerRadiusTokens));
} }
var scale = Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, appearanceSnapshot.GlobalCornerRadiusScale); return Math.Max(0d, appearanceSnapshot.CornerRadiusTokens.Component.TopLeft);
return Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18) * scale;
} }
private Thickness GetDesktopComponentVisualInset(int widthCells, int heightCells) private Thickness GetDesktopComponentVisualInset(int widthCells, int heightCells)
@@ -2809,7 +2806,6 @@ public partial class MainWindow
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent(); var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
var createContext = new ComponentLibraryCreateContext( var createContext = new ComponentLibraryCreateContext(
cellSize, cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
_timeZoneService, _timeZoneService,
_weatherDataService, _weatherDataService,
_recommendationInfoService, _recommendationInfoService,

View File

@@ -51,6 +51,7 @@ public partial class MainWindow
string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.SelectedWallpaperSeed), 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.LastUpdateCheckUtcMs), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateVersion), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateVersion), StringComparison.OrdinalIgnoreCase) ||
@@ -611,7 +612,7 @@ public partial class MainWindow
SystemMaterialMode = latestThemeState.SystemMaterialMode, SystemMaterialMode = latestThemeState.SystemMaterialMode,
SelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed, SelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed,
UseSystemChrome = latestThemeState.UseSystemChrome, UseSystemChrome = latestThemeState.UseSystemChrome,
GlobalCornerRadiusScale = latestThemeState.GlobalCornerRadiusScale, CornerRadiusStyle = latestThemeState.CornerRadiusStyle,
WallpaperPath = latestWallpaperState.WallpaperPath, WallpaperPath = latestWallpaperState.WallpaperPath,
WallpaperType = latestWallpaperState.Type, WallpaperType = latestWallpaperState.Type,
WallpaperColor = string.Equals(latestWallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) WallpaperColor = string.Equals(latestWallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase)

View File

@@ -508,38 +508,34 @@
</Grid.RenderTransform> </Grid.RenderTransform>
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<Button x:Name="TaskbarPowerBackButton" <Button x:Name="TaskbarPowerBackButton"
Padding="4,6" Classes="taskbar-profile-popup-action"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
HorizontalAlignment="Left" HorizontalAlignment="Left"
Click="OnPowerMenuBackClick"> Click="OnPowerMenuBackClick">
<StackPanel Orientation="Horizontal" Spacing="8"> <Grid ColumnDefinitions="Auto,*"
<fi:SymbolIcon Classes="icon-s" ColumnSpacing="12">
Symbol="ArrowLeft" <mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
IconVariant="Regular" /> Kind="ArrowLeft" />
<TextBlock x:Name="TaskbarPowerBackTextBlock" <TextBlock x:Name="TaskbarPowerBackTextBlock"
VerticalAlignment="Center" Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Back" /> Text="Back" />
</StackPanel> </Grid>
</Button> </Button>
<TextBlock x:Name="TaskbarPowerTitleTextBlock" <TextBlock x:Name="TaskbarPowerTitleTextBlock"
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}" Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
Margin="2,6,0,0"
Text="Power" /> Text="Power" />
<Border Height="1" <Border Height="1"
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
Margin="0,4" />
<Button x:Name="PowerShutdownButton" <Button x:Name="PowerShutdownButton"
Classes="taskbar-profile-popup-action" Classes="taskbar-profile-popup-action"
Click="OnPowerShutdownClick"> Click="OnPowerShutdownClick">
<Grid ColumnDefinitions="Auto,*" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14"> ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon" <mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Power" /> Kind="Power" />
<TextBlock x:Name="PowerShutdownTextBlock" <TextBlock x:Name="PowerShutdownTextBlock"
@@ -553,7 +549,7 @@
Classes="taskbar-profile-popup-action" Classes="taskbar-profile-popup-action"
Click="OnPowerRestartClick"> Click="OnPowerRestartClick">
<Grid ColumnDefinitions="Auto,*" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14"> ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon" <mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Refresh" /> Kind="Refresh" />
<TextBlock x:Name="PowerRestartTextBlock" <TextBlock x:Name="PowerRestartTextBlock"
@@ -567,7 +563,7 @@
Classes="taskbar-profile-popup-action" Classes="taskbar-profile-popup-action"
Click="OnPowerLogoutClick"> Click="OnPowerLogoutClick">
<Grid ColumnDefinitions="Auto,*" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14"> ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon" <mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="ExitToApp" /> Kind="ExitToApp" />
<TextBlock x:Name="PowerLogoutTextBlock" <TextBlock x:Name="PowerLogoutTextBlock"
@@ -581,7 +577,7 @@
Classes="taskbar-profile-popup-action" Classes="taskbar-profile-popup-action"
Click="OnPowerSleepClick"> Click="OnPowerSleepClick">
<Grid ColumnDefinitions="Auto,*" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14"> ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon" <mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="WeatherNight" /> Kind="WeatherNight" />
<TextBlock x:Name="PowerSleepTextBlock" <TextBlock x:Name="PowerSleepTextBlock"
@@ -595,7 +591,7 @@
Classes="taskbar-profile-popup-action" Classes="taskbar-profile-popup-action"
Click="OnPowerLockClick"> Click="OnPowerLockClick">
<Grid ColumnDefinitions="Auto,*" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14"> ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon" <mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Lock" /> Kind="Lock" />
<TextBlock x:Name="PowerLockTextBlock" <TextBlock x:Name="PowerLockTextBlock"

View File

@@ -73,28 +73,27 @@
Text="{Binding ComponentRadiusHeader}" Text="{Binding ComponentRadiusHeader}"
Margin="0,12,0,4" /> Margin="0,12,0,4" />
<ui:SettingsExpander Header="{Binding GlobalCornerRadiusLabel}" <ui:SettingsExpander Header="{Binding CornerRadiusStyleLabel}"
Description="{Binding GlobalCornerRadiusDescription}"> Description="{Binding CornerRadiusStyleDescription}">
<ui:SettingsExpander.IconSource> <ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ShapeOrganic" /> <fi:SymbolIconSource Symbol="ShapeOrganic" />
</ui:SettingsExpander.IconSource> </ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem> <ui:SettingsExpander.Footer>
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="16"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding GlobalCornerRadiusLabel}" <ComboBox Width="200"
VerticalAlignment="Center" /> ItemsSource="{Binding CornerRadiusStyleOptions}"
<Slider Grid.Column="1" SelectedItem="{Binding SelectedCornerRadiusStyle}">
Minimum="{Binding GlobalCornerRadiusMinimum}" <ComboBox.ItemTemplate>
Maximum="{Binding GlobalCornerRadiusMaximum}" <DataTemplate x:DataType="vm:SelectionOption">
SmallChange="0.01" <TextBlock Text="{Binding Label}" />
LargeChange="0.1" </DataTemplate>
Value="{Binding GlobalCornerRadiusScale}" /> </ComboBox.ItemTemplate>
<TextBlock Grid.Column="2" </ComboBox>
Width="56" <Button Classes="AppBarButton" ToolTip.Tip="View Corner Radius Specification" Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).OpenDesignSpecCommand}" CommandParameter="CORNER_RADIUS_SPEC.md">
Text="{Binding GlobalCornerRadiusScale, StringFormat={}{0:F2}x}" <fi:SymbolIcon Symbol="QuestionCircle" />
VerticalAlignment="Center" </Button>
HorizontalAlignment="Right" /> </StackPanel>
</Grid> </ui:SettingsExpander.Footer>
</ui:SettingsExpanderItem>
</ui:SettingsExpander> </ui:SettingsExpander>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

View File

@@ -339,8 +339,7 @@ public sealed class PluginLoader
private static PluginAppearanceSnapshot BuildAppearanceSnapshot(IServiceProvider? hostServices) private static PluginAppearanceSnapshot BuildAppearanceSnapshot(IServiceProvider? hostServices)
{ {
var defaultSnapshot = new PluginAppearanceSnapshot( var defaultSnapshot = new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: 1d, CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 18),
ThemeVariant: "Unknown"); ThemeVariant: "Unknown");
if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService) if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService)
@@ -352,7 +351,6 @@ public sealed class PluginLoader
{ {
var hostSnapshot = appearanceThemeService.GetCurrent(); var hostSnapshot = appearanceThemeService.GetCurrent();
return new PluginAppearanceSnapshot( return new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: Math.Max(0d, hostSnapshot.GlobalCornerRadiusScale),
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(hostSnapshot.CornerRadiusTokens), CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(hostSnapshot.CornerRadiusTokens),
ThemeVariant: hostSnapshot.IsNightMode ? "Dark" : "Light"); ThemeVariant: hostSnapshot.IsNightMode ? "Dark" : "Light");
} }

View File

@@ -1,4 +1,4 @@
# 阑山桌面 / LanMountainDesktop # 阑山桌面LanMountainDesktop
> 你的桌面,不止一面 > 你的桌面,不止一面

View File

@@ -1,39 +1,59 @@
# 圆角设计规范 # 圆角设计规范 (LanMountain Desktop Corner Radius Spec)
## 中文 ## 核心理念 (Core Philosophy)
本规范用于统一阑山桌面不同层级容器和控件的圆角尺度 为了确保桌面组件在不同尺寸、缩放比例下都能保持视觉一致性和美感,阑山桌面采用了 **固定圆角风格预设 (Fixed Corner Radius Styles)**全面参考小米澎湃OS (Xiaomi HyperOS) 的设计语言
### 基础层级 所有的组件和容器必须使用统一的资源键,禁止在 XAML 或代码中使用硬编码的像素值。
- Level 112px小元素和图标容器 ## 预设风格 (Preset Styles)
- Level 216px小型色块和紧凑控件
- Level 320px普通按钮
- Level 424px输入面板和小型容器
- Component18px桌面组件的标准圆角默认值
- Level 528px普通玻璃面板
- Level 632px强化容器
- Level 736px大容器、窗口、任务栏
### 使用建议 用户可以在设置中选择以下四种风格之一。系统会自动根据选中的风格动态映射全局圆角 Token。
- 同层级元素保持相同圆角。 | 风格 (ID) | 名称 (Local) | 组件圆角 (Component) | 设计语义 |
- 大容器的圆角大于内部子面板。 | :--- | :--- | :--- | :--- |
- 动态尺寸组件可按 `cellSize` 计算圆角,但仍要落在统一范围内。 | **Sharp** | 锐利 | 20px | 紧凑、精确、利落 |
| **Balanced** | 平衡 | 24px | **默认值**。和谐、自然、普适 |
| **Rounded** | 圆润 | 28px | 保守、柔和、亲切 |
| **Open** | 开放 | 32px | 现代、沉浸、夸张 |
### 动态圆角建议 ## Token 阶梯映射 (Token Step Mapping)
```csharp 每个风格都定义了一套完整的圆角阶梯,以确保在大容器包裹小元素时满足 **圆角嵌套一致性 (Nesting Consistency)**
var cornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
```
## 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 > [!TIP]
- 20px for common buttons > **2. 圆角嵌套规则**
- 28px for normal glass panels > 当一个容器包裹另一个元素时,外层圆角应比内层圆角大一个阶梯。例如:
- 36px for large containers and windows > - 外部使用 `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
View 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.

View File

@@ -25,6 +25,12 @@
- `glass-strong`:主要大容器 - `glass-strong`:主要大容器
- `glass-panel`:子区域、小面板、卡片 - `glass-panel`:子区域、小面板、卡片
### 形状与圆角 (Shape & Corner Radius)
- **全局统一**:所有 UI 元素的圆角必须遵循 [圆角设计规范](file:///c:/Users/USER154971/Documents/GitHub/LanMountainDesktop/docs/CORNER_RADIUS_SPEC.md)。
- **禁止硬编码**:严禁在资源库以外的地方硬编码 `CornerRadius` 数值。
- **动态适配**:桌面组件必须使用 `DesignCornerRadiusComponent` 动态资源,以支持用户在设置中全局切换“锐利/平衡/圆润/开放”风格。
### 可访问性 ### 可访问性
- 正文对比度目标不低于 `4.5:1` - 正文对比度目标不低于 `4.5:1`