Compare commits

...

3 Commits

Author SHA1 Message Date
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
55 changed files with 2956 additions and 400 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`

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

@@ -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,5 @@ public static class BuiltInComponentIds
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
public const string DesktopFileManager = "DesktopFileManager";
public const string DesktopNotificationBox = "DesktopNotificationBox";
}

View File

@@ -410,6 +410,16 @@ 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)
};

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

@@ -1087,5 +1087,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

@@ -1081,5 +1081,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,45 @@ 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
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)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

@@ -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,6 +267,11 @@ public static class DesktopComponentEditorRegistryFactory
BuiltInComponentIds.DesktopZhiJiaoHub,
context => new ZhiJiaoHubComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d),
[BuiltInComponentIds.DesktopNotificationBox] = new(
BuiltInComponentIds.DesktopNotificationBox,
context => new NotificationBoxComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d)
};

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,253 @@
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)
{
var slideToShutDownPath = Environment.ExpandEnvironmentVariables(@"%windir%\System32\SlideToShutDown.exe");
if (System.IO.File.Exists(slideToShutDownPath))
{
Process.Start(new ProcessStartInfo
{
FileName = slideToShutDownPath,
UseShellExecute = true
});
return;
}
switch (action)
{
case PowerAction.Shutdown:
Process.Start(new ProcessStartInfo
{
FileName = "shutdown",
Arguments = "/s /t 5 /c \"LanMountainDesktop: Shutting down...\"",
UseShellExecute = true
});
break;
case PowerAction.Restart:
Process.Start(new ProcessStartInfo
{
FileName = "shutdown",
Arguments = "/r /t 5 /c \"LanMountainDesktop: Restarting...\"",
UseShellExecute = true
});
break;
}
}
[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

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

@@ -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,11 @@ public sealed class DesktopComponentRuntimeRegistry
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopFileManager,
"component.file_manager",
() => new FileManagerWidget())
() => new FileManagerWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopNotificationBox,
"component.notification_box",
() => new NotificationBoxWidget())
];
}

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

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

@@ -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,202 @@ 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())
{
_powerService.ShowNativePowerUI(PowerAction.Shutdown);
}
else
{
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();
if (OperatingSystem.IsWindows())
{
_powerService.ShowNativePowerUI(PowerAction.Restart);
}
else
{
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 +1548,6 @@ public partial class MainWindow
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
return new ComponentLibraryCreateContext(
cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
_timeZoneService,
_weatherDataService,
_recommendationInfoService,
@@ -2341,12 +2551,10 @@ public partial class MainWindow
componentId,
null,
_currentDesktopCellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
appearanceSnapshot.CornerRadiusTokens));
}
var scale = Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, appearanceSnapshot.GlobalCornerRadiusScale);
return Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18) * scale;
return Math.Max(0d, appearanceSnapshot.CornerRadiusTokens.Component.TopLeft);
}
private Thickness GetDesktopComponentVisualInset(int widthCells, int heightCells)
@@ -2598,7 +2806,6 @@ public partial class MainWindow
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
var createContext = new ComponentLibraryCreateContext(
cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
_timeZoneService,
_weatherDataService,
_recommendationInfoService,

View File

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

View File

@@ -398,69 +398,215 @@
<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"
Padding="4,6"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
HorizontalAlignment="Left"
Click="OnPowerMenuBackClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:SymbolIcon Classes="icon-s"
Symbol="ArrowLeft"
IconVariant="Regular" />
<TextBlock x:Name="TaskbarPowerBackTextBlock"
VerticalAlignment="Center"
Text="Back" />
</StackPanel>
</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}"
Margin="2,6,0,0"
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}"
Margin="0,4" />
<Button x:Name="PowerShutdownButton"
Classes="taskbar-profile-popup-action"
Click="OnPowerShutdownClick">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14">
<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="14">
<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="14">
<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="14">
<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="14">
<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

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