mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e795e9964e | ||
|
|
11130cfdb3 | ||
|
|
66ae0b0270 | ||
|
|
a671db8b69 | ||
|
|
8c94253f92 | ||
|
|
6849a467d6 | ||
|
|
e69bbf8b19 | ||
|
|
d30af21317 | ||
|
|
8583465a67 | ||
|
|
e1d5a0c6de | ||
|
|
5fa2031ad6 |
52
.github/workflows/release.yml
vendored
52
.github/workflows/release.yml
vendored
@@ -66,8 +66,19 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, x86]
|
||||
name: Build_Windows_${{ matrix.arch }}
|
||||
include:
|
||||
# 完整版(自包含 .NET 运行时)
|
||||
- arch: x64
|
||||
self_contained: true
|
||||
suffix: ''
|
||||
- arch: x86
|
||||
self_contained: true
|
||||
suffix: ''
|
||||
# 轻盈版(框架依赖,仅 x64)
|
||||
- arch: x64
|
||||
self_contained: false
|
||||
suffix: '-lite'
|
||||
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -95,13 +106,16 @@ jobs:
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
|
||||
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
|
||||
-c Release `
|
||||
-o ./publish/windows-${{ matrix.arch }} `
|
||||
--self-contained `
|
||||
-o ./$publishDir `
|
||||
--self-contained:$selfContained `
|
||||
-r win-${{ matrix.arch }} `
|
||||
-p:PublishSingleFile=false `
|
||||
-p:SelfContained=true `
|
||||
-p:SelfContained=$selfContained `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false `
|
||||
-p:PublishTrimmed=false `
|
||||
@@ -110,6 +124,9 @@ jobs:
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
Write-Host "Published to: $publishDir"
|
||||
Write-Host "Self-contained: $selfContained"
|
||||
shell: pwsh
|
||||
|
||||
- name: Install Inno Setup
|
||||
@@ -120,7 +137,9 @@ jobs:
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish\windows-$arch"
|
||||
$selfContained = "${{ matrix.self_contained }}" -eq "true"
|
||||
$suffix = "${{ matrix.suffix }}"
|
||||
$publishDir = if ($selfContained) { "publish\windows-$arch" } else { "publish\windows-$arch-lite" }
|
||||
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
|
||||
$outputDir = "build-installer"
|
||||
|
||||
@@ -187,6 +206,8 @@ jobs:
|
||||
"/DPublishDir=$publishDir",
|
||||
"/DMyOutputDir=$outputDir",
|
||||
"/DMyAppArch=$arch",
|
||||
"/DMyAppSuffix=$suffix",
|
||||
"/DIsSelfContained=$selfContained",
|
||||
$installerScript
|
||||
)
|
||||
|
||||
@@ -213,7 +234,7 @@ jobs:
|
||||
- name: Upload Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
name: release-windows-${{ matrix.arch }}${{ matrix.suffix }}
|
||||
path: build-installer/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -548,19 +569,22 @@ jobs:
|
||||
artifacts: "release-files/**"
|
||||
body: |
|
||||
## Release ${{ needs.prepare.outputs.version }}
|
||||
|
||||
|
||||
### Windows
|
||||
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer
|
||||
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer
|
||||
|
||||
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (完整版,包含 .NET 运行时)
|
||||
- **LanMountainDesktop-Setup-{version}-x64-lite.exe** - 64-bit installer (轻量版,需安装 .NET 10 Runtime)
|
||||
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (完整版,包含 .NET 运行时)
|
||||
|
||||
> **轻量版说明**:轻量版不包含 .NET 运行时,体积更小。首次运行前需安装 [.NET 10 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0)。
|
||||
|
||||
Installation: Double-click the .exe file and follow the wizard.
|
||||
|
||||
|
||||
### Linux
|
||||
- **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64)
|
||||
|
||||
|
||||
### macOS
|
||||
- **LanMountainDesktop-{version}-macos-x64.dmg** - Intel processor
|
||||
- **LanMountainDesktop-{version}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
|
||||
|
||||
|
||||
See commits for changes.
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
100
.trae/specs/fused-desktop-library-redesign/spec.md
Normal file
100
.trae/specs/fused-desktop-library-redesign/spec.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 融合桌面组件库窗口重设计规格
|
||||
|
||||
## Why
|
||||
当前融合桌面组件库窗口(FusedDesktopComponentLibraryWindow)的UI设计较为基础,与Windows 11小组件编辑面板相比,缺乏现代化的交互体验和视觉层次。用户需要一个更直观、更美观的界面来浏览和添加组件到系统桌面(负一屏)。
|
||||
|
||||
参考Windows 11小组件编辑面板的设计特点:
|
||||
- 左侧分类列表,右侧选中组件的详细预览
|
||||
- 大型组件预览区域,让用户清楚看到组件效果
|
||||
- 底部明显的"添加"操作按钮
|
||||
- 简洁的关闭按钮(X)在右上角
|
||||
- 深色主题配合毛玻璃效果
|
||||
|
||||
## What Changes
|
||||
- **重新设计窗口布局**:从左右分栏(分类列表+组件网格)改为左侧面板+右侧预览区的布局
|
||||
- **添加组件详情预览区**:选中组件后右侧显示大尺寸预览和组件信息
|
||||
- **优化关闭按钮**:使用标准的X图标按钮,不使用圆形样式
|
||||
- **添加底部操作栏**:包含"添加到桌面"主操作按钮和"查找更多组件"链接
|
||||
- **复用阑山桌面组件库分类**:使用相同的分类ID、图标和本地化文本
|
||||
- **移除搜索功能**:参考Windows 11设计,暂不提供搜索
|
||||
|
||||
## Impact
|
||||
- 受影响文件:
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml`
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs`
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
|
||||
- `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(可能需要添加新属性)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 窗口布局重设计
|
||||
系统应提供一个类似于Windows 11小组件编辑面板的组件库窗口。
|
||||
|
||||
#### Scenario: 窗口整体结构
|
||||
- **GIVEN** 用户从托盘菜单打开融合桌面组件库
|
||||
- **WHEN** 窗口显示时
|
||||
- **THEN** 窗口应呈现:
|
||||
- 顶部标题栏:左侧显示"添加小组件"标题,右侧有关闭按钮(X)
|
||||
- 左侧面板:分类列表(复用阑山桌面组件库的分类和图标)
|
||||
- 右侧主区域:选中组件的大尺寸预览 + 组件信息 + 添加按钮
|
||||
- 底部:"查找更多组件"链接
|
||||
|
||||
#### Scenario: 分类列表交互
|
||||
- **GIVEN** 左侧显示组件分类列表
|
||||
- **WHEN** 用户点击某个分类
|
||||
- **THEN** 右侧应显示该分类下的第一个组件的预览
|
||||
- **AND** 分类项应有选中状态视觉反馈
|
||||
- **AND** 分类图标和名称应与阑山桌面组件库保持一致
|
||||
|
||||
#### Scenario: 组件预览区
|
||||
- **GIVEN** 用户选中一个组件
|
||||
- **WHEN** 预览区显示时
|
||||
- **THEN** 应显示:
|
||||
- 组件标题(大字号)
|
||||
- 大尺寸组件预览图(接近实际尺寸)
|
||||
- 组件描述/功能说明
|
||||
- 底部"添加到桌面"按钮
|
||||
|
||||
#### Scenario: 添加组件操作
|
||||
- **GIVEN** 用户查看组件预览
|
||||
- **WHEN** 用户点击"添加到桌面"按钮
|
||||
- **THEN** 组件应被添加到系统桌面(负一屏)中央
|
||||
- **AND** 窗口应关闭
|
||||
|
||||
#### Scenario: 关闭按钮样式
|
||||
- **GIVEN** 窗口标题栏有关闭按钮
|
||||
- **THEN** 关闭按钮应使用标准的X图标
|
||||
- **AND** 不使用圆形背景或特殊样式
|
||||
- **AND** 使用 `DesignCornerRadiusSm` 动态资源
|
||||
|
||||
#### Scenario: 查找更多组件链接
|
||||
- **GIVEN** 窗口底部显示"查找更多组件"链接
|
||||
- **WHEN** 用户点击该链接
|
||||
- **THEN** 应打开设置窗口的插件目录页面(后续将改为插件市场)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 组件列表展示
|
||||
原实现使用网格展示所有组件,新实现改为:
|
||||
- 左侧列表仅显示分类(复用阑山桌面组件库的分类ID和图标映射)
|
||||
- 右侧预览区一次只显示一个组件的详细信息
|
||||
- ~~移除搜索功能~~(根据Windows 11设计,暂不提供搜索)
|
||||
|
||||
### Requirement: 关闭按钮圆角规范
|
||||
原实现关闭按钮使用硬编码 `CornerRadius="18"`,应改为使用动态资源 `DesignCornerRadiusSm`。
|
||||
|
||||
### Requirement: 分类图标复用
|
||||
分类图标映射应与阑山桌面组件库保持一致:
|
||||
- Clock -> Symbol.Clock
|
||||
- Date -> Symbol.CalendarDate
|
||||
- Weather -> Symbol.WeatherSunny
|
||||
- Board -> Symbol.Edit
|
||||
- Media -> Symbol.Play
|
||||
- Info -> Symbol.Info
|
||||
- Calculator -> Symbol.Calculator
|
||||
- Study -> Symbol.Hourglass
|
||||
- 其他 -> Symbol.Apps
|
||||
|
||||
## REMOVED Requirements
|
||||
- ~~搜索功能~~:根据Windows 11小组件面板设计,暂不提供搜索功能
|
||||
35
.trae/specs/fused-desktop-library-redesign/tasks.md
Normal file
35
.trae/specs/fused-desktop-library-redesign/tasks.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Task 1: 修改 FusedDesktopComponentLibraryWindow.axaml 窗口布局
|
||||
- [x] SubTask 1.1: 重新设计标题栏,使用标准X关闭按钮,移除圆形样式,使用 DesignCornerRadiusSm
|
||||
- [x] SubTask 1.2: 调整窗口整体布局为左侧面板+右侧预览区
|
||||
- [x] SubTask 1.3: 添加底部"查找更多组件"链接区域
|
||||
|
||||
- [x] Task 2: 修改 FusedDesktopComponentLibraryControl.axaml 控件布局
|
||||
- [x] SubTask 2.1: 重新设计左侧面板:仅保留分类列表(移除搜索框)
|
||||
- [x] SubTask 2.2: 重新设计右侧预览区:组件标题 + 大尺寸预览 + 描述 + 添加按钮
|
||||
- [x] SubTask 2.3: 优化分类列表项样式,添加选中状态视觉反馈
|
||||
- [x] SubTask 2.4: 复用阑山桌面组件库的分类图标映射
|
||||
|
||||
- [x] Task 3: 更新 ViewModel 支持新交互模式
|
||||
- [x] SubTask 3.1: 在 ComponentLibraryWindowViewModel 中添加 SelectedComponent 属性
|
||||
- [x] SubTask 3.2: 添加组件描述属性支持
|
||||
|
||||
- [x] Task 4: 更新 FusedDesktopComponentLibraryControl.axaml.cs 代码逻辑
|
||||
- [x] SubTask 4.1: 修改分类选择逻辑,选中分类时显示该分类第一个组件
|
||||
- [x] SubTask 4.2: 添加组件选中逻辑
|
||||
- [x] SubTask 4.3: 移除搜索相关代码
|
||||
- [x] SubTask 4.4: 复用阑山桌面组件库的分类图标和本地化方法
|
||||
- [x] SubTask 4.5: 添加"查找更多组件"链接点击处理(打开设置窗口插件目录)
|
||||
|
||||
- [x] Task 5: 验证和测试
|
||||
- [x] SubTask 5.1: 验证关闭按钮使用动态圆角资源 DesignCornerRadiusSm
|
||||
- [x] SubTask 5.2: 验证窗口布局符合Windows 11小组件面板风格
|
||||
- [x] SubTask 5.3: 验证分类图标与阑山桌面组件库一致
|
||||
- [x] SubTask 5.4: 验证组件添加功能正常工作
|
||||
- [x] SubTask 5.5: 验证"查找更多组件"链接能打开设置窗口
|
||||
|
||||
# Task Dependencies
|
||||
- Task 3 依赖于 Task 1 和 Task 2 的UI设计确定
|
||||
- Task 4 依赖于 Task 3 的ViewModel更新
|
||||
- Task 5 依赖于所有前置任务完成
|
||||
@@ -62,7 +62,10 @@ dotnet test LanMountainDesktop.slnx -c Debug
|
||||
### UI
|
||||
|
||||
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md` 与 `docs/CORNER_RADIUS_SPEC.md`
|
||||
- **组件圆角**:所有内置与插件组件的根边框必须使用 `{DynamicResource DesignCornerRadiusComponent}` 资源。
|
||||
- **圆角规范 (AI 强制建议)**:
|
||||
- **桌面组件根容器**:必须且仅能使用 `{DynamicResource DesignCornerRadiusComponent}`。
|
||||
- **内部元素**:必须根据嵌套层级使用 `DesignCornerRadiusSm/Md/Lg` 等 Token,严禁硬编码像素值。
|
||||
- **禁止修改系数**:严禁在圆角资源上乘以任何 `scale` 变量,圆角现在由全局样式固定控制。
|
||||
- 设置页相关改动通常同时落在 `Views/`、`ViewModels/`、`Services/` 和 `.trae/specs/`
|
||||
- UI 启动与窗口生命周期主线在 `Program.cs` 和 `App.axaml.cs`
|
||||
|
||||
|
||||
110
CHANGELOG.md
Normal file
110
CHANGELOG.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 更新日志 / Changelog
|
||||
|
||||
所有重要的更改都将记录在此文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增 (Added)
|
||||
- 待发布的新功能
|
||||
|
||||
### 变更 (Changed)
|
||||
- 待发布的变更
|
||||
|
||||
### 修复 (Fixed)
|
||||
- 待发布的修复
|
||||
|
||||
### 移除 (Removed)
|
||||
- 待发布的移除项
|
||||
|
||||
---
|
||||
|
||||
## [0.8.3.2] - 2026-04-09
|
||||
|
||||
### 新增 (Added)
|
||||
- ✨ **应用启动台图标卡片显示选项**: 新增应用启动台图标卡片显示设置
|
||||
- 用户可在设置中选择是否显示应用图标的专属卡片背景
|
||||
- 关闭后仅显示应用图标本身,更加简洁
|
||||
- 支持动态切换,实时预览效果
|
||||
|
||||
### 变更 (Changed)
|
||||
- 无
|
||||
|
||||
### 修复 (Fixed)
|
||||
- 🐛 **应用启动台文件夹应用数量限制**: 修复了应用启动台文件夹无法查看超过12个应用的问题
|
||||
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
|
||||
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
|
||||
- 🐛 **电源菜单重启导致关机问题**: 修复了点击电源菜单"重启"选项却触发关机的问题
|
||||
- 问题原因: `SlideToShutDown.exe` 仅支持关机操作,不支持重启,错误地将其用于重启功能
|
||||
- 修复方案: 重启操作改为使用标准的二次确认对话框(所有平台统一),仅关机操作使用 SlideToShutDown 滑动界面
|
||||
- 🐛 **课表组件字体显示问题**: 修复了日间模式下课表组件字体颜色与背景色相近导致看不清的问题
|
||||
- 问题原因: 主题切换时增量更新逻辑未同步更新文字颜色
|
||||
- 修复方案: 在 `IncrementalUpdateItems()` 方法中同步更新课程项的文字颜色
|
||||
|
||||
### 移除 (Removed)
|
||||
- 🗑️ **更新页面重复标题**: 移除了更新页面中重复的更新标题,优化页面布局
|
||||
|
||||
---
|
||||
|
||||
## [0.8.3.1] - 2026-04-08
|
||||
|
||||
### 新增 (Added)
|
||||
- ✨ **快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
|
||||
- 支持创建快捷方式,统一管理应用和文件
|
||||
- 提供单击打开和双击打开两种交互模式
|
||||
- 支持配置是否显示背景
|
||||
- 📝 初始化更新日志文档,为后续版本发布建立基础
|
||||
|
||||
### 变更 (Changed)
|
||||
- 无
|
||||
|
||||
### 修复 (Fixed)
|
||||
- 无
|
||||
|
||||
### 移除 (Removed)
|
||||
- 无
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 版本号规则
|
||||
|
||||
本项目采用语义化版本号 `MAJOR.MINOR.PATCH.BUILD`:
|
||||
|
||||
- **MAJOR (主版本号)**: 不兼容的 API 修改
|
||||
- **MINOR (次版本号)**: 向下兼容的功能性新增
|
||||
- **PATCH (修订号)**: 向下兼容的问题修正
|
||||
- **BUILD (构建号)**: 内部构建版本,用于区分同一 PATCH 版本的不同构建
|
||||
|
||||
### 分类说明
|
||||
|
||||
- **新增 (Added)**: 新功能、新特性
|
||||
- **变更 (Changed)**: 对现有功能的变更
|
||||
- **修复 (Fixed)**: Bug 修复
|
||||
- **移除 (Removed)**: 移除的功能或特性
|
||||
|
||||
### 图例
|
||||
|
||||
- 🎉 **重大更新**: 重要功能或里程碑
|
||||
- ✨ **新功能**: 新增功能特性
|
||||
- 🐛 **Bug修复**: 问题修复
|
||||
- 🔧 **配置**: 配置相关变更
|
||||
- 📝 **文档**: 文档更新
|
||||
- 🎨 **样式**: UI/UX 改进
|
||||
- ♻️ **重构**: 代码重构
|
||||
- ⚡ **性能**: 性能优化
|
||||
- 🔒 **安全**: 安全相关
|
||||
- 🌐 **国际化**: 国际化/本地化
|
||||
|
||||
---
|
||||
|
||||
## 链接
|
||||
|
||||
[Unreleased]: https://github.com/yourorg/LanMountainDesktop/compare/v0.8.3.2...HEAD
|
||||
[0.8.3.2]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2
|
||||
[0.8.3.1]: https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1
|
||||
@@ -6,23 +6,48 @@ namespace LanMountainDesktop.Appearance;
|
||||
|
||||
public static class AppearanceCornerRadiusTokenFactory
|
||||
{
|
||||
public static AppearanceCornerRadiusTokens Create(double scale)
|
||||
public static AppearanceCornerRadiusTokens Create(string style)
|
||||
{
|
||||
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
|
||||
return new AppearanceCornerRadiusTokens(
|
||||
Radius(6, normalizedScale),
|
||||
Radius(12, normalizedScale),
|
||||
Radius(14, normalizedScale),
|
||||
Radius(20, normalizedScale),
|
||||
Radius(28, normalizedScale),
|
||||
Radius(32, normalizedScale),
|
||||
Radius(36, normalizedScale),
|
||||
Radius(18, normalizedScale));
|
||||
}
|
||||
|
||||
private static CornerRadius Radius(double value, double scale)
|
||||
{
|
||||
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
|
||||
return new CornerRadius(scaled);
|
||||
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(style);
|
||||
return normalized switch
|
||||
{
|
||||
GlobalAppearanceSettings.CornerRadiusStyleSharp => new AppearanceCornerRadiusTokens(
|
||||
Micro: new CornerRadius(4),
|
||||
Xs: new CornerRadius(8),
|
||||
Sm: new CornerRadius(10),
|
||||
Md: new CornerRadius(14),
|
||||
Lg: new CornerRadius(20),
|
||||
Xl: new CornerRadius(24),
|
||||
Island: new CornerRadius(28),
|
||||
Component: new CornerRadius(20)),
|
||||
GlobalAppearanceSettings.CornerRadiusStyleRounded => new AppearanceCornerRadiusTokens(
|
||||
Micro: new CornerRadius(8),
|
||||
Xs: new CornerRadius(14),
|
||||
Sm: new CornerRadius(16),
|
||||
Md: new CornerRadius(24),
|
||||
Lg: new CornerRadius(32),
|
||||
Xl: new CornerRadius(36),
|
||||
Island: new CornerRadius(40),
|
||||
Component: new CornerRadius(28)),
|
||||
GlobalAppearanceSettings.CornerRadiusStyleOpen => new AppearanceCornerRadiusTokens(
|
||||
Micro: new CornerRadius(10),
|
||||
Xs: new CornerRadius(16),
|
||||
Sm: new CornerRadius(20),
|
||||
Md: new CornerRadius(28),
|
||||
Lg: new CornerRadius(36),
|
||||
Xl: new CornerRadius(40),
|
||||
Island: new CornerRadius(44),
|
||||
Component: new CornerRadius(32)),
|
||||
// Balanced (default)
|
||||
_ => new AppearanceCornerRadiusTokens(
|
||||
Micro: new CornerRadius(6),
|
||||
Xs: new CornerRadius(12),
|
||||
Sm: new CornerRadius(14),
|
||||
Md: new CornerRadius(20),
|
||||
Lg: new CornerRadius(28),
|
||||
Xl: new CornerRadius(32),
|
||||
Island: new CornerRadius(36),
|
||||
Component: new CornerRadius(24))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,5 @@ public sealed record ComponentChromeContext(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
double CellSize,
|
||||
double GlobalCornerRadiusScale,
|
||||
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||
SettingsScope Scope = SettingsScope.App);
|
||||
|
||||
165
LanMountainDesktop.PluginSdk/LICENSE
Normal file
165
LanMountainDesktop.PluginSdk/LICENSE
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,6 @@ public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
||||
|
||||
Snapshot = snapshot with
|
||||
{
|
||||
GlobalCornerRadiusScale = Math.Max(0d, snapshot.GlobalCornerRadiusScale),
|
||||
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
|
||||
? "Unknown"
|
||||
: snapshot.ThemeVariant.Trim()
|
||||
@@ -20,13 +19,15 @@ public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
||||
|
||||
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
||||
{
|
||||
var scale = Snapshot.GlobalCornerRadiusScale;
|
||||
var scaled = Math.Max(0d, baseRadius) * scale;
|
||||
var scaledMin = minimum.HasValue ? minimum.Value * scale : scaled;
|
||||
var scaledMax = maximum.HasValue ? maximum.Value * scale : scaled;
|
||||
return minimum.HasValue || maximum.HasValue
|
||||
? Math.Clamp(scaled, scaledMin, scaledMax)
|
||||
: scaled;
|
||||
var value = Math.Max(0d, baseRadius);
|
||||
if (!minimum.HasValue && !maximum.HasValue)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var clampedMin = minimum ?? value;
|
||||
var clampedMax = maximum ?? value;
|
||||
return Math.Clamp(value, clampedMin, clampedMax);
|
||||
}
|
||||
|
||||
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginAppearanceSnapshot(
|
||||
double GlobalCornerRadiusScale,
|
||||
PluginCornerRadiusTokens CornerRadiusTokens,
|
||||
string ThemeVariant);
|
||||
|
||||
@@ -52,8 +52,6 @@ public sealed class PluginDesktopComponentContext
|
||||
|
||||
public IPluginAppearanceContext Appearance { get; }
|
||||
|
||||
public double GlobalCornerRadiusScale => Appearance.Snapshot.GlobalCornerRadiusScale;
|
||||
|
||||
public PluginCornerRadiusTokens CornerRadiusTokens => Appearance.Snapshot.CornerRadiusTokens;
|
||||
|
||||
public IPluginSettingsService? PluginSettings { get; }
|
||||
|
||||
@@ -2,17 +2,69 @@ namespace LanMountainDesktop.Settings.Core;
|
||||
|
||||
public static class GlobalAppearanceSettings
|
||||
{
|
||||
public const string CornerRadiusStyleSharp = "Sharp";
|
||||
public const string CornerRadiusStyleBalanced = "Balanced";
|
||||
public const string CornerRadiusStyleRounded = "Rounded";
|
||||
public const string CornerRadiusStyleOpen = "Open";
|
||||
public const string DefaultCornerRadiusStyle = CornerRadiusStyleBalanced;
|
||||
|
||||
/// <summary>
|
||||
/// Kept for backward compatibility during settings migration.
|
||||
/// New code should not reference this constant.
|
||||
/// </summary>
|
||||
public const double DefaultCornerRadiusScale = 1.0;
|
||||
public const double MinimumCornerRadiusScale = 0.0;
|
||||
public const double MaximumCornerRadiusScale = 2.50;
|
||||
|
||||
public static double NormalizeCornerRadiusScale(double value)
|
||||
public static string NormalizeCornerRadiusStyle(string? value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return DefaultCornerRadiusScale;
|
||||
return DefaultCornerRadiusStyle;
|
||||
}
|
||||
|
||||
return Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
|
||||
var trimmed = value.Trim();
|
||||
if (string.Equals(trimmed, CornerRadiusStyleSharp, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CornerRadiusStyleSharp;
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, CornerRadiusStyleBalanced, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CornerRadiusStyleBalanced;
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, CornerRadiusStyleRounded, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CornerRadiusStyleRounded;
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, CornerRadiusStyleOpen, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CornerRadiusStyleOpen;
|
||||
}
|
||||
|
||||
return DefaultCornerRadiusStyle;
|
||||
}
|
||||
|
||||
public static readonly IReadOnlyList<string> AllCornerRadiusStyles =
|
||||
[
|
||||
CornerRadiusStyleSharp,
|
||||
CornerRadiusStyleBalanced,
|
||||
CornerRadiusStyleRounded,
|
||||
CornerRadiusStyleOpen
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Backward compatibility: map previous scale values to the closest style.
|
||||
/// </summary>
|
||||
public static string MigrateScaleToStyle(double scale)
|
||||
{
|
||||
return scale switch
|
||||
{
|
||||
<= 0.60 => CornerRadiusStyleSharp,
|
||||
<= 1.20 => CornerRadiusStyleBalanced,
|
||||
<= 1.70 => CornerRadiusStyleRounded,
|
||||
_ => CornerRadiusStyleOpen
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
165
LanMountainDesktop.Shared.Contracts/LICENSE
Normal file
165
LanMountainDesktop.Shared.Contracts/LICENSE
Normal 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.
|
||||
@@ -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" />
|
||||
|
||||
@@ -11,19 +11,19 @@ namespace LanMountainDesktop.Tests;
|
||||
public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(80d, 0d)]
|
||||
[InlineData(120d, 1d)]
|
||||
[InlineData(160d, 2.5d)]
|
||||
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, double globalScale)
|
||||
[InlineData(80d, "Sharp")]
|
||||
[InlineData(120d, "Balanced")]
|
||||
[InlineData(160d, "Rounded")]
|
||||
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, string style)
|
||||
{
|
||||
var registry = new DesktopComponentRuntimeRegistry(
|
||||
ComponentRegistry.CreateDefault(),
|
||||
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
|
||||
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Component.TopLeft;
|
||||
var expected = AppearanceCornerRadiusTokenFactory.Create(style).Component.TopLeft;
|
||||
|
||||
foreach (var descriptor in registry.GetDesktopComponents())
|
||||
{
|
||||
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, globalScale));
|
||||
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, style));
|
||||
Assert.Equal(expected, resolved, 3);
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,12 @@ public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
|
||||
private static ComponentChromeContext CreateChromeContext(
|
||||
string componentId,
|
||||
double cellSize,
|
||||
double globalScale)
|
||||
string style)
|
||||
{
|
||||
return new ComponentChromeContext(
|
||||
componentId,
|
||||
null,
|
||||
cellSize,
|
||||
globalScale,
|
||||
AppearanceCornerRadiusTokenFactory.Create(globalScale));
|
||||
AppearanceCornerRadiusTokenFactory.Create(style));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class CornerRadiusScaleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(-1d, 0d)]
|
||||
[InlineData(0d, 0d)]
|
||||
[InlineData(0.33d, 0.33d)]
|
||||
[InlineData(1.234d, 1.234d)]
|
||||
[InlineData(2.5d, 2.5d)]
|
||||
[InlineData(3d, 2.5d)]
|
||||
public void NormalizeCornerRadiusScale_ClampsWithoutSnapping(double input, double expected)
|
||||
{
|
||||
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusScale(input), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeCornerRadiusScale_UsesDefaultForInvalidValues()
|
||||
{
|
||||
Assert.Equal(
|
||||
GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.NaN),
|
||||
3);
|
||||
Assert.Equal(
|
||||
GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.PositiveInfinity),
|
||||
3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginDesktopComponentContext_AllowsZeroRadiusScaling()
|
||||
{
|
||||
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: 0d,
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(12),
|
||||
new CornerRadius(14),
|
||||
new CornerRadius(20),
|
||||
new CornerRadius(28),
|
||||
new CornerRadius(32),
|
||||
new CornerRadius(36),
|
||||
new CornerRadius(8))),
|
||||
ThemeVariant: "Unknown"));
|
||||
|
||||
var context = new PluginDesktopComponentContext(
|
||||
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
|
||||
"C:\\Plugins\\plugin.id",
|
||||
"C:\\Data\\plugin.id",
|
||||
new NullServiceProvider(),
|
||||
new Dictionary<string, object?>(),
|
||||
"component-1",
|
||||
null,
|
||||
96d,
|
||||
appearanceContext);
|
||||
|
||||
Assert.Equal(0d, context.GlobalCornerRadiusScale, 3);
|
||||
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d), 3);
|
||||
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginAppearanceContext_ResolveCornerRadius_DoesNotDoubleScalePresetTokens()
|
||||
{
|
||||
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: 2d,
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(
|
||||
Micro: 12d,
|
||||
Xs: 20d,
|
||||
Sm: 28d,
|
||||
Md: 36d,
|
||||
Lg: 48d,
|
||||
Xl: 60d,
|
||||
Island: 72d,
|
||||
Component: 16d),
|
||||
ThemeVariant: "Light"));
|
||||
|
||||
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
|
||||
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 40d), 3);
|
||||
Assert.Equal(36d, context.ResolveScaledCornerRadius(18d), 3);
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
}
|
||||
71
LanMountainDesktop.Tests/CornerRadiusStyleTests.cs
Normal file
71
LanMountainDesktop.Tests/CornerRadiusStyleTests.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class CornerRadiusStyleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Sharp", "Sharp")]
|
||||
[InlineData("Balanced", "Balanced")]
|
||||
[InlineData("Rounded", "Rounded")]
|
||||
[InlineData("Open", "Open")]
|
||||
[InlineData("Unknown", "Balanced")]
|
||||
[InlineData(null, "Balanced")]
|
||||
public void NormalizeCornerRadiusStyle_ReturnsValidStyleOrDefault(string? input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusStyle(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginAppearanceContext_ResolveCornerRadius_ReturnsFixedTokenValues()
|
||||
{
|
||||
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(
|
||||
Micro: 6d,
|
||||
Xs: 12d,
|
||||
Sm: 14d,
|
||||
Md: 20d,
|
||||
Lg: 28d,
|
||||
Xl: 32d,
|
||||
Island: 36d,
|
||||
Component: 24d),
|
||||
ThemeVariant: "Light"));
|
||||
|
||||
// Preset resolution should return fixed values from tokens regardless of any legacy scale
|
||||
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
|
||||
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 15d), 3);
|
||||
Assert.Equal(20d, context.ResolveScaledCornerRadius(18d), 3);
|
||||
Assert.Equal(24d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Component), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginDesktopComponentContext_ProvidesDirectTokenAccess()
|
||||
{
|
||||
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
|
||||
ThemeVariant: "Dark"));
|
||||
|
||||
var context = new PluginDesktopComponentContext(
|
||||
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
|
||||
"C:\\Plugins\\plugin.id",
|
||||
"C:\\Data\\plugin.id",
|
||||
new NullServiceProvider(),
|
||||
new Dictionary<string, object?>(),
|
||||
"component-1",
|
||||
null,
|
||||
96d,
|
||||
appearanceContext);
|
||||
|
||||
Assert.Equal(24d, context.ResolveScaledCornerRadius(12d), 3);
|
||||
Assert.Equal(24d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Tests;
|
||||
public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
|
||||
{
|
||||
[Fact]
|
||||
public void LegacyCellSizeResolver_AppliesGlobalCornerRadiusScale()
|
||||
public void LegacyCellSizeResolver_ReturnsUnscaledFixedValue()
|
||||
{
|
||||
var registration = new DesktopComponentRuntimeRegistration(
|
||||
componentId: "test.component",
|
||||
@@ -19,41 +19,42 @@ public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.30, 10, 40));
|
||||
|
||||
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
|
||||
var resolved = resolver(CreateChromeContext(cellSize: 120, globalScale: 2.0));
|
||||
// Previously: (120 * 0.30) * 2.0 = 72.0
|
||||
// Now: (120 * 0.30) = 36.0 (No scale applied automatically by the wrapper)
|
||||
var resolved = resolver(CreateChromeContext(cellSize: 120));
|
||||
|
||||
Assert.Equal(72.0, resolved, 3);
|
||||
Assert.Equal(36.0, resolved, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChromeContextResolver_IsNotDoubleScaledByRegistrationWrapper()
|
||||
public void ChromeContextResolver_UsesTokenValue()
|
||||
{
|
||||
var registration = new DesktopComponentRuntimeRegistration(
|
||||
componentId: "test.component",
|
||||
displayNameLocalizationKey: null,
|
||||
controlFactory: _ => new Border(),
|
||||
cornerRadiusResolver: chromeContext => chromeContext.CellSize + chromeContext.GlobalCornerRadiusScale);
|
||||
cornerRadiusResolver: chromeContext => chromeContext.CornerRadiusTokens.Component.TopLeft);
|
||||
|
||||
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
|
||||
var resolved = resolver(CreateChromeContext(cellSize: 50, globalScale: 2.5));
|
||||
var resolved = resolver(CreateChromeContext(cellSize: 50));
|
||||
|
||||
Assert.Equal(52.5, resolved, 3);
|
||||
Assert.Equal(24.0, resolved, 3);
|
||||
}
|
||||
|
||||
private static ComponentChromeContext CreateChromeContext(double cellSize, double globalScale)
|
||||
private static ComponentChromeContext CreateChromeContext(double cellSize)
|
||||
{
|
||||
return new ComponentChromeContext(
|
||||
ComponentId: "test.component",
|
||||
PlacementId: null,
|
||||
CellSize: cellSize,
|
||||
GlobalCornerRadiusScale: globalScale,
|
||||
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(12),
|
||||
new CornerRadius(14),
|
||||
new CornerRadius(20),
|
||||
new CornerRadius(28),
|
||||
new CornerRadius(32),
|
||||
new CornerRadius(36),
|
||||
new CornerRadius(8)));
|
||||
Micro: new CornerRadius(6),
|
||||
Xs: new CornerRadius(12),
|
||||
Sm: new CornerRadius(14),
|
||||
Md: new CornerRadius(20),
|
||||
Lg: new CornerRadius(28),
|
||||
Xl: new CornerRadius(32),
|
||||
Island: new CornerRadius(36),
|
||||
Component: new CornerRadius(24)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,26 +48,27 @@ public sealed class InfoRecommendationHostCornerRadiusTests
|
||||
registry.TryGetDescriptor(componentId, out var descriptor),
|
||||
$"Missing runtime registration for '{componentId}'.");
|
||||
|
||||
var zero = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 0d));
|
||||
var unit = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 1d));
|
||||
var max = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 2.5d));
|
||||
var sharp = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Sharp"));
|
||||
var balanced = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Balanced"));
|
||||
var rounded = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Rounded"));
|
||||
var open = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, "Open"));
|
||||
|
||||
Assert.Equal(0d, zero, 3);
|
||||
Assert.Equal(18d, unit, 3);
|
||||
Assert.Equal(45d, max, 3);
|
||||
Assert.True(zero <= unit && unit <= max);
|
||||
// All info widgets should resolve to the Component token in the new system
|
||||
Assert.Equal(20d, sharp, 3);
|
||||
Assert.Equal(24d, balanced, 3);
|
||||
Assert.Equal(28d, rounded, 3);
|
||||
Assert.Equal(32d, open, 3);
|
||||
}
|
||||
|
||||
private static ComponentChromeContext CreateChromeContext(
|
||||
string componentId,
|
||||
double cellSize,
|
||||
double globalScale)
|
||||
string style)
|
||||
{
|
||||
return new ComponentChromeContext(
|
||||
componentId,
|
||||
null,
|
||||
cellSize,
|
||||
globalScale,
|
||||
AppearanceCornerRadiusTokenFactory.Create(globalScale));
|
||||
AppearanceCornerRadiusTokenFactory.Create(style));
|
||||
}
|
||||
}
|
||||
|
||||
113
LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs
Normal file
113
LanMountainDesktop.Tests/StudyAnalyticsServiceTests.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class StudyAnalyticsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SnapshotUpdated_UsesUiPublishThrottle()
|
||||
{
|
||||
using var recorder = new FakeAudioRecorderService();
|
||||
using var service = new StudyAnalyticsService(recorder);
|
||||
service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
|
||||
|
||||
var updateCount = 0;
|
||||
service.SnapshotUpdated += (_, _) => Interlocked.Increment(ref updateCount);
|
||||
|
||||
Assert.True(service.StartOrResumeMonitoring());
|
||||
Thread.Sleep(280);
|
||||
Assert.True(service.PauseMonitoring());
|
||||
|
||||
var totalUpdates = Volatile.Read(ref updateCount);
|
||||
Assert.InRange(totalUpdates, 2, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_ReusesRealtimeBufferSnapshot_WhenNoNewFramesArrive()
|
||||
{
|
||||
using var recorder = new FakeAudioRecorderService();
|
||||
using var service = new StudyAnalyticsService(recorder);
|
||||
service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
|
||||
|
||||
using var firstUpdate = new ManualResetEventSlim(false);
|
||||
service.SnapshotUpdated += (_, args) =>
|
||||
{
|
||||
if (args.Snapshot.RealtimeBuffer.Count > 0)
|
||||
{
|
||||
firstUpdate.Set();
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(service.StartOrResumeMonitoring());
|
||||
Assert.True(firstUpdate.Wait(TimeSpan.FromSeconds(2)));
|
||||
Assert.True(service.PauseMonitoring());
|
||||
|
||||
var firstSnapshot = service.GetSnapshot();
|
||||
var secondSnapshot = service.GetSnapshot();
|
||||
|
||||
Assert.NotEmpty(firstSnapshot.RealtimeBuffer);
|
||||
Assert.Same(firstSnapshot.RealtimeBuffer, secondSnapshot.RealtimeBuffer);
|
||||
}
|
||||
|
||||
private sealed class FakeAudioRecorderService : IAudioRecorderService
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Ready;
|
||||
|
||||
public AudioRecorderSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return new AudioRecorderSnapshot(
|
||||
State: _state,
|
||||
Duration: TimeSpan.Zero,
|
||||
InputLevel: _state == AudioRecorderRuntimeState.Recording ? 0.55 : 0,
|
||||
LastSavedFilePath: string.Empty,
|
||||
LastError: string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public bool StartOrResume()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Recording;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Pause()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Paused;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public string? StopAndSave(string? outputPath = null)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Ready;
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
|
||||
public void Discard()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Ready;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -664,7 +664,7 @@ public partial class App : Application
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
|
||||
@@ -45,4 +45,6 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
||||
public const string DesktopFileManager = "DesktopFileManager";
|
||||
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
||||
public const string DesktopShortcut = "DesktopShortcut";
|
||||
}
|
||||
|
||||
@@ -410,6 +410,26 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"消息盒子",
|
||||
"Inbox",
|
||||
"Info",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
"快捷方式",
|
||||
"App",
|
||||
"File",
|
||||
MinWidthCells: 1,
|
||||
MinHeightCells: 1,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free)
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -564,6 +564,10 @@
|
||||
"settings.launcher.hidden_type_folder": "Folder",
|
||||
"settings.launcher.hidden_type_shortcut": "App",
|
||||
"settings.launcher.restore_button": "Unhide",
|
||||
"settings.launcher.appearance_header": "Appearance",
|
||||
"settings.launcher.appearance_desc": "Customize the appearance of the App Launcher.",
|
||||
"settings.launcher.show_tile_background_header": "Show tile background",
|
||||
"settings.launcher.show_tile_background_desc": "Display a background card behind each app icon. When turned off, only the icon is shown for a cleaner look.",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
||||
@@ -1087,5 +1091,23 @@
|
||||
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
|
||||
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
|
||||
"zhijiaohub.settings.about": "About",
|
||||
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
|
||||
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally.",
|
||||
"power.menu": "Power",
|
||||
"power.title": "Power",
|
||||
"power.back": "Back",
|
||||
"power.shutdown": "Shutdown",
|
||||
"power.restart": "Restart",
|
||||
"power.logout": "Log Out",
|
||||
"power.sleep": "Sleep",
|
||||
"power.lock_screen": "Lock Screen",
|
||||
"power.shutdown_confirm_title": "Shutdown Confirmation",
|
||||
"power.shutdown_confirm_message": "Are you sure you want to shut down this computer? Unsaved data may be lost.",
|
||||
"power.restart_confirm_title": "Restart Confirmation",
|
||||
"power.restart_confirm_message": "Are you sure you want to restart this computer? Unsaved data may be lost.",
|
||||
"power.logout_confirm_title": "Log Out Confirmation",
|
||||
"power.logout_confirm_message": "Are you sure you want to log out?",
|
||||
"power.sleep_confirm_title": "Sleep Confirmation",
|
||||
"power.sleep_confirm_message": "Are you sure you want to put the computer to sleep?",
|
||||
"power.confirm_yes": "Yes",
|
||||
"power.confirm_cancel": "Cancel"
|
||||
}
|
||||
|
||||
@@ -558,6 +558,10 @@
|
||||
"settings.launcher.hidden_type_folder": "文件夹",
|
||||
"settings.launcher.hidden_type_shortcut": "应用",
|
||||
"settings.launcher.restore_button": "取消隐藏",
|
||||
"settings.launcher.appearance_header": "外观",
|
||||
"settings.launcher.appearance_desc": "自定义应用启动台的外观样式。",
|
||||
"settings.launcher.show_tile_background_header": "显示图标卡片背景",
|
||||
"settings.launcher.show_tile_background_desc": "在应用图标后显示卡片背景,关闭后仅显示图标更加简洁。",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.runtime_header": "插件运行时",
|
||||
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
||||
@@ -1081,5 +1085,23 @@
|
||||
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
|
||||
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
|
||||
"zhijiaohub.settings.about": "关于",
|
||||
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。"
|
||||
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。",
|
||||
"power.menu": "电源",
|
||||
"power.title": "电源",
|
||||
"power.back": "返回",
|
||||
"power.shutdown": "关机",
|
||||
"power.restart": "重启",
|
||||
"power.logout": "注销",
|
||||
"power.sleep": "睡眠",
|
||||
"power.lock_screen": "锁定屏幕",
|
||||
"power.shutdown_confirm_title": "关机确认",
|
||||
"power.shutdown_confirm_message": "确定要关闭计算机吗?未保存的数据可能会丢失。",
|
||||
"power.restart_confirm_title": "重启确认",
|
||||
"power.restart_confirm_message": "确定要重启计算机吗?未保存的数据可能会丢失。",
|
||||
"power.logout_confirm_title": "注销确认",
|
||||
"power.logout_confirm_message": "确定要注销当前用户吗?",
|
||||
"power.sleep_confirm_title": "睡眠确认",
|
||||
"power.sleep_confirm_message": "确定要让计算机进入睡眠状态吗?",
|
||||
"power.confirm_yes": "确定",
|
||||
"power.confirm_cancel": "取消"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,64 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
|
||||
|
||||
#region Notification Box Component Settings (消息盒子组件设置)
|
||||
|
||||
/// <summary>
|
||||
/// 组件内最大显示通知数量
|
||||
/// </summary>
|
||||
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// 排序方式:TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
|
||||
/// </summary>
|
||||
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示应用图标
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowAppIcon { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示时间戳
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowTimestamp { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 时间格式:Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
|
||||
/// </summary>
|
||||
public string NotificationBoxTimeFormat { get; set; } = "Relative";
|
||||
|
||||
/// <summary>
|
||||
/// 是否按应用分组显示
|
||||
/// </summary>
|
||||
public bool NotificationBoxGroupByApp { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示清除按钮
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowClearButton { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shortcut Component Settings (快捷方式组件设置)
|
||||
|
||||
/// <summary>
|
||||
/// 快捷方式目标路径
|
||||
/// </summary>
|
||||
public string? ShortcutTargetPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 点击模式:Single(单击打开) 或 Double(双击打开)
|
||||
/// </summary>
|
||||
public string ShortcutClickMode { get; set; } = "Double";
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示背景
|
||||
/// </summary>
|
||||
public bool ShortcutShowBackground { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
|
||||
@@ -8,6 +8,8 @@ public sealed class LauncherSettingsSnapshot
|
||||
|
||||
public List<string> HiddenLauncherAppPaths { get; set; } = [];
|
||||
|
||||
public bool ShowTileBackground { get; set; } = true;
|
||||
|
||||
public LauncherSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (LauncherSettingsSnapshot)MemberwiseClone();
|
||||
|
||||
54
LanMountainDesktop/Models/NotificationItem.cs
Normal file
54
LanMountainDesktop/Models/NotificationItem.cs
Normal 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; }
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public enum StudyDataMode
|
||||
|
||||
public sealed record StudyAnalyticsConfig(
|
||||
int FrameMs = 50,
|
||||
int UiPublishIntervalMs = 125,
|
||||
int SliceSec = 30,
|
||||
double ScoreThresholdDbfs = -50,
|
||||
int SegmentMergeGapMs = 500,
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed record AppearanceThemeSnapshot(
|
||||
string ThemeColorMode,
|
||||
string? UserThemeColor,
|
||||
string? SelectedWallpaperSeed,
|
||||
double GlobalCornerRadiusScale,
|
||||
string CornerRadiusStyle,
|
||||
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||
string ResolvedSeedSource,
|
||||
MonetPalette MonetPalette,
|
||||
@@ -551,7 +551,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
if (!refreshAll &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) &&
|
||||
!changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) &&
|
||||
!(respondsToThemeColor &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
|
||||
!(respondsToWallpaper &&
|
||||
@@ -573,8 +573,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
bool queueWallpaperPaletteBuild)
|
||||
{
|
||||
var availableModes = _windowMaterialService.GetAvailableModes();
|
||||
var globalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(themeState.GlobalCornerRadiusScale);
|
||||
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(globalCornerRadiusScale);
|
||||
var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(themeState.CornerRadiusStyle);
|
||||
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(cornerRadiusStyle);
|
||||
MonetPalette palette;
|
||||
IReadOnlyList<Color> wallpaperSeedCandidates;
|
||||
Color effectiveSeedColor;
|
||||
@@ -614,7 +614,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
|
||||
themeColorMode,
|
||||
themeState.ThemeColor,
|
||||
selectedWallpaperSeed,
|
||||
globalCornerRadiusScale,
|
||||
cornerRadiusStyle,
|
||||
cornerRadiusTokens,
|
||||
resolvedSeedSource,
|
||||
palette,
|
||||
|
||||
@@ -267,7 +267,17 @@ public static class DesktopComponentEditorRegistryFactory
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
context => new ZhiJiaoHubComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d)
|
||||
preferredHeight: 520d),
|
||||
[BuiltInComponentIds.DesktopNotificationBox] = new(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
context => new NotificationBoxComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d),
|
||||
[BuiltInComponentIds.DesktopShortcut] = new(
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
context => new ShortcutComponentEditor(context),
|
||||
preferredWidth: 420d,
|
||||
preferredHeight: 400d)
|
||||
};
|
||||
|
||||
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
|
||||
|
||||
@@ -129,7 +129,6 @@ public static class DesktopComponentRegistryFactory
|
||||
settingsService);
|
||||
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||
var pluginAppearance = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(appearanceSnapshot.CornerRadiusTokens),
|
||||
ThemeVariant: appearanceSnapshot.IsNightMode ? "Dark" : "Light"));
|
||||
var pluginContext = new PluginDesktopComponentContext(
|
||||
@@ -157,7 +156,6 @@ public static class DesktopComponentRegistryFactory
|
||||
private static IPluginAppearanceContext CreatePluginAppearanceContext(ComponentChromeContext chromeContext)
|
||||
{
|
||||
return new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: chromeContext.GlobalCornerRadiusScale,
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(chromeContext.CornerRadiusTokens),
|
||||
ThemeVariant: "Unknown"));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ public sealed record ComponentLibraryCategoryEntry(
|
||||
|
||||
public sealed record ComponentLibraryCreateContext(
|
||||
double CellSize,
|
||||
double GlobalCornerRadiusScale,
|
||||
TimeZoneService TimeZoneService,
|
||||
IWeatherInfoService WeatherInfoService,
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
|
||||
216
LanMountainDesktop/Services/LinuxNotificationListener.cs
Normal file
216
LanMountainDesktop/Services/LinuxNotificationListener.cs
Normal 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("<", "<");
|
||||
result = result.Replace(">", ">");
|
||||
result = result.Replace("&", "&");
|
||||
result = result.Replace(""", "\"");
|
||||
return result.Trim();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_isRunning = false;
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
201
LanMountainDesktop/Services/NotificationListenerService.cs
Normal file
201
LanMountainDesktop/Services/NotificationListenerService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
245
LanMountainDesktop/Services/PowerManagementService.cs
Normal file
245
LanMountainDesktop/Services/PowerManagementService.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IPowerManagementService
|
||||
{
|
||||
bool IsShutdownSupported { get; }
|
||||
bool IsRestartSupported { get; }
|
||||
bool IsLogoutSupported { get; }
|
||||
bool IsLockSupported { get; }
|
||||
bool IsSleepSupported { get; }
|
||||
|
||||
Task ShutdownAsync();
|
||||
Task RestartAsync();
|
||||
Task LogoutAsync();
|
||||
Task LockAsync();
|
||||
Task SleepAsync();
|
||||
|
||||
void ShowNativePowerUI(PowerAction action);
|
||||
}
|
||||
|
||||
public enum PowerAction
|
||||
{
|
||||
Shutdown,
|
||||
Restart
|
||||
}
|
||||
|
||||
public static class PowerManagementServiceFactory
|
||||
{
|
||||
private static IPowerManagementService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IPowerManagementService GetOrCreate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _instance ??= CreatePlatformService();
|
||||
}
|
||||
}
|
||||
|
||||
private static IPowerManagementService CreatePlatformService()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return new WindowsPowerManagementService();
|
||||
if (OperatingSystem.IsLinux())
|
||||
return new LinuxPowerManagementService();
|
||||
return new NullPowerManagementService();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WindowsPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => true;
|
||||
public bool IsRestartSupported => true;
|
||||
public bool IsLogoutSupported => true;
|
||||
public bool IsLockSupported => true;
|
||||
public bool IsSleepSupported => true;
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/s /t 0",
|
||||
UseShellExecute = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/r /t 0",
|
||||
UseShellExecute = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
ExitWindowsEx(0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LockAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
LockWorkStation();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SleepAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
SetSuspendState(false, false, false);
|
||||
});
|
||||
}
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action)
|
||||
{
|
||||
// SlideToShutDown.exe 只支持关机,不支持重启
|
||||
// 重启操作应该通过 RestartAsync() 使用 shutdown /r 命令
|
||||
if (action != PowerAction.Shutdown)
|
||||
return;
|
||||
|
||||
var slideToShutDownPath = Environment.ExpandEnvironmentVariables(@"%windir%\System32\SlideToShutDown.exe");
|
||||
if (System.IO.File.Exists(slideToShutDownPath))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = slideToShutDownPath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 回退到标准关机命令
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/s /t 5 /c \"LanMountainDesktop: Shutting down...\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool ExitWindowsEx(uint uFlags, uint dwReason);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void LockWorkStation();
|
||||
|
||||
[DllImport("powrprof.dll", SetLastError = true)]
|
||||
private static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);
|
||||
}
|
||||
|
||||
internal sealed class LinuxPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => true;
|
||||
public bool IsRestartSupported => true;
|
||||
public bool IsLogoutSupported => true;
|
||||
public bool IsLockSupported => true;
|
||||
public bool IsSleepSupported => true;
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
await RunSystemctlCommand("poweroff -i");
|
||||
}
|
||||
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
await RunSystemctlCommand("reboot -i");
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
await RunLoginctlCommand("terminate-session $XDG_SESSION_ID");
|
||||
}
|
||||
|
||||
public async Task LockAsync()
|
||||
{
|
||||
await RunLoginctlCommand("lock-session");
|
||||
}
|
||||
|
||||
public async Task SleepAsync()
|
||||
{
|
||||
await RunSystemctlCommand("suspend -i");
|
||||
}
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case PowerAction.Shutdown:
|
||||
RunProcess("systemctl", "poweroff -i");
|
||||
break;
|
||||
case PowerAction.Restart:
|
||||
RunProcess("systemctl", "reboot -i");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunSystemctlCommand(string args)
|
||||
{
|
||||
await RunProcess("systemctl", args);
|
||||
}
|
||||
|
||||
private static async Task RunLoginctlCommand(string args)
|
||||
{
|
||||
await RunProcess("loginctl", args);
|
||||
}
|
||||
|
||||
private static async Task RunProcess(string command, string args)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
})?.WaitForExit(5000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Error("LinuxPowerManagement", $"Failed to execute {command} {args}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => false;
|
||||
public bool IsRestartSupported => false;
|
||||
public bool IsLogoutSupported => false;
|
||||
public bool IsLockSupported => false;
|
||||
public bool IsSleepSupported => false;
|
||||
|
||||
public Task ShutdownAsync() => Task.CompletedTask;
|
||||
public Task RestartAsync() => Task.CompletedTask;
|
||||
public Task LogoutAsync() => Task.CompletedTask;
|
||||
public Task LockAsync() => Task.CompletedTask;
|
||||
public Task SleepAsync() => Task.CompletedTask;
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action) { }
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public sealed record ThemeAppearanceSettingsState(
|
||||
bool IsNightMode,
|
||||
string? ThemeColor,
|
||||
bool UseSystemChrome,
|
||||
double GlobalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
string CornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle,
|
||||
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
|
||||
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
|
||||
string? SelectedWallpaperSeed = null);
|
||||
|
||||
@@ -254,11 +254,19 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
public ThemeAppearanceSettingsState Get()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(snapshot.CornerRadiusStyle);
|
||||
if (string.Equals(cornerRadiusStyle, GlobalAppearanceSettings.DefaultCornerRadiusStyle, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.IsNullOrWhiteSpace(snapshot.CornerRadiusStyle) &&
|
||||
Math.Abs(snapshot.GlobalCornerRadiusScale - GlobalAppearanceSettings.DefaultCornerRadiusScale) > 0.01)
|
||||
{
|
||||
cornerRadiusStyle = GlobalAppearanceSettings.MigrateScaleToStyle(snapshot.GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
return new ThemeAppearanceSettingsState(
|
||||
snapshot.IsNightMode ?? false,
|
||||
snapshot.ThemeColor,
|
||||
snapshot.UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale),
|
||||
cornerRadiusStyle,
|
||||
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
|
||||
snapshot.SelectedWallpaperSeed);
|
||||
@@ -269,7 +277,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
var snapshot = _settingsService.Load();
|
||||
var changedKeys = new List<string>();
|
||||
var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor;
|
||||
var normalizedCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(state.GlobalCornerRadiusScale);
|
||||
var normalizedCornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(state.CornerRadiusStyle);
|
||||
var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor);
|
||||
var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode);
|
||||
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
|
||||
@@ -294,10 +302,10 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome));
|
||||
}
|
||||
|
||||
if (Math.Abs(GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale) - normalizedCornerRadiusScale) > 0.0001d)
|
||||
if (!string.Equals(GlobalAppearanceSettings.NormalizeCornerRadiusStyle(snapshot.CornerRadiusStyle), normalizedCornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
snapshot.GlobalCornerRadiusScale = normalizedCornerRadiusScale;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale));
|
||||
snapshot.CornerRadiusStyle = normalizedCornerRadiusStyle;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.CornerRadiusStyle));
|
||||
}
|
||||
|
||||
if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -12,8 +12,13 @@ internal readonly record struct NoisePipelineTickResult(
|
||||
internal sealed class NoiseFramePipeline
|
||||
{
|
||||
private StudyAnalyticsConfig _config;
|
||||
private readonly Queue<NoiseRealtimePoint> _realtimeBuffer = new();
|
||||
private readonly List<NoiseRealtimePoint> _slicePoints = [];
|
||||
private NoiseRealtimePoint[] _realtimeBuffer;
|
||||
private IReadOnlyList<NoiseRealtimePoint> _realtimeSnapshot = Array.Empty<NoiseRealtimePoint>();
|
||||
private int _realtimeBufferStart;
|
||||
private int _realtimeBufferCount;
|
||||
private int _realtimeBufferVersion;
|
||||
private int _realtimeSnapshotVersion = -1;
|
||||
|
||||
private DateTimeOffset _sliceStartAt;
|
||||
private DateTimeOffset _lastFrameAt;
|
||||
@@ -28,18 +33,29 @@ internal sealed class NoiseFramePipeline
|
||||
public NoiseFramePipeline(StudyAnalyticsConfig config)
|
||||
{
|
||||
_config = NormalizeConfig(config);
|
||||
_realtimeBuffer = new NoiseRealtimePoint[_config.RealtimeBufferCapacity];
|
||||
}
|
||||
|
||||
public void UpdateConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
_config = NormalizeConfig(config);
|
||||
var normalized = NormalizeConfig(config);
|
||||
if (normalized.RealtimeBufferCapacity != _config.RealtimeBufferCapacity)
|
||||
{
|
||||
_realtimeBuffer = new NoiseRealtimePoint[normalized.RealtimeBufferCapacity];
|
||||
}
|
||||
|
||||
_config = normalized;
|
||||
Reset();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_realtimeBuffer.Clear();
|
||||
_slicePoints.Clear();
|
||||
_realtimeBufferStart = 0;
|
||||
_realtimeBufferCount = 0;
|
||||
_realtimeBufferVersion++;
|
||||
_realtimeSnapshot = Array.Empty<NoiseRealtimePoint>();
|
||||
_realtimeSnapshotVersion = -1;
|
||||
_sliceStartAt = default;
|
||||
_lastFrameAt = default;
|
||||
_lastOverThresholdAt = default;
|
||||
@@ -52,7 +68,27 @@ internal sealed class NoiseFramePipeline
|
||||
|
||||
public IReadOnlyList<NoiseRealtimePoint> GetRealtimeBufferSnapshot()
|
||||
{
|
||||
return _realtimeBuffer.ToArray();
|
||||
if (_realtimeBufferCount == 0)
|
||||
{
|
||||
return Array.Empty<NoiseRealtimePoint>();
|
||||
}
|
||||
|
||||
if (_realtimeSnapshotVersion == _realtimeBufferVersion)
|
||||
{
|
||||
return _realtimeSnapshot;
|
||||
}
|
||||
|
||||
var snapshot = new NoiseRealtimePoint[_realtimeBufferCount];
|
||||
var firstSegmentLength = Math.Min(_realtimeBufferCount, _realtimeBuffer.Length - _realtimeBufferStart);
|
||||
Array.Copy(_realtimeBuffer, _realtimeBufferStart, snapshot, 0, firstSegmentLength);
|
||||
if (firstSegmentLength < _realtimeBufferCount)
|
||||
{
|
||||
Array.Copy(_realtimeBuffer, 0, snapshot, firstSegmentLength, _realtimeBufferCount - firstSegmentLength);
|
||||
}
|
||||
|
||||
_realtimeSnapshot = snapshot;
|
||||
_realtimeSnapshotVersion = _realtimeBufferVersion;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public NoisePipelineTickResult AddFrame(DateTimeOffset timestamp, double rms, double dbfs, double displayDb, double peak)
|
||||
@@ -114,12 +150,7 @@ internal sealed class NoiseFramePipeline
|
||||
peak,
|
||||
isOverThreshold);
|
||||
_slicePoints.Add(point);
|
||||
_realtimeBuffer.Enqueue(point);
|
||||
|
||||
while (_realtimeBuffer.Count > _config.RealtimeBufferCapacity)
|
||||
{
|
||||
_realtimeBuffer.Dequeue();
|
||||
}
|
||||
AddRealtimePoint(point);
|
||||
|
||||
var elapsedSeconds = (timestamp - _sliceStartAt).TotalSeconds;
|
||||
if (elapsedSeconds + 1e-6 < _config.SliceSec)
|
||||
@@ -132,6 +163,29 @@ internal sealed class NoiseFramePipeline
|
||||
return new NoisePipelineTickResult(point, slice);
|
||||
}
|
||||
|
||||
private void AddRealtimePoint(NoiseRealtimePoint point)
|
||||
{
|
||||
if (_realtimeBuffer.Length == 0)
|
||||
{
|
||||
_realtimeBuffer = new NoiseRealtimePoint[Math.Max(1, _config.RealtimeBufferCapacity)];
|
||||
}
|
||||
|
||||
if (_realtimeBufferCount < _realtimeBuffer.Length)
|
||||
{
|
||||
var writeIndex = (_realtimeBufferStart + _realtimeBufferCount) % _realtimeBuffer.Length;
|
||||
_realtimeBuffer[writeIndex] = point;
|
||||
_realtimeBufferCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_realtimeBuffer[_realtimeBufferStart] = point;
|
||||
_realtimeBufferStart = (_realtimeBufferStart + 1) % _realtimeBuffer.Length;
|
||||
}
|
||||
|
||||
_realtimeBufferVersion++;
|
||||
_realtimeSnapshotVersion = -1;
|
||||
}
|
||||
|
||||
private NoiseSliceSummary BuildClosedSlice(DateTimeOffset endAt)
|
||||
{
|
||||
var sampledDurationMs = _slicePoints.Count * _config.FrameMs;
|
||||
@@ -247,6 +301,7 @@ internal sealed class NoiseFramePipeline
|
||||
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||
var uiPublishIntervalMs = Math.Clamp(config.UiPublishIntervalMs, 50, 500);
|
||||
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||
@@ -259,6 +314,7 @@ internal sealed class NoiseFramePipeline
|
||||
return config with
|
||||
{
|
||||
FrameMs = frameMs,
|
||||
UiPublishIntervalMs = uiPublishIntervalMs,
|
||||
SliceSec = sliceSec,
|
||||
ScoreThresholdDbfs = threshold,
|
||||
SegmentMergeGapMs = mergeGapMs,
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
private readonly List<StudySessionReport> _sessionHistory = [];
|
||||
private string? _selectedSessionReportId;
|
||||
private string _lastError = string.Empty;
|
||||
private DateTimeOffset _lastUiPublishedAt;
|
||||
private bool _disposed;
|
||||
|
||||
public StudyAnalyticsService(IAudioRecorderService? audioRecorderService = null)
|
||||
@@ -102,6 +103,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
ThrowIfDisposedLocked();
|
||||
_config = NormalizeConfig(config);
|
||||
_pipeline.UpdateConfig(_config);
|
||||
_lastUiPublishedAt = default;
|
||||
if (_state == StudyAnalyticsRuntimeState.Running)
|
||||
{
|
||||
StartTimerLocked();
|
||||
@@ -546,7 +548,11 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
|
||||
_lastError = string.Empty;
|
||||
UpdateDataModeLocked();
|
||||
snapshot = BuildSnapshotLocked(now);
|
||||
if (ShouldPublishRealtimeSnapshotLocked(now, closedSlice is not null))
|
||||
{
|
||||
snapshot = BuildSnapshotLocked(now);
|
||||
_lastUiPublishedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,6 +605,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
|
||||
private void StartTimerLocked()
|
||||
{
|
||||
_lastUiPublishedAt = default;
|
||||
_samplingTimer.Change(
|
||||
dueTime: TimeSpan.Zero,
|
||||
period: TimeSpan.FromMilliseconds(_config.FrameMs));
|
||||
@@ -673,6 +680,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
private static StudyAnalyticsConfig NormalizeConfig(StudyAnalyticsConfig config)
|
||||
{
|
||||
var frameMs = Math.Clamp(config.FrameMs, 20, 250);
|
||||
var uiPublishIntervalMs = Math.Clamp(config.UiPublishIntervalMs, 50, 500);
|
||||
var sliceSec = Math.Clamp(config.SliceSec, 5, 600);
|
||||
var threshold = Math.Clamp(config.ScoreThresholdDbfs, -100, -5);
|
||||
var mergeGapMs = Math.Clamp(config.SegmentMergeGapMs, 100, 4000);
|
||||
@@ -685,6 +693,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
return config with
|
||||
{
|
||||
FrameMs = frameMs,
|
||||
UiPublishIntervalMs = uiPublishIntervalMs,
|
||||
SliceSec = sliceSec,
|
||||
ScoreThresholdDbfs = threshold,
|
||||
SegmentMergeGapMs = mergeGapMs,
|
||||
@@ -696,6 +705,16 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
};
|
||||
}
|
||||
|
||||
private bool ShouldPublishRealtimeSnapshotLocked(DateTimeOffset now, bool hasClosedSlice)
|
||||
{
|
||||
if (hasClosedSlice || _lastUiPublishedAt == default)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return (now - _lastUiPublishedAt).TotalMilliseconds >= _config.UiPublishIntervalMs;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposedLocked()
|
||||
{
|
||||
if (_disposed)
|
||||
|
||||
@@ -222,10 +222,37 @@
|
||||
</Style>
|
||||
|
||||
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||
<Style Selector="Border.glass-panel" />
|
||||
<Style Selector="Border.glass-strong" />
|
||||
<Style Selector="Border.glass-island" />
|
||||
<Style Selector="Border.mica-strong" />
|
||||
<Style Selector="Border.glass-overlay" />
|
||||
<Style Selector="Border.glass-panel">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.2" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-island">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.mica-strong">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
|
||||
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.glass-overlay">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace LanMountainDesktop.ViewModels;
|
||||
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
|
||||
{
|
||||
private string _title = "Widgets";
|
||||
private ComponentLibraryItemViewModel? _selectedComponent;
|
||||
|
||||
public string Title
|
||||
{
|
||||
@@ -20,6 +21,12 @@ public sealed class ComponentLibraryWindowViewModel : ViewModelBase
|
||||
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
|
||||
|
||||
public ObservableCollection<ComponentLibraryItemViewModel> Components { get; } = [];
|
||||
|
||||
public ComponentLibraryItemViewModel? SelectedComponent
|
||||
{
|
||||
get => _selectedComponent;
|
||||
set => SetProperty(ref _selectedComponent, value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ComponentLibraryCategoryViewModel
|
||||
@@ -51,6 +58,7 @@ public sealed class ComponentLibraryItemViewModel
|
||||
private readonly string _loadingPreviewText;
|
||||
private readonly string _previewUnavailableText;
|
||||
private string _displayName;
|
||||
private string? _description;
|
||||
private ComponentPreviewKey _previewKey;
|
||||
private ComponentPreviewImageEntry? _previewImageEntry;
|
||||
private ComponentPreviewImageState _previewState;
|
||||
@@ -61,12 +69,14 @@ public sealed class ComponentLibraryItemViewModel
|
||||
string componentId,
|
||||
string displayName,
|
||||
ComponentPreviewKey previewKey,
|
||||
string? description = null,
|
||||
string loadingPreviewText = "Loading preview...",
|
||||
string previewUnavailableText = "Preview unavailable",
|
||||
ComponentPreviewImageEntry? previewImageEntry = null)
|
||||
{
|
||||
ComponentId = componentId;
|
||||
_displayName = displayName;
|
||||
_description = description;
|
||||
_previewKey = previewKey;
|
||||
_loadingPreviewText = loadingPreviewText;
|
||||
_previewUnavailableText = previewUnavailableText;
|
||||
@@ -82,6 +92,12 @@ public sealed class ComponentLibraryItemViewModel
|
||||
set => SetProperty(ref _displayName, value);
|
||||
}
|
||||
|
||||
public string? Description
|
||||
{
|
||||
get => _description;
|
||||
set => SetProperty(ref _description, value);
|
||||
}
|
||||
|
||||
public ComponentPreviewKey PreviewKey
|
||||
{
|
||||
get => _previewKey;
|
||||
|
||||
@@ -117,6 +117,36 @@ public sealed partial class LauncherSettingsPageViewModel : ViewModelBase, IDisp
|
||||
[ObservableProperty]
|
||||
private bool _isHiddenItemsEmpty = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _appearanceHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _appearanceDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _showTileBackgroundHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _showTileBackgroundDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showTileBackground;
|
||||
|
||||
partial void OnShowTileBackgroundChanged(bool value)
|
||||
{
|
||||
SaveShowTileBackgroundSetting(value);
|
||||
}
|
||||
|
||||
private void SaveShowTileBackgroundSetting(bool value)
|
||||
{
|
||||
var snapshot = _settingsFacade.LauncherPolicy.Get()?.Clone() ?? new LauncherSettingsSnapshot();
|
||||
snapshot.ShowTileBackground = value;
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.Launcher,
|
||||
snapshot,
|
||||
changedKeys: [nameof(LauncherSettingsSnapshot.ShowTileBackground)]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -157,6 +187,8 @@ public sealed partial class LauncherSettingsPageViewModel : ViewModelBase, IDisp
|
||||
ResolveCulture(),
|
||||
L("settings.launcher.hidden_summary_format", "{0} hidden items"),
|
||||
HiddenItems.Count);
|
||||
|
||||
ShowTileBackground = snapshot.ShowTileBackground;
|
||||
}
|
||||
|
||||
private StartMenuFolderNode LoadCatalogSafe()
|
||||
@@ -317,6 +349,10 @@ public sealed partial class LauncherSettingsPageViewModel : ViewModelBase, IDisp
|
||||
HiddenDescription = L("settings.launcher.hidden_desc", "Review hidden launcher entries and show them again.");
|
||||
HiddenHint = L("settings.launcher.hidden_hint", "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.");
|
||||
HiddenEmptyText = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||
AppearanceHeader = L("settings.launcher.appearance_header", "Appearance");
|
||||
AppearanceDescription = L("settings.launcher.appearance_desc", "Customize the appearance of the App Launcher.");
|
||||
ShowTileBackgroundHeader = L("settings.launcher.show_tile_background_header", "Show tile background");
|
||||
ShowTileBackgroundDescription = L("settings.launcher.show_tile_background_desc", "Display a background card behind each app icon in the launcher.");
|
||||
}
|
||||
|
||||
private CultureInfo ResolveCulture()
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
115
LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs
Normal file
115
LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs
Normal 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();
|
||||
}
|
||||
@@ -614,10 +614,10 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
private string _systemMaterialLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusLabel = string.Empty;
|
||||
private string _cornerRadiusStyleLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusDescription = string.Empty;
|
||||
private string _cornerRadiusStyleDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _themeHeader = string.Empty;
|
||||
@@ -701,6 +701,15 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public IBrush NeutralDarkPreviewBrush => NeutralDarkBrushValue;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption? _selectedCornerRadiusStyle;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var theme = _settingsFacade.Theme.Get();
|
||||
@@ -740,29 +749,14 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
partial void OnGlobalCornerRadiusScaleChanged(double value)
|
||||
partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
|
||||
if (Math.Abs(normalized - value) > 0.0001d)
|
||||
{
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
GlobalCornerRadiusScale = normalized;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
CornerRadiusStyle = value.Value;
|
||||
PersistCurrentState(restartRequired: false);
|
||||
}
|
||||
|
||||
@@ -830,8 +824,12 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
|
||||
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
|
||||
SystemMaterialLabel = L("settings.appearance.system_material_label", "System material");
|
||||
GlobalCornerRadiusLabel = L("settings.appearance.corner_radius.label", "Global corner radius");
|
||||
GlobalCornerRadiusDescription = L("settings.appearance.corner_radius.description", "Adjust the shared radius scale used by cards, panels, and component containers.");
|
||||
CornerRadiusStyleLabel = L("settings.appearance.corner_radius.label", "Global corner radius style");
|
||||
CornerRadiusStyleDescription = L("settings.appearance.corner_radius.description", "Select a fixed corner radius style inspired by Xiaomi HyperOS.");
|
||||
|
||||
CornerRadiusStyleOptions = GlobalAppearanceSettings.AllCornerRadiusStyles
|
||||
.Select(style => new SelectionOption(style, L($"settings.appearance.corner_radius.style_{style.ToLower()}", style)))
|
||||
.ToList();
|
||||
ThemeSourceNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral");
|
||||
ThemeSourceUserColorText = L("settings.appearance.theme_color_mode.user", "User theme color Monet");
|
||||
ThemeSourceWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet");
|
||||
@@ -876,7 +874,10 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
IsNightMode = theme.IsNightMode;
|
||||
ThemeColor = theme.ThemeColor ?? string.Empty;
|
||||
UseSystemChrome = theme.UseSystemChrome;
|
||||
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
|
||||
CornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(theme.CornerRadiusStyle);
|
||||
SelectedCornerRadiusStyle = CornerRadiusStyleOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, CornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
|
||||
?? CornerRadiusStyleOptions.FirstOrDefault(o => o.Value == GlobalAppearanceSettings.DefaultCornerRadiusStyle);
|
||||
_selectedWallpaperSeed = theme.SelectedWallpaperSeed;
|
||||
SyncCustomSeedPickerWithSavedThemeColor();
|
||||
|
||||
@@ -926,7 +927,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
|
||||
IsNightMode,
|
||||
themeColor,
|
||||
UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
|
||||
themeColorMode,
|
||||
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
|
||||
_selectedWallpaperSeed);
|
||||
@@ -1070,20 +1071,22 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
private string _spacingPresetLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _globalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale;
|
||||
private string _cornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle;
|
||||
|
||||
public double GlobalCornerRadiusMinimum => GlobalAppearanceSettings.MinimumCornerRadiusScale;
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<SelectionOption> _cornerRadiusStyleOptions = [];
|
||||
|
||||
public double GlobalCornerRadiusMaximum => GlobalAppearanceSettings.MaximumCornerRadiusScale;
|
||||
[ObservableProperty]
|
||||
private SelectionOption? _selectedCornerRadiusStyle;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _componentRadiusHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusLabel = string.Empty;
|
||||
private string _cornerRadiusStyleLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _globalCornerRadiusDescription = string.Empty;
|
||||
private string _cornerRadiusStyleDescription = string.Empty;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
@@ -1096,7 +1099,10 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
?? SpacingPresets[1];
|
||||
|
||||
var theme = _settingsFacade.Theme.Get();
|
||||
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
|
||||
CornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(theme.CornerRadiusStyle);
|
||||
SelectedCornerRadiusStyle = CornerRadiusStyleOptions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, CornerRadiusStyle, StringComparison.OrdinalIgnoreCase))
|
||||
?? CornerRadiusStyleOptions.FirstOrDefault(o => o.Value == GlobalAppearanceSettings.DefaultCornerRadiusStyle);
|
||||
}
|
||||
|
||||
partial void OnShortSideCellsChanged(int value)
|
||||
@@ -1129,29 +1135,14 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
SaveGrid();
|
||||
}
|
||||
|
||||
partial void OnGlobalCornerRadiusScaleChanged(double value)
|
||||
partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
|
||||
if (Math.Abs(normalized - value) > 0.0001d)
|
||||
{
|
||||
_isInitializing = true;
|
||||
try
|
||||
{
|
||||
GlobalCornerRadiusScale = normalized;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
CornerRadiusStyle = value.Value;
|
||||
SaveComponentCornerRadius();
|
||||
}
|
||||
|
||||
@@ -1170,7 +1161,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
theme.IsNightMode,
|
||||
theme.ThemeColor,
|
||||
theme.UseSystemChrome,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
|
||||
theme.ThemeColorMode,
|
||||
theme.SystemMaterialMode,
|
||||
theme.SelectedWallpaperSeed));
|
||||
@@ -1194,10 +1185,14 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
|
||||
EdgeInsetPercentLabel = L("settings.components.edge_inset_label", "Screen Inset");
|
||||
SpacingPresetLabel = L("settings.components.spacing_label", "Component Spacing");
|
||||
ComponentRadiusHeader = L("settings.components.corner_radius.header", "Corner Design");
|
||||
GlobalCornerRadiusLabel = L("settings.components.corner_radius.label", "Component Corner Radius");
|
||||
GlobalCornerRadiusDescription = L(
|
||||
CornerRadiusStyleLabel = L("settings.components.corner_radius.label", "Component Corner Radius Style");
|
||||
CornerRadiusStyleDescription = L(
|
||||
"settings.components.corner_radius.description",
|
||||
"Adjust the shared corner radius from a square edge to a capsule-like shape, and expand the internal safe area with it.");
|
||||
"Select a fixed corner radius style (inspired by Xiaomi HyperOS) to ensure consistency across all components.");
|
||||
|
||||
CornerRadiusStyleOptions = GlobalAppearanceSettings.AllCornerRadiusStyles
|
||||
.Select(style => new SelectionOption(style, L($"settings.appearance.corner_radius.style_{style.ToLower()}", style)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
|
||||
87
LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs
Normal file
87
LanMountainDesktop/ViewModels/ShortcutEditorViewModel.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed partial class ShortcutEditorViewModel : ViewModelBase
|
||||
{
|
||||
private readonly DesktopComponentEditorContext? _context;
|
||||
private bool _isInitializing;
|
||||
|
||||
public ShortcutEditorViewModel(DesktopComponentEditorContext? context)
|
||||
{
|
||||
_context = context;
|
||||
|
||||
ClickModeOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("Double", "双击打开"),
|
||||
new("Single", "单击打开")
|
||||
};
|
||||
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>()
|
||||
?? new ComponentSettingsSnapshot();
|
||||
|
||||
_isInitializing = true;
|
||||
|
||||
TargetPath = snapshot.ShortcutTargetPath ?? string.Empty;
|
||||
SelectedClickMode = ClickModeOptions.FirstOrDefault(o => o.Value == snapshot.ShortcutClickMode)
|
||||
?? ClickModeOptions[0];
|
||||
ShowBackground = snapshot.ShortcutShowBackground;
|
||||
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
if (_isInitializing || _context == null) return;
|
||||
|
||||
var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
|
||||
snapshot.ShortcutTargetPath = string.IsNullOrWhiteSpace(TargetPath) ? null : TargetPath;
|
||||
snapshot.ShortcutClickMode = SelectedClickMode?.Value ?? "Double";
|
||||
snapshot.ShortcutShowBackground = ShowBackground;
|
||||
|
||||
_context.ComponentSettingsAccessor.SaveSnapshot(snapshot);
|
||||
|
||||
_context.HostContext.RequestRefresh();
|
||||
}
|
||||
|
||||
[ObservableProperty] private string _descriptionText = "配置此快捷方式组件的目标路径和打开方式。这些设置仅作用于当前组件实例。";
|
||||
[ObservableProperty] private string _targetPathLabel = "目标路径";
|
||||
[ObservableProperty] private string _targetPathPlaceholder = "未选择目标";
|
||||
[ObservableProperty] private string _browseButtonText = "浏览...";
|
||||
[ObservableProperty] private string _clearButtonText = "清除";
|
||||
[ObservableProperty] private string _clickModeLabel = "打开方式";
|
||||
[ObservableProperty] private string _backgroundLabel = "显示背景";
|
||||
[ObservableProperty] private string _backgroundDescription = "关闭后组件背景将变为透明。";
|
||||
|
||||
[ObservableProperty] private string _targetPath = string.Empty;
|
||||
[ObservableProperty] private SelectionOption? _selectedClickMode;
|
||||
[ObservableProperty] private bool _showBackground = true;
|
||||
|
||||
public ObservableCollection<SelectionOption> ClickModeOptions { get; }
|
||||
|
||||
public void SetTargetPath(string? path)
|
||||
{
|
||||
TargetPath = path ?? string.Empty;
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
public void ClearTargetPath()
|
||||
{
|
||||
TargetPath = string.Empty;
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
partial void OnSelectedClickModeChanged(SelectionOption? value) => SaveSettings();
|
||||
partial void OnShowBackgroundChanged(bool value) => SaveSettings();
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor"
|
||||
x:DataType="vm:ShortcutEditorViewModel">
|
||||
|
||||
<StackPanel Spacing="16">
|
||||
<!-- 说明卡片 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<TextBlock Text="{Binding DescriptionText}"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- 目标路径 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding TargetPathLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Text="{Binding TargetPath}"
|
||||
IsReadOnly="True"
|
||||
Watermark="{Binding TargetPathPlaceholder}"
|
||||
Grid.Column="0" />
|
||||
<Button Content="{Binding BrowseButtonText}"
|
||||
Click="OnBrowseClick"
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0" />
|
||||
</Grid>
|
||||
<Button Content="{Binding ClearButtonText}"
|
||||
Click="OnClearClick"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 打开方式 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding ClickModeLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox ItemsSource="{Binding ClickModeOptions}"
|
||||
SelectedItem="{Binding SelectedClickMode}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 背景设置 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding BackgroundLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock Text="{Binding BackgroundDescription}"
|
||||
Classes="component-editor-secondary-text" />
|
||||
<CheckBox IsChecked="{Binding ShowBackground}"
|
||||
Content="{Binding BackgroundLabel}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class ShortcutComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
private ShortcutEditorViewModel? _viewModel;
|
||||
|
||||
public ShortcutComponentEditor()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public ShortcutComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = new ShortcutEditorViewModel(context);
|
||||
DataContext = _viewModel;
|
||||
}
|
||||
|
||||
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel?.StorageProvider is not { } storageProvider)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new FilePickerOpenOptions
|
||||
{
|
||||
Title = "选择目标文件",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType("可执行文件")
|
||||
{
|
||||
Patterns = ["*.exe", "*.lnk", "*.bat", "*.cmd"]
|
||||
},
|
||||
new FilePickerFileType("所有文件")
|
||||
{
|
||||
Patterns = ["*.*"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var files = await storageProvider.OpenFilePickerAsync(options);
|
||||
var localPath = files.FirstOrDefault()?.TryGetLocalPath();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(localPath))
|
||||
{
|
||||
var folderOptions = new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "选择目标文件夹",
|
||||
AllowMultiple = false
|
||||
};
|
||||
|
||||
var folders = await storageProvider.OpenFolderPickerAsync(folderOptions);
|
||||
localPath = folders.FirstOrDefault()?.TryGetLocalPath();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(localPath))
|
||||
{
|
||||
_viewModel?.SetTargetPath(localPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClearClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_viewModel?.ClearTargetPath();
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ public partial class ComponentLibraryWindow : Window
|
||||
entry.ComponentId,
|
||||
displayName,
|
||||
previewKey,
|
||||
description: null,
|
||||
_localize?.Invoke("component_library.preview.loading", "Loading preview...") ?? "Loading preview...",
|
||||
_localize?.Invoke("component_library.preview.unavailable", "Preview unavailable") ?? "Preview unavailable",
|
||||
previewEntry);
|
||||
|
||||
@@ -725,6 +725,8 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
? CreateBrush("#FF4FC3F7")
|
||||
: CreateBrush("#FF4D5A");
|
||||
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
|
||||
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
|
||||
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
|
||||
|
||||
for (var i = 0; i < _courseItems.Count && i < CourseListPanel.Children.Count; i++)
|
||||
{
|
||||
@@ -746,19 +748,31 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
var timeText = textStack.Children[1] as TextBlock;
|
||||
var detailText = textStack.Children[2] as TextBlock;
|
||||
|
||||
if (titleText != null && titleText.Text != item.Name)
|
||||
if (titleText != null)
|
||||
{
|
||||
titleText.Text = item.Name;
|
||||
if (titleText.Text != item.Name)
|
||||
{
|
||||
titleText.Text = item.Name;
|
||||
}
|
||||
titleText.Foreground = primaryBrush;
|
||||
}
|
||||
|
||||
if (timeText != null && timeText.Text != item.TimeRange)
|
||||
if (timeText != null)
|
||||
{
|
||||
timeText.Text = item.TimeRange;
|
||||
if (timeText.Text != item.TimeRange)
|
||||
{
|
||||
timeText.Text = item.TimeRange;
|
||||
}
|
||||
timeText.Foreground = secondaryBrush;
|
||||
}
|
||||
|
||||
if (detailText != null && detailText.Text != item.Detail)
|
||||
if (detailText != null)
|
||||
{
|
||||
detailText.Text = item.Detail;
|
||||
if (detailText.Text != item.Detail)
|
||||
{
|
||||
detailText.Text = item.Detail;
|
||||
}
|
||||
detailText.Foreground = secondaryBrush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -552,7 +552,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
Width = 160,
|
||||
Height = 90,
|
||||
CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16, 8, 22),
|
||||
CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16, 8, 22),
|
||||
ClipToBounds = true,
|
||||
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
|
||||
IsHitTestVisible = false
|
||||
@@ -647,8 +647,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
News1ImageHost.Height = imageHeight;
|
||||
News2ImageHost.Width = imageWidth;
|
||||
News2ImageHost.Height = imageHeight;
|
||||
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
|
||||
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
|
||||
News1ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
News2ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
|
||||
@@ -691,7 +691,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
row.ImageHost.Width = imageWidth;
|
||||
row.ImageHost.Height = imageHeight;
|
||||
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
|
||||
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
|
||||
row.ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
|
||||
|
||||
row.TitleTextBlock.MaxWidth = availableTextWidth;
|
||||
|
||||
@@ -3,13 +3,14 @@ using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal static class ComponentChromeCornerRadiusHelper
|
||||
{
|
||||
public static double ResolveMainRectangleRadiusValue(ComponentChromeContext? chromeContext = null, double fallback = 18d)
|
||||
public static double ResolveMainRectangleRadiusValue(ComponentChromeContext? chromeContext = null, double fallback = 24d)
|
||||
{
|
||||
if (chromeContext is not null)
|
||||
{
|
||||
@@ -20,7 +21,7 @@ internal static class ComponentChromeCornerRadiusHelper
|
||||
var resolved = snapshot.CornerRadiusTokens.Component.TopLeft;
|
||||
return double.IsFinite(resolved)
|
||||
? Math.Max(0d, resolved)
|
||||
: Math.Max(0d, fallback * ResolveScale(chromeContext));
|
||||
: Math.Max(0d, fallback);
|
||||
}
|
||||
|
||||
public static CornerRadius ResolveMainRectangleRadius(ComponentChromeContext? chromeContext = null, double fallback = 24d)
|
||||
@@ -28,24 +29,6 @@ internal static class ComponentChromeCornerRadiusHelper
|
||||
return new CornerRadius(ResolveMainRectangleRadiusValue(chromeContext, fallback));
|
||||
}
|
||||
|
||||
public static double ResolveScale(ComponentChromeContext? chromeContext = null)
|
||||
{
|
||||
if (chromeContext is not null)
|
||||
{
|
||||
return Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
return Math.Max(
|
||||
GlobalAppearanceSettings.MinimumCornerRadiusScale,
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().GlobalCornerRadiusScale);
|
||||
}
|
||||
|
||||
public static CornerRadius Scale(double baseRadius, double min, double max, ComponentChromeContext? chromeContext = null)
|
||||
{
|
||||
var scale = ResolveScale(chromeContext);
|
||||
return new CornerRadius(Math.Clamp(baseRadius * scale, min * scale, max * scale));
|
||||
}
|
||||
|
||||
public static void Apply(CornerRadius radius, params Border?[] chromeLayers)
|
||||
{
|
||||
foreach (var chromeLayer in chromeLayers)
|
||||
@@ -67,28 +50,57 @@ internal static class ComponentChromeCornerRadiusHelper
|
||||
: new CornerRadius(fallback);
|
||||
}
|
||||
|
||||
public static double ScaleValue(double value, ComponentChromeContext? chromeContext = null)
|
||||
public static double SafeValue(double value, double min, double max, ComponentChromeContext? context = null)
|
||||
{
|
||||
return value * ResolveScale(chromeContext);
|
||||
_ = context;
|
||||
return Math.Clamp(value, min, max);
|
||||
}
|
||||
|
||||
public static double ResolveContentSafetyScale(
|
||||
ComponentChromeContext? chromeContext = null,
|
||||
double responsiveness = 0.45d)
|
||||
public static double Scale(double value, double min, double max, ComponentChromeContext? context = null)
|
||||
{
|
||||
var scale = ResolveScale(chromeContext);
|
||||
var normalizedResponsiveness = Math.Clamp(responsiveness, 0d, 1d);
|
||||
return 1d + ((scale - 1d) * normalizedResponsiveness);
|
||||
_ = context;
|
||||
return Math.Clamp(value, min, max);
|
||||
}
|
||||
|
||||
public static double SafeValue(
|
||||
double baseValue,
|
||||
double min,
|
||||
double max,
|
||||
ComponentChromeContext? chromeContext = null,
|
||||
double responsiveness = 0.45d)
|
||||
public static CornerRadius SafeRadius(double value, double min, double max, ComponentChromeContext? context = null)
|
||||
{
|
||||
var safetyScale = ResolveContentSafetyScale(chromeContext, responsiveness);
|
||||
return Math.Clamp(baseValue * safetyScale, min * safetyScale, max * safetyScale);
|
||||
_ = context;
|
||||
return new CornerRadius(Math.Clamp(value, min, max));
|
||||
}
|
||||
|
||||
public static CornerRadius ScaleRadius(double value, double min, double max, ComponentChromeContext? context = null)
|
||||
{
|
||||
_ = context;
|
||||
return new CornerRadius(Math.Clamp(value, min, max));
|
||||
}
|
||||
|
||||
public static double Mini(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Micro.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusMicro", 6).TopLeft;
|
||||
}
|
||||
|
||||
public static double Micro(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Micro.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusMicro", 6).TopLeft;
|
||||
}
|
||||
|
||||
public static double Small(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Sm.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusSm", 14).TopLeft;
|
||||
}
|
||||
|
||||
public static double Medium(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Md.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusMd", 20).TopLeft;
|
||||
}
|
||||
|
||||
public static double Large(ComponentChromeContext? context = null)
|
||||
{
|
||||
if (context is not null) return context.CornerRadiusTokens.Lg.TopLeft;
|
||||
return ResolveToken("DesignCornerRadiusLg", 28).TopLeft;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,7 @@ public sealed class DesktopComponentRuntimeRegistration
|
||||
_ => controlFactory(),
|
||||
cornerRadiusResolver is null
|
||||
? null
|
||||
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) *
|
||||
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
|
||||
: chromeContext => cornerRadiusResolver(chromeContext.CellSize))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -55,8 +54,7 @@ public sealed class DesktopComponentRuntimeRegistration
|
||||
controlFactory,
|
||||
cornerRadiusResolver is null
|
||||
? null
|
||||
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) *
|
||||
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
|
||||
: chromeContext => cornerRadiusResolver(chromeContext.CellSize))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -131,7 +129,6 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
Definition.Id,
|
||||
placementId,
|
||||
cellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
appearanceSnapshot.CornerRadiusTokens);
|
||||
var control = _controlFactory(new DesktopComponentControlFactoryContext(
|
||||
Definition,
|
||||
@@ -226,8 +223,7 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
Definition.Id,
|
||||
null,
|
||||
Math.Max(1, cellSize),
|
||||
1d,
|
||||
AppearanceCornerRadiusTokenFactory.Create(1d)));
|
||||
AppearanceCornerRadiusTokenFactory.Create(GlobalAppearanceSettings.DefaultCornerRadiusStyle)));
|
||||
}
|
||||
|
||||
private static void ApplySettingsDependencies(
|
||||
@@ -479,7 +475,15 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"component.file_manager",
|
||||
() => new FileManagerWidget())
|
||||
() => new FileManagerWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"component.notification_box",
|
||||
() => new NotificationBoxWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
"component.shortcut",
|
||||
() => new ShortcutWidget())
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
46
LanMountainDesktop/Views/Components/ShortcutWidget.axaml
Normal file
46
LanMountainDesktop/Views/Components/ShortcutWidget.axaml
Normal file
@@ -0,0 +1,46 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="96"
|
||||
d:DesignHeight="96"
|
||||
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
x:Name="ContentGrid">
|
||||
|
||||
<Border x:Name="IconHost"
|
||||
Grid.Row="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<Panel>
|
||||
<Image x:Name="IconImage"
|
||||
Stretch="Uniform"
|
||||
IsVisible="False" />
|
||||
<ContentControl x:Name="SymbolIconHost"
|
||||
IsVisible="False" />
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="NameTextBlock"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
TextWrapping="Wrap"
|
||||
Margin="4,0,4,4"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
396
LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
Normal file
396
LanMountainDesktop/Views/Components/ShortcutWidget.axaml.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using FluentIcons.Avalonia;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IComponentSettingsContextAware, IDisposable
|
||||
{
|
||||
private string _componentId = BuiltInComponentIds.DesktopShortcut;
|
||||
private string _placementId = string.Empty;
|
||||
private string? _targetPath;
|
||||
private string _clickMode = "Double";
|
||||
private bool _showBackground = true;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isDisposed;
|
||||
|
||||
private const double TapMovementThreshold = 10;
|
||||
private const long TapTimeThresholdMs = 500;
|
||||
|
||||
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
|
||||
|
||||
private record PointerGestureState(
|
||||
Point StartPosition,
|
||||
long StartTime
|
||||
);
|
||||
|
||||
public ShortcutWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
DoubleTapped += OnDoubleTapped;
|
||||
UpdateDisplay();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopShortcut
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||
{
|
||||
var snapshot = context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
ApplySettings(snapshot);
|
||||
}
|
||||
|
||||
public void ApplySettings(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
_targetPath = snapshot.ShortcutTargetPath;
|
||||
_clickMode = string.Equals(snapshot.ShortcutClickMode, "Single", StringComparison.OrdinalIgnoreCase)
|
||||
? "Single"
|
||||
: "Double";
|
||||
_showBackground = snapshot.ShortcutShowBackground;
|
||||
UpdateDisplay();
|
||||
ApplyChrome();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = cellSize;
|
||||
|
||||
// 图标大小:从 cellSize 的 50% 计算,最小 24px,最大 128px
|
||||
var iconSize = Math.Clamp(cellSize * 0.5, 24, 128);
|
||||
IconImage.Width = iconSize;
|
||||
IconImage.Height = iconSize;
|
||||
|
||||
// 字体大小:从 cellSize 的 18% 计算,最小 10px,最大 24px
|
||||
var fontSize = Math.Clamp(cellSize * 0.18, 10, 24);
|
||||
NameTextBlock.FontSize = fontSize;
|
||||
|
||||
// 更新符号图标的大小(如果当前显示的是符号图标)
|
||||
if (SymbolIconHost.Content is SymbolIcon symbolIcon)
|
||||
{
|
||||
symbolIcon.FontSize = iconSize;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDisplay()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||
{
|
||||
ShowEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var name = GetDisplayName(_targetPath);
|
||||
NameTextBlock.Text = name;
|
||||
// 文字颜色由 XAML 中的 DynamicResource 自动适配主题
|
||||
|
||||
LoadIcon(_targetPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ShowEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowEmptyState()
|
||||
{
|
||||
NameTextBlock.Text = "添加快捷方式";
|
||||
// 使用次要文字颜色(由主题自动适配)
|
||||
NameTextBlock.Foreground = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
|
||||
|
||||
var iconBrush = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
|
||||
|
||||
// 隐藏图片图标,显示符号图标
|
||||
IconImage.IsVisible = false;
|
||||
IconImage.Source = null;
|
||||
|
||||
// 计算图标大小
|
||||
var iconSize = Math.Clamp(_currentCellSize * 0.5, 24, 128);
|
||||
|
||||
var iconHostContent = new SymbolIcon
|
||||
{
|
||||
Symbol = FluentIcons.Common.Symbol.Add,
|
||||
FontSize = iconSize,
|
||||
Foreground = iconBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
SymbolIconHost.Content = iconHostContent;
|
||||
SymbolIconHost.IsVisible = true;
|
||||
}
|
||||
|
||||
private static string GetDisplayName(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return "快捷方式";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
return Path.GetFileName(path.TrimEnd('\\', '/'));
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||
return string.IsNullOrWhiteSpace(fileName) ? path : fileName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadIcon(string path)
|
||||
{
|
||||
byte[]? pngBytes = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
pngBytes = WindowsIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (File.Exists(path))
|
||||
{
|
||||
pngBytes = WindowsIconService.TryGetIconPngBytes(path);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
pngBytes = LinuxIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (File.Exists(path))
|
||||
{
|
||||
pngBytes = LinuxIconService.TryGetIconPngBytes(path);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
pngBytes = MacIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (File.Exists(path))
|
||||
{
|
||||
pngBytes = MacIconService.TryGetIconPngBytes(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pngBytes = null;
|
||||
}
|
||||
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(pngBytes);
|
||||
IconImage.Source = new Bitmap(stream);
|
||||
IconImage.IsVisible = true;
|
||||
SymbolIconHost.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
LoadFallbackIcon(path);
|
||||
}
|
||||
|
||||
private void LoadFallbackIcon(string path)
|
||||
{
|
||||
var symbol = Directory.Exists(path)
|
||||
? FluentIcons.Common.Symbol.Folder
|
||||
: FluentIcons.Common.Symbol.Document;
|
||||
|
||||
// 使用强调色(由主题自动适配)
|
||||
var iconBrush = this.FindResource("AdaptiveAccentBrush") as IBrush;
|
||||
|
||||
// 隐藏图片图标,显示符号图标
|
||||
IconImage.IsVisible = false;
|
||||
IconImage.Source = null;
|
||||
|
||||
// 计算图标大小
|
||||
var iconSize = Math.Clamp(_currentCellSize * 0.5, 24, 128);
|
||||
|
||||
var iconHostContent = new SymbolIcon
|
||||
{
|
||||
Symbol = symbol,
|
||||
FontSize = iconSize,
|
||||
Foreground = iconBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
SymbolIconHost.Content = iconHostContent;
|
||||
SymbolIconHost.IsVisible = true;
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (!_showBackground)
|
||||
{
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复默认的实心背景样式
|
||||
RootBorder.Background = this.FindResource("AdaptiveSurfaceRaisedBrush") as IBrush ?? Brushes.Transparent;
|
||||
RootBorder.BorderBrush = this.FindResource("AdaptiveButtonBorderBrush") as IBrush ?? Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(1);
|
||||
}
|
||||
|
||||
protected override void OnPointerPressed(PointerPressedEventArgs e)
|
||||
{
|
||||
base.OnPointerPressed(e);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointer = e.GetCurrentPoint(this);
|
||||
var pointerId = e.Pointer.Id;
|
||||
var position = pointer.Position;
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
_gestureStates[pointerId] = new PointerGestureState(position, timestamp);
|
||||
e.Pointer.Capture(this);
|
||||
}
|
||||
|
||||
protected override void OnPointerMoved(PointerEventArgs e)
|
||||
{
|
||||
base.OnPointerMoved(e);
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.TryGetValue(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(this);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
if (distance > TapMovementThreshold)
|
||||
{
|
||||
_gestureStates.Remove(pointerId);
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPointerReleased(PointerReleasedEventArgs e)
|
||||
{
|
||||
base.OnPointerReleased(e);
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.Remove(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Pointer.Capture(null);
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(this);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
|
||||
|
||||
if (distance > TapMovementThreshold || elapsed > TapTimeThresholdMs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_clickMode == "Single")
|
||||
{
|
||||
OpenTarget();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDoubleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_clickMode == "Double")
|
||||
{
|
||||
OpenTarget();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenTarget()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_targetPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(_targetPath)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
Process.Start("xdg-open", _targetPath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
Process.Start("open", _targetPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("ShortcutWidget", $"Failed to open target: {_targetPath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
_gestureStates.Clear();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -638,7 +638,7 @@ public partial class Stcn24ForumWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
foreach (var visual in _itemVisuals)
|
||||
{
|
||||
visual.Host.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(10 * softScale, 6, 14);
|
||||
visual.Host.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(10 * softScale, 6, 14);
|
||||
visual.Host.Padding = new Thickness(rowPaddingHorizontal, rowPaddingVertical);
|
||||
visual.RowGrid.ColumnSpacing = Math.Clamp(8 * softScale, 4, 12);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
@@ -20,10 +20,24 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
|
||||
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private Point[]? _pointBuffer;
|
||||
private StreamGeometry? _lineGeometry;
|
||||
private StreamGeometry? _fillGeometry;
|
||||
private Rect _cachedPlot;
|
||||
private bool _geometryDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points)
|
||||
{
|
||||
_points = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextSignature = ComputeSeriesSignature(nextPoints);
|
||||
if (ReferenceEquals(_points, nextPoints) && _lastSeriesSignature == nextSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_points = nextPoints;
|
||||
_lastSeriesSignature = nextSignature;
|
||||
_geometryDirty = true;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
@@ -34,11 +48,18 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
_pointBuffer = null;
|
||||
}
|
||||
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
_geometryDirty = true;
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
ReleasePointBuffer();
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
_geometryDirty = true;
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
@@ -64,16 +85,14 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
return;
|
||||
}
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
|
||||
var pointCount = BuildPlotPoints(plot, maxSamples);
|
||||
if (pointCount < 2 || _pointBuffer is null)
|
||||
EnsureGeometry(plot);
|
||||
if (_lineGeometry is null || _fillGeometry is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var span = _pointBuffer.AsSpan(0, pointCount);
|
||||
DrawAreaFill(context, plot.Bottom, span);
|
||||
DrawLine(context, span);
|
||||
context.DrawGeometry(FillBrush, pen: null, _fillGeometry);
|
||||
context.DrawGeometry(brush: null, pen: LinePen, _lineGeometry);
|
||||
}
|
||||
|
||||
private static void DrawGrid(DrawingContext context, Rect plot)
|
||||
@@ -97,42 +116,56 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
context.DrawLine(AxisPen, new Point(plot.Left, plot.Bottom), new Point(plot.Right, plot.Bottom));
|
||||
}
|
||||
|
||||
private void DrawLine(DrawingContext context, ReadOnlySpan<Point> points)
|
||||
private void EnsureGeometry(Rect plot)
|
||||
{
|
||||
var geometry = new StreamGeometry();
|
||||
using (var builder = geometry.Open())
|
||||
if (!_geometryDirty && _cachedPlot == plot)
|
||||
{
|
||||
builder.BeginFigure(points[0], false);
|
||||
for (var i = 1; i < points.Length; i++)
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedPlot = plot;
|
||||
_lineGeometry = null;
|
||||
_fillGeometry = null;
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Floor(plot.Width), 56, 360);
|
||||
var pointCount = BuildPlotPoints(plot, maxSamples);
|
||||
if (pointCount < 2 || _pointBuffer is null)
|
||||
{
|
||||
_geometryDirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var lineGeometry = new StreamGeometry();
|
||||
using (var builder = lineGeometry.Open())
|
||||
{
|
||||
builder.BeginFigure(_pointBuffer[0], false);
|
||||
for (var i = 1; i < pointCount; i++)
|
||||
{
|
||||
builder.LineTo(points[i]);
|
||||
builder.LineTo(_pointBuffer[i]);
|
||||
}
|
||||
}
|
||||
|
||||
context.DrawGeometry(brush: null, pen: LinePen, geometry);
|
||||
}
|
||||
|
||||
private void DrawAreaFill(DrawingContext context, double baselineY, ReadOnlySpan<Point> points)
|
||||
{
|
||||
var geometry = new StreamGeometry();
|
||||
using (var builder = geometry.Open())
|
||||
var fillGeometry = new StreamGeometry();
|
||||
using (var builder = fillGeometry.Open())
|
||||
{
|
||||
var first = points[0];
|
||||
builder.BeginFigure(new Point(first.X, baselineY), true);
|
||||
var first = _pointBuffer[0];
|
||||
builder.BeginFigure(new Point(first.X, plot.Bottom), true);
|
||||
builder.LineTo(first);
|
||||
|
||||
for (var i = 1; i < points.Length; i++)
|
||||
for (var i = 1; i < pointCount; i++)
|
||||
{
|
||||
builder.LineTo(points[i]);
|
||||
builder.LineTo(_pointBuffer[i]);
|
||||
}
|
||||
|
||||
var last = points[^1];
|
||||
builder.LineTo(new Point(last.X, baselineY));
|
||||
builder.LineTo(new Point(first.X, baselineY));
|
||||
var last = _pointBuffer[pointCount - 1];
|
||||
builder.LineTo(new Point(last.X, plot.Bottom));
|
||||
builder.LineTo(new Point(first.X, plot.Bottom));
|
||||
builder.EndFigure(true);
|
||||
}
|
||||
|
||||
context.DrawGeometry(FillBrush, pen: null, geometry);
|
||||
_lineGeometry = lineGeometry;
|
||||
_fillGeometry = fillGeometry;
|
||||
_geometryDirty = false;
|
||||
}
|
||||
|
||||
private int BuildPlotPoints(Rect plot, int maxSamples)
|
||||
@@ -295,4 +328,20 @@ public sealed class StudyNoiseCurveChartControl : Control
|
||||
ArrayPool<Point>.Shared.Return(_pointBuffer, clearArray: false);
|
||||
_pointBuffer = null;
|
||||
}
|
||||
|
||||
private static int ComputeSeriesSignature(IReadOnlyList<NoiseRealtimePoint> points)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var first = points[0];
|
||||
var last = points[^1];
|
||||
return HashCode.Combine(
|
||||
points.Count,
|
||||
first.Timestamp.UtcTicks,
|
||||
last.Timestamp.UtcTicks,
|
||||
Math.Round(last.DisplayDb, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
{
|
||||
private readonly record struct SampledPoint(double X, double Y, NoiseDistributionLevel Level);
|
||||
|
||||
private static readonly IBrush GridBrush = new SolidColorBrush(Color.Parse("#2E5E7A96"));
|
||||
private static readonly IBrush AxisBrush = new SolidColorBrush(Color.Parse("#5C6D86A1"));
|
||||
private static readonly Pen GridPen = new(GridBrush, 1);
|
||||
@@ -18,14 +20,35 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
private static readonly IBrush NormalBrush = new SolidColorBrush(Color.Parse("#FF60A5FA"));
|
||||
private static readonly IBrush NoisyBrush = new SolidColorBrush(Color.Parse("#FFF59E0B"));
|
||||
private static readonly IBrush ExtremeBrush = new SolidColorBrush(Color.Parse("#FFEF4444"));
|
||||
private static readonly byte[] CloudAlphas = [44, 58, 72, 86];
|
||||
private static readonly byte[] GlowAlphas = [26, 36];
|
||||
private static readonly IBrush[][] CloudBrushes = CreateBrushTable(CloudAlphas);
|
||||
private static readonly IBrush[][] GlowBrushes = CreateBrushTable(GlowAlphas);
|
||||
|
||||
private IReadOnlyList<NoiseRealtimePoint> _points = Array.Empty<NoiseRealtimePoint>();
|
||||
private SampledPoint[] _sampledPoints = Array.Empty<SampledPoint>();
|
||||
private int _sampledPointCount;
|
||||
private double _baselineDb = 45;
|
||||
private Rect _cachedPlot;
|
||||
private bool _sampleCacheDirty = true;
|
||||
private int _lastSeriesSignature;
|
||||
|
||||
public void UpdateSeries(IReadOnlyList<NoiseRealtimePoint>? points, double baselineDb)
|
||||
{
|
||||
_points = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
_baselineDb = Math.Clamp(baselineDb, 20, 85);
|
||||
var nextPoints = points ?? Array.Empty<NoiseRealtimePoint>();
|
||||
var nextBaselineDb = Math.Clamp(baselineDb, 20, 85);
|
||||
var nextSignature = ComputeSeriesSignature(nextPoints, nextBaselineDb);
|
||||
if (ReferenceEquals(_points, nextPoints) &&
|
||||
Math.Abs(_baselineDb - nextBaselineDb) < 0.001 &&
|
||||
_lastSeriesSignature == nextSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_points = nextPoints;
|
||||
_baselineDb = nextBaselineDb;
|
||||
_lastSeriesSignature = nextSignature;
|
||||
_sampleCacheDirty = true;
|
||||
InvalidateVisual();
|
||||
}
|
||||
|
||||
@@ -52,45 +75,34 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureSampleCache(plot);
|
||||
if (_sampledPointCount < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DrawElectronCloud(context, plot);
|
||||
}
|
||||
|
||||
private void DrawElectronCloud(DrawingContext context, Rect plot)
|
||||
{
|
||||
var start = _points[0].Timestamp;
|
||||
var end = _points[^1].Timestamp;
|
||||
var totalTicks = Math.Max(1, (end - start).Ticks);
|
||||
|
||||
var pointCount = _points.Count;
|
||||
var cloudLayers = 8;
|
||||
var cloudLayers = CloudAlphas.Length;
|
||||
var baseRadius = Math.Clamp(Math.Min(plot.Width, plot.Height) / 45d, 3, 12);
|
||||
|
||||
var sortedPoints = new List<(double X, double Y, NoiseDistributionLevel Level)>();
|
||||
for (var i = 0; i < pointCount; i++)
|
||||
{
|
||||
var point = _points[i];
|
||||
var x = MapX(plot, point.Timestamp, start, totalTicks);
|
||||
var y = MapYContinuous(plot, point.DisplayDb);
|
||||
var level = ResolveLevel(point.DisplayDb, _baselineDb);
|
||||
sortedPoints.Add((x, y, level));
|
||||
}
|
||||
|
||||
sortedPoints.Sort((a, b) => a.X.CompareTo(b.X));
|
||||
|
||||
for (var layer = cloudLayers - 1; layer >= 0; layer--)
|
||||
{
|
||||
var layerRatio = (double)layer / (cloudLayers - 1);
|
||||
var layerRatio = cloudLayers == 1 ? 0d : layer / (double)(cloudLayers - 1);
|
||||
var layerRadius = baseRadius * (1.2 + layerRatio * 0.8);
|
||||
var layerAlpha = (byte)(40 + layerRatio * 25);
|
||||
var layerBrushes = CloudBrushes[layer];
|
||||
|
||||
foreach (var pt in sortedPoints)
|
||||
for (var i = 0; i < _sampledPointCount; i++)
|
||||
{
|
||||
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
|
||||
var pt = _sampledPoints[i];
|
||||
var jitterX = ComputeJitter(pt.X * 1000 + layer) * layerRadius * 0.3;
|
||||
var jitterY = ComputeJitter(pt.Y * 1000 + layer) * layerRadius * 0.3;
|
||||
|
||||
|
||||
context.DrawEllipse(
|
||||
brush,
|
||||
layerBrushes[(int)pt.Level],
|
||||
pen: null,
|
||||
center: new Point(pt.X + jitterX, pt.Y + jitterY),
|
||||
radiusX: layerRadius,
|
||||
@@ -98,18 +110,17 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
}
|
||||
}
|
||||
|
||||
var glowLayers = 5;
|
||||
var glowLayers = GlowAlphas.Length;
|
||||
for (var layer = glowLayers - 1; layer >= 0; layer--)
|
||||
{
|
||||
var layerRatio = (double)layer / (glowLayers - 1);
|
||||
var layerRatio = glowLayers == 1 ? 0d : layer / (double)(glowLayers - 1);
|
||||
var layerRadius = baseRadius * (0.8 + layerRatio * 0.6);
|
||||
var layerAlpha = (byte)(20 + layerRatio * 15);
|
||||
|
||||
foreach (var pt in sortedPoints)
|
||||
var layerBrushes = GlowBrushes[layer];
|
||||
for (var i = 0; i < _sampledPointCount; i++)
|
||||
{
|
||||
var brush = GetLevelBrushWithAlpha(pt.Level, layerAlpha);
|
||||
var pt = _sampledPoints[i];
|
||||
context.DrawEllipse(
|
||||
brush,
|
||||
layerBrushes[(int)pt.Level],
|
||||
pen: null,
|
||||
center: new Point(pt.X, pt.Y),
|
||||
radiusX: layerRadius,
|
||||
@@ -117,34 +128,42 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
}
|
||||
}
|
||||
|
||||
var latest = _points[^1];
|
||||
var latestX = MapX(plot, latest.Timestamp, start, totalTicks);
|
||||
var latestY = MapYContinuous(plot, latest.DisplayDb);
|
||||
var latestLevel = ResolveLevel(latest.DisplayDb, _baselineDb);
|
||||
|
||||
var latest = _sampledPoints[_sampledPointCount - 1];
|
||||
for (var i = 3; i >= 0; i--)
|
||||
{
|
||||
var radius = baseRadius * (1.5 + i * 0.8);
|
||||
var alpha = (byte)(30 - i * 6);
|
||||
var glowBrush = GetLevelBrushWithAlpha(latestLevel, alpha);
|
||||
context.DrawEllipse(glowBrush, null, new Point(latestX, latestY), radius, radius * 0.6);
|
||||
var glowBrush = GetAlphaBrush(latest.Level, alpha);
|
||||
context.DrawEllipse(glowBrush, null, new Point(latest.X, latest.Y), radius, radius * 0.6);
|
||||
}
|
||||
|
||||
context.DrawEllipse(
|
||||
GetLevelBrush(latestLevel),
|
||||
GetLevelBrush(latest.Level),
|
||||
new Pen(Brushes.White, 1.5),
|
||||
new Point(latestX, latestY),
|
||||
new Point(latest.X, latest.Y),
|
||||
baseRadius + 1,
|
||||
baseRadius * 0.7 + 1);
|
||||
|
||||
context.DrawEllipse(
|
||||
Brushes.White,
|
||||
null,
|
||||
new Point(latestX, latestY),
|
||||
new Point(latest.X, latest.Y),
|
||||
2,
|
||||
2);
|
||||
}
|
||||
|
||||
private void EnsureSampleCache(Rect plot)
|
||||
{
|
||||
if (!_sampleCacheDirty && _cachedPlot == plot)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedPlot = plot;
|
||||
_sampledPointCount = BuildSampledPoints(plot);
|
||||
_sampleCacheDirty = false;
|
||||
}
|
||||
|
||||
private static void DrawGrid(DrawingContext context, Rect plot)
|
||||
{
|
||||
const int verticalDivisions = 4;
|
||||
@@ -176,7 +195,10 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
var minDb = _baselineDb - 5;
|
||||
var maxDb = _baselineDb + 25;
|
||||
var dbRange = maxDb - minDb;
|
||||
if (dbRange <= 0) dbRange = 30;
|
||||
if (dbRange <= 0)
|
||||
{
|
||||
dbRange = 30;
|
||||
}
|
||||
|
||||
var normalizedDb = (displayDb - minDb) / dbRange;
|
||||
normalizedDb = Math.Clamp(normalizedDb, 0, 1);
|
||||
@@ -243,6 +265,106 @@ public sealed class StudyNoiseDistributionScatterChartControl : Control
|
||||
_ => new SolidColorBrush(Color.FromArgb(alpha, 0x60, 0xA5, 0xFA))
|
||||
};
|
||||
}
|
||||
|
||||
private int BuildSampledPoints(Rect plot)
|
||||
{
|
||||
if (_points.Count < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var maxSamples = Math.Clamp((int)Math.Ceiling(plot.Width / 2d), 48, 144);
|
||||
var targetCount = Math.Min(_points.Count, maxSamples);
|
||||
if (_sampledPoints.Length < targetCount)
|
||||
{
|
||||
_sampledPoints = new SampledPoint[targetCount];
|
||||
}
|
||||
|
||||
var start = _points[0].Timestamp;
|
||||
var end = _points[^1].Timestamp;
|
||||
var totalTicks = Math.Max(1, (end - start).Ticks);
|
||||
var step = _points.Count <= targetCount
|
||||
? 1d
|
||||
: (_points.Count - 1d) / Math.Max(1d, targetCount - 1d);
|
||||
|
||||
var outputIndex = 0;
|
||||
var lastSourceIndex = -1;
|
||||
for (var i = 0; i < targetCount; i++)
|
||||
{
|
||||
var sourceIndex = i == targetCount - 1
|
||||
? _points.Count - 1
|
||||
: (int)Math.Round(i * step);
|
||||
sourceIndex = Math.Clamp(sourceIndex, 0, _points.Count - 1);
|
||||
if (sourceIndex == lastSourceIndex)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var point = _points[sourceIndex];
|
||||
_sampledPoints[outputIndex++] = new SampledPoint(
|
||||
MapX(plot, point.Timestamp, start, totalTicks),
|
||||
MapYContinuous(plot, point.DisplayDb),
|
||||
ResolveLevel(point.DisplayDb, _baselineDb));
|
||||
lastSourceIndex = sourceIndex;
|
||||
}
|
||||
|
||||
return outputIndex;
|
||||
}
|
||||
|
||||
private static int ComputeSeriesSignature(IReadOnlyList<NoiseRealtimePoint> points, double baselineDb)
|
||||
{
|
||||
if (points.Count == 0)
|
||||
{
|
||||
return HashCode.Combine(0, baselineDb);
|
||||
}
|
||||
|
||||
var first = points[0];
|
||||
var last = points[^1];
|
||||
return HashCode.Combine(
|
||||
points.Count,
|
||||
first.Timestamp.UtcTicks,
|
||||
last.Timestamp.UtcTicks,
|
||||
Math.Round(last.DisplayDb, 2),
|
||||
Math.Round(baselineDb, 2));
|
||||
}
|
||||
|
||||
private static IBrush[][] CreateBrushTable(IReadOnlyList<byte> alphas)
|
||||
{
|
||||
var table = new IBrush[alphas.Count][];
|
||||
for (var i = 0; i < alphas.Count; i++)
|
||||
{
|
||||
table[i] =
|
||||
[
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Quiet, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Normal, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Noisy, alphas[i]),
|
||||
GetLevelBrushWithAlpha(NoiseDistributionLevel.Extreme, alphas[i])
|
||||
];
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static IBrush GetAlphaBrush(NoiseDistributionLevel level, byte alpha)
|
||||
{
|
||||
for (var i = 0; i < CloudAlphas.Length; i++)
|
||||
{
|
||||
if (CloudAlphas[i] == alpha)
|
||||
{
|
||||
return CloudBrushes[i][(int)level];
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < GlowAlphas.Length; i++)
|
||||
{
|
||||
if (GlowAlphas[i] == alpha)
|
||||
{
|
||||
return GlowBrushes[i][(int)level];
|
||||
}
|
||||
}
|
||||
|
||||
return GetLevelBrushWithAlpha(level, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
public enum NoiseDistributionLevel
|
||||
|
||||
@@ -39,21 +39,22 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
private static readonly Color DarkSubstrate = Color.Parse("#FF0B1220");
|
||||
private static readonly Color LightSubstrate = Color.Parse("#FFF1F5FA");
|
||||
|
||||
private readonly object _snapshotSync = new();
|
||||
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
|
||||
private readonly StudyAnalyticsMonitoringLeaseCoordinator _monitoringLeaseCoordinator = StudyAnalyticsMonitoringLeaseCoordinatorFactory.CreateDefault();
|
||||
private LanMountainDesktop.PluginSdk.ISettingsService _settingsService = LanMountainDesktop.Services.Settings.HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly DispatcherTimer _uiTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(100)
|
||||
};
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private StudyAnalyticsSnapshot? _pendingSnapshot;
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _dispatchQueued;
|
||||
private bool _hasPendingSnapshot;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isDisposed;
|
||||
private bool _isCompactMode;
|
||||
private bool _isSubscribed;
|
||||
private bool _isUltraCompactMode;
|
||||
private bool _studyEnabled = true;
|
||||
private IDisposable? _monitoringLease;
|
||||
@@ -71,7 +72,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_uiTimer.Tick += OnUiTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
@@ -80,7 +80,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ApplyDefaultXAxisLabels();
|
||||
ApplyLocalizedAxisLabels();
|
||||
RefreshVisual();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -94,24 +94,28 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
_ = isEditMode;
|
||||
var wasOnActivePage = _isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
|
||||
|
||||
if (isOnActivePage && !wasOnActivePage)
|
||||
{
|
||||
RefreshVisual();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
UpdateTimerState();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
ReloadLanguageCode();
|
||||
|
||||
if (!_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated += OnStudySnapshotUpdated;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
|
||||
UpdateMonitoringLeaseState();
|
||||
UpdateTimerState();
|
||||
RefreshVisual();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
@@ -119,7 +123,12 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
_isAttached = false;
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
_uiTimer.Stop();
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -130,27 +139,7 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnUiTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void UpdateTimerState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_uiTimer.IsEnabled)
|
||||
{
|
||||
_uiTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_uiTimer.Stop();
|
||||
QueueSnapshotForRender(_studyAnalyticsService.GetSnapshot());
|
||||
}
|
||||
|
||||
private void UpdateMonitoringLeaseState()
|
||||
@@ -172,7 +161,52 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
_monitoringLease = null;
|
||||
}
|
||||
|
||||
private void RefreshVisual()
|
||||
private void OnStudySnapshotUpdated(object? sender, StudyAnalyticsSnapshotChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
QueueSnapshotForRender(e.Snapshot);
|
||||
}
|
||||
|
||||
private void QueueSnapshotForRender(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_pendingSnapshot = snapshot;
|
||||
_hasPendingSnapshot = true;
|
||||
if (_dispatchQueued)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dispatchQueued = true;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(ProcessPendingSnapshot, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void ProcessPendingSnapshot()
|
||||
{
|
||||
StudyAnalyticsSnapshot? snapshot = null;
|
||||
lock (_snapshotSync)
|
||||
{
|
||||
_dispatchQueued = false;
|
||||
if (_hasPendingSnapshot)
|
||||
{
|
||||
snapshot = _pendingSnapshot;
|
||||
_pendingSnapshot = null;
|
||||
_hasPendingSnapshot = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isAttached || !_isOnActivePage || snapshot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySnapshot(snapshot);
|
||||
}
|
||||
|
||||
private void ApplySnapshot(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
var panelColor = ResolvePanelBackgroundColor();
|
||||
ApplyTypographyByBackground(panelColor);
|
||||
@@ -189,8 +223,6 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _studyAnalyticsService.GetSnapshot();
|
||||
|
||||
var isSessionRunning = snapshot.Session.State == StudySessionRuntimeState.Running;
|
||||
var isSessionReport = snapshot.DataMode == StudyDataMode.SessionReport && snapshot.LastSessionReport is not null;
|
||||
var isSessionView = isSessionRunning || isSessionReport;
|
||||
@@ -634,13 +666,17 @@ public partial class StudyNoiseDistributionWidget : UserControl, IDesktopCompone
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
_uiTimer.Stop();
|
||||
_uiTimer.Tick -= OnUiTimerTick;
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||
|
||||
if (_isSubscribed)
|
||||
{
|
||||
_studyAnalyticsService.SnapshotUpdated -= OnStudySnapshotUpdated;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
|
||||
_monitoringLease?.Dispose();
|
||||
_monitoringLease = null;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
private string _placementId = string.Empty;
|
||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||
private bool _isApplyingPersistedSnapshot;
|
||||
private bool? _lastBitmapCacheEnabled;
|
||||
private int _lastBitmapCacheSize;
|
||||
private bool _noteDirty;
|
||||
private int _noteLoadRevision;
|
||||
private bool _disposed;
|
||||
@@ -119,11 +121,10 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
settings.IgnorePressure = true;
|
||||
settings.InkThickness = _selectedInkThickness;
|
||||
settings.EraserSize = new Size(20, 20);
|
||||
settings.IsBitmapCacheEnabled = true;
|
||||
settings.MaxBitmapCacheSize = 2048;
|
||||
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
||||
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
||||
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -157,6 +158,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
||||
settings.EraserSize = new Size(eraserSize, eraserSize);
|
||||
UpdateInkCanvasCacheSettings(forceRefresh: false);
|
||||
}
|
||||
|
||||
private void ApplyThemeVisual(bool force)
|
||||
@@ -711,8 +713,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
||||
}
|
||||
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||
}
|
||||
|
||||
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
||||
@@ -765,9 +766,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
}
|
||||
}
|
||||
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
||||
}
|
||||
|
||||
private bool HasValidPersistenceContext()
|
||||
@@ -785,4 +784,47 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
|
||||
return Array.Empty<InkStylusPoint>();
|
||||
}
|
||||
|
||||
private void UpdateInkCanvasCacheSettings(bool forceRefresh)
|
||||
{
|
||||
var renderScaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1d;
|
||||
var widthPx = Math.Max(1d, CanvasBorder.Bounds.Width * renderScaling);
|
||||
var heightPx = Math.Max(1d, CanvasBorder.Bounds.Height * renderScaling);
|
||||
var longestSide = Math.Max(widthPx, heightPx);
|
||||
var area = widthPx * heightPx;
|
||||
|
||||
var cacheEnabled = longestSide <= 1536d && area <= 1_400_000d;
|
||||
var cacheSize = (int)Math.Clamp(Math.Ceiling(longestSide), 384d, 1536d);
|
||||
if (!forceRefresh &&
|
||||
_lastBitmapCacheEnabled == cacheEnabled &&
|
||||
_lastBitmapCacheSize == cacheSize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastBitmapCacheEnabled = cacheEnabled;
|
||||
_lastBitmapCacheSize = cacheSize;
|
||||
|
||||
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
||||
settings.IsBitmapCacheEnabled = cacheEnabled;
|
||||
settings.MaxBitmapCacheSize = cacheSize;
|
||||
|
||||
try
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(cacheEnabled);
|
||||
if (cacheEnabled)
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
||||
}
|
||||
else
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep drawing available even if the underlying cache backend rejects the cache update.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,159 +2,203 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:converters="using:Avalonia.Data.Converters"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
|
||||
x:DataType="vm:ComponentLibraryWindowViewModel">
|
||||
|
||||
<Grid ColumnDefinitions="240,*"
|
||||
ColumnSpacing="12"
|
||||
|
||||
<UserControl.Styles>
|
||||
<!-- 分类列表项样式 -->
|
||||
<Style Selector="ListBoxItem.category-item">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
|
||||
<!-- 分类项内容容器 - 默认状态 -->
|
||||
<Style Selector="Border.category-content">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemBackgroundBrush}"/>
|
||||
<Setter Property="Padding" Value="12,10"/>
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:selected Border.category-content">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:pointerover Border.category-content">
|
||||
<Setter Property="Opacity" Value="0.9"/>
|
||||
</Style>
|
||||
|
||||
<!-- 分类项图标和文字 - 默认状态 -->
|
||||
<Style Selector="ListBoxItem.category-item fi|SymbolIcon.category-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:selected fi|SymbolIcon.category-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBoxItem.category-item TextBlock.category-text">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem.category-item:selected TextBlock.category-text">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}"/>
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid ColumnDefinitions="280,*"
|
||||
ColumnSpacing="16"
|
||||
Margin="0">
|
||||
<!-- 分类列表 (左侧) -->
|
||||
<Border Classes="surface-translucent-panel"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<TextBox x:Name="SearchBox"
|
||||
Watermark="搜索组件..."
|
||||
Margin="0,0,0,12"
|
||||
Classes="clear"
|
||||
Background="{DynamicResource AdaptiveSurfaceLowBrush}"
|
||||
CornerRadius="12"
|
||||
Padding="12,8">
|
||||
<TextBox.InnerLeftContent>
|
||||
<fi:SymbolIcon Symbol="Search" FontSize="16" Margin="10,0,0,0" Opacity="0.5" />
|
||||
</TextBox.InnerLeftContent>
|
||||
</TextBox>
|
||||
|
||||
<ListBox x:Name="CategoryListBox"
|
||||
Grid.Row="1"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
SelectionChanged="OnCategorySelectionChanged"
|
||||
ItemsSource="{Binding Categories}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
||||
<Border Padding="10"
|
||||
Margin="0,0,0,6"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveNavItemBackgroundBrush}">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<fi:SymbolIcon Symbol="{Binding Icon}"
|
||||
IconVariant="Regular"
|
||||
FontSize="16" />
|
||||
<TextBlock Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding Title}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
Padding="12">
|
||||
<ListBox x:Name="CategoryListBox"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
SelectionChanged="OnCategorySelectionChanged"
|
||||
ItemsSource="{Binding Categories}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
|
||||
<Border Classes="category-content">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<fi:SymbolIcon Symbol="{Binding Icon}"
|
||||
IconVariant="Regular"
|
||||
FontSize="20"
|
||||
Classes="category-icon"/>
|
||||
<TextBlock Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Classes="category-text"
|
||||
Text="{Binding Title}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- 组件网格 (右侧) -->
|
||||
<!-- 组件预览区 (右侧) -->
|
||||
<Border Grid.Column="1"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="10">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl x:Name="ComponentItemsControl"
|
||||
ItemsSource="{Binding Components}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
Padding="24">
|
||||
<Panel>
|
||||
<!-- 有选中组件时的显示 -->
|
||||
<Grid RowDefinitions="Auto,*,Auto"
|
||||
IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
|
||||
<!-- 组件标题 -->
|
||||
<TextBlock Grid.Row="0"
|
||||
FontSize="28"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding SelectedComponent.DisplayName}"
|
||||
Margin="0,0,0,20"/>
|
||||
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentLibraryItemViewModel">
|
||||
<Border Width="240"
|
||||
Height="220"
|
||||
Margin="6"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="10"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1">
|
||||
<Grid RowDefinitions="*,Auto,Auto"
|
||||
RowSpacing="8">
|
||||
<!-- 预览区域 -->
|
||||
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
Padding="8">
|
||||
<Grid>
|
||||
<Image Source="{Binding PreviewBitmap}"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
IsVisible="{Binding IsPreviewReady}" />
|
||||
<!-- 预览区域 -->
|
||||
<Border Grid.Row="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
Padding="20"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<Grid Width="400"
|
||||
Height="300">
|
||||
<!-- 预览图片 -->
|
||||
<Image Source="{Binding SelectedComponent.PreviewBitmap}"
|
||||
Stretch="Uniform"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
IsVisible="{Binding SelectedComponent.IsPreviewReady}"/>
|
||||
|
||||
<!-- 加载中状态 -->
|
||||
<Border IsVisible="{Binding IsPreviewPending}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<ProgressBar Width="96"
|
||||
IsIndeterminate="True" />
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding PreviewStatusText}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- 加载中状态 -->
|
||||
<Border IsVisible="{Binding SelectedComponent.IsPreviewPending}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<ProgressBar Width="120"
|
||||
IsIndeterminate="True"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding SelectedComponent.PreviewStatusText}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 失败状态 -->
|
||||
<Border IsVisible="{Binding IsPreviewFailed}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding PreviewStatusText}" />
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding PreviewErrorMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件名称 -->
|
||||
<TextBlock Grid.Row="1"
|
||||
HorizontalAlignment="Center"
|
||||
<!-- 失败状态 -->
|
||||
<Border IsVisible="{Binding SelectedComponent.IsPreviewFailed}"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="ImageOff"
|
||||
FontSize="48"
|
||||
Opacity="0.5"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="{Binding DisplayName}" />
|
||||
|
||||
<!-- 添加按钮 -->
|
||||
<Button Grid.Row="2"
|
||||
HorizontalAlignment="Center"
|
||||
Padding="12,6"
|
||||
Tag="{Binding ComponentId}"
|
||||
Click="OnAddComponentClick">
|
||||
<TextBlock Text="添加到桌面" />
|
||||
</Button>
|
||||
</Grid>
|
||||
Text="{Binding SelectedComponent.PreviewStatusText}"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding SelectedComponent.PreviewErrorMessage}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 底部操作区 -->
|
||||
<Grid Grid.Row="2"
|
||||
ColumnDefinitions="*,Auto"
|
||||
Margin="0,24,0,0">
|
||||
<TextBlock Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding SelectedComponent.ComponentId, StringFormat='组件 ID: {0}'}"/>
|
||||
<Button Grid.Column="1"
|
||||
Classes="accent"
|
||||
Padding="20,12"
|
||||
Tag="{Binding SelectedComponent.ComponentId}"
|
||||
Click="OnAddComponentClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Symbol="Add" FontSize="16"/>
|
||||
<TextBlock Text="添加到桌面" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Spacing="16" HorizontalAlignment="Center">
|
||||
<fi:SymbolIcon Symbol="Apps"
|
||||
FontSize="64"
|
||||
Opacity="0.3"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
FontSize="16"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="请从左侧选择一个组件"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -39,7 +39,9 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
|
||||
LoadRegistry();
|
||||
LoadCategories();
|
||||
SearchBox.KeyUp += (s, e) => FilterComponents();
|
||||
|
||||
// 为 ListBoxItem 添加 category-item 样式类
|
||||
CategoryListBox.ContainerPrepared += OnCategoryListBoxContainerPrepared;
|
||||
|
||||
// 默认选择第一个分类
|
||||
if (_viewModel.Categories.Count > 0)
|
||||
@@ -48,6 +50,14 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCategoryListBoxContainerPrepared(object? sender, ContainerPreparedEventArgs e)
|
||||
{
|
||||
if (e.Container is ListBoxItem listBoxItem)
|
||||
{
|
||||
listBoxItem.Classes.Add("category-item");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadRegistry()
|
||||
{
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
@@ -65,7 +75,6 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
private void LoadCategories()
|
||||
{
|
||||
_viewModel.Categories.Clear();
|
||||
_viewModel.Components.Clear();
|
||||
|
||||
// 添加"全部组件"分类
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
@@ -130,10 +139,11 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
definition.Id,
|
||||
definition.DisplayName,
|
||||
previewKey,
|
||||
description: null,
|
||||
"正在加载预览...",
|
||||
"预览不可用",
|
||||
previewEntry);
|
||||
|
||||
|
||||
if (mainWindow is not null && (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending))
|
||||
{
|
||||
mainWindow.RequestDetachedLibraryPreview(previewKey);
|
||||
@@ -158,25 +168,49 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
|
||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
FilterComponents();
|
||||
UpdateSelectedComponent();
|
||||
}
|
||||
|
||||
private void FilterComponents()
|
||||
private void UpdateSelectedComponent()
|
||||
{
|
||||
var selectedCategory = (CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel)?.Id;
|
||||
var searchText = SearchBox.Text?.ToLower() ?? "";
|
||||
|
||||
var filtered = _allDefinitions.Where(d =>
|
||||
var selectedCategory = CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel;
|
||||
if (selectedCategory is null)
|
||||
{
|
||||
var matchesCategory = selectedCategory == "all" || string.Equals(d.Category, selectedCategory, StringComparison.OrdinalIgnoreCase);
|
||||
var matchesSearch = string.IsNullOrEmpty(searchText) || d.DisplayName.ToLower().Contains(searchText) || d.Id.ToLower().Contains(searchText);
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
_viewModel.SelectedComponent = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_viewModel.Components.Clear();
|
||||
foreach (var def in filtered)
|
||||
// 获取该分类下的组件列表
|
||||
IEnumerable<DesktopComponentDefinition> filtered;
|
||||
if (selectedCategory.Id == "all")
|
||||
{
|
||||
_viewModel.Components.Add(CreateComponentItem(def));
|
||||
filtered = _allDefinitions.OrderBy(d => d.DisplayName);
|
||||
}
|
||||
else
|
||||
{
|
||||
filtered = _allDefinitions
|
||||
.Where(d => string.Equals(d.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(d => d.DisplayName);
|
||||
}
|
||||
|
||||
// 选择该分类下的第一个组件作为默认选中
|
||||
var firstComponent = filtered.FirstOrDefault();
|
||||
if (firstComponent is not null)
|
||||
{
|
||||
// 查找或创建对应的 ViewModel
|
||||
var existingComponent = selectedCategory.Components.FirstOrDefault(c => c.ComponentId == firstComponent.Id);
|
||||
if (existingComponent is not null)
|
||||
{
|
||||
_viewModel.SelectedComponent = existingComponent;
|
||||
}
|
||||
else
|
||||
{
|
||||
_viewModel.SelectedComponent = CreateComponentItem(firstComponent);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_viewModel.SelectedComponent = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,21 +12,21 @@
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Mica"
|
||||
Title="融合桌面组件库">
|
||||
|
||||
Title="添加小组件">
|
||||
|
||||
<Panel>
|
||||
<!-- 背景磨砂效果 -->
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceLowBrush}"
|
||||
Opacity="0.85" />
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- 自定义标题栏 -->
|
||||
<Border Background="Transparent"
|
||||
IsHitTestVisible="True"
|
||||
Padding="20,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Text="融合桌面组件库"
|
||||
<TextBlock Text="添加小组件"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="20"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
@@ -35,23 +35,35 @@
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Classes="accent"
|
||||
Width="36" Height="36"
|
||||
Padding="0"
|
||||
CornerRadius="18"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
BorderThickness="0"
|
||||
Background="{DynamicResource AdaptiveButtonHoverBackgroundBrush}"
|
||||
Click="OnCloseClick">
|
||||
<fi:SymbolIcon Symbol="Dismiss" FontSize="18" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
<!-- 组件库控件 -->
|
||||
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
||||
Grid.Row="1" />
|
||||
|
||||
<!-- 底部查找更多组件链接 -->
|
||||
<Border Grid.Row="2"
|
||||
Background="Transparent"
|
||||
Padding="20,12">
|
||||
<Button Classes="hyperlink"
|
||||
HorizontalAlignment="Center"
|
||||
Click="OnFindMoreComponentsClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<fi:SymbolIcon Symbol="Globe" FontSize="14" />
|
||||
<TextBlock Text="查找更多组件" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Window>
|
||||
|
||||
@@ -102,6 +102,26 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找更多组件链接点击处理 - 打开设置窗口的插件目录页面
|
||||
/// </summary>
|
||||
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
// 关闭当前窗口
|
||||
Close();
|
||||
|
||||
// 打开设置窗口并导航到插件目录页面
|
||||
if (Application.Current is App app && app.SettingsWindowService is { } settingsWindowService)
|
||||
{
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
var request = new SettingsWindowOpenRequest(
|
||||
Source: "FusedDesktopComponentLibrary",
|
||||
Owner: mainWindow,
|
||||
PageId: "plugin-catalog");
|
||||
settingsWindowService.Open(request);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -74,6 +74,10 @@ public partial class MainWindow
|
||||
Color PressedColor,
|
||||
Color DividerColor);
|
||||
|
||||
private readonly IPowerManagementService _powerService = PowerManagementServiceFactory.GetOrCreate();
|
||||
private bool _isPowerMenuOpen;
|
||||
private bool _isPowerMenuAnimating;
|
||||
|
||||
private void InitializeTaskbarProfileFlyout()
|
||||
{
|
||||
if (TaskbarProfileButton is null || TaskbarProfilePopup is null)
|
||||
@@ -98,6 +102,16 @@ public partial class MainWindow
|
||||
TaskbarProfileDisplayNameTextBlock.Text = profile.DisplayName;
|
||||
TaskbarProfileSettingsActionTextBlock.Text = L("tooltip.open_settings", "Settings");
|
||||
TaskbarProfileDesktopEditActionTextBlock.Text = L("button.component_library", "Edit Desktop");
|
||||
TaskbarProfilePowerActionTextBlock.Text = L("power.menu", "Power");
|
||||
TaskbarPowerTitleTextBlock.Text = L("power.title", "Power");
|
||||
TaskbarPowerBackTextBlock.Text = L("power.back", "Back");
|
||||
PowerShutdownTextBlock.Text = L("power.shutdown", "Shutdown");
|
||||
PowerRestartTextBlock.Text = L("power.restart", "Restart");
|
||||
PowerLogoutTextBlock.Text = L("power.logout", "Log Out");
|
||||
PowerSleepTextBlock.Text = L("power.sleep", "Sleep");
|
||||
PowerLockTextBlock.Text = L("power.lock_screen", "Lock Screen");
|
||||
|
||||
UpdatePowerMenuVisibility();
|
||||
ApplyTaskbarProfilePopupTheme(_appearanceThemeService.GetCurrent());
|
||||
|
||||
ToolTip.SetTip(TaskbarProfileButton, profile.DisplayName);
|
||||
@@ -216,6 +230,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
ResetPowerMenuState();
|
||||
RefreshTaskbarProfilePresentation();
|
||||
TaskbarProfilePopup.IsOpen = true;
|
||||
}
|
||||
@@ -279,6 +294,199 @@ public partial class MainWindow
|
||||
app?.OpenIndependentSettingsModule("MainWindowTaskbar");
|
||||
}
|
||||
|
||||
private void OnPowerMenuEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
EnterPowerMenu();
|
||||
}
|
||||
|
||||
private void OnPowerMenuBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ExitPowerMenu();
|
||||
}
|
||||
|
||||
private void ResetPowerMenuState()
|
||||
{
|
||||
_isPowerMenuOpen = false;
|
||||
_isPowerMenuAnimating = false;
|
||||
|
||||
if (TaskbarProfileMainPanel is not null)
|
||||
{
|
||||
TaskbarProfileMainPanel.IsVisible = true;
|
||||
TaskbarProfileMainPanel.Opacity = 1d;
|
||||
}
|
||||
|
||||
if (TaskbarProfilePowerPanel is not null)
|
||||
{
|
||||
TaskbarProfilePowerPanel.IsVisible = false;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
var transform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (transform is not null) transform.X = 340d;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePowerMenuVisibility()
|
||||
{
|
||||
var supported = _powerService.IsShutdownSupported ||
|
||||
_powerService.IsRestartSupported ||
|
||||
_powerService.IsLogoutSupported ||
|
||||
_powerService.IsSleepSupported ||
|
||||
_powerService.IsLockSupported;
|
||||
|
||||
if (TaskbarProfilePowerActionButton is not null)
|
||||
{
|
||||
TaskbarProfilePowerActionButton.IsVisible = supported;
|
||||
}
|
||||
}
|
||||
|
||||
private async void EnterPowerMenu()
|
||||
{
|
||||
if (_isPowerMenuAnimating || _isPowerMenuOpen || TaskbarProfileMainPanel is null || TaskbarProfilePowerPanel is null)
|
||||
return;
|
||||
|
||||
_isPowerMenuAnimating = true;
|
||||
|
||||
TaskbarProfilePowerPanel.IsVisible = true;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
var powerTransform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (powerTransform is not null) powerTransform.X = 340d;
|
||||
|
||||
await Task.Delay(16);
|
||||
|
||||
TaskbarProfileMainPanel.Opacity = 0d;
|
||||
TaskbarProfilePowerPanel.Opacity = 1d;
|
||||
if (powerTransform is not null) powerTransform.X = 0d;
|
||||
|
||||
await Task.Delay(280);
|
||||
|
||||
TaskbarProfileMainPanel.IsVisible = false;
|
||||
_isPowerMenuOpen = true;
|
||||
_isPowerMenuAnimating = false;
|
||||
}
|
||||
|
||||
private async void ExitPowerMenu()
|
||||
{
|
||||
if (_isPowerMenuAnimating || !_isPowerMenuOpen || TaskbarProfileMainPanel is null || TaskbarProfilePowerPanel is null)
|
||||
return;
|
||||
|
||||
_isPowerMenuAnimating = true;
|
||||
|
||||
TaskbarProfileMainPanel.IsVisible = true;
|
||||
TaskbarProfileMainPanel.Opacity = 0d;
|
||||
var powerTransform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (powerTransform is not null) powerTransform.X = 0d;
|
||||
|
||||
await Task.Delay(16);
|
||||
|
||||
TaskbarProfileMainPanel.Opacity = 1d;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
if (powerTransform is not null) powerTransform.X = 340d;
|
||||
|
||||
await Task.Delay(280);
|
||||
|
||||
TaskbarProfilePowerPanel.IsVisible = false;
|
||||
_isPowerMenuOpen = false;
|
||||
_isPowerMenuAnimating = false;
|
||||
}
|
||||
|
||||
private async void OnPowerShutdownClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Windows: 使用 SlideToShutDown 滑动关机界面
|
||||
_powerService.ShowNativePowerUI(PowerAction.Shutdown);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Linux: 二次确认对话框
|
||||
await ShowPowerConfirmDialogAsync(L("power.shutdown_confirm_title", "Shutdown"),
|
||||
L("power.shutdown_confirm_message", "Are you sure you want to shut down this computer?"),
|
||||
() => _powerService.ShutdownAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPowerRestartClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
// 所有平台:统一使用二次确认对话框
|
||||
// Note: SlideToShutDown.exe 只支持关机,不支持重启
|
||||
await ShowPowerConfirmDialogAsync(L("power.restart_confirm_title", "Restart"),
|
||||
L("power.restart_confirm_message", "Are you sure you want to restart this computer?"),
|
||||
() => _powerService.RestartAsync());
|
||||
}
|
||||
|
||||
private async void OnPowerLogoutClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
await ShowPowerConfirmDialogAsync(L("power.logout_confirm_title", "Log Out"),
|
||||
L("power.logout_confirm_message", "Are you sure you want to log out?"),
|
||||
() => _powerService.LogoutAsync());
|
||||
}
|
||||
|
||||
private async void OnPowerSleepClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
await ShowPowerConfirmDialogAsync(L("power.sleep_confirm_title", "Sleep"),
|
||||
L("power.sleep_confirm_message", "Are you sure you want to put the computer to sleep?"),
|
||||
() => _powerService.SleepAsync());
|
||||
}
|
||||
|
||||
private async void OnPowerLockClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
await _powerService.LockAsync();
|
||||
}
|
||||
|
||||
private async Task ShowPowerConfirmDialogAsync(string title, string message, Func<Task> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = title,
|
||||
Content = message,
|
||||
PrimaryButtonText = L("power.confirm_yes", "Yes"),
|
||||
SecondaryButtonText = L("power.confirm_cancel", "Cancel")
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
await action();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Error("PowerMenu", $"Dialog error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ClosePopupIfOpen()
|
||||
{
|
||||
if (TaskbarProfilePopup is not null && TaskbarProfilePopup.IsOpen)
|
||||
{
|
||||
TaskbarProfilePopup.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCloseComponentLibraryClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_componentLibraryWindowService.Close(this);
|
||||
@@ -1337,7 +1545,6 @@ public partial class MainWindow
|
||||
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||
return new ComponentLibraryCreateContext(
|
||||
cellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
@@ -2341,12 +2548,10 @@ public partial class MainWindow
|
||||
componentId,
|
||||
null,
|
||||
_currentDesktopCellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
appearanceSnapshot.CornerRadiusTokens));
|
||||
}
|
||||
|
||||
var scale = Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, appearanceSnapshot.GlobalCornerRadiusScale);
|
||||
return Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18) * scale;
|
||||
return Math.Max(0d, appearanceSnapshot.CornerRadiusTokens.Component.TopLeft);
|
||||
}
|
||||
|
||||
private Thickness GetDesktopComponentVisualInset(int widthCells, int heightCells)
|
||||
@@ -2598,7 +2803,6 @@ public partial class MainWindow
|
||||
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
|
||||
var createContext = new ComponentLibraryCreateContext(
|
||||
cellSize,
|
||||
appearanceSnapshot.GlobalCornerRadiusScale,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
|
||||
@@ -47,6 +47,7 @@ public partial class MainWindow
|
||||
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
|
||||
private readonly HashSet<string> _hiddenLauncherFolderPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _hiddenLauncherAppPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _showLauncherTileBackground = true;
|
||||
private Button? _selectedLauncherTileButton;
|
||||
private LauncherEntryKind? _selectedLauncherEntryKind;
|
||||
private string? _selectedLauncherEntryKey;
|
||||
@@ -116,6 +117,8 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_showLauncherTileBackground = snapshot.ShowTileBackground;
|
||||
}
|
||||
|
||||
private void InitializeDesktopSurfaceSwipeHandlers()
|
||||
@@ -1137,7 +1140,6 @@ public partial class MainWindow
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
Margin = new Thickness(0, 0, 12, 12),
|
||||
BorderThickness = new Thickness(0),
|
||||
BorderBrush = Brushes.Transparent,
|
||||
@@ -1146,6 +1148,16 @@ public partial class MainWindow
|
||||
Content = content
|
||||
// 不设置固定 Width 和 Height,由 UpdateLauncherTileLayout 动态设置
|
||||
};
|
||||
|
||||
// 根据设置决定是否显示背景
|
||||
if (_showLauncherTileBackground)
|
||||
{
|
||||
button.Classes.Add("glass-panel");
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Background = Brushes.Transparent;
|
||||
}
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
@@ -1676,7 +1688,6 @@ public partial class MainWindow
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
BorderThickness = new Thickness(0),
|
||||
@@ -1684,6 +1695,17 @@ public partial class MainWindow
|
||||
Padding = new Thickness(8, 8, 8, 6),
|
||||
Content = content
|
||||
};
|
||||
|
||||
// 根据设置决定是否显示背景
|
||||
if (_showLauncherTileBackground)
|
||||
{
|
||||
button.Classes.Add("glass-panel");
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Background = Brushes.Transparent;
|
||||
}
|
||||
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
@@ -1745,7 +1767,6 @@ public partial class MainWindow
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
BorderThickness = new Thickness(0),
|
||||
@@ -1753,6 +1774,17 @@ public partial class MainWindow
|
||||
Padding = new Thickness(8, 8, 8, 6),
|
||||
Content = content
|
||||
};
|
||||
|
||||
// 根据设置决定是否显示背景
|
||||
if (_showLauncherTileBackground)
|
||||
{
|
||||
button.Classes.Add("glass-panel");
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Background = Brushes.Transparent;
|
||||
}
|
||||
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
|
||||
@@ -44,6 +44,23 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动台设置变化时,重新渲染启动台图标
|
||||
if (e.Scope == SettingsScope.Launcher && e.ChangedKeys is { Count: > 0 })
|
||||
{
|
||||
var changedKeys = e.ChangedKeys.ToArray();
|
||||
if (changedKeys.Any(key =>
|
||||
string.Equals(key, nameof(LauncherSettingsSnapshot.ShowTileBackground), StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var launcherSnapshot = _settingsService.LoadSnapshot<LauncherSettingsSnapshot>(SettingsScope.Launcher);
|
||||
InitializeLauncherVisibilitySettings(launcherSnapshot);
|
||||
RenderLauncherRootTiles();
|
||||
}, DispatcherPriority.Background);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
|
||||
{
|
||||
var changedKeys = e.ChangedKeys.ToArray();
|
||||
@@ -51,6 +68,7 @@ public partial class MainWindow
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.ThemeColorMode), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateInstallerPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, nameof(AppSettingsSnapshot.PendingUpdateVersion), StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -611,7 +629,7 @@ public partial class MainWindow
|
||||
SystemMaterialMode = latestThemeState.SystemMaterialMode,
|
||||
SelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed,
|
||||
UseSystemChrome = latestThemeState.UseSystemChrome,
|
||||
GlobalCornerRadiusScale = latestThemeState.GlobalCornerRadiusScale,
|
||||
CornerRadiusStyle = latestThemeState.CornerRadiusStyle,
|
||||
WallpaperPath = latestWallpaperState.WallpaperPath,
|
||||
WallpaperType = latestWallpaperState.Type,
|
||||
WallpaperColor = string.Equals(latestWallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
@@ -398,69 +398,211 @@
|
||||
<Border x:Name="TaskbarProfilePopupPanel"
|
||||
Classes="taskbar-profile-popup-panel"
|
||||
Margin="0,0,0,10">
|
||||
<StackPanel Width="280"
|
||||
Spacing="12">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
|
||||
Classes="taskbar-profile-popup-avatar"
|
||||
Width="44"
|
||||
Height="44"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="TaskbarProfileHeaderAvatarImage"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False" />
|
||||
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
|
||||
Classes="taskbar-profile-popup-primary"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="U" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Width="340">
|
||||
<Grid x:Name="TaskbarProfileMainPanel"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
|
||||
Classes="taskbar-profile-popup-avatar"
|
||||
Width="44"
|
||||
Height="44"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="TaskbarProfileHeaderAvatarImage"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False" />
|
||||
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
|
||||
Classes="taskbar-profile-popup-primary"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="U" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
|
||||
Classes="taskbar-profile-popup-title"
|
||||
Text="User" />
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
|
||||
Classes="taskbar-profile-popup-title"
|
||||
Text="User" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="TaskbarProfilePopupDivider"
|
||||
Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
|
||||
<Button x:Name="TaskbarProfileSettingsActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenSettingsClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Settings" />
|
||||
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Settings" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfileDesktopEditActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenComponentLibraryClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Pencil" />
|
||||
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Edit Desktop" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfilePowerActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerMenuEnterClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Power" />
|
||||
<TextBlock x:Name="TaskbarProfilePowerActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Power" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="TaskbarProfilePopupDivider"
|
||||
Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
<Grid x:Name="TaskbarProfilePowerPanel"
|
||||
IsVisible="False"
|
||||
Opacity="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X"
|
||||
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
|
||||
Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
</Grid.RenderTransform>
|
||||
<StackPanel Spacing="8">
|
||||
<Button x:Name="TaskbarPowerBackButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
HorizontalAlignment="Left"
|
||||
Click="OnPowerMenuBackClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="ArrowLeft" />
|
||||
<TextBlock x:Name="TaskbarPowerBackTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Back" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfileSettingsActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenSettingsClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Settings" />
|
||||
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Settings" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
|
||||
Text="Power" />
|
||||
|
||||
<Button x:Name="TaskbarProfileDesktopEditActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenComponentLibraryClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Pencil" />
|
||||
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Edit Desktop" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<Border Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
|
||||
<Button x:Name="PowerShutdownButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerShutdownClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Power" />
|
||||
<TextBlock x:Name="PowerShutdownTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Shutdown" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerRestartButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerRestartClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Refresh" />
|
||||
<TextBlock x:Name="PowerRestartTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Restart" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerLogoutButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLogoutClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="ExitToApp" />
|
||||
<TextBlock x:Name="PowerLogoutTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Log Out" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerSleepButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerSleepClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="WeatherNight" />
|
||||
<TextBlock x:Name="PowerSleepTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Sleep" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerLockButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLockClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Lock" />
|
||||
<TextBlock x:Name="PowerLockTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Lock Screen" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
xmlns:controls="using:LanMountainDesktop.Controls"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:symbol="using:FluentIcons.Common"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage"
|
||||
x:DataType="vm:LauncherSettingsPageViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
@@ -52,9 +53,33 @@
|
||||
</Border>
|
||||
|
||||
<controls:IconText Icon="Apps"
|
||||
Text="{Binding HiddenHeader}"
|
||||
Text="{Binding AppearanceHeader}"
|
||||
Margin="0,0,0,4" />
|
||||
|
||||
<ui:SettingsExpander Classes="settings-expander-card"
|
||||
Header="{Binding AppearanceHeader}"
|
||||
Description="{Binding AppearanceDescription}"
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="{x:Static symbol:Symbol.Apps}" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding ShowTileBackgroundHeader}" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding ShowTileBackgroundDescription}" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1"
|
||||
IsChecked="{Binding ShowTileBackground}" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<controls:IconText Icon="Apps"
|
||||
Text="{Binding HiddenHeader}"
|
||||
Margin="0,24,0,4" />
|
||||
|
||||
<ui:SettingsExpander Classes="settings-expander-card"
|
||||
Header="{Binding HiddenHeader}"
|
||||
Description="{Binding HiddenDescription}"
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container settings-page-animated">
|
||||
<TextBlock Classes="settings-section-title"
|
||||
Text="{Binding PageTitle}" />
|
||||
<TextBlock Classes="settings-section-description"
|
||||
Text="{Binding PageDescription}" />
|
||||
|
||||
<Border Classes="update-status-card">
|
||||
<StackPanel Spacing="18">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
#define MyAppArch "x64"
|
||||
#endif
|
||||
|
||||
#ifndef MyAppSuffix
|
||||
#define MyAppSuffix ""
|
||||
#endif
|
||||
|
||||
#ifndef IsSelfContained
|
||||
#define IsSelfContained "true"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={#MyAppId}
|
||||
AppName={#MyAppName}
|
||||
@@ -34,7 +42,7 @@ LanguageDetectionMethod=uilanguage
|
||||
DefaultGroupName={cm:AppShortcutName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
OutputDir={#MyOutputDir}
|
||||
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}
|
||||
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}{#MyAppSuffix}
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
@@ -93,7 +101,17 @@ chinesesimplified.UpgradeCleanupMissingUninstaller=安装程序发现了现有
|
||||
english.UpgradeCleanupFailedPrefix=Setup could not remove the existing installation automatically. Error code:
|
||||
chinesesimplified.UpgradeCleanupFailedPrefix=安装程序无法自动移除现有安装。错误代码:
|
||||
english.UpgradeCleanupFailedSuffix=Please close LanMountainDesktop, uninstall the current version manually, and then run this installer again.
|
||||
chinesesimplified.UpgradeCleanupFailedSuffix=请关闭 LanMountainDesktop,手动卸载当前版本,然后重新运行此安装程序。
|
||||
chinesesimplified.UpgradeCleanupFailedSuffix=请关闭 LanMountain Desktop,手动卸载当前版本,然后重新运行此安装程序。
|
||||
english.DotNetRuntimeMissingTitle=.NET Desktop Runtime Required
|
||||
chinesesimplified.DotNetRuntimeMissingTitle=需要 .NET Desktop Runtime
|
||||
english.DotNetRuntimeMissingMessage=This application requires .NET 10.0 Desktop Runtime to run.
|
||||
chinesesimplified.DotNetRuntimeMissingMessage=此应用程序需要 .NET 10.0 Desktop Runtime 才能运行。
|
||||
english.DotNetRuntimeMissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again.
|
||||
chinesesimplified.DotNetRuntimeMissingAction=单击"是"打开官方下载页面。请先完成安装,然后重新运行此安装程序。
|
||||
english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automatically.
|
||||
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
|
||||
english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
|
||||
chinesesimplified.DotNetRuntimeOpenFailedAction=请手动打开以下链接:
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||
@@ -127,6 +145,7 @@ const
|
||||
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
|
||||
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
|
||||
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
|
||||
DotNetRuntimeDownloadUrl = 'https://dotnet.microsoft.com/download/dotnet/10.0';
|
||||
UpgradeChoiceInPlace = 0;
|
||||
UpgradeChoiceRelocate = 1;
|
||||
|
||||
@@ -435,10 +454,58 @@ begin
|
||||
RegQueryStringValue(HKCU32, WebView2RuntimeKeyPath, 'pv', VersionValue);
|
||||
end;
|
||||
|
||||
function IsDotNetDesktopRuntimeInstalled(): Boolean;
|
||||
var
|
||||
RuntimePath: String;
|
||||
begin
|
||||
Result := False;
|
||||
|
||||
RuntimePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
||||
if DirExists(RuntimePath) then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
|
||||
RuntimePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
|
||||
if DirExists(RuntimePath) then
|
||||
begin
|
||||
Result := True;
|
||||
exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
function InitializeSetup(): Boolean;
|
||||
var
|
||||
ErrorCode: Integer;
|
||||
IsSelfContainedBuild: Boolean;
|
||||
begin
|
||||
IsSelfContainedBuild := ('{#IsSelfContained}' = 'true');
|
||||
|
||||
if not IsSelfContainedBuild then
|
||||
begin
|
||||
if not IsDotNetDesktopRuntimeInstalled() then
|
||||
begin
|
||||
if MsgBox(
|
||||
CustomMessage('DotNetRuntimeMissingMessage') + #13#10#13#10 +
|
||||
CustomMessage('DotNetRuntimeMissingAction'),
|
||||
mbConfirmation,
|
||||
MB_YESNO) = IDYES then
|
||||
begin
|
||||
if not ShellExec('open', DotNetRuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
|
||||
begin
|
||||
MsgBox(
|
||||
CustomMessage('DotNetRuntimeOpenFailedMessage') + #13#10 +
|
||||
CustomMessage('DotNetRuntimeOpenFailedAction') + #13#10 + DotNetRuntimeDownloadUrl,
|
||||
mbError,
|
||||
MB_OK);
|
||||
end;
|
||||
end;
|
||||
Result := False;
|
||||
exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
if IsWebView2RuntimeInstalled() then
|
||||
begin
|
||||
Result := True;
|
||||
|
||||
@@ -339,8 +339,7 @@ public sealed class PluginLoader
|
||||
private static PluginAppearanceSnapshot BuildAppearanceSnapshot(IServiceProvider? hostServices)
|
||||
{
|
||||
var defaultSnapshot = new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: 1d,
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 18),
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
|
||||
ThemeVariant: "Unknown");
|
||||
|
||||
if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService)
|
||||
@@ -352,7 +351,6 @@ public sealed class PluginLoader
|
||||
{
|
||||
var hostSnapshot = appearanceThemeService.GetCurrent();
|
||||
return new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: Math.Max(0d, hostSnapshot.GlobalCornerRadiusScale),
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(hostSnapshot.CornerRadiusTokens),
|
||||
ThemeVariant: hostSnapshot.IsNightMode ? "Dark" : "Light");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 阑山桌面 / LanMountainDesktop
|
||||
# 阑山桌面LanMountainDesktop
|
||||
|
||||
> 你的桌面,不止一面
|
||||
|
||||
|
||||
@@ -1,39 +1,59 @@
|
||||
# 圆角设计规范
|
||||
# 圆角设计规范 (LanMountain Desktop Corner Radius Spec)
|
||||
|
||||
## 中文
|
||||
## 核心理念 (Core Philosophy)
|
||||
|
||||
本规范用于统一阑山桌面不同层级容器和控件的圆角尺度。
|
||||
为了确保桌面组件在不同尺寸、缩放比例下都能保持视觉一致性和美感,阑山桌面采用了 **固定圆角风格预设 (Fixed Corner Radius Styles)**,全面参考小米澎湃OS (Xiaomi HyperOS) 的设计语言。
|
||||
|
||||
### 基础层级
|
||||
所有的组件和容器必须使用统一的资源键,禁止在 XAML 或代码中使用硬编码的像素值。
|
||||
|
||||
- Level 1:12px,小元素和图标容器
|
||||
- Level 2:16px,小型色块和紧凑控件
|
||||
- Level 3:20px,普通按钮
|
||||
- Level 4:24px,输入面板和小型容器
|
||||
- Component:18px,桌面组件的标准圆角(默认值)
|
||||
- Level 5:28px,普通玻璃面板
|
||||
- Level 6:32px,强化容器
|
||||
- Level 7:36px,大容器、窗口、任务栏
|
||||
## 预设风格 (Preset Styles)
|
||||
|
||||
### 使用建议
|
||||
用户可以在设置中选择以下四种风格之一。系统会自动根据选中的风格动态映射全局圆角 Token。
|
||||
|
||||
- 同层级元素保持相同圆角。
|
||||
- 大容器的圆角大于内部子面板。
|
||||
- 动态尺寸组件可按 `cellSize` 计算圆角,但仍要落在统一范围内。
|
||||
| 风格 (ID) | 名称 (Local) | 组件圆角 (Component) | 设计语义 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Sharp** | 锐利 | 20px | 紧凑、精确、利落 |
|
||||
| **Balanced** | 平衡 | 24px | **默认值**。和谐、自然、普适 |
|
||||
| **Rounded** | 圆润 | 28px | 保守、柔和、亲切 |
|
||||
| **Open** | 开放 | 32px | 现代、沉浸、夸张 |
|
||||
|
||||
### 动态圆角建议
|
||||
## Token 阶梯映射 (Token Step Mapping)
|
||||
|
||||
```csharp
|
||||
var cornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
|
||||
```
|
||||
每个风格都定义了一套完整的圆角阶梯,以确保在大容器包裹小元素时满足 **圆角嵌套一致性 (Nesting Consistency)**。
|
||||
|
||||
## English
|
||||
| Token | Sharp | Balanced | Rounded | Open | 典型场景 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **Micro** | 4px | 6px | 8px | 10px | 小图标容器、角标 (Badge) |
|
||||
| **Xs** | 8px | 12px | 14px | 16px | 小标签 (Tag)、输入框 |
|
||||
| **Sm** | 10px | 14px | 16px | 20px | 普通按钮、搜索栏、复选框 |
|
||||
| **Md** | 14px | 20px | 24px | 28px | 悬浮菜单、小提示框、子卡片 |
|
||||
| **Lg** | 20px | 28px | 32px | 36px | 普通面板、对话框内容区 |
|
||||
| **Xl** | 24px | 32px | 36px | 40px | 大尺寸容器、设置中心页面 |
|
||||
| **Island** | 28px | 36px | 40px | 44px | 任务栏、全局大悬浮容器 |
|
||||
| **Component** | **20px** | **24px** | **28px** | **32px** | **所有桌面组件 (Widget) 的主边框** |
|
||||
|
||||
This specification keeps corner radius usage consistent across containers and controls.
|
||||
## 开发准则 (Implementation Rules)
|
||||
|
||||
### Reference levels
|
||||
> [!IMPORTANT]
|
||||
> **1. 桌面组件强制约束**:
|
||||
> 所有桌面组件(Widget / Desktop Component)的根容器边框必须使用 `{DynamicResource DesignCornerRadiusComponent}`。严禁对其进行任何比例运算或系数乘积(如 `* scale`),必须保持固定。
|
||||
|
||||
- 12px for small elements
|
||||
- 20px for common buttons
|
||||
- 28px for normal glass panels
|
||||
- 36px for large containers and windows
|
||||
> [!TIP]
|
||||
> **2. 圆角嵌套规则**:
|
||||
> 当一个容器包裹另一个元素时,外层圆角应比内层圆角大一个阶梯。例如:
|
||||
> - 外部使用 `DesignCornerRadiusLg`
|
||||
> - 内部紧贴边缘的内容应使用 `DesignCornerRadiusMd`
|
||||
> 这样可以保证两条圆弧的圆心趋于重合,视觉重心更稳固。
|
||||
|
||||
> [!CAUTION]
|
||||
> **3. 禁止硬编码 (No Hardcoding)**:
|
||||
> 禁止写死数字(如 `CornerRadius="24"`)或私有资源。如果现有 Token 无法满足需求,应优先考虑使用 `SafeValue` 辅助方法封装,但必须声明理由。
|
||||
|
||||
## 常用资源键 (Common Resource Keys)
|
||||
|
||||
- `DesignCornerRadiusComponent` (最常用)
|
||||
- `DesignCornerRadiusMicro`
|
||||
- `DesignCornerRadiusSm`
|
||||
- `DesignCornerRadiusMd`
|
||||
- `DesignCornerRadiusLg`
|
||||
- `DesignCornerRadiusXl`
|
||||
|
||||
62
docs/TYPOGRAPHY_SPEC.md
Normal file
62
docs/TYPOGRAPHY_SPEC.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 字体排版设计规范 (Typography Specification)
|
||||
|
||||
## 中文
|
||||
|
||||
本规范用于统一阑山桌面各组件(Widget)及页面的字体样式,解决目前组件间字体不协调、厚度不一的问题。通过引入标准化的设计 Token,确保在不同 DPI 和设备上呈现一致的高级感(Premium Look)。
|
||||
|
||||
### 1. 字体家族 (Font Family)
|
||||
|
||||
- **默认字体**:优先使用内置的 `MiSans VF` (Variable Font)。
|
||||
- **回退顺序**:`MiSans VF` -> `MiSans` -> `Microsoft YaHei` -> `Sans-serif`。
|
||||
|
||||
### 2. 字重标准 (Font Weights)
|
||||
|
||||
为了达到“不粗不细”的协调感,我们采用 `Medium (500)` 作为默认正文字重,以应对复杂的背景环境。
|
||||
|
||||
| 角色 | Token | MiSans 权重 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Caption/Secondary** | `DesignFontWeightCaption` | `Normal (400)` | 用于不重要的补充说明信息 |
|
||||
| **Body (Default)** | `DesignFontWeightBody` | `Medium (500)` | **核心全局字重**,用于所有常规正文 |
|
||||
| **Title/Header** | `DesignFontWeightTitle` | `SemiBold (600)` | 用于卡片标题、分类标题 |
|
||||
| **Display (Large)** | `DesignFontWeightDisplay` | `SemiBold (600)` | 用于超大号文本(如温度数字) |
|
||||
|
||||
> **注意**:除非极特殊艺术需求,应避免使用 `Thin`, `ExtraLight`, `Light` 或 `Bold (700)`, `Heavy`。
|
||||
|
||||
### 3. 字号标准 (Font Sizes)
|
||||
|
||||
| 角色 | Token | 数值 (px) | 典型应用场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Caption** | `DesignFontSizeCaption` | 12 | 底部说明、状态提示 |
|
||||
| **BodySmall** | `DesignFontSizeBodySmall` | 13 | 设置项描述、次要标签 |
|
||||
| **Body** | `DesignFontSizeBody` | 14 | 标准文本、正文内容 |
|
||||
| **BodyLarge** | `DesignFontSizeBodyLarge` | 16 | 加大正文、菜单项 |
|
||||
| **Subtitle** | `DesignFontSizeSubtitle` | 18 | 小节标题、大按钮文字 |
|
||||
| **Title** | `DesignFontSizeTitle` | 24 | 组件标题、大卡片标题 |
|
||||
| **Headline** | `DesignFontSizeHeadline` | 32 | 重要数据指标 |
|
||||
| **Display** | `DesignFontSizeDisplay` | 48 | 天气温度、时间分钟 |
|
||||
| **DisplayLarge** | `DesignFontSizeDisplayLarge` | 54 | 诗词正文、欢迎语 |
|
||||
|
||||
### 4. 行高标准 (Line Heights)
|
||||
|
||||
统一行高可以增强视觉节奏感。
|
||||
|
||||
| Token | 数值 (倍率) | 应用场景 |
|
||||
| --- | --- | --- |
|
||||
| `DesignLineHeightStandard` | 1.2 | 单行标签、紧凑卡片 |
|
||||
| `DesignLineHeightLoose` | 1.5 | 多行诗词、新闻摘要、说明文档 |
|
||||
|
||||
### 5. 使用规范
|
||||
|
||||
1. **禁止硬编码**:严禁在 `.axaml` 中直接写入 `FontSize="18"` 或 `FontWeight="Bold"`。
|
||||
2. **动态资源绑定**:始终使用 `{DynamicResource DesignFontSize...}` 进行绑定。
|
||||
3. **全局样式继承**:`App.axaml` 已经设置了 `TextBlock` 的默认 `FontWeight` 为 `Medium`,除非是 `Caption` 或 `Title`,否则无需重复声明。
|
||||
|
||||
---
|
||||
|
||||
## English (Summary)
|
||||
|
||||
- **Default Font**: MiSans VF.
|
||||
- **Base Weight**: `Medium (500)` for better readability on glass/dark backgrounds.
|
||||
- **Header Weight**: `SemiBold (600)` for a modern premium feel.
|
||||
- **Line Height**: Standardized to 1.2x and 1.5x.
|
||||
- **Tokens**: All components must use `DesignFontSize...` and `DesignFontWeight...` resource keys.
|
||||
@@ -25,6 +25,12 @@
|
||||
- `glass-strong`:主要大容器
|
||||
- `glass-panel`:子区域、小面板、卡片
|
||||
|
||||
### 形状与圆角 (Shape & Corner Radius)
|
||||
|
||||
- **全局统一**:所有 UI 元素的圆角必须遵循 [圆角设计规范](file:///c:/Users/USER154971/Documents/GitHub/LanMountainDesktop/docs/CORNER_RADIUS_SPEC.md)。
|
||||
- **禁止硬编码**:严禁在资源库以外的地方硬编码 `CornerRadius` 数值。
|
||||
- **动态适配**:桌面组件必须使用 `DesignCornerRadiusComponent` 动态资源,以支持用户在设置中全局切换“锐利/平衡/圆润/开放”风格。
|
||||
|
||||
### 可访问性
|
||||
|
||||
- 正文对比度目标不低于 `4.5:1`
|
||||
|
||||
Reference in New Issue
Block a user