Compare commits

...

10 Commits

Author SHA1 Message Date
lincube
11130cfdb3 feat.更新界面多标题修复。支持了,应用启动台不显示应用卡片背景。。。 2026-04-09 19:15:06 +08:00
lincube
66ae0b0270 fix.课表组件日间模式字体颜色修复 2026-04-09 00:53:28 +08:00
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
lincube
e1d5a0c6de fead.添加了电源菜单 2026-04-07 12:18:15 +08:00
lincube
5fa2031ad6 fead.消息盒子组件 2026-04-07 00:49:33 +08:00
77 changed files with 4509 additions and 553 deletions

View File

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

110
CHANGELOG.md Normal file
View File

@@ -0,0 +1,110 @@
# 更新日志 / 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.2] - 2026-04-09
### 新增 (Added)
-**应用启动台图标卡片显示选项**: 新增应用启动台图标卡片显示设置
- 用户可在设置中选择是否显示应用图标的专属卡片背景
- 关闭后仅显示应用图标本身,更加简洁
- 支持动态切换,实时预览效果
### 变更 (Changed)
-
### 修复 (Fixed)
- 🐛 **应用启动台文件夹应用数量限制**: 修复了应用启动台文件夹无法查看超过12个应用的问题
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
- 🐛 **电源菜单重启导致关机问题**: 修复了点击电源菜单"重启"选项却触发关机的问题
- 问题原因: `SlideToShutDown.exe` 仅支持关机操作,不支持重启,错误地将其用于重启功能
- 修复方案: 重启操作改为使用标准的二次确认对话框(所有平台统一),仅关机操作使用 SlideToShutDown 滑动界面
- 🐛 **课表组件字体显示问题**: 修复了日间模式下课表组件字体颜色与背景色相近导致看不清的问题
- 问题原因: 主题切换时增量更新逻辑未同步更新文字颜色
- 修复方案: 在 `IncrementalUpdateItems()` 方法中同步更新课程项的文字颜色
### 移除 (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.2...HEAD
[0.8.3.2]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2
[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 AppearanceCornerRadiusTokens Create(double scale)
public static AppearanceCornerRadiusTokens Create(string style)
{
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
return new AppearanceCornerRadiusTokens(
Radius(6, normalizedScale),
Radius(12, normalizedScale),
Radius(14, normalizedScale),
Radius(20, normalizedScale),
Radius(28, normalizedScale),
Radius(32, normalizedScale),
Radius(36, normalizedScale),
Radius(18, normalizedScale));
}
private static CornerRadius Radius(double value, double scale)
{
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
return new CornerRadius(scaled);
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(style);
return normalized switch
{
GlobalAppearanceSettings.CornerRadiusStyleSharp => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(4),
Xs: new CornerRadius(8),
Sm: new CornerRadius(10),
Md: new CornerRadius(14),
Lg: new CornerRadius(20),
Xl: new CornerRadius(24),
Island: new CornerRadius(28),
Component: new CornerRadius(20)),
GlobalAppearanceSettings.CornerRadiusStyleRounded => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(8),
Xs: new CornerRadius(14),
Sm: new CornerRadius(16),
Md: new CornerRadius(24),
Lg: new CornerRadius(32),
Xl: new CornerRadius(36),
Island: new CornerRadius(40),
Component: new CornerRadius(28)),
GlobalAppearanceSettings.CornerRadiusStyleOpen => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(10),
Xs: new CornerRadius(16),
Sm: new CornerRadius(20),
Md: new CornerRadius(28),
Lg: new CornerRadius(36),
Xl: new CornerRadius(40),
Island: new CornerRadius(44),
Component: new CornerRadius(32)),
// Balanced (default)
_ => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(6),
Xs: new CornerRadius(12),
Sm: new CornerRadius(14),
Md: new CornerRadius(20),
Lg: new CornerRadius(28),
Xl: new CornerRadius(32),
Island: new CornerRadius(36),
Component: new CornerRadius(24))
};
}
}

View File

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

View File

@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version of
the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@@ -13,6 +13,8 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>

View File

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

View File

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

View File

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

View File

@@ -2,17 +2,69 @@ namespace LanMountainDesktop.Settings.Core;
public static class GlobalAppearanceSettings
{
public const string CornerRadiusStyleSharp = "Sharp";
public const string CornerRadiusStyleBalanced = "Balanced";
public const string CornerRadiusStyleRounded = "Rounded";
public const string CornerRadiusStyleOpen = "Open";
public const string DefaultCornerRadiusStyle = CornerRadiusStyleBalanced;
/// <summary>
/// Kept for backward compatibility during settings migration.
/// New code should not reference this constant.
/// </summary>
public const double DefaultCornerRadiusScale = 1.0;
public const double MinimumCornerRadiusScale = 0.0;
public const double MaximumCornerRadiusScale = 2.50;
public static double NormalizeCornerRadiusScale(double value)
public static string NormalizeCornerRadiusStyle(string? value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
if (string.IsNullOrWhiteSpace(value))
{
return DefaultCornerRadiusScale;
return DefaultCornerRadiusStyle;
}
return Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
var trimmed = value.Trim();
if (string.Equals(trimmed, CornerRadiusStyleSharp, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleSharp;
}
if (string.Equals(trimmed, CornerRadiusStyleBalanced, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleBalanced;
}
if (string.Equals(trimmed, CornerRadiusStyleRounded, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleRounded;
}
if (string.Equals(trimmed, CornerRadiusStyleOpen, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleOpen;
}
return DefaultCornerRadiusStyle;
}
public static readonly IReadOnlyList<string> AllCornerRadiusStyles =
[
CornerRadiusStyleSharp,
CornerRadiusStyleBalanced,
CornerRadiusStyleRounded,
CornerRadiusStyleOpen
];
/// <summary>
/// Backward compatibility: map previous scale values to the closest style.
/// </summary>
public static string MigrateScaleToStyle(double scale)
{
return scale switch
{
<= 0.60 => CornerRadiusStyleSharp,
<= 1.20 => CornerRadiusStyleBalanced,
<= 1.70 => CornerRadiusStyleRounded,
_ => CornerRadiusStyleOpen
};
}
}

View File

@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version of
the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@@ -13,6 +13,8 @@
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />

View File

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

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
{
[Fact]
public void LegacyCellSizeResolver_AppliesGlobalCornerRadiusScale()
public void LegacyCellSizeResolver_ReturnsUnscaledFixedValue()
{
var registration = new DesktopComponentRuntimeRegistration(
componentId: "test.component",
@@ -19,41 +19,42 @@ public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.30, 10, 40));
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
var resolved = resolver(CreateChromeContext(cellSize: 120, globalScale: 2.0));
// Previously: (120 * 0.30) * 2.0 = 72.0
// Now: (120 * 0.30) = 36.0 (No scale applied automatically by the wrapper)
var resolved = resolver(CreateChromeContext(cellSize: 120));
Assert.Equal(72.0, resolved, 3);
Assert.Equal(36.0, resolved, 3);
}
[Fact]
public void ChromeContextResolver_IsNotDoubleScaledByRegistrationWrapper()
public void ChromeContextResolver_UsesTokenValue()
{
var registration = new DesktopComponentRuntimeRegistration(
componentId: "test.component",
displayNameLocalizationKey: null,
controlFactory: _ => new Border(),
cornerRadiusResolver: chromeContext => chromeContext.CellSize + chromeContext.GlobalCornerRadiusScale);
cornerRadiusResolver: chromeContext => chromeContext.CornerRadiusTokens.Component.TopLeft);
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
var resolved = resolver(CreateChromeContext(cellSize: 50, globalScale: 2.5));
var resolved = resolver(CreateChromeContext(cellSize: 50));
Assert.Equal(52.5, resolved, 3);
Assert.Equal(24.0, resolved, 3);
}
private static ComponentChromeContext CreateChromeContext(double cellSize, double globalScale)
private static ComponentChromeContext CreateChromeContext(double cellSize)
{
return new ComponentChromeContext(
ComponentId: "test.component",
PlacementId: null,
CellSize: cellSize,
GlobalCornerRadiusScale: globalScale,
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8)));
Micro: new CornerRadius(6),
Xs: new CornerRadius(12),
Sm: new CornerRadius(14),
Md: new CornerRadius(20),
Lg: new CornerRadius(28),
Xl: new CornerRadius(32),
Island: new CornerRadius(36),
Component: new CornerRadius(24)));
}
}

View File

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

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 ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&

View File

@@ -45,4 +45,6 @@ public static class BuiltInComponentIds
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
public const string DesktopFileManager = "DesktopFileManager";
public const string DesktopNotificationBox = "DesktopNotificationBox";
public const string DesktopShortcut = "DesktopShortcut";
}

View File

@@ -410,6 +410,26 @@ public sealed class ComponentRegistry
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopNotificationBox,
"消息盒子",
"Inbox",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopShortcut,
"快捷方式",
"App",
"File",
MinWidthCells: 1,
MinHeightCells: 1,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free)
};

View File

@@ -76,6 +76,7 @@
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
</ItemGroup>
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">

View File

@@ -564,6 +564,10 @@
"settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "App",
"settings.launcher.restore_button": "Unhide",
"settings.launcher.appearance_header": "Appearance",
"settings.launcher.appearance_desc": "Customize the appearance of the App Launcher.",
"settings.launcher.show_tile_background_header": "Show tile background",
"settings.launcher.show_tile_background_desc": "Display a background card behind each app icon. When turned off, only the icon is shown for a cleaner look.",
"settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
@@ -1087,5 +1091,23 @@
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
"zhijiaohub.settings.about": "About",
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally.",
"power.menu": "Power",
"power.title": "Power",
"power.back": "Back",
"power.shutdown": "Shutdown",
"power.restart": "Restart",
"power.logout": "Log Out",
"power.sleep": "Sleep",
"power.lock_screen": "Lock Screen",
"power.shutdown_confirm_title": "Shutdown Confirmation",
"power.shutdown_confirm_message": "Are you sure you want to shut down this computer? Unsaved data may be lost.",
"power.restart_confirm_title": "Restart Confirmation",
"power.restart_confirm_message": "Are you sure you want to restart this computer? Unsaved data may be lost.",
"power.logout_confirm_title": "Log Out Confirmation",
"power.logout_confirm_message": "Are you sure you want to log out?",
"power.sleep_confirm_title": "Sleep Confirmation",
"power.sleep_confirm_message": "Are you sure you want to put the computer to sleep?",
"power.confirm_yes": "Yes",
"power.confirm_cancel": "Cancel"
}

View File

@@ -558,6 +558,10 @@
"settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "应用",
"settings.launcher.restore_button": "取消隐藏",
"settings.launcher.appearance_header": "外观",
"settings.launcher.appearance_desc": "自定义应用启动台的外观样式。",
"settings.launcher.show_tile_background_header": "显示图标卡片背景",
"settings.launcher.show_tile_background_desc": "在应用图标后显示卡片背景,关闭后仅显示图标更加简洁。",
"settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
@@ -1081,5 +1085,23 @@
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
"zhijiaohub.settings.about": "关于",
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。"
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。",
"power.menu": "电源",
"power.title": "电源",
"power.back": "返回",
"power.shutdown": "关机",
"power.restart": "重启",
"power.logout": "注销",
"power.sleep": "睡眠",
"power.lock_screen": "锁定屏幕",
"power.shutdown_confirm_title": "关机确认",
"power.shutdown_confirm_message": "确定要关闭计算机吗?未保存的数据可能会丢失。",
"power.restart_confirm_title": "重启确认",
"power.restart_confirm_message": "确定要重启计算机吗?未保存的数据可能会丢失。",
"power.logout_confirm_title": "注销确认",
"power.logout_confirm_message": "确定要注销当前用户吗?",
"power.sleep_confirm_title": "睡眠确认",
"power.sleep_confirm_message": "确定要让计算机进入睡眠状态吗?",
"power.confirm_yes": "确定",
"power.confirm_cancel": "取消"
}

View File

@@ -19,6 +19,8 @@ public sealed class AppSettingsSnapshot
public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale;
public string CornerRadiusStyle { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
public string ThemeColorMode { get; set; } = "default_neutral";
public string SystemMaterialMode { get; set; } = "none";
@@ -200,6 +202,35 @@ public sealed class AppSettingsSnapshot
#endregion
#region Notification Box Settings ()
/// <summary>
/// 启用消息盒子功能Windows通知监听
/// </summary>
public bool NotificationBoxEnabled { get; set; } = true;
/// <summary>
/// 隐私模式:开启后只显示"您有新的通知",不显示具体内容
/// </summary>
public bool NotificationBoxPrivacyMode { get; set; } = false;
/// <summary>
/// 被屏蔽的应用列表(不接收这些应用的通知)
/// </summary>
public List<string> NotificationBoxBlockedApps { get; set; } = [];
/// <summary>
/// 历史记录保留天数
/// </summary>
public int NotificationBoxHistoryRetentionDays { get; set; } = 7;
/// <summary>
/// 最大存储通知数量(防止内存无限增长)
/// </summary>
public int NotificationBoxMaxStoredCount { get; set; } = 500;
#endregion
public AppSettingsSnapshot Clone()
{
var clone = (AppSettingsSnapshot)MemberwiseClone();
@@ -213,6 +244,9 @@ public sealed class AppSettingsSnapshot
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
? new List<string>(DisabledPluginIds)
: [];
clone.NotificationBoxBlockedApps = NotificationBoxBlockedApps is { Count: > 0 }
? new List<string>(NotificationBoxBlockedApps)
: [];
return clone;
}

View File

@@ -84,6 +84,64 @@ public sealed class ComponentSettingsSnapshot
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
#region Notification Box Component Settings ()
/// <summary>
/// 组件内最大显示通知数量
/// </summary>
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
/// <summary>
/// 排序方式TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
/// </summary>
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
/// <summary>
/// 是否显示应用图标
/// </summary>
public bool NotificationBoxShowAppIcon { get; set; } = true;
/// <summary>
/// 是否显示时间戳
/// </summary>
public bool NotificationBoxShowTimestamp { get; set; } = true;
/// <summary>
/// 时间格式Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
/// </summary>
public string NotificationBoxTimeFormat { get; set; } = "Relative";
/// <summary>
/// 是否按应用分组显示
/// </summary>
public bool NotificationBoxGroupByApp { get; set; } = false;
/// <summary>
/// 是否显示清除按钮
/// </summary>
public bool NotificationBoxShowClearButton { get; set; } = true;
#endregion
#region Shortcut Component Settings ()
/// <summary>
/// 快捷方式目标路径
/// </summary>
public string? ShortcutTargetPath { get; set; }
/// <summary>
/// 点击模式Single(单击打开) 或 Double(双击打开)
/// </summary>
public string ShortcutClickMode { get; set; } = "Double";
/// <summary>
/// 是否显示背景
/// </summary>
public bool ShortcutShowBackground { get; set; } = true;
#endregion
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();

View File

@@ -8,6 +8,8 @@ public sealed class LauncherSettingsSnapshot
public List<string> HiddenLauncherAppPaths { get; set; } = [];
public bool ShowTileBackground { get; set; } = true;
public LauncherSettingsSnapshot Clone()
{
var clone = (LauncherSettingsSnapshot)MemberwiseClone();

View File

@@ -0,0 +1,54 @@
using System;
namespace LanMountainDesktop.Models;
/// <summary>
/// 通知项数据模型
/// </summary>
public sealed class NotificationItem
{
/// <summary>
/// 唯一标识
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// 应用ID如 WeChat, Outlook 等)
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 应用名称
/// </summary>
public string AppName { get; set; } = string.Empty;
/// <summary>
/// 应用图标路径或Base64
/// </summary>
public string? AppIconPath { get; set; }
/// <summary>
/// 通知标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 通知内容
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 接收时间
/// </summary>
public DateTime ReceivedTime { get; set; } = DateTime.Now;
/// <summary>
/// 是否已读
/// </summary>
public bool IsRead { get; set; } = false;
/// <summary>
/// 原始通知的额外数据(用于点击跳转)
/// </summary>
public string? LaunchArgs { get; set; }
}

View File

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

View File

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

View File

@@ -267,7 +267,17 @@ public static class DesktopComponentEditorRegistryFactory
BuiltInComponentIds.DesktopZhiJiaoHub,
context => new ZhiJiaoHubComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d)
preferredHeight: 520d),
[BuiltInComponentIds.DesktopNotificationBox] = new(
BuiltInComponentIds.DesktopNotificationBox,
context => new NotificationBoxComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d),
[BuiltInComponentIds.DesktopShortcut] = new(
BuiltInComponentIds.DesktopShortcut,
context => new ShortcutComponentEditor(context),
preferredWidth: 420d,
preferredHeight: 400d)
};
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))

View File

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

View File

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

View File

@@ -0,0 +1,216 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
/// <summary>
/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications
/// </summary>
[SupportedOSPlatform("linux")]
internal sealed class LinuxNotificationListener : IDisposable
{
private readonly NotificationListenerService _parent;
private CancellationTokenSource? _cts;
private bool _isRunning;
public LinuxNotificationListener(NotificationListenerService parent)
{
_parent = parent;
}
/// <summary>
/// 初始化并启动DBus监听
/// </summary>
public async Task<bool> InitializeAsync()
{
try
{
// 检查DBus环境变量
var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS");
if (string.IsNullOrEmpty(dbusSessionBus))
{
Console.WriteLine("[NotificationBox] DBus Session Bus 环境变量未设置");
return false;
}
// 检查通知守护进程是否运行
// 通过检查常见进程名
var hasNotificationDaemon = await CheckNotificationDaemonAsync();
if (!hasNotificationDaemon)
{
Console.WriteLine("[NotificationBox] 未检测到通知守护进程,消息盒子功能可能不可用");
// 仍然返回true因为守护进程可能在之后启动
}
_cts = new CancellationTokenSource();
_ = StartListeningAsync(_cts.Token);
Console.WriteLine("[NotificationBox] Linux通知监听已启动");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[NotificationBox] Linux通知监听初始化失败: {ex.Message}");
return false;
}
}
private async Task<bool> CheckNotificationDaemonAsync()
{
try
{
// 检查常见通知守护进程
var processNames = new[] { "gnome-shell", "kded5", "dunst", "mako", "swaync" };
foreach (var name in processNames)
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "pgrep",
Arguments = $"-x {name}",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = System.Diagnostics.Process.Start(psi);
if (process != null)
{
await process.WaitForExitAsync();
if (process.ExitCode == 0)
{
return true;
}
}
}
return false;
}
catch
{
return false;
}
}
private async Task StartListeningAsync(CancellationToken ct)
{
_isRunning = true;
try
{
// 注意Tmds.DBus.Protocol 是低层API
// 这里使用简化方案实际生产环境需要完整的DBus信号订阅实现
// 当前版本为框架实现后续可以完善DBus监听逻辑
while (!ct.IsCancellationRequested && _isRunning)
{
try
{
await Task.Delay(1000, ct);
}
catch (OperationCanceledException)
{
break;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
}
}
/// <summary>
/// 处理接收到的通知供DBus信号处理器调用
/// </summary>
public void HandleNotification(
string appName,
uint replacesId,
string appIcon,
string summary,
string body,
string[] actions,
object hints,
int expireTimeout)
{
try
{
var notification = new NotificationItem
{
Id = Guid.NewGuid().ToString(),
AppId = appName.ToLowerInvariant().Replace(" ", ""),
AppName = appName,
Title = summary,
Content = StripHtmlTags(body),
ReceivedTime = DateTime.Now,
AppIconPath = ResolveIconPath(appIcon, appName)
};
_parent.AddNotification(notification);
}
catch (Exception ex)
{
Console.WriteLine($"[NotificationBox] 处理通知失败: {ex.Message}");
}
}
/// <summary>
/// 解析应用图标路径
/// </summary>
private static string? ResolveIconPath(string iconName, string appName)
{
if (string.IsNullOrEmpty(iconName))
{
return null;
}
// 如果是绝对路径,直接使用
if (File.Exists(iconName))
{
return iconName;
}
// 尝试从图标主题中查找
var iconPaths = new[]
{
$"/usr/share/icons/hicolor/48x48/apps/{iconName}.png",
$"/usr/share/icons/hicolor/64x64/apps/{iconName}.png",
$"/usr/share/pixmaps/{iconName}.png",
$"/usr/share/pixmaps/{iconName}.svg",
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
$".local/share/icons/{iconName}.png")
};
return iconPaths.FirstOrDefault(File.Exists);
}
/// <summary>
/// 去除HTML标签通知内容可能包含HTML
/// </summary>
private static string StripHtmlTags(string html)
{
if (string.IsNullOrEmpty(html))
{
return string.Empty;
}
// 简单的HTML标签去除
var result = html;
result = System.Text.RegularExpressions.Regex.Replace(result, "<[^>]+>", "");
result = result.Replace("&lt;", "<");
result = result.Replace("&gt;", ">");
result = result.Replace("&amp;", "&");
result = result.Replace("&quot;", "\"");
return result.Trim();
}
public void Dispose()
{
_isRunning = false;
_cts?.Cancel();
_cts?.Dispose();
}
}

View File

@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
/// <summary>
/// 跨平台通知监听服务
/// </summary>
public sealed class NotificationListenerService : IDisposable
{
private readonly List<NotificationItem> _notifications = [];
private readonly object _lock = new();
private readonly ISettingsService _settingsService;
// 平台特定的监听器
private LinuxNotificationListener? _linuxListener;
public event EventHandler<NotificationItem>? NotificationReceived;
public event EventHandler<string>? NotificationRemoved;
public NotificationListenerService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
/// <summary>
/// 初始化并启动监听
/// </summary>
public async Task InitializeAsync()
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Windows: 使用 UserNotificationListener (需要Windows SDK)
// 当前为模拟实现
await InitializeWindowsAsync();
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
// Linux: 使用 DBus
await InitializeLinuxAsync();
}
else
{
// macOS 或其他平台:功能不可用
Console.WriteLine("[NotificationBox] 当前平台不支持通知监听");
}
}
catch (Exception ex)
{
Console.WriteLine($"[NotificationBox] 初始化失败: {ex.Message}");
}
}
private async Task InitializeWindowsAsync()
{
// Windows通知监听实现
// 实际项目中需要添加Windows SDK引用并使用UserNotificationListener
// 由于需要UWP API这里使用模拟实现
await Task.CompletedTask;
Console.WriteLine("[NotificationBox] Windows通知监听已启动模拟模式");
}
private async Task InitializeLinuxAsync()
{
try
{
_linuxListener = new LinuxNotificationListener(this);
var success = await _linuxListener.InitializeAsync();
if (!success)
{
Console.WriteLine("[NotificationBox] Linux通知监听初始化失败可能未运行通知守护进程");
}
}
catch (Exception ex)
{
Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
}
}
/// <summary>
/// 添加通知(供平台监听器调用)
/// </summary>
public void AddNotification(NotificationItem notification)
{
var settings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
// 检查全局开关
if (!settings.NotificationBoxEnabled)
return;
// 检查是否在屏蔽列表中
if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase))
return;
lock (_lock)
{
_notifications.Add(notification);
CleanupOldNotifications(settings);
}
// 在UI线程触发事件
Dispatcher.UIThread.InvokeAsync(() =>
{
NotificationReceived?.Invoke(this, notification);
});
}
/// <summary>
/// 移除通知
/// </summary>
public void RemoveNotification(string notificationId)
{
lock (_lock)
{
var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
if (notification != null)
{
_notifications.Remove(notification);
}
}
NotificationRemoved?.Invoke(this, notificationId);
}
private void CleanupOldNotifications(AppSettingsSnapshot settings)
{
// 按数量清理
var maxCount = settings.NotificationBoxMaxStoredCount;
while (_notifications.Count > maxCount)
{
_notifications.RemoveAt(0);
}
// 按时间清理
var cutoffDate = DateTime.Now.AddDays(-settings.NotificationBoxHistoryRetentionDays);
_notifications.RemoveAll(n => n.ReceivedTime < cutoffDate);
}
/// <summary>
/// 获取所有通知
/// </summary>
public IReadOnlyList<NotificationItem> GetNotifications()
{
lock (_lock)
{
return _notifications.ToList().AsReadOnly();
}
}
/// <summary>
/// 清空所有通知
/// </summary>
public void ClearAll()
{
lock (_lock)
{
_notifications.Clear();
}
}
/// <summary>
/// 标记通知为已读
/// </summary>
public void MarkAsRead(string notificationId)
{
lock (_lock)
{
var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
if (notification != null)
{
notification.IsRead = true;
}
}
}
/// <summary>
/// 获取未读通知数量
/// </summary>
public int GetUnreadCount()
{
lock (_lock)
{
return _notifications.Count(n => !n.IsRead);
}
}
public void Dispose()
{
_linuxListener?.Dispose();
ClearAll();
}
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public interface IPowerManagementService
{
bool IsShutdownSupported { get; }
bool IsRestartSupported { get; }
bool IsLogoutSupported { get; }
bool IsLockSupported { get; }
bool IsSleepSupported { get; }
Task ShutdownAsync();
Task RestartAsync();
Task LogoutAsync();
Task LockAsync();
Task SleepAsync();
void ShowNativePowerUI(PowerAction action);
}
public enum PowerAction
{
Shutdown,
Restart
}
public static class PowerManagementServiceFactory
{
private static IPowerManagementService? _instance;
private static readonly object _lock = new();
public static IPowerManagementService GetOrCreate()
{
lock (_lock)
{
return _instance ??= CreatePlatformService();
}
}
private static IPowerManagementService CreatePlatformService()
{
if (OperatingSystem.IsWindows())
return new WindowsPowerManagementService();
if (OperatingSystem.IsLinux())
return new LinuxPowerManagementService();
return new NullPowerManagementService();
}
}
internal sealed class WindowsPowerManagementService : IPowerManagementService
{
public bool IsShutdownSupported => true;
public bool IsRestartSupported => true;
public bool IsLogoutSupported => true;
public bool IsLockSupported => true;
public bool IsSleepSupported => true;
public async Task ShutdownAsync()
{
await Task.Run(() =>
{
Process.Start(new ProcessStartInfo
{
FileName = "shutdown",
Arguments = "/s /t 0",
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden
});
});
}
public async Task RestartAsync()
{
await Task.Run(() =>
{
Process.Start(new ProcessStartInfo
{
FileName = "shutdown",
Arguments = "/r /t 0",
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden
});
});
}
public async Task LogoutAsync()
{
await Task.Run(() =>
{
ExitWindowsEx(0, 0);
});
}
public async Task LockAsync()
{
await Task.Run(() =>
{
LockWorkStation();
});
}
public async Task SleepAsync()
{
await Task.Run(() =>
{
SetSuspendState(false, false, false);
});
}
public void ShowNativePowerUI(PowerAction action)
{
// SlideToShutDown.exe 只支持关机,不支持重启
// 重启操作应该通过 RestartAsync() 使用 shutdown /r 命令
if (action != PowerAction.Shutdown)
return;
var slideToShutDownPath = Environment.ExpandEnvironmentVariables(@"%windir%\System32\SlideToShutDown.exe");
if (System.IO.File.Exists(slideToShutDownPath))
{
Process.Start(new ProcessStartInfo
{
FileName = slideToShutDownPath,
UseShellExecute = true
});
return;
}
// 回退到标准关机命令
Process.Start(new ProcessStartInfo
{
FileName = "shutdown",
Arguments = "/s /t 5 /c \"LanMountainDesktop: Shutting down...\"",
UseShellExecute = true
});
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool ExitWindowsEx(uint uFlags, uint dwReason);
[DllImport("user32.dll")]
private static extern void LockWorkStation();
[DllImport("powrprof.dll", SetLastError = true)]
private static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);
}
internal sealed class LinuxPowerManagementService : IPowerManagementService
{
public bool IsShutdownSupported => true;
public bool IsRestartSupported => true;
public bool IsLogoutSupported => true;
public bool IsLockSupported => true;
public bool IsSleepSupported => true;
public async Task ShutdownAsync()
{
await RunSystemctlCommand("poweroff -i");
}
public async Task RestartAsync()
{
await RunSystemctlCommand("reboot -i");
}
public async Task LogoutAsync()
{
await RunLoginctlCommand("terminate-session $XDG_SESSION_ID");
}
public async Task LockAsync()
{
await RunLoginctlCommand("lock-session");
}
public async Task SleepAsync()
{
await RunSystemctlCommand("suspend -i");
}
public void ShowNativePowerUI(PowerAction action)
{
switch (action)
{
case PowerAction.Shutdown:
RunProcess("systemctl", "poweroff -i");
break;
case PowerAction.Restart:
RunProcess("systemctl", "reboot -i");
break;
}
}
private static async Task RunSystemctlCommand(string args)
{
await RunProcess("systemctl", args);
}
private static async Task RunLoginctlCommand(string args)
{
await RunProcess("loginctl", args);
}
private static async Task RunProcess(string command, string args)
{
await Task.Run(() =>
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = command,
Arguments = args,
UseShellExecute = false,
RedirectStandardError = true,
CreateNoWindow = true
})?.WaitForExit(5000);
}
catch (Exception ex)
{
AppLogger.Error("LinuxPowerManagement", $"Failed to execute {command} {args}: {ex.Message}");
}
});
}
}
internal sealed class NullPowerManagementService : IPowerManagementService
{
public bool IsShutdownSupported => false;
public bool IsRestartSupported => false;
public bool IsLogoutSupported => false;
public bool IsLockSupported => false;
public bool IsSleepSupported => false;
public Task ShutdownAsync() => Task.CompletedTask;
public Task RestartAsync() => Task.CompletedTask;
public Task LogoutAsync() => Task.CompletedTask;
public Task LockAsync() => Task.CompletedTask;
public Task SleepAsync() => Task.CompletedTask;
public void ShowNativePowerUI(PowerAction action) { }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -222,10 +222,37 @@
</Style>
<!-- 向后兼容的旧样式类(已弃用) -->
<Style Selector="Border.glass-panel" />
<Style Selector="Border.glass-strong" />
<Style Selector="Border.glass-island" />
<Style Selector="Border.mica-strong" />
<Style Selector="Border.glass-overlay" />
<Style Selector="Border.glass-panel">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1.2" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
</Style>
<Style Selector="Border.glass-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
</Style>
<Style Selector="Border.glass-island">
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
</Style>
<Style Selector="Border.mica-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
</Style>
<Style Selector="Border.glass-overlay">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
</Style>
</Styles>

View File

@@ -117,6 +117,36 @@ public sealed partial class LauncherSettingsPageViewModel : ViewModelBase, IDisp
[ObservableProperty]
private bool _isHiddenItemsEmpty = true;
[ObservableProperty]
private string _appearanceHeader = string.Empty;
[ObservableProperty]
private string _appearanceDescription = string.Empty;
[ObservableProperty]
private string _showTileBackgroundHeader = string.Empty;
[ObservableProperty]
private string _showTileBackgroundDescription = string.Empty;
[ObservableProperty]
private bool _showTileBackground;
partial void OnShowTileBackgroundChanged(bool value)
{
SaveShowTileBackgroundSetting(value);
}
private void SaveShowTileBackgroundSetting(bool value)
{
var snapshot = _settingsFacade.LauncherPolicy.Get()?.Clone() ?? new LauncherSettingsSnapshot();
snapshot.ShowTileBackground = value;
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.Launcher,
snapshot,
changedKeys: [nameof(LauncherSettingsSnapshot.ShowTileBackground)]);
}
public void Dispose()
{
if (_disposed)
@@ -157,6 +187,8 @@ public sealed partial class LauncherSettingsPageViewModel : ViewModelBase, IDisp
ResolveCulture(),
L("settings.launcher.hidden_summary_format", "{0} hidden items"),
HiddenItems.Count);
ShowTileBackground = snapshot.ShowTileBackground;
}
private StartMenuFolderNode LoadCatalogSafe()
@@ -317,6 +349,10 @@ public sealed partial class LauncherSettingsPageViewModel : ViewModelBase, IDisp
HiddenDescription = L("settings.launcher.hidden_desc", "Review hidden launcher entries and show them again.");
HiddenHint = L("settings.launcher.hidden_hint", "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.");
HiddenEmptyText = L("settings.launcher.hidden_empty", "No hidden items.");
AppearanceHeader = L("settings.launcher.appearance_header", "Appearance");
AppearanceDescription = L("settings.launcher.appearance_desc", "Customize the appearance of the App Launcher.");
ShowTileBackgroundHeader = L("settings.launcher.show_tile_background_header", "Show tile background");
ShowTileBackgroundDescription = L("settings.launcher.show_tile_background_desc", "Display a background card behind each app icon in the launcher.");
}
private CultureInfo ResolveCulture()

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

@@ -0,0 +1,115 @@
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 NotificationBoxEditorViewModel : ViewModelBase
{
private readonly DesktopComponentEditorContext? _context;
private bool _isInitializing;
public NotificationBoxEditorViewModel(DesktopComponentEditorContext? context)
{
_context = context;
MaxDisplayCountOptions = new ObservableCollection<SelectionOption>
{
new("20", "20条"),
new("50", "50条"),
new("100", "100条"),
new("200", "200条")
};
SortOrderOptions = new ObservableCollection<SelectionOption>
{
new("TimeDesc", "最新优先"),
new("TimeAsc", "最早优先"),
new("AppGroup", "按应用分组")
};
TimeFormatOptions = new ObservableCollection<SelectionOption>
{
new("Relative", "相对时间5分钟前"),
new("Absolute", "绝对时间14:30")
};
LoadSettings();
}
private void LoadSettings()
{
var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>()
?? new ComponentSettingsSnapshot();
_isInitializing = true;
var countValue = snapshot.NotificationBoxMaxDisplayCount.ToString();
SelectedMaxDisplayCount = MaxDisplayCountOptions.FirstOrDefault(o => o.Value == countValue)
?? MaxDisplayCountOptions[1]; // 默认50
SelectedSortOrder = SortOrderOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxSortOrder)
?? SortOrderOptions[0];
ShowAppIcon = snapshot.NotificationBoxShowAppIcon;
ShowTimestamp = snapshot.NotificationBoxShowTimestamp;
SelectedTimeFormat = TimeFormatOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxTimeFormat)
?? TimeFormatOptions[0];
GroupByApp = snapshot.NotificationBoxGroupByApp;
ShowClearButton = snapshot.NotificationBoxShowClearButton;
_isInitializing = false;
}
private void SaveSettings()
{
if (_isInitializing || _context == null) return;
var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
snapshot.NotificationBoxMaxDisplayCount = int.TryParse(SelectedMaxDisplayCount?.Value, out var count) ? count : 50;
snapshot.NotificationBoxSortOrder = SelectedSortOrder?.Value ?? "TimeDesc";
snapshot.NotificationBoxShowAppIcon = ShowAppIcon;
snapshot.NotificationBoxShowTimestamp = ShowTimestamp;
snapshot.NotificationBoxTimeFormat = SelectedTimeFormat?.Value ?? "Relative";
snapshot.NotificationBoxGroupByApp = GroupByApp;
snapshot.NotificationBoxShowClearButton = ShowClearButton;
_context.ComponentSettingsAccessor.SaveSnapshot(snapshot);
_context.HostContext.RequestRefresh();
}
[ObservableProperty] private string _descriptionText = "配置此消息盒子组件的显示方式。这些设置仅作用于当前组件实例。";
[ObservableProperty] private string _maxDisplayCountLabel = "最大显示数量";
[ObservableProperty] private string _maxDisplayCountDescription = "组件中最多显示的通知条数";
[ObservableProperty] private string _sortOrderLabel = "排序方式";
[ObservableProperty] private string _displayOptionsLabel = "显示选项";
[ObservableProperty] private string _showAppIconLabel = "显示应用图标";
[ObservableProperty] private string _showTimestampLabel = "显示时间戳";
[ObservableProperty] private string _groupByAppLabel = "按应用分组显示";
[ObservableProperty] private string _showClearButtonLabel = "显示清空按钮";
[ObservableProperty] private string _timeFormatLabel = "时间格式";
[ObservableProperty] private SelectionOption? _selectedMaxDisplayCount;
[ObservableProperty] private SelectionOption? _selectedSortOrder;
[ObservableProperty] private bool _showAppIcon = true;
[ObservableProperty] private bool _showTimestamp = true;
[ObservableProperty] private SelectionOption? _selectedTimeFormat;
[ObservableProperty] private bool _groupByApp = false;
[ObservableProperty] private bool _showClearButton = true;
public ObservableCollection<SelectionOption> MaxDisplayCountOptions { get; }
public ObservableCollection<SelectionOption> SortOrderOptions { get; }
public ObservableCollection<SelectionOption> TimeFormatOptions { get; }
partial void OnSelectedMaxDisplayCountChanged(SelectionOption? value) => SaveSettings();
partial void OnSelectedSortOrderChanged(SelectionOption? value) => SaveSettings();
partial void OnShowAppIconChanged(bool value) => SaveSettings();
partial void OnShowTimestampChanged(bool value) => SaveSettings();
partial void OnSelectedTimeFormatChanged(SelectionOption? value) => SaveSettings();
partial void OnGroupByAppChanged(bool value) => SaveSettings();
partial void OnShowClearButtonChanged(bool value) => SaveSettings();
}

View File

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

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,88 @@
<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.NotificationBoxComponentEditor"
x:DataType="vm:NotificationBoxEditorViewModel">
<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 MaxDisplayCountLabel}"
Classes="component-editor-section-title" />
<TextBlock Text="{Binding MaxDisplayCountDescription}"
Classes="component-editor-secondary-text" />
<ComboBox ItemsSource="{Binding MaxDisplayCountOptions}"
SelectedItem="{Binding SelectedMaxDisplayCount}"
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 SortOrderLabel}"
Classes="component-editor-section-title" />
<ComboBox ItemsSource="{Binding SortOrderOptions}"
SelectedItem="{Binding SelectedSortOrder}"
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="16">
<TextBlock Text="{Binding DisplayOptionsLabel}"
Classes="component-editor-section-title" />
<CheckBox IsChecked="{Binding ShowAppIcon}"
Content="{Binding ShowAppIconLabel}" />
<CheckBox IsChecked="{Binding ShowTimestamp}"
Content="{Binding ShowTimestampLabel}" />
<CheckBox IsChecked="{Binding GroupByApp}"
Content="{Binding GroupByAppLabel}" />
<CheckBox IsChecked="{Binding ShowClearButton}"
Content="{Binding ShowClearButtonLabel}" />
</StackPanel>
</Border>
<!-- 时间格式 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding TimeFormatLabel}"
Classes="component-editor-section-title" />
<ComboBox ItemsSource="{Binding TimeFormatOptions}"
SelectedItem="{Binding SelectedTimeFormat}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,15 @@
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class NotificationBoxComponentEditor : ComponentEditorViewBase
{
public NotificationBoxComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
DataContext = new NotificationBoxEditorViewModel(context);
}
}

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

@@ -725,6 +725,8 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
? CreateBrush("#FF4FC3F7")
: CreateBrush("#FF4D5A");
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
for (var i = 0; i < _courseItems.Count && i < CourseListPanel.Children.Count; i++)
{
@@ -746,19 +748,31 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var timeText = textStack.Children[1] as TextBlock;
var detailText = textStack.Children[2] as TextBlock;
if (titleText != null && titleText.Text != item.Name)
if (titleText != null)
{
titleText.Text = item.Name;
if (titleText.Text != item.Name)
{
titleText.Text = item.Name;
}
titleText.Foreground = primaryBrush;
}
if (timeText != null && timeText.Text != item.TimeRange)
if (timeText != null)
{
timeText.Text = item.TimeRange;
if (timeText.Text != item.TimeRange)
{
timeText.Text = item.TimeRange;
}
timeText.Foreground = secondaryBrush;
}
if (detailText != null && detailText.Text != item.Detail)
if (detailText != null)
{
detailText.Text = item.Detail;
if (detailText.Text != item.Detail)
{
detailText.Text = item.Detail;
}
detailText.Foreground = secondaryBrush;
}
}
}

View File

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

View File

@@ -3,13 +3,14 @@ using Avalonia.Controls;
using Avalonia.Media;
using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Views.Components;
internal static class ComponentChromeCornerRadiusHelper
{
public static double ResolveMainRectangleRadiusValue(ComponentChromeContext? chromeContext = null, double fallback = 18d)
public static double ResolveMainRectangleRadiusValue(ComponentChromeContext? chromeContext = null, double fallback = 24d)
{
if (chromeContext is not null)
{
@@ -20,7 +21,7 @@ internal static class ComponentChromeCornerRadiusHelper
var resolved = snapshot.CornerRadiusTokens.Component.TopLeft;
return double.IsFinite(resolved)
? Math.Max(0d, resolved)
: Math.Max(0d, fallback * ResolveScale(chromeContext));
: Math.Max(0d, fallback);
}
public static CornerRadius ResolveMainRectangleRadius(ComponentChromeContext? chromeContext = null, double fallback = 24d)
@@ -28,24 +29,6 @@ internal static class ComponentChromeCornerRadiusHelper
return new CornerRadius(ResolveMainRectangleRadiusValue(chromeContext, fallback));
}
public static double ResolveScale(ComponentChromeContext? chromeContext = null)
{
if (chromeContext is not null)
{
return Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale);
}
return Math.Max(
GlobalAppearanceSettings.MinimumCornerRadiusScale,
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().GlobalCornerRadiusScale);
}
public static CornerRadius Scale(double baseRadius, double min, double max, ComponentChromeContext? chromeContext = null)
{
var scale = ResolveScale(chromeContext);
return new CornerRadius(Math.Clamp(baseRadius * scale, min * scale, max * scale));
}
public static void Apply(CornerRadius radius, params Border?[] chromeLayers)
{
foreach (var chromeLayer in chromeLayers)
@@ -67,28 +50,57 @@ internal static class ComponentChromeCornerRadiusHelper
: new CornerRadius(fallback);
}
public static double ScaleValue(double value, ComponentChromeContext? chromeContext = null)
public static double SafeValue(double value, double min, double max, ComponentChromeContext? context = null)
{
return value * ResolveScale(chromeContext);
_ = context;
return Math.Clamp(value, min, max);
}
public static double ResolveContentSafetyScale(
ComponentChromeContext? chromeContext = null,
double responsiveness = 0.45d)
public static double Scale(double value, double min, double max, ComponentChromeContext? context = null)
{
var scale = ResolveScale(chromeContext);
var normalizedResponsiveness = Math.Clamp(responsiveness, 0d, 1d);
return 1d + ((scale - 1d) * normalizedResponsiveness);
_ = context;
return Math.Clamp(value, min, max);
}
public static double SafeValue(
double baseValue,
double min,
double max,
ComponentChromeContext? chromeContext = null,
double responsiveness = 0.45d)
public static CornerRadius SafeRadius(double value, double min, double max, ComponentChromeContext? context = null)
{
var safetyScale = ResolveContentSafetyScale(chromeContext, responsiveness);
return Math.Clamp(baseValue * safetyScale, min * safetyScale, max * safetyScale);
_ = context;
return new CornerRadius(Math.Clamp(value, min, max));
}
public static CornerRadius ScaleRadius(double value, double min, double max, ComponentChromeContext? context = null)
{
_ = context;
return new CornerRadius(Math.Clamp(value, min, max));
}
public static double Mini(ComponentChromeContext? context = null)
{
if (context is not null) return context.CornerRadiusTokens.Micro.TopLeft;
return ResolveToken("DesignCornerRadiusMicro", 6).TopLeft;
}
public static double Micro(ComponentChromeContext? context = null)
{
if (context is not null) return context.CornerRadiusTokens.Micro.TopLeft;
return ResolveToken("DesignCornerRadiusMicro", 6).TopLeft;
}
public static double Small(ComponentChromeContext? context = null)
{
if (context is not null) return context.CornerRadiusTokens.Sm.TopLeft;
return ResolveToken("DesignCornerRadiusSm", 14).TopLeft;
}
public static double Medium(ComponentChromeContext? context = null)
{
if (context is not null) return context.CornerRadiusTokens.Md.TopLeft;
return ResolveToken("DesignCornerRadiusMd", 20).TopLeft;
}
public static double Large(ComponentChromeContext? context = null)
{
if (context is not null) return context.CornerRadiusTokens.Lg.TopLeft;
return ResolveToken("DesignCornerRadiusLg", 28).TopLeft;
}
}

View File

@@ -39,8 +39,7 @@ public sealed class DesktopComponentRuntimeRegistration
_ => controlFactory(),
cornerRadiusResolver is null
? null
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) *
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
: chromeContext => cornerRadiusResolver(chromeContext.CellSize))
{
}
@@ -55,8 +54,7 @@ public sealed class DesktopComponentRuntimeRegistration
controlFactory,
cornerRadiusResolver is null
? null
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) *
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
: chromeContext => cornerRadiusResolver(chromeContext.CellSize))
{
}
@@ -131,7 +129,6 @@ public sealed class DesktopComponentRuntimeDescriptor
Definition.Id,
placementId,
cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
appearanceSnapshot.CornerRadiusTokens);
var control = _controlFactory(new DesktopComponentControlFactoryContext(
Definition,
@@ -226,8 +223,7 @@ public sealed class DesktopComponentRuntimeDescriptor
Definition.Id,
null,
Math.Max(1, cellSize),
1d,
AppearanceCornerRadiusTokenFactory.Create(1d)));
AppearanceCornerRadiusTokenFactory.Create(GlobalAppearanceSettings.DefaultCornerRadiusStyle)));
}
private static void ApplySettingsDependencies(
@@ -479,7 +475,15 @@ public sealed class DesktopComponentRuntimeRegistry
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopFileManager,
"component.file_manager",
() => new FileManagerWidget())
() => new FileManagerWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopNotificationBox,
"component.notification_box",
() => 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.Diagnostics;
using System.IO;
@@ -730,7 +730,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
_imageHost.Width = imageWidth;
_imageHost.Height = imageHeight;
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(imageHeight * 0.15, 8, 16);
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
_titleTextBlock.MaxWidth = textWidth;

View File

@@ -0,0 +1,92 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
xmlns:symbol="using:FluentIcons.Common"
x:Class="LanMountainDesktop.Views.Components.NotificationBoxWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Background="Transparent"
ClipToBounds="True">
<Grid>
<!-- 主卡片 -->
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="12,10">
<Grid RowDefinitions="Auto,*,Auto">
<!-- 头部 -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<fi:SymbolIcon x:Name="HeaderIcon" Symbol="{x:Static symbol:Symbol.MailInbox}" FontSize="16" />
<TextBlock x:Name="HeaderTextBlock"
Text="消息盒子"
FontSize="15"
FontWeight="SemiBold" />
<Border x:Name="UnreadBadge"
Background="#E24B2D"
CornerRadius="8"
Padding="5,2"
IsVisible="False">
<TextBlock x:Name="UnreadCountText"
Foreground="White"
FontSize="11"
FontWeight="Bold" />
</Border>
</StackPanel>
<Button x:Name="ClearButton"
Grid.Column="1"
IsVisible="False"
Click="OnClearButtonClick"
Padding="6"
Background="Transparent"
BorderThickness="0">
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.Delete}" FontSize="14" />
</Button>
</Grid>
<!-- 通知列表 -->
<ScrollViewer Grid.Row="1"
Margin="0,8,0,0"
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="NotificationListPanel" Spacing="6" />
</ScrollViewer>
<!-- 空状态 -->
<TextBlock x:Name="EmptyStateText"
Grid.Row="1"
Text="暂无通知"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="#8B95A5"
FontSize="13"
IsVisible="False" />
<!-- 隐私模式遮罩 -->
<Border x:Name="PrivacyOverlay"
Grid.Row="1"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
IsVisible="False">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="6">
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.EyeOff}" FontSize="24" Foreground="#8B95A5" />
<TextBlock Text="您有新的通知"
Foreground="#8B95A5"
FontSize="12" />
</StackPanel>
</Border>
<!-- 底部状态 -->
<TextBlock x:Name="StatusTextBlock"
Grid.Row="2"
FontSize="11"
Foreground="#8B95A5"
Margin="0,6,0,0" />
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,529 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Views.Components;
public partial class NotificationBoxWidget : UserControl,
IDesktopComponentWidget,
IComponentSettingsContextAware,
IComponentRuntimeContextAware
{
private readonly List<NotificationItemControl> _notificationControls = [];
private NotificationListenerService? _notificationService;
private IComponentInstanceSettingsStore _componentSettings = null!;
private ISettingsService _appSettingsService = null!;
private AppSettingsSnapshot _appSettings = new();
private ComponentSettingsSnapshot _componentSettingsSnapshot = new();
private bool _isAttached;
private bool _isPrivacyMode;
private bool _isNightVisual;
private double _currentCellSize = 48d;
public NotificationBoxWidget()
{
InitializeComponent();
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
PointerPressed += (_, e) =>
{
if (e.Source == NotificationListPanel)
{
ClearSelection();
}
};
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
{
_componentSettings = context.ComponentSettingsStore;
LoadSettings();
RefreshUI();
}
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
{
_notificationService = NotificationListenerServiceProvider.GetOrCreate(_appSettingsService);
if (_notificationService != null)
{
_notificationService.NotificationReceived += OnNotificationReceived;
_notificationService.NotificationRemoved += OnNotificationRemoved;
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
_isNightVisual = ResolveNightMode();
LoadSettings();
RefreshUI();
UpdateAdaptiveLayout();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
if (_notificationService != null)
{
_notificationService.NotificationReceived -= OnNotificationReceived;
_notificationService.NotificationRemoved -= OnNotificationRemoved;
}
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void LoadSettings()
{
var appSettingsFacade = HostSettingsFacadeProvider.GetOrCreate();
_appSettingsService = appSettingsFacade.Settings;
_appSettings = _appSettingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
_isPrivacyMode = _appSettings.NotificationBoxPrivacyMode;
_componentSettingsSnapshot = _componentSettings?.Load()
?? new ComponentSettingsSnapshot();
}
private void RefreshUI()
{
if (!_isAttached) return;
var hasNotifications = _notificationService?.GetNotifications().Count > 0;
PrivacyOverlay.IsVisible = _isPrivacyMode && hasNotifications && _notificationService?.GetUnreadCount() > 0;
NotificationListPanel.IsVisible = !PrivacyOverlay.IsVisible;
var unreadCount = _notificationService?.GetUnreadCount() ?? 0;
UnreadBadge.IsVisible = unreadCount > 0;
UnreadCountText.Text = unreadCount.ToString();
ClearButton.IsVisible = _componentSettingsSnapshot.NotificationBoxShowClearButton
&& hasNotifications;
UpdateStatusText();
RenderNotifications();
}
private void RenderNotifications()
{
NotificationListPanel.Children.Clear();
_notificationControls.Clear();
if (_notificationService == null)
{
EmptyStateText.IsVisible = true;
EmptyStateText.Text = "通知服务未启动";
return;
}
var notifications = _notificationService.GetNotifications();
if (notifications.Count == 0)
{
EmptyStateText.IsVisible = true;
EmptyStateText.Text = "暂无通知";
return;
}
EmptyStateText.IsVisible = false;
notifications = ApplySorting(notifications);
var maxCount = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount;
notifications = notifications.Take(maxCount).ToList();
if (_componentSettingsSnapshot.NotificationBoxGroupByApp)
{
RenderGroupedNotifications(notifications);
}
else
{
RenderFlatNotifications(notifications);
}
}
private IReadOnlyList<NotificationItem> ApplySorting(IReadOnlyList<NotificationItem> notifications)
{
return _componentSettingsSnapshot.NotificationBoxSortOrder switch
{
"TimeAsc" => notifications.OrderBy(n => n.ReceivedTime).ToList(),
"AppGroup" => notifications.OrderBy(n => n.AppName).ThenByDescending(n => n.ReceivedTime).ToList(),
_ => notifications.OrderByDescending(n => n.ReceivedTime).ToList()
};
}
private void RenderFlatNotifications(IReadOnlyList<NotificationItem> notifications)
{
foreach (var notification in notifications)
{
var control = CreateNotificationControl(notification);
NotificationListPanel.Children.Add(control);
_notificationControls.Add(control);
}
}
private void RenderGroupedNotifications(IReadOnlyList<NotificationItem> notifications)
{
var grouped = notifications.GroupBy(n => n.AppName).ToList();
foreach (var group in grouped)
{
var groupHeader = new TextBlock
{
Text = group.Key,
FontWeight = FontWeight.SemiBold,
FontSize = 11,
Foreground = new SolidColorBrush(Color.Parse("#8B95A5")),
Margin = new Thickness(0, 6, 0, 3)
};
NotificationListPanel.Children.Add(groupHeader);
foreach (var notification in group)
{
var control = CreateNotificationControl(notification);
NotificationListPanel.Children.Add(control);
_notificationControls.Add(control);
}
}
}
private NotificationItemControl CreateNotificationControl(NotificationItem notification)
{
var control = new NotificationItemControl(notification, _componentSettingsSnapshot, _isNightVisual);
control.Clicked += OnNotificationClicked;
control.MarkAsRead += OnMarkAsRead;
return control;
}
private void OnNotificationReceived(object? sender, NotificationItem notification)
{
Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
RefreshUI();
});
}
private void OnNotificationRemoved(object? sender, string notificationId)
{
Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_isAttached) return;
RefreshUI();
});
}
private void OnNotificationClicked(object? sender, NotificationItem notification)
{
}
private void OnMarkAsRead(object? sender, NotificationItem notification)
{
_notificationService?.MarkAsRead(notification.Id);
RefreshUI();
}
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
{
_notificationService?.ClearAll();
RefreshUI();
e.Handled = true;
}
private void UpdateStatusText()
{
var total = _notificationService?.GetNotifications().Count ?? 0;
var max = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount;
StatusTextBlock.Text = $"共 {total} 条" + (total > max ? $"(显{max})" : "");
}
private void ClearSelection()
{
foreach (var control in _notificationControls)
{
control.IsSelected = false;
}
}
private void UpdateAdaptiveLayout()
{
var scale = Math.Clamp(_currentCellSize / 48.0, 0.7, 1.8);
var fontScale = Math.Clamp(scale, 0.8, 1.4);
var cornerRadius = ResolveUnifiedMainRadiusValue();
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
CardBorder.CornerRadius = new CornerRadius(cornerRadius);
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
HeaderTextBlock.FontSize = 15 * fontScale;
HeaderIcon.FontSize = 16 * fontScale;
UnreadCountText.FontSize = 11 * fontScale;
EmptyStateText.FontSize = 13 * fontScale;
StatusTextBlock.FontSize = 11 * fontScale;
var padding = Math.Clamp(12 * scale, 8, 20);
var verticalPadding = Math.Clamp(10 * scale, 6, 16);
CardBorder.Padding = new Thickness(padding, verticalPadding);
foreach (var control in _notificationControls)
{
control.UpdateTheme(_isNightVisual, fontScale);
}
}
private static double ResolveUnifiedMainRadiusValue() =>
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
}
public class NotificationItemControl : Border
{
private readonly NotificationItem _item;
private readonly ComponentSettingsSnapshot _settings;
private bool _isPointerPressed;
private Point _pointerPressedPosition;
private bool _isNightVisual;
public NotificationItemControl(NotificationItem item, ComponentSettingsSnapshot settings, bool isNightVisual)
{
_item = item;
_settings = settings;
_isNightVisual = isNightVisual;
Background = _item.IsRead
? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
: new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
CornerRadius = new CornerRadius(6);
Padding = new Thickness(10, 6);
Cursor = new Cursor(StandardCursorType.Hand);
BorderBrush = _item.IsRead
? new SolidColorBrush(Colors.Transparent)
: new SolidColorBrush(Color.Parse("#E24B2D"));
BorderThickness = _item.IsRead ? new Thickness(0) : new Thickness(2, 0, 0, 0);
BuildUI();
PointerPressed += OnPointerPressed;
PointerReleased += OnPointerReleased;
}
private void BuildUI()
{
var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("Auto,*,Auto") };
if (_settings.NotificationBoxShowAppIcon)
{
var iconBorder = new Border
{
Width = 28,
Height = 28,
CornerRadius = new CornerRadius(4),
Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#4D5560") : Color.Parse("#E8EAED")),
Margin = new Thickness(0, 0, 8, 0)
};
var iconText = new TextBlock
{
Text = _item.AppName.Length > 0 ? _item.AppName[0].ToString() : "?",
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = 12,
FontWeight = FontWeight.SemiBold,
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"))
};
iconBorder.Child = iconText;
grid.Children.Add(iconBorder);
}
var contentPanel = new StackPanel { Spacing = 1 };
Grid.SetColumn(contentPanel, 1);
var titleBlock = new TextBlock
{
Text = _item.Title,
FontWeight = FontWeight.SemiBold,
FontSize = 12,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 1,
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"))
};
var contentBlock = new TextBlock
{
Text = _item.Content,
FontSize = 11,
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671")),
TextTrimming = TextTrimming.CharacterEllipsis,
MaxLines = 2,
TextWrapping = TextWrapping.Wrap
};
contentPanel.Children.Add(titleBlock);
if (!string.IsNullOrWhiteSpace(_item.Content))
{
contentPanel.Children.Add(contentBlock);
}
grid.Children.Add(contentPanel);
if (_settings.NotificationBoxShowTimestamp)
{
var timeText = _settings.NotificationBoxTimeFormat == "Relative"
? GetRelativeTime(_item.ReceivedTime)
: _item.ReceivedTime.ToString("HH:mm");
var timeBlock = new TextBlock
{
Text = timeText,
FontSize = 10,
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#8B95A5")),
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
Margin = new Thickness(6, 0, 0, 0)
};
Grid.SetColumn(timeBlock, 2);
grid.Children.Add(timeBlock);
}
Child = grid;
}
public void UpdateTheme(bool isNightVisual, double fontScale)
{
_isNightVisual = isNightVisual;
Background = _item.IsRead
? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
: new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
if (Child is Grid grid)
{
foreach (var child in grid.Children)
{
if (child is StackPanel panel)
{
foreach (var textBlock in panel.Children.OfType<TextBlock>())
{
textBlock.FontSize *= fontScale;
}
}
}
}
}
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
_isPointerPressed = true;
_pointerPressedPosition = e.GetPosition(this);
IsSelected = true;
e.Handled = true;
}
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_isPointerPressed) return;
_isPointerPressed = false;
var releasePosition = e.GetPosition(this);
var distance = Math.Sqrt(
Math.Pow(releasePosition.X - _pointerPressedPosition.X, 2) +
Math.Pow(releasePosition.Y - _pointerPressedPosition.Y, 2));
if (distance < 5)
{
Clicked?.Invoke(this, _item);
MarkAsRead?.Invoke(this, _item);
}
e.Handled = true;
}
private static string GetRelativeTime(DateTime time)
{
var diff = DateTime.Now - time;
if (diff.TotalMinutes < 1) return "刚刚";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}分前";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}小时前";
return $"{(int)diff.TotalDays}天前";
}
public bool IsSelected { get; set; }
public event EventHandler<NotificationItem>? Clicked;
public event EventHandler<NotificationItem>? MarkAsRead;
}
public static class NotificationListenerServiceProvider
{
private static NotificationListenerService? _instance;
public static NotificationListenerService GetOrCreate(ISettingsService settingsService)
{
if (_instance == null)
{
_instance = new NotificationListenerService(settingsService);
_instance.InitializeAsync().ConfigureAwait(false);
}
return _instance;
}
}

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

View File

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

View File

@@ -9,6 +9,8 @@ namespace LanMountainDesktop.Views.Components;
public sealed class StudyNoiseDistributionScatterChartControl : Control
{
private readonly record struct SampledPoint(double X, double Y, NoiseDistributionLevel Level);
private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96"));
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
private static readonly Pen GridPen = new(GridBrush, 1);
@@ -18,14 +20,35 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
private static readonly IBrush NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444"));
private static readonly byte[] CloudAlphas = [44, 58, 72, 86];
private static readonly byte[] GlowAlphas = [26, 36];
private static readonly IBrush[][] CloudBrushes = CreateBrushTable(CloudAlphas);
private static readonly IBrush[][] GlowBrushes = CreateBrushTable(GlowAlphas);
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
private SampledPoint[] _sampledPoints = Array.Empty<SampledPoint>();
private int _sampledPointCount;
private double _baselineDb = 45;
private Rect _cachedPlot;
private bool _sampleCacheDirty = true;
private int _lastSeriesSignature;
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
{
_points = points ?? Array.Empty<NoiseRealtimePoint>();
_baselineDb = Math.Clamp(baselineDb, 20, 85);
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
var nextBaselineDb = Math.Clamp(baselineDb, 20, 85);
var nextSignature = ComputeSeriesSignature(nextPoints, nextBaselineDb);
if (ReferenceEquals(_points, nextPoints) &&
Math.Abs(_baselineDb - nextBaselineDb) < 0.001 &&
_lastSeriesSignature == nextSignature)
{
return;
}
_points = nextPoints;
_baselineDb = nextBaselineDb;
_lastSeriesSignature = nextSignature;
_sampleCacheDirty = true;
InvalidateVisual();
}
@@ -52,45 +75,34 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
return;
}
EnsureSampleCache(plot);
if (_sampledPointCount < 2)
{
return;
}
DrawElectronCloud(context, plot);
}
private void DrawElectronCloud(DrawingContext context, Rect plot)
{
var start = _points[0].Timestamp;
var end = _points[^1].Timestamp;
var totalTicks = Math.Max(1, (end - start).Ticks);
var pointCount = _points.Count;
var cloudLayers = 8;
var cloudLayers = CloudAlphas.Length;
var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12);
var sortedPoints = new List<(double X, double Y, NoiseDistributionLevel Level)>();
for (var i = 0; i < pointCount; i++)
{
var point = _points[i];
var x = MapX(plot, point.Timestamp, start, totalTicks);
var y = MapYContinuous(plot, point.DisplayDb);
var level = ResolveLevel(point.DisplayDb, _baselineDb);
sortedPoints.Add((x, y, level));
}
sortedPoints.Sort((a, b) => a.X.CompareTo(b.X));
for (var layer = cloudLayers - 1; layer >= 0; layer--)
{
var layerRatio = (double)layer / (cloudLayers - 1);
var layerRatio = cloudLayers == 1 ? 0d : layer / (double)(cloudLayers - 1);
var layerRadius = baseRadius * (1.2 + layerRatio * 0.8);
var layerAlpha = (byte)(40 + layerRatio * 25);
var layerBrushes = CloudBrushes[layer];
foreach (var pt in sortedPoints)
for (var i = 0; i < _sampledPointCount; i++)
{
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
var pt = _sampledPoints[i];
var jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3;
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
context.DrawEllipse(
brush,
layerBrushes[(int)pt.Level],
pen: null,
center: new Point(pt.X + jitterX, pt.Y + jitterY),
radiusX: layerRadius,
@@ -98,18 +110,17 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
}
}
var glowLayers = 5;
var glowLayers = GlowAlphas.Length;
for (var layer = glowLayers - 1; layer >= 0; layer--)
{
var layerRatio = (double)layer / (glowLayers - 1);
var layerRatio = glowLayers == 1 ? 0d : layer / (double)(glowLayers - 1);
var layerRadius = baseRadius * (0.8 + layerRatio * 0.6);
var layerAlpha = (byte)(20 + layerRatio * 15);
foreach (var pt in sortedPoints)
var layerBrushes = GlowBrushes[layer];
for (var i = 0; i < _sampledPointCount; i++)
{
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
var pt = _sampledPoints[i];
context.DrawEllipse(
brush,
layerBrushes[(int)pt.Level],
pen: null,
center: new Point(pt.X, pt.Y),
radiusX: layerRadius,
@@ -117,34 +128,42 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
}
}
var latest = _points[^1];
var latestX = MapX(plot, latest.Timestamp, start, totalTicks);
var latestY = MapYContinuous(plot, latest.DisplayDb);
var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb);
var latest = _sampledPoints[_sampledPointCount - 1];
for (var i = 3; i >= 0; i--)
{
var radius = baseRadius * (1.5 + i * 0.8);
var alpha = (byte)(30 - i * 6);
var glowBrush = GetLevelBrushWithAlpha(latestLevel, alpha);
context.DrawEllipse(glowBrush, null, new Point(latestX, latestY), radius, radius * 0.6);
var glowBrush = GetAlphaBrush(latest.Level, alpha);
context.DrawEllipse(glowBrush, null, new Point(latest.X, latest.Y), radius, radius * 0.6);
}
context.DrawEllipse(
GetLevelBrush(latestLevel),
GetLevelBrush(latest.Level),
new Pen(Brushes.White, 1.5),
new Point(latestX, latestY),
new Point(latest.X, latest.Y),
baseRadius + 1,
baseRadius * 0.7 + 1);
context.DrawEllipse(
Brushes.White,
null,
new Point(latestX, latestY),
new Point(latest.X, latest.Y),
2,
2);
}
private void EnsureSampleCache(Rect plot)
{
if (!_sampleCacheDirty && _cachedPlot == plot)
{
return;
}
_cachedPlot = plot;
_sampledPointCount = BuildSampledPoints(plot);
_sampleCacheDirty = false;
}
private static void DrawGrid(DrawingContext context, Rect plot)
{
const int verticalDivisions = 4;
@@ -176,7 +195,10 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
var minDb = _baselineDb - 5;
var maxDb = _baselineDb + 25;
var dbRange = maxDb - minDb;
if (dbRange <= 0) dbRange = 30;
if (dbRange <= 0)
{
dbRange = 30;
}
var normalizedDb = (displayDb - minDb) / dbRange;
normalizedDb = Math.Clamp(normalizedDb, 0, 1);
@@ -243,6 +265,106 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
_ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA))
};
}
private int BuildSampledPoints(Rect plot)
{
if (_points.Count < 2)
{
return 0;
}
var maxSamples = Math.Clamp((int)Math.Ceiling(plot.Width / 2d), 48, 144);
var targetCount = Math.Min(_points.Count, maxSamples);
if (_sampledPoints.Length < targetCount)
{
_sampledPoints = new SampledPoint[targetCount];
}
var start = _points[0].Timestamp;
var end = _points[^1].Timestamp;
var totalTicks = Math.Max(1, (end - start).Ticks);
var step = _points.Count <= targetCount
? 1d
: (_points.Count - 1d) / Math.Max(1d, targetCount - 1d);
var outputIndex = 0;
var lastSourceIndex = -1;
for (var i = 0; i < targetCount; i++)
{
var sourceIndex = i == targetCount - 1
? _points.Count - 1
: (int)Math.Round(i * step);
sourceIndex = Math.Clamp(sourceIndex, 0, _points.Count - 1);
if (sourceIndex == lastSourceIndex)
{
continue;
}
var point = _points[sourceIndex];
_sampledPoints[outputIndex++] = new SampledPoint(
MapX(plot, point.Timestamp, start, totalTicks),
MapYContinuous(plot, point.DisplayDb),
ResolveLevel(point.DisplayDb, _baselineDb));
lastSourceIndex = sourceIndex;
}
return outputIndex;
}
private static int ComputeSeriesSignature(IReadOnlyList<NoiseRealtimePoint> points, double baselineDb)
{
if (points.Count == 0)
{
return HashCode.Combine(0, baselineDb);
}
var first = points[0];
var last = points[^1];
return HashCode.Combine(
points.Count,
first.Timestamp.UtcTicks,
last.Timestamp.UtcTicks,
Math.Round(last.DisplayDb, 2),
Math.Round(baselineDb, 2));
}
private static IBrush[][] CreateBrushTable(IReadOnlyList<byte> alphas)
{
var table = new IBrush[alphas.Count][];
for (var i = 0; i < alphas.Count; i++)
{
table[i] =
[
GetLevelBrushWithAlpha(NoiseDistributionLevel.Quiet, alphas[i]),
GetLevelBrushWithAlpha(NoiseDistributionLevel.Normal, alphas[i]),
GetLevelBrushWithAlpha(NoiseDistributionLevel.Noisy, alphas[i]),
GetLevelBrushWithAlpha(NoiseDistributionLevel.Extreme, alphas[i])
];
}
return table;
}
private static IBrush GetAlphaBrush(NoiseDistributionLevel level, byte alpha)
{
for (var i = 0; i < CloudAlphas.Length; i++)
{
if (CloudAlphas[i] == alpha)
{
return CloudBrushes[i][(int)level];
}
}
for (var i = 0; i < GlowAlphas.Length; i++)
{
if (GlowAlphas[i] == alpha)
{
return GlowBrushes[i][(int)level];
}
}
return GetLevelBrushWithAlpha(level, alpha);
}
}
public enum NoiseDistributionLevel

View File

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

View File

@@ -45,6 +45,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private string _placementId = string.Empty;
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
private bool _isApplyingPersistedSnapshot;
private bool? _lastBitmapCacheEnabled;
private int _lastBitmapCacheSize;
private bool _noteDirty;
private int _noteLoadRevision;
private bool _disposed;
@@ -119,11 +121,10 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
settings.IgnorePressure = true;
settings.InkThickness = _selectedInkThickness;
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
UpdateInkCanvasCacheSettings(forceRefresh: true);
}
public void ApplyCellSize(double cellSize)
@@ -157,6 +158,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
settings.EraserSize = new Size(eraserSize, eraserSize);
UpdateInkCanvasCacheSettings(forceRefresh: false);
}
private void ApplyThemeVisual(bool force)
@@ -711,8 +713,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
}
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
InkCanvas.InvalidateVisual();
UpdateInkCanvasCacheSettings(forceRefresh: true);
}
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
@@ -765,9 +766,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
}
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
UpdateInkCanvasCacheSettings(forceRefresh: true);
}
private bool HasValidPersistenceContext()
@@ -785,4 +784,47 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return Array.Empty<InkStylusPoint>();
}
private void UpdateInkCanvasCacheSettings(bool forceRefresh)
{
var renderScaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1d;
var widthPx = Math.Max(1d, CanvasBorder.Bounds.Width * renderScaling);
var heightPx = Math.Max(1d, CanvasBorder.Bounds.Height * renderScaling);
var longestSide = Math.Max(widthPx, heightPx);
var area = widthPx * heightPx;
var cacheEnabled = longestSide <= 1536d && area <= 1_400_000d;
var cacheSize = (int)Math.Clamp(Math.Ceiling(longestSide), 384d, 1536d);
if (!forceRefresh &&
_lastBitmapCacheEnabled == cacheEnabled &&
_lastBitmapCacheSize == cacheSize)
{
return;
}
_lastBitmapCacheEnabled = cacheEnabled;
_lastBitmapCacheSize = cacheSize;
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.IsBitmapCacheEnabled = cacheEnabled;
settings.MaxBitmapCacheSize = cacheSize;
try
{
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(cacheEnabled);
if (cacheEnabled)
{
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
}
else
{
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
}
catch
{
// Keep drawing available even if the underlying cache backend rejects the cache update.
}
}
}

View File

@@ -295,7 +295,7 @@ public partial class MainWindow
var renderScale = RenderScaling > 0 ? RenderScaling : 1d;
return string.Create(
CultureInfo.InvariantCulture,
$"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.GlobalCornerRadiusScale:F3}|Accent={FormatSignatureColor(appearance.AccentColor)}");
$"{key}|Cell={renderCellSize:F2}|Scale={renderScale:F2}|Night={(appearance.IsNightMode ? 1 : 0)}|Corner={appearance.CornerRadiusStyle}|Accent={FormatSignatureColor(appearance.AccentColor)}");
}
private ComponentPreviewKey CreateComponentTypePreviewKey(string componentId, int widthCells, int heightCells)

View File

@@ -74,6 +74,10 @@ public partial class MainWindow
Color PressedColor,
Color DividerColor);
private readonly IPowerManagementService _powerService = PowerManagementServiceFactory.GetOrCreate();
private bool _isPowerMenuOpen;
private bool _isPowerMenuAnimating;
private void InitializeTaskbarProfileFlyout()
{
if (TaskbarProfileButton is null || TaskbarProfilePopup is null)
@@ -98,6 +102,16 @@ public partial class MainWindow
TaskbarProfileDisplayNameTextBlock.Text = profile.DisplayName;
TaskbarProfileSettingsActionTextBlock.Text = L("tooltip.open_settings", "Settings");
TaskbarProfileDesktopEditActionTextBlock.Text = L("button.component_library", "Edit Desktop");
TaskbarProfilePowerActionTextBlock.Text = L("power.menu", "Power");
TaskbarPowerTitleTextBlock.Text = L("power.title", "Power");
TaskbarPowerBackTextBlock.Text = L("power.back", "Back");
PowerShutdownTextBlock.Text = L("power.shutdown", "Shutdown");
PowerRestartTextBlock.Text = L("power.restart", "Restart");
PowerLogoutTextBlock.Text = L("power.logout", "Log Out");
PowerSleepTextBlock.Text = L("power.sleep", "Sleep");
PowerLockTextBlock.Text = L("power.lock_screen", "Lock Screen");
UpdatePowerMenuVisibility();
ApplyTaskbarProfilePopupTheme(_appearanceThemeService.GetCurrent());
ToolTip.SetTip(TaskbarProfileButton, profile.DisplayName);
@@ -216,6 +230,7 @@ public partial class MainWindow
return;
}
ResetPowerMenuState();
RefreshTaskbarProfilePresentation();
TaskbarProfilePopup.IsOpen = true;
}
@@ -279,6 +294,199 @@ public partial class MainWindow
app?.OpenIndependentSettingsModule("MainWindowTaskbar");
}
private void OnPowerMenuEnterClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
EnterPowerMenu();
}
private void OnPowerMenuBackClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
ExitPowerMenu();
}
private void ResetPowerMenuState()
{
_isPowerMenuOpen = false;
_isPowerMenuAnimating = false;
if (TaskbarProfileMainPanel is not null)
{
TaskbarProfileMainPanel.IsVisible = true;
TaskbarProfileMainPanel.Opacity = 1d;
}
if (TaskbarProfilePowerPanel is not null)
{
TaskbarProfilePowerPanel.IsVisible = false;
TaskbarProfilePowerPanel.Opacity = 0d;
var transform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
if (transform is not null) transform.X = 340d;
}
}
private void UpdatePowerMenuVisibility()
{
var supported = _powerService.IsShutdownSupported ||
_powerService.IsRestartSupported ||
_powerService.IsLogoutSupported ||
_powerService.IsSleepSupported ||
_powerService.IsLockSupported;
if (TaskbarProfilePowerActionButton is not null)
{
TaskbarProfilePowerActionButton.IsVisible = supported;
}
}
private async void EnterPowerMenu()
{
if (_isPowerMenuAnimating || _isPowerMenuOpen || TaskbarProfileMainPanel is null || TaskbarProfilePowerPanel is null)
return;
_isPowerMenuAnimating = true;
TaskbarProfilePowerPanel.IsVisible = true;
TaskbarProfilePowerPanel.Opacity = 0d;
var powerTransform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
if (powerTransform is not null) powerTransform.X = 340d;
await Task.Delay(16);
TaskbarProfileMainPanel.Opacity = 0d;
TaskbarProfilePowerPanel.Opacity = 1d;
if (powerTransform is not null) powerTransform.X = 0d;
await Task.Delay(280);
TaskbarProfileMainPanel.IsVisible = false;
_isPowerMenuOpen = true;
_isPowerMenuAnimating = false;
}
private async void ExitPowerMenu()
{
if (_isPowerMenuAnimating || !_isPowerMenuOpen || TaskbarProfileMainPanel is null || TaskbarProfilePowerPanel is null)
return;
_isPowerMenuAnimating = true;
TaskbarProfileMainPanel.IsVisible = true;
TaskbarProfileMainPanel.Opacity = 0d;
var powerTransform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
if (powerTransform is not null) powerTransform.X = 0d;
await Task.Delay(16);
TaskbarProfileMainPanel.Opacity = 1d;
TaskbarProfilePowerPanel.Opacity = 0d;
if (powerTransform is not null) powerTransform.X = 340d;
await Task.Delay(280);
TaskbarProfilePowerPanel.IsVisible = false;
_isPowerMenuOpen = false;
_isPowerMenuAnimating = false;
}
private async void OnPowerShutdownClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
ClosePopupIfOpen();
if (OperatingSystem.IsWindows())
{
// Windows: 使用 SlideToShutDown 滑动关机界面
_powerService.ShowNativePowerUI(PowerAction.Shutdown);
}
else
{
// Linux: 二次确认对话框
await ShowPowerConfirmDialogAsync(L("power.shutdown_confirm_title", "Shutdown"),
L("power.shutdown_confirm_message", "Are you sure you want to shut down this computer?"),
() => _powerService.ShutdownAsync());
}
}
private async void OnPowerRestartClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
ClosePopupIfOpen();
// 所有平台:统一使用二次确认对话框
// Note: SlideToShutDown.exe 只支持关机,不支持重启
await ShowPowerConfirmDialogAsync(L("power.restart_confirm_title", "Restart"),
L("power.restart_confirm_message", "Are you sure you want to restart this computer?"),
() => _powerService.RestartAsync());
}
private async void OnPowerLogoutClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
ClosePopupIfOpen();
await ShowPowerConfirmDialogAsync(L("power.logout_confirm_title", "Log Out"),
L("power.logout_confirm_message", "Are you sure you want to log out?"),
() => _powerService.LogoutAsync());
}
private async void OnPowerSleepClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
ClosePopupIfOpen();
await ShowPowerConfirmDialogAsync(L("power.sleep_confirm_title", "Sleep"),
L("power.sleep_confirm_message", "Are you sure you want to put the computer to sleep?"),
() => _powerService.SleepAsync());
}
private async void OnPowerLockClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
ClosePopupIfOpen();
await _powerService.LockAsync();
}
private async Task ShowPowerConfirmDialogAsync(string title, string message, Func<Task> action)
{
try
{
var dialog = new ContentDialog
{
Title = title,
Content = message,
PrimaryButtonText = L("power.confirm_yes", "Yes"),
SecondaryButtonText = L("power.confirm_cancel", "Cancel")
};
var result = await dialog.ShowAsync(this);
if (result == ContentDialogResult.Primary)
{
await action();
}
}
catch (Exception ex)
{
AppLogger.Error("PowerMenu", $"Dialog error: {ex.Message}");
}
}
private void ClosePopupIfOpen()
{
if (TaskbarProfilePopup is not null && TaskbarProfilePopup.IsOpen)
{
TaskbarProfilePopup.IsOpen = false;
}
}
private void OnCloseComponentLibraryClick(object? sender, RoutedEventArgs e)
{
_componentLibraryWindowService.Close(this);
@@ -1337,7 +1545,6 @@ public partial class MainWindow
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
return new ComponentLibraryCreateContext(
cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
_timeZoneService,
_weatherDataService,
_recommendationInfoService,
@@ -2341,12 +2548,10 @@ public partial class MainWindow
componentId,
null,
_currentDesktopCellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
appearanceSnapshot.CornerRadiusTokens));
}
var scale = Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, appearanceSnapshot.GlobalCornerRadiusScale);
return Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18) * scale;
return Math.Max(0d, appearanceSnapshot.CornerRadiusTokens.Component.TopLeft);
}
private Thickness GetDesktopComponentVisualInset(int widthCells, int heightCells)
@@ -2598,7 +2803,6 @@ public partial class MainWindow
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
var createContext = new ComponentLibraryCreateContext(
cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
_timeZoneService,
_weatherDataService,
_recommendationInfoService,

View File

@@ -47,6 +47,7 @@ public partial class MainWindow
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
private readonly HashSet<string> _hiddenLauncherFolderPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _hiddenLauncherAppPaths = new(StringComparer.OrdinalIgnoreCase);
private bool _showLauncherTileBackground = true;
private Button? _selectedLauncherTileButton;
private LauncherEntryKind? _selectedLauncherEntryKind;
private string? _selectedLauncherEntryKey;
@@ -116,6 +117,8 @@ public partial class MainWindow
}
}
}
_showLauncherTileBackground = snapshot.ShowTileBackground;
}
private void InitializeDesktopSurfaceSwipeHandlers()
@@ -1137,7 +1140,6 @@ public partial class MainWindow
var button = new Button
{
Classes = { "glass-panel" },
Margin = new Thickness(0, 0, 12, 12),
BorderThickness = new Thickness(0),
BorderBrush = Brushes.Transparent,
@@ -1146,6 +1148,16 @@ public partial class MainWindow
Content = content
// 不设置固定 Width 和 Height由 UpdateLauncherTileLayout 动态设置
};
// 根据设置决定是否显示背景
if (_showLauncherTileBackground)
{
button.Classes.Add("glass-panel");
}
else
{
button.Background = Brushes.Transparent;
}
button.Click += (_, _) =>
{
if (_isComponentLibraryOpen)
@@ -1676,7 +1688,6 @@ public partial class MainWindow
var button = new Button
{
Classes = { "glass-panel" },
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
BorderThickness = new Thickness(0),
@@ -1684,6 +1695,17 @@ public partial class MainWindow
Padding = new Thickness(8, 8, 8, 6),
Content = content
};
// 根据设置决定是否显示背景
if (_showLauncherTileBackground)
{
button.Classes.Add("glass-panel");
}
else
{
button.Background = Brushes.Transparent;
}
button.Click += (_, _) =>
{
if (_isComponentLibraryOpen)
@@ -1745,7 +1767,6 @@ public partial class MainWindow
var button = new Button
{
Classes = { "glass-panel" },
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
BorderThickness = new Thickness(0),
@@ -1753,6 +1774,17 @@ public partial class MainWindow
Padding = new Thickness(8, 8, 8, 6),
Content = content
};
// 根据设置决定是否显示背景
if (_showLauncherTileBackground)
{
button.Classes.Add("glass-panel");
}
else
{
button.Background = Brushes.Transparent;
}
button.Click += (_, _) =>
{
if (_isComponentLibraryOpen)

View File

@@ -44,6 +44,23 @@ public partial class MainWindow
return;
}
// 启动台设置变化时,重新渲染启动台图标
if (e.Scope == SettingsScope.Launcher && e.ChangedKeys is { Count: > 0 })
{
var changedKeys = e.ChangedKeys.ToArray();
if (changedKeys.Any(key =>
string.Equals(key, nameof(LauncherSettingsSnapshot.ShowTileBackground), StringComparison.OrdinalIgnoreCase)))
{
Dispatcher.UIThread.Post(() =>
{
var launcherSnapshot = _settingsService.LoadSnapshot<LauncherSettingsSnapshot>(SettingsScope.Launcher);
InitializeLauncherVisibilitySettings(launcherSnapshot);
RenderLauncherRootTiles();
}, DispatcherPriority.Background);
return;
}
}
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
{
var changedKeys = e.ChangedKeys.ToArray();
@@ -51,6 +68,7 @@ public partial class MainWindow
string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateVersion), StringComparison.OrdinalIgnoreCase) ||
@@ -611,7 +629,7 @@ public partial class MainWindow
SystemMaterialMode = latestThemeState.SystemMaterialMode,
SelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed,
UseSystemChrome = latestThemeState.UseSystemChrome,
GlobalCornerRadiusScale = latestThemeState.GlobalCornerRadiusScale,
CornerRadiusStyle = latestThemeState.CornerRadiusStyle,
WallpaperPath = latestWallpaperState.WallpaperPath,
WallpaperType = latestWallpaperState.Type,
WallpaperColor = string.Equals(latestWallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase)

View File

@@ -398,69 +398,211 @@
<Border x:Name="TaskbarProfilePopupPanel"
Classes="taskbar-profile-popup-panel"
Margin="0,0,0,10">
<StackPanel Width="280"
Spacing="12">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
Classes="taskbar-profile-popup-avatar"
Width="44"
Height="44"
ClipToBounds="True">
<Grid>
<Image x:Name="TaskbarProfileHeaderAvatarImage"
Stretch="UniformToFill"
IsVisible="False" />
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
Classes="taskbar-profile-popup-primary"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="U" />
</Grid>
</Border>
<Grid Width="340">
<Grid x:Name="TaskbarProfileMainPanel"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
</Transitions>
</Grid.Transitions>
<StackPanel Spacing="12">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
Classes="taskbar-profile-popup-avatar"
Width="44"
Height="44"
ClipToBounds="True">
<Grid>
<Image x:Name="TaskbarProfileHeaderAvatarImage"
Stretch="UniformToFill"
IsVisible="False" />
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
Classes="taskbar-profile-popup-primary"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="U" />
</Grid>
</Border>
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
Classes="taskbar-profile-popup-title"
Text="User" />
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
Classes="taskbar-profile-popup-title"
Text="User" />
</StackPanel>
</Grid>
<Border x:Name="TaskbarProfilePopupDivider"
Height="1"
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
<Button x:Name="TaskbarProfileSettingsActionButton"
Classes="taskbar-profile-popup-action"
Click="OnOpenSettingsClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Settings" />
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Settings" />
</Grid>
</Button>
<Button x:Name="TaskbarProfileDesktopEditActionButton"
Classes="taskbar-profile-popup-action"
Click="OnOpenComponentLibraryClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Pencil" />
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Edit Desktop" />
</Grid>
</Button>
<Button x:Name="TaskbarProfilePowerActionButton"
Classes="taskbar-profile-popup-action"
Click="OnPowerMenuEnterClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Power" />
<TextBlock x:Name="TaskbarProfilePowerActionTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Power" />
</Grid>
</Button>
</StackPanel>
</Grid>
<Border x:Name="TaskbarProfilePopupDivider"
Height="1"
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
<Grid x:Name="TaskbarProfilePowerPanel"
IsVisible="False"
Opacity="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
</Transitions>
</Grid.Transitions>
<Grid.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X"
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
Easing="0.22,1,0.36,1" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform>
<StackPanel Spacing="8">
<Button x:Name="TaskbarPowerBackButton"
Classes="taskbar-profile-popup-action"
HorizontalAlignment="Left"
Click="OnPowerMenuBackClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="ArrowLeft" />
<TextBlock x:Name="TaskbarPowerBackTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Back" />
</Grid>
</Button>
<Button x:Name="TaskbarProfileSettingsActionButton"
Classes="taskbar-profile-popup-action"
Click="OnOpenSettingsClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Settings" />
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Settings" />
</Grid>
</Button>
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
FontSize="16"
FontWeight="SemiBold"
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
Text="Power" />
<Button x:Name="TaskbarProfileDesktopEditActionButton"
Classes="taskbar-profile-popup-action"
Click="OnOpenComponentLibraryClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Pencil" />
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Edit Desktop" />
</Grid>
</Button>
</StackPanel>
<Border Height="1"
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
<Button x:Name="PowerShutdownButton"
Classes="taskbar-profile-popup-action"
Click="OnPowerShutdownClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Power" />
<TextBlock x:Name="PowerShutdownTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Shutdown" />
</Grid>
</Button>
<Button x:Name="PowerRestartButton"
Classes="taskbar-profile-popup-action"
Click="OnPowerRestartClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Refresh" />
<TextBlock x:Name="PowerRestartTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Restart" />
</Grid>
</Button>
<Button x:Name="PowerLogoutButton"
Classes="taskbar-profile-popup-action"
Click="OnPowerLogoutClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="ExitToApp" />
<TextBlock x:Name="PowerLogoutTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Log Out" />
</Grid>
</Button>
<Button x:Name="PowerSleepButton"
Classes="taskbar-profile-popup-action"
Click="OnPowerSleepClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="WeatherNight" />
<TextBlock x:Name="PowerSleepTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Sleep" />
</Grid>
</Button>
<Button x:Name="PowerLockButton"
Classes="taskbar-profile-popup-action"
Click="OnPowerLockClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12">
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
Kind="Lock" />
<TextBlock x:Name="PowerLockTextBlock"
Grid.Column="1"
Classes="taskbar-profile-popup-action-text"
Text="Lock Screen" />
</Grid>
</Button>
</StackPanel>
</Grid>
</Grid>
</Border>
</Popup>
</Grid>

View File

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

View File

@@ -4,6 +4,7 @@
xmlns:controls="using:LanMountainDesktop.Controls"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
xmlns:symbol="using:FluentIcons.Common"
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
x:DataType="vm:LauncherSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
@@ -52,9 +53,33 @@
</Border>
<controls:IconText Icon="Apps"
Text="{Binding HiddenHeader}"
Text="{Binding AppearanceHeader}"
Margin="0,0,0,4" />
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding AppearanceHeader}"
Description="{Binding AppearanceDescription}"
IsExpanded="True">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="{x:Static symbol:Symbol.Apps}" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="2">
<TextBlock Text="{Binding ShowTileBackgroundHeader}" />
<TextBlock Classes="settings-item-description"
Text="{Binding ShowTileBackgroundDescription}" />
</StackPanel>
<ToggleSwitch Grid.Column="1"
IsChecked="{Binding ShowTileBackground}" />
</Grid>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<controls:IconText Icon="Apps"
Text="{Binding HiddenHeader}"
Margin="0,24,0,4" />
<ui:SettingsExpander Classes="settings-expander-card"
Header="{Binding HiddenHeader}"
Description="{Binding HiddenDescription}"

View File

@@ -37,11 +37,6 @@
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<TextBlock Classes="settings-section-title"
Text="{Binding PageTitle}" />
<TextBlock Classes="settings-section-description"
Text="{Binding PageDescription}" />
<Border Classes="update-status-card">
<StackPanel Spacing="18">
<Grid ColumnDefinitions="Auto,*,Auto"

View File

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

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小元素和图标容器
- Level 216px小型色块和紧凑控件
- Level 320px普通按钮
- Level 424px输入面板和小型容器
- Component18px桌面组件的标准圆角默认值
- Level 528px普通玻璃面板
- Level 632px强化容器
- Level 736px大容器、窗口、任务栏
## 预设风格 (Preset Styles)
### 使用建议
用户可以在设置中选择以下四种风格之一。系统会自动根据选中的风格动态映射全局圆角 Token。
- 同层级元素保持相同圆角。
- 大容器的圆角大于内部子面板。
- 动态尺寸组件可按 `cellSize` 计算圆角,但仍要落在统一范围内。
| 风格 (ID) | 名称 (Local) | 组件圆角 (Component) | 设计语义 |
| :--- | :--- | :--- | :--- |
| **Sharp** | 锐利 | 20px | 紧凑、精确、利落 |
| **Balanced** | 平衡 | 24px | **默认值**。和谐、自然、普适 |
| **Rounded** | 圆润 | 28px | 保守、柔和、亲切 |
| **Open** | 开放 | 32px | 现代、沉浸、夸张 |
### 动态圆角建议
## Token 阶梯映射 (Token Step Mapping)
```csharp
var cornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
```
每个风格都定义了一套完整的圆角阶梯,以确保在大容器包裹小元素时满足 **圆角嵌套一致性 (Nesting Consistency)**
## English
| Token | Sharp | Balanced | Rounded | Open | 典型场景 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **Micro** | 4px | 6px | 8px | 10px | 小图标容器、角标 (Badge) |
| **Xs** | 8px | 12px | 14px | 16px | 小标签 (Tag)、输入框 |
| **Sm** | 10px | 14px | 16px | 20px | 普通按钮、搜索栏、复选框 |
| **Md** | 14px | 20px | 24px | 28px | 悬浮菜单、小提示框、子卡片 |
| **Lg** | 20px | 28px | 32px | 36px | 普通面板、对话框内容区 |
| **Xl** | 24px | 32px | 36px | 40px | 大尺寸容器、设置中心页面 |
| **Island** | 28px | 36px | 40px | 44px | 任务栏、全局大悬浮容器 |
| **Component** | **20px** | **24px** | **28px** | **32px** | **所有桌面组件 (Widget) 的主边框** |
This specification keeps corner radius usage consistent across containers and controls.
## 开发准则 (Implementation Rules)
### Reference levels
> [!IMPORTANT]
> **1. 桌面组件强制约束**
> 所有桌面组件Widget / Desktop Component的根容器边框必须使用 `{DynamicResource DesignCornerRadiusComponent}`。严禁对其进行任何比例运算或系数乘积(如 `* scale`),必须保持固定。
- 12px for small elements
- 20px for common buttons
- 28px for normal glass panels
- 36px for large containers and windows
> [!TIP]
> **2. 圆角嵌套规则**
> 当一个容器包裹另一个元素时,外层圆角应比内层圆角大一个阶梯。例如:
> - 外部使用 `DesignCornerRadiusLg`
> - 内部紧贴边缘的内容应使用 `DesignCornerRadiusMd`
> 这样可以保证两条圆弧的圆心趋于重合,视觉重心更稳固。
> [!CAUTION]
> **3. 禁止硬编码 (No Hardcoding)**
> 禁止写死数字(如 `CornerRadius="24"`)或私有资源。如果现有 Token 无法满足需求,应优先考虑使用 `SafeValue` 辅助方法封装,但必须声明理由。
## 常用资源键 (Common Resource Keys)
- `DesignCornerRadiusComponent` (最常用)
- `DesignCornerRadiusMicro`
- `DesignCornerRadiusSm`
- `DesignCornerRadiusMd`
- `DesignCornerRadiusLg`
- `DesignCornerRadiusXl`

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