mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e795e9964e | ||
|
|
11130cfdb3 | ||
|
|
66ae0b0270 | ||
|
|
a671db8b69 | ||
|
|
8c94253f92 | ||
|
|
6849a467d6 | ||
|
|
e69bbf8b19 | ||
|
|
d30af21317 | ||
|
|
8583465a67 | ||
|
|
e1d5a0c6de | ||
|
|
5fa2031ad6 | ||
|
|
0662565dca | ||
|
|
12a2f6729b | ||
|
|
5d2449fa8f | ||
|
|
00339f0ed0 | ||
|
|
021c7ff245 | ||
|
|
675096b6c4 | ||
|
|
1c3cc76f21 | ||
|
|
44b87ba12e | ||
|
|
35976c3f3d |
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,11 @@ public partial class App : Application
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
if (!Design.IsDesignMode && OperatingSystem.IsWindows())
|
||||
{
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().Initialize();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
@@ -226,8 +231,11 @@ public partial class App : Application
|
||||
AppLogger.Warn("FusedDesktop", "Fused desktop is only supported on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换进入编辑模式,隐藏常态零散的小部件
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
||||
|
||||
// 确保透明覆盖层窗口存在
|
||||
// 确保透明覆盖层窗口存在并显示
|
||||
EnsureTransparentOverlayWindow();
|
||||
|
||||
// 打开融合桌面组件库窗口
|
||||
@@ -235,6 +243,12 @@ public partial class App : Application
|
||||
{
|
||||
try
|
||||
{
|
||||
// 确保覆盖层窗口已显示(组件要渲染在上面,必须先 Show)
|
||||
if (_transparentOverlayWindow is not null && !_transparentOverlayWindow.IsVisible)
|
||||
{
|
||||
_transparentOverlayWindow.Show();
|
||||
}
|
||||
|
||||
var window = new FusedDesktopComponentLibraryWindow();
|
||||
|
||||
if (_transparentOverlayWindow is not null)
|
||||
@@ -242,6 +256,19 @@ public partial class App : Application
|
||||
window.SetOverlayWindow(_transparentOverlayWindow);
|
||||
}
|
||||
|
||||
// 当组件库关闭时,退出编辑态
|
||||
window.Closed += (s, ev) =>
|
||||
{
|
||||
if (_transparentOverlayWindow is not null)
|
||||
{
|
||||
// 触发画布保存,并隐藏画布
|
||||
_transparentOverlayWindow.SaveLayoutAndHide();
|
||||
}
|
||||
|
||||
// 让管理器根据已存储的最新快照重建生成所有实体小组件
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||
};
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
@@ -637,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) &&
|
||||
|
||||
@@ -44,4 +44,7 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -400,6 +400,36 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"文件管理",
|
||||
"Folder",
|
||||
"File",
|
||||
MinWidthCells: 4,
|
||||
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">
|
||||
|
||||
@@ -388,6 +388,41 @@
|
||||
"settings.status_bar.clock_format_label": "Clock format",
|
||||
"settings.status_bar.clock_format.hm": "Hour:Minute",
|
||||
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
|
||||
"settings.status_bar.clock_position_label": "Clock position",
|
||||
"settings.status_bar.clock_position.left": "Left",
|
||||
"settings.status_bar.clock_position.center": "Center",
|
||||
"settings.status_bar.clock_position.right": "Right",
|
||||
"settings.status_bar.text_capsule_header": "Text Capsule",
|
||||
"settings.status_bar.text_capsule_description": "Display custom text on the status bar with Markdown support.",
|
||||
"settings.status_bar.text_capsule_position_label": "Text capsule position",
|
||||
"settings.status_bar.text_capsule_position.left": "Left",
|
||||
"settings.status_bar.text_capsule_position.center": "Center",
|
||||
"settings.status_bar.text_capsule_position.right": "Right",
|
||||
"settings.status_bar.text_capsule_content_label": "Text content (Markdown supported)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "Transparent background",
|
||||
"settings.status_bar.network_speed_header": "Network Speed",
|
||||
"settings.status_bar.network_speed_description": "Display real-time network upload and download speed on the status bar.",
|
||||
"settings.status_bar.network_speed_position_label": "Network speed position",
|
||||
"settings.status_bar.network_speed_position.left": "Left",
|
||||
"settings.status_bar.network_speed_position.center": "Center",
|
||||
"settings.status_bar.network_speed_position.right": "Right",
|
||||
"settings.status_bar.network_speed_mode_label": "Display mode",
|
||||
"settings.status_bar.network_speed_mode.both": "Upload + Download",
|
||||
"settings.status_bar.network_speed_mode.upload": "Upload only",
|
||||
"settings.status_bar.network_speed_mode.download": "Download only",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "Transparent background",
|
||||
"settings.status_bar.show_network_type_icon_label": "Show network type icon",
|
||||
"settings.status_bar.shadow_header": "Status Bar Shadow",
|
||||
"settings.status_bar.shadow_desc": "Add shadow effect to the status bar for better visibility of transparent components.",
|
||||
"settings.status_bar.shadow_enabled_label": "Enable shadow",
|
||||
"settings.status_bar.shadow_color_label": "Shadow color",
|
||||
"settings.status_bar.shadow_opacity_label": "Shadow opacity",
|
||||
"settings.status_bar.theme_header": "Status Bar Theme",
|
||||
"settings.status_bar.theme_desc": "Set the theme mode for the status bar independently.",
|
||||
"settings.status_bar.theme_mode_label": "Theme mode",
|
||||
"settings.status_bar.theme_mode.follow_global": "Follow Global",
|
||||
"settings.status_bar.theme_mode.dark": "Dark",
|
||||
"settings.status_bar.theme_mode.light": "Light",
|
||||
"settings.components.title": "Components",
|
||||
"settings.components.description": "Adjust component layout and corner design.",
|
||||
"settings.components.grid_header": "Grid Settings",
|
||||
@@ -529,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.",
|
||||
@@ -1040,7 +1079,9 @@
|
||||
"zhijiaohub.settings.source": "Image Source",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
|
||||
"zhijiaohub.settings.sectl": "SECTL Gallery",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
|
||||
"zhijiaohub.settings.rinlit": "Rin's Gallery",
|
||||
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto Memes",
|
||||
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community, Rin's Gallery contains content from Rin's community, Jiangtokoto Memes contains rich meme resources.",
|
||||
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
|
||||
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
|
||||
@@ -1050,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"
|
||||
}
|
||||
|
||||
@@ -331,6 +331,41 @@
|
||||
"settings.status_bar.clock_format_label": "時計の形式",
|
||||
"settings.status_bar.clock_format.hm": "時:分",
|
||||
"settings.status_bar.clock_format.hms": "時:分:秒",
|
||||
"settings.status_bar.clock_position_label": "時計の位置",
|
||||
"settings.status_bar.clock_position.left": "左",
|
||||
"settings.status_bar.clock_position.center": "中央",
|
||||
"settings.status_bar.clock_position.right": "右",
|
||||
"settings.status_bar.text_capsule_header": "テキストカプセル",
|
||||
"settings.status_bar.text_capsule_description": "ステータスバーにMarkdown形式のカスタムテキストを表示します。",
|
||||
"settings.status_bar.text_capsule_position_label": "テキストカプセルの位置",
|
||||
"settings.status_bar.text_capsule_position.left": "左",
|
||||
"settings.status_bar.text_capsule_position.center": "中央",
|
||||
"settings.status_bar.text_capsule_position.right": "右",
|
||||
"settings.status_bar.text_capsule_content_label": "テキスト内容(Markdown対応)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "透明な背景",
|
||||
"settings.status_bar.network_speed_header": "ネットワーク速度",
|
||||
"settings.status_bar.network_speed_description": "ステータスバーにリアルタイムのネットワーク速度を表示します。",
|
||||
"settings.status_bar.network_speed_position_label": "ネットワーク速度の位置",
|
||||
"settings.status_bar.network_speed_position.left": "左",
|
||||
"settings.status_bar.network_speed_position.center": "中央",
|
||||
"settings.status_bar.network_speed_position.right": "右",
|
||||
"settings.status_bar.network_speed_mode_label": "表示モード",
|
||||
"settings.status_bar.network_speed_mode.both": "アップロード + ダウンロード",
|
||||
"settings.status_bar.network_speed_mode.upload": "アップロードのみ",
|
||||
"settings.status_bar.network_speed_mode.download": "ダウンロードのみ",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "透明な背景",
|
||||
"settings.status_bar.show_network_type_icon_label": "ネットワークタイプアイコンを表示",
|
||||
"settings.status_bar.shadow_header": "ステータスバーの影",
|
||||
"settings.status_bar.shadow_desc": "透明なコンポーネントの視認性を高めるために、ステータスバーに影効果を追加します。",
|
||||
"settings.status_bar.shadow_enabled_label": "影を有効にする",
|
||||
"settings.status_bar.shadow_color_label": "影の色",
|
||||
"settings.status_bar.shadow_opacity_label": "影の不透明度",
|
||||
"settings.status_bar.theme_header": "ステータスバーのテーマ",
|
||||
"settings.status_bar.theme_desc": "ステータスバーのテーマモードを独立して設定します。",
|
||||
"settings.status_bar.theme_mode_label": "テーマモード",
|
||||
"settings.status_bar.theme_mode.follow_global": "グローバルに従う",
|
||||
"settings.status_bar.theme_mode.dark": "ダーク",
|
||||
"settings.status_bar.theme_mode.light": "ライト",
|
||||
"settings.components.title": "コンポーネント",
|
||||
"settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。",
|
||||
"settings.components.grid_header": "グリッド設定",
|
||||
|
||||
@@ -377,6 +377,41 @@
|
||||
"settings.status_bar.clock_format_label": "시계 형식",
|
||||
"settings.status_bar.clock_format.hm": "시:분",
|
||||
"settings.status_bar.clock_format.hms": "시:분:초",
|
||||
"settings.status_bar.clock_position_label": "시계 위치",
|
||||
"settings.status_bar.clock_position.left": "왼쪽",
|
||||
"settings.status_bar.clock_position.center": "가욍데",
|
||||
"settings.status_bar.clock_position.right": "오른쪽",
|
||||
"settings.status_bar.text_capsule_header": "텍스트 캡슐",
|
||||
"settings.status_bar.text_capsule_description": "Markdown 형식의 사용자 정의 텍스트를 상태 표시줄에 표시합니다.",
|
||||
"settings.status_bar.text_capsule_position_label": "텍스트 캡슐 위치",
|
||||
"settings.status_bar.text_capsule_position.left": "왼쪽",
|
||||
"settings.status_bar.text_capsule_position.center": "가욍데",
|
||||
"settings.status_bar.text_capsule_position.right": "오른쪽",
|
||||
"settings.status_bar.text_capsule_content_label": "텍스트 내용 (Markdown 지원)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "투명 배경",
|
||||
"settings.status_bar.network_speed_header": "네트워크 속도",
|
||||
"settings.status_bar.network_speed_description": "상태 표시줄에 실시간 네트워크 속도를 표시합니다.",
|
||||
"settings.status_bar.network_speed_position_label": "네트워크 속도 위치",
|
||||
"settings.status_bar.network_speed_position.left": "왼쪽",
|
||||
"settings.status_bar.network_speed_position.center": "가욍데",
|
||||
"settings.status_bar.network_speed_position.right": "오른쪽",
|
||||
"settings.status_bar.network_speed_mode_label": "표시 모드",
|
||||
"settings.status_bar.network_speed_mode.both": "업로드 + 다운로드",
|
||||
"settings.status_bar.network_speed_mode.upload": "업로드만",
|
||||
"settings.status_bar.network_speed_mode.download": "다운로드만",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "투명 배경",
|
||||
"settings.status_bar.show_network_type_icon_label": "네트워크 유형 아이콘 표시",
|
||||
"settings.status_bar.shadow_header": "상태 표시줄 그림자",
|
||||
"settings.status_bar.shadow_desc": "투명한 구성 요소의 가시성을 높이기 위해 상태 표시줄에 그림자 효과를 추가합니다.",
|
||||
"settings.status_bar.shadow_enabled_label": "그림자 활성화",
|
||||
"settings.status_bar.shadow_color_label": "그림자 색상",
|
||||
"settings.status_bar.shadow_opacity_label": "그림자 불투명도",
|
||||
"settings.status_bar.theme_header": "상태 표시줄 테마",
|
||||
"settings.status_bar.theme_desc": "상태 표시줄의 테마 모드를 독립적으로 설정합니다.",
|
||||
"settings.status_bar.theme_mode_label": "테마 모드",
|
||||
"settings.status_bar.theme_mode.follow_global": "전역 따르기",
|
||||
"settings.status_bar.theme_mode.dark": "다크",
|
||||
"settings.status_bar.theme_mode.light": "라이트",
|
||||
"settings.components.title": "컴포넌트",
|
||||
"settings.components.description": "컴포넌트 레이아웃과 모서리 디자인을 조정합니다.",
|
||||
"settings.components.grid_header": "그리드 설정",
|
||||
|
||||
@@ -383,6 +383,41 @@
|
||||
"settings.status_bar.clock_format_label": "时钟格式",
|
||||
"settings.status_bar.clock_format.hm": "时:分",
|
||||
"settings.status_bar.clock_format.hms": "时:分:秒",
|
||||
"settings.status_bar.clock_position_label": "时钟位置",
|
||||
"settings.status_bar.clock_position.left": "靠左",
|
||||
"settings.status_bar.clock_position.center": "居中",
|
||||
"settings.status_bar.clock_position.right": "靠右",
|
||||
"settings.status_bar.text_capsule_header": "文字胶囊",
|
||||
"settings.status_bar.text_capsule_description": "在状态栏显示自定义文字,支持 Markdown 格式。",
|
||||
"settings.status_bar.text_capsule_position_label": "文字胶囊位置",
|
||||
"settings.status_bar.text_capsule_position.left": "靠左",
|
||||
"settings.status_bar.text_capsule_position.center": "居中",
|
||||
"settings.status_bar.text_capsule_position.right": "靠右",
|
||||
"settings.status_bar.text_capsule_content_label": "文字内容(支持 Markdown)",
|
||||
"settings.status_bar.text_capsule_transparent_background_label": "透明背景",
|
||||
"settings.status_bar.network_speed_header": "网速显示",
|
||||
"settings.status_bar.network_speed_description": "在状态栏显示实时网络上传和下载速度。",
|
||||
"settings.status_bar.network_speed_position_label": "网速显示位置",
|
||||
"settings.status_bar.network_speed_position.left": "靠左",
|
||||
"settings.status_bar.network_speed_position.center": "居中",
|
||||
"settings.status_bar.network_speed_position.right": "靠右",
|
||||
"settings.status_bar.network_speed_mode_label": "显示模式",
|
||||
"settings.status_bar.network_speed_mode.both": "上传 + 下载",
|
||||
"settings.status_bar.network_speed_mode.upload": "仅上传",
|
||||
"settings.status_bar.network_speed_mode.download": "仅下载",
|
||||
"settings.status_bar.network_speed_transparent_background_label": "透明背景",
|
||||
"settings.status_bar.show_network_type_icon_label": "显示网络类型图标",
|
||||
"settings.status_bar.shadow_header": "状态栏阴影",
|
||||
"settings.status_bar.shadow_desc": "为状态栏添加阴影效果,使透明背景的组件更清晰。",
|
||||
"settings.status_bar.shadow_enabled_label": "启用阴影",
|
||||
"settings.status_bar.shadow_color_label": "阴影颜色",
|
||||
"settings.status_bar.shadow_opacity_label": "阴影透明度",
|
||||
"settings.status_bar.theme_header": "状态栏主题",
|
||||
"settings.status_bar.theme_desc": "独立设置状态栏的主题模式。",
|
||||
"settings.status_bar.theme_mode_label": "主题模式",
|
||||
"settings.status_bar.theme_mode.follow_global": "跟随全局",
|
||||
"settings.status_bar.theme_mode.dark": "暗色",
|
||||
"settings.status_bar.theme_mode.light": "浅色",
|
||||
"settings.components.title": "组件",
|
||||
"settings.components.description": "调整组件布局与圆角设计。",
|
||||
"settings.components.grid_header": "网格设置",
|
||||
@@ -523,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": "查看插件运行时状态、加载结果与诊断信息。",
|
||||
@@ -1034,7 +1073,9 @@
|
||||
"zhijiaohub.settings.source": "图片源",
|
||||
"zhijiaohub.settings.classisland": "ClassIsland 图库",
|
||||
"zhijiaohub.settings.sectl": "SECTL 图库",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。",
|
||||
"zhijiaohub.settings.rinlit": "Rin's 图库",
|
||||
"zhijiaohub.settings.jiangtokoto": "Jiangtokoto 表情包",
|
||||
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容,Jiangtokoto 表情包包含丰富的表情包资源。",
|
||||
"zhijiaohub.settings.mirror_source": "镜像加速",
|
||||
"zhijiaohub.settings.mirror_direct": "直连(GitHub)",
|
||||
"zhijiaohub.settings.mirror_ghproxy": "镜像加速(推荐)",
|
||||
@@ -1044,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";
|
||||
@@ -112,8 +114,40 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool StatusBarClockTransparentBackground { get; set; }
|
||||
|
||||
public string ClockPosition { get; set; } = "Left"; // Left, Center, Right
|
||||
|
||||
public string ClockFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public bool ShowTextCapsule { get; set; } = false;
|
||||
|
||||
public string TextCapsuleContent { get; set; } = "**Hello** World!";
|
||||
|
||||
public string TextCapsulePosition { get; set; } = "Right"; // Left, Center, Right
|
||||
|
||||
public bool TextCapsuleTransparentBackground { get; set; } = false;
|
||||
|
||||
public string TextCapsuleFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public bool ShowNetworkSpeed { get; set; } = false;
|
||||
|
||||
public string NetworkSpeedPosition { get; set; } = "Right"; // Left, Center, Right
|
||||
|
||||
public string NetworkSpeedDisplayMode { get; set; } = "Both"; // Upload, Download, Both
|
||||
|
||||
public bool NetworkSpeedTransparentBackground { get; set; } = false;
|
||||
|
||||
public bool ShowNetworkTypeIcon { get; set; } = false;
|
||||
|
||||
public string NetworkSpeedFontSize { get; set; } = "Medium"; // Small, Medium, Large
|
||||
|
||||
public string StatusBarSpacingMode { get; set; } = "Relaxed";
|
||||
|
||||
public bool StatusBarShadowEnabled { get; set; } = false;
|
||||
|
||||
public string StatusBarShadowColor { get; set; } = "#000000";
|
||||
|
||||
public double StatusBarShadowOpacity { get; set; } = 0.3;
|
||||
|
||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||
|
||||
public bool EnableThreeFingerSwipe { get; set; } = false;
|
||||
@@ -168,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();
|
||||
@@ -181,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();
|
||||
@@ -124,15 +182,83 @@ public static class ZhiJiaoHubSources
|
||||
{
|
||||
public const string ClassIsland = "classisland";
|
||||
public const string Sectl = "sectl";
|
||||
public const string RinLit = "rinlit";
|
||||
public const string Jiangtokoto = "jiangtokoto";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
return value?.ToLowerInvariant() switch
|
||||
{
|
||||
"sectl" => Sectl,
|
||||
"rinlit" => RinLit,
|
||||
"jiangtokoto" => Jiangtokoto,
|
||||
_ => ClassIsland
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetDisplayName(string source)
|
||||
{
|
||||
return source?.ToLowerInvariant() switch
|
||||
{
|
||||
Sectl => "SECTL 图库",
|
||||
RinLit => "Rin's 图库",
|
||||
Jiangtokoto => "Jiangtokoto 表情包",
|
||||
_ => "ClassIsland 图库"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 智教Hub数据源配置
|
||||
public sealed class ZhiJiaoHubSourceConfig
|
||||
{
|
||||
public string Owner { get; init; } = string.Empty;
|
||||
public string Repo { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
public bool UseJsonIndex { get; init; } = false;
|
||||
public string? JsonIndexPath { get; init; } = null;
|
||||
public string ApiUrl => $"https://api.github.com/repos/{Owner}/{Repo}/contents/{Path}";
|
||||
public string RawUrlTemplate => $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{Path}/{{0}}";
|
||||
public string? JsonIndexUrl => JsonIndexPath != null
|
||||
? $"https://raw.githubusercontent.com/{Owner}/{Repo}/main/{JsonIndexPath}"
|
||||
: null;
|
||||
|
||||
public static ZhiJiaoHubSourceConfig GetConfig(string source)
|
||||
{
|
||||
return source?.ToLowerInvariant() switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "SECTL",
|
||||
Repo = "SECTL-hub",
|
||||
Path = "docs/.vuepress/public/images",
|
||||
DisplayName = "SECTL 图库"
|
||||
},
|
||||
ZhiJiaoHubSources.RinLit => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "RinLit-233-shiroko",
|
||||
Repo = "Rin-sHub",
|
||||
Path = "updates/images",
|
||||
DisplayName = "Rin's 图库",
|
||||
UseJsonIndex = true,
|
||||
JsonIndexPath = "updates/images.json"
|
||||
},
|
||||
ZhiJiaoHubSources.Jiangtokoto => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "unDefFtr",
|
||||
Repo = "jiangtokoto-images",
|
||||
Path = "images",
|
||||
DisplayName = "Jiangtokoto 表情包"
|
||||
},
|
||||
_ => new ZhiJiaoHubSourceConfig
|
||||
{
|
||||
Owner = "ClassIsland",
|
||||
Repo = "classisland-hub",
|
||||
Path = "images",
|
||||
DisplayName = "ClassIsland 图库"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 智教Hub镜像加速源常量
|
||||
|
||||
87
LanMountainDesktop/Models/FileSystemItem.cs
Normal file
87
LanMountainDesktop/Models/FileSystemItem.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public enum FileSystemItemType
|
||||
{
|
||||
Drive,
|
||||
Directory,
|
||||
File
|
||||
}
|
||||
|
||||
public sealed class FileSystemItem
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string FullPath { get; init; } = string.Empty;
|
||||
public FileSystemItemType ItemType { get; init; }
|
||||
public long? Size { get; init; }
|
||||
public DateTime? LastModified { get; init; }
|
||||
public string? Extension { get; init; }
|
||||
|
||||
public bool IsDirectory => ItemType == FileSystemItemType.Directory || ItemType == FileSystemItemType.Drive;
|
||||
|
||||
public static FileSystemItem FromDriveInfo(DriveInfo drive)
|
||||
{
|
||||
string name;
|
||||
long? size = null;
|
||||
|
||||
try
|
||||
{
|
||||
var volumeLabel = drive.VolumeLabel;
|
||||
name = string.IsNullOrWhiteSpace(volumeLabel)
|
||||
? $"{drive.Name.TrimEnd('\\', '/')}"
|
||||
: $"{volumeLabel} ({drive.Name.TrimEnd('\\', '/').ToUpperInvariant()})";
|
||||
}
|
||||
catch
|
||||
{
|
||||
name = $"{drive.Name.TrimEnd('\\', '/')}";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var totalSize = drive.TotalSize;
|
||||
size = totalSize > 0 ? totalSize : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
size = null;
|
||||
}
|
||||
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = name,
|
||||
FullPath = drive.Name,
|
||||
ItemType = FileSystemItemType.Drive,
|
||||
Size = size,
|
||||
LastModified = null,
|
||||
Extension = null
|
||||
};
|
||||
}
|
||||
|
||||
public static FileSystemItem FromDirectoryInfo(DirectoryInfo directory)
|
||||
{
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = directory.Name,
|
||||
FullPath = directory.FullName,
|
||||
ItemType = FileSystemItemType.Directory,
|
||||
Size = null,
|
||||
LastModified = directory.LastWriteTime,
|
||||
Extension = null
|
||||
};
|
||||
}
|
||||
|
||||
public static FileSystemItem FromFileInfo(FileInfo file)
|
||||
{
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = file.Name,
|
||||
FullPath = file.FullName,
|
||||
ItemType = FileSystemItemType.File,
|
||||
Size = file.Length,
|
||||
LastModified = file.LastWriteTime,
|
||||
Extension = file.Extension
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
196
LanMountainDesktop/Services/FusedDesktopManagerService.cs
Normal file
196
LanMountainDesktop/Services/FusedDesktopManagerService.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Views;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面中央管理器服务接口
|
||||
/// </summary>
|
||||
public interface IFusedDesktopManagerService
|
||||
{
|
||||
void Initialize();
|
||||
void EnterEditMode();
|
||||
void ExitEditMode();
|
||||
void ReloadWidgets();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面中央管理器服务实现。用于管理常态下的各个小窗口实体。
|
||||
/// </summary>
|
||||
internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly Dictionary<string, DesktopWidgetWindow> _widgetWindows = [];
|
||||
|
||||
// 基础服务依赖
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private bool _isEditMode;
|
||||
|
||||
private const double DefaultCellSize = 100;
|
||||
|
||||
public FusedDesktopManagerService(
|
||||
IFusedDesktopLayoutService layoutService,
|
||||
ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_layoutService = layoutService;
|
||||
_settingsFacade = settingsFacade;
|
||||
|
||||
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
|
||||
EnsureRegistries();
|
||||
ReloadWidgets();
|
||||
}
|
||||
|
||||
private void EnsureRegistries()
|
||||
{
|
||||
if (_componentRuntimeRegistry is not null) return;
|
||||
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
}
|
||||
|
||||
public void EnterEditMode()
|
||||
{
|
||||
if (_isEditMode) return;
|
||||
_isEditMode = true;
|
||||
|
||||
// 【修复问题3】不再隐藏窗口,而是将窗口内容转移到编辑模式覆盖层
|
||||
// 这样可以保持组件的运行状态(动画、输入等)
|
||||
foreach (var window in _widgetWindows.Values)
|
||||
{
|
||||
window.Hide();
|
||||
}
|
||||
}
|
||||
|
||||
public void ExitEditMode()
|
||||
{
|
||||
if (!_isEditMode) return;
|
||||
_isEditMode = false;
|
||||
|
||||
// 编辑完成,重新加载布局(可能已发生更改)并显示
|
||||
ReloadWidgets();
|
||||
}
|
||||
|
||||
public void ReloadWidgets()
|
||||
{
|
||||
if (_isEditMode) return; // 编辑模式下不渲染小窗口
|
||||
|
||||
var layout = _layoutService.Load();
|
||||
var existingIds = new HashSet<string>(_widgetWindows.Keys);
|
||||
|
||||
foreach (var placement in layout.ComponentPlacements)
|
||||
{
|
||||
existingIds.Remove(placement.PlacementId);
|
||||
|
||||
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
|
||||
{
|
||||
// 已存在,可能只更新位置或尺寸
|
||||
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
if (existingWindow.IsVisible == false)
|
||||
{
|
||||
existingWindow.Show();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 新组件,生成窗口
|
||||
try
|
||||
{
|
||||
var window = CreateWidgetWindow(placement);
|
||||
if (window != null)
|
||||
{
|
||||
_widgetWindows[placement.PlacementId] = window;
|
||||
window.Show();
|
||||
window.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopMgr", $"Failed to render tiny window for {placement.ComponentId}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除被删除的组件
|
||||
foreach (var id in existingIds)
|
||||
{
|
||||
if (_widgetWindows.Remove(id, out var windowToRemove))
|
||||
{
|
||||
windowToRemove.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DesktopWidgetWindow? CreateWidgetWindow(FusedDesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
EnsureRegistries();
|
||||
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(placement.ComponentId, out var descriptor))
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopMgr", $"Unknown component: {placement.ComponentId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var control = descriptor.CreateControl(
|
||||
DefaultCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
|
||||
// 将组件包装到一个具有准确宽高的容器内(如果组件自身没有设置宽度)
|
||||
control.Width = placement.Width;
|
||||
control.Height = placement.Height;
|
||||
|
||||
var window = new DesktopWidgetWindow(control);
|
||||
return window;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 工厂
|
||||
/// </summary>
|
||||
public static class FusedDesktopManagerServiceFactory
|
||||
{
|
||||
private static IFusedDesktopManagerService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IFusedDesktopManagerService GetOrCreate()
|
||||
{
|
||||
if (_instance is not null) return _instance;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
var settings = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_instance ??= new FusedDesktopManagerService(layoutService, settings);
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ public sealed record ComponentLibraryCategoryEntry(
|
||||
|
||||
public sealed record ComponentLibraryCreateContext(
|
||||
double CellSize,
|
||||
double GlobalCornerRadiusScale,
|
||||
TimeZoneService TimeZoneService,
|
||||
IWeatherInfoService WeatherInfoService,
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
|
||||
@@ -317,11 +317,15 @@ public sealed record RecommendationApiOptions
|
||||
|
||||
public string ClassIslandHubApiUrl { get; init; } = "https://api.github.com/repos/ClassIsland/classisland-hub/contents/images";
|
||||
|
||||
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/images";
|
||||
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/docs/.vuepress/public/images";
|
||||
|
||||
public string RinLitHubApiUrl { get; init; } = "https://api.github.com/repos/RinLit-233-shiroko/Rin-sHub/contents/images";
|
||||
|
||||
public string ClassIslandHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/ClassIsland/classisland-hub/main/images/{0}";
|
||||
|
||||
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/images/{0}";
|
||||
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/docs/.vuepress/public/images/{0}";
|
||||
|
||||
public string RinLitHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/RinLit-233-shiroko/Rin-sHub/main/images/{0}";
|
||||
}
|
||||
|
||||
public interface IRecommendationInfoService
|
||||
|
||||
@@ -1,214 +1,265 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("linux")]
|
||||
internal static class LinuxIconService
|
||||
{
|
||||
private static readonly string[] SupportedRasterExtensions =
|
||||
[
|
||||
".png",
|
||||
".ico"
|
||||
];
|
||||
private static readonly string[] IconThemePaths = {
|
||||
"/usr/share/icons",
|
||||
"/usr/share/pixmaps",
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/icons"),
|
||||
"/var/lib/snapd/desktop/icons"
|
||||
};
|
||||
|
||||
private static readonly Regex SizeDirectoryRegex =
|
||||
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] IconSizes = { "512x512", "256x256", "128x128", "96x96", "64x64", "48x48", "32x32", "24x24", "16x16" };
|
||||
|
||||
private static readonly ConcurrentDictionary<string, string?> IconPathCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly string[] FolderIconNames = { "folder", "inode-directory", "folder-default" };
|
||||
private static readonly string[] DriveIconNames = { "drive-harddisk", "drive-removable-media", "media-removable" };
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null)
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey))
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory))
|
||||
try
|
||||
{
|
||||
if (TryReadIconBytes(candidatePath, out var bytes))
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
var iconName = GetIconNameForExtension(extension);
|
||||
|
||||
return TryGetThemeIcon(iconName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string iconName, string? searchDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Path.IsPathRooted(iconName) && File.Exists(iconName))
|
||||
{
|
||||
return bytes;
|
||||
if (iconName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return File.ReadAllBytes(iconName);
|
||||
}
|
||||
|
||||
if (iconName.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (iconName.EndsWith(".xpm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var pngBytes = TryGetThemeIcon(iconName);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchDirectory))
|
||||
{
|
||||
var localIconPath = Path.Combine(searchDirectory, "icons", iconName + ".png");
|
||||
if (File.Exists(localIconPath))
|
||||
{
|
||||
return File.ReadAllBytes(localIconPath);
|
||||
}
|
||||
|
||||
localIconPath = Path.Combine(searchDirectory, iconName + ".png");
|
||||
if (File.Exists(localIconPath))
|
||||
{
|
||||
return File.ReadAllBytes(localIconPath);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
foreach (var iconName in FolderIconNames)
|
||||
{
|
||||
var iconBytes = TryGetThemeIcon(iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
return iconBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ResolveIconCandidates(string iconKey, string? desktopFileDirectory)
|
||||
public static byte[]? TryGetDriveIconPngBytes()
|
||||
{
|
||||
if (Path.HasExtension(iconKey))
|
||||
foreach (var iconName in DriveIconNames)
|
||||
{
|
||||
var directPath = ExpandHome(iconKey);
|
||||
if (Path.IsPathRooted(directPath))
|
||||
var iconBytes = TryGetThemeIcon(iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
yield return directPath;
|
||||
return iconBytes;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(desktopFileDirectory))
|
||||
{
|
||||
yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var resolvedThemePath = ResolveThemedIconPath(iconKey);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetIconNameForExtension(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
yield return resolvedThemePath;
|
||||
".txt" => "text-x-generic",
|
||||
".md" => "text-x-markdown",
|
||||
".pdf" => "application-pdf",
|
||||
".doc" or ".docx" => "application-msword",
|
||||
".xls" or ".xlsx" => "application-vnd.ms-excel",
|
||||
".ppt" or ".pptx" => "application-vnd.ms-powerpoint",
|
||||
".zip" or ".rar" or ".7z" or ".tar" or ".gz" => "application-x-archive",
|
||||
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" => "audio-x-generic",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" => "video-x-generic",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".svg" => "image-x-generic",
|
||||
".cs" => "text-x-csharp",
|
||||
".js" or ".ts" => "text-x-javascript",
|
||||
".py" => "text-x-python",
|
||||
".java" => "text-x-java",
|
||||
".cpp" or ".c" or ".h" => "text-x-c++",
|
||||
".json" => "application-json",
|
||||
".xml" => "text-xml",
|
||||
".html" or ".htm" => "text-html",
|
||||
".css" => "text-css",
|
||||
".sh" or ".bash" => "text-x-script",
|
||||
".exe" or ".msi" => "application-x-executable",
|
||||
".deb" or ".rpm" => "application-x-package",
|
||||
".iso" or ".img" => "application-x-cd-image",
|
||||
_ => "text-x-generic"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[]? TryGetThemeIcon(string iconName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveThemedIconPath(string iconName)
|
||||
{
|
||||
return IconPathCache.GetOrAdd(iconName, static key => FindBestMatchingIconPath(key));
|
||||
}
|
||||
|
||||
private static string? FindBestMatchingIconPath(string iconName)
|
||||
{
|
||||
var candidates = new List<(string Path, int Score)>();
|
||||
foreach (var iconRoot in EnumerateIconRoots())
|
||||
foreach (var themePath in IconThemePaths)
|
||||
{
|
||||
foreach (var extension in SupportedRasterExtensions)
|
||||
if (!Directory.Exists(themePath))
|
||||
{
|
||||
foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension))
|
||||
continue;
|
||||
}
|
||||
|
||||
var iconBytes = TryFindIconInTheme(themePath, iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
return iconBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconFromGtkTheme(iconName);
|
||||
}
|
||||
|
||||
private static byte[]? TryFindIconInTheme(string themePath, string iconName)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var sizeDir in IconSizes)
|
||||
{
|
||||
var iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "mimetypes", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
candidates.Add((candidatePath, ScoreIconPath(candidatePath)));
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "places", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "devices", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.Path.Length)
|
||||
.Select(candidate => candidate.Path)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateIconRoots()
|
||||
{
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
dataHome = Path.Combine(homeDirectory, ".local", "share");
|
||||
}
|
||||
|
||||
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
|
||||
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var candidates = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(dataHome))
|
||||
{
|
||||
candidates.Add(Path.Combine(dataHome, "icons"));
|
||||
candidates.Add(Path.Combine(dataHome, "pixmaps"));
|
||||
}
|
||||
|
||||
foreach (var dataDir in dataDirs)
|
||||
{
|
||||
candidates.Add(Path.Combine(dataDir, "icons"));
|
||||
candidates.Add(Path.Combine(dataDir, "pixmaps"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
candidates.Add(Path.Combine(homeDirectory, ".icons"));
|
||||
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "icons"));
|
||||
}
|
||||
|
||||
candidates.Add("/var/lib/flatpak/exports/share/icons");
|
||||
candidates.Add("/var/lib/snapd/desktop/icons");
|
||||
|
||||
return candidates
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFilesSafe(string rootPath, string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadIconBytes(string filePath, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (!SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||
!File.Exists(filePath))
|
||||
foreach (var sizeDir in IconSizes)
|
||||
{
|
||||
return false;
|
||||
var iconPath = Path.Combine(themePath, "hicolor", sizeDir, "mimetypes", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "hicolor", sizeDir, "places", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "hicolor", sizeDir, "devices", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
}
|
||||
|
||||
bytes = File.ReadAllBytes(filePath);
|
||||
return bytes.Length > 0;
|
||||
var directPath = Path.Combine(themePath, $"{iconName}.png");
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
return File.ReadAllBytes(directPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ScoreIconPath(string filePath)
|
||||
private static byte[]? TryGetIconFromGtkTheme(string iconName)
|
||||
{
|
||||
var score = 0;
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
|
||||
try
|
||||
{
|
||||
score += 4_000;
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "gtk3-icon-browser",
|
||||
Arguments = $"--icon={iconName}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
|
||||
catch
|
||||
{
|
||||
score += 2_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filePath.Contains($"{Path.DirectorySeparatorChar}hicolor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 8_000;
|
||||
}
|
||||
|
||||
if (filePath.Contains($"{Path.DirectorySeparatorChar}apps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 1_000;
|
||||
}
|
||||
|
||||
var match = SizeDirectoryRegex.Match(filePath);
|
||||
if (match.Success &&
|
||||
int.TryParse(match.Groups["size"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size))
|
||||
{
|
||||
score += Math.Min(size, 512);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static string ExpandHome(string path)
|
||||
{
|
||||
if (!path.StartsWith("~", StringComparison.Ordinal))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path.Length == 1
|
||||
? homeDirectory
|
||||
: Path.Combine(homeDirectory, path[2..]);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
296
LanMountainDesktop/Services/MacIconService.cs
Normal file
296
LanMountainDesktop/Services/MacIconService.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal static class MacIconService
|
||||
{
|
||||
private const int IconSize = 256;
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSWorkspace_sharedWorkspace();
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSWorkspace_iconForFile(IntPtr workspace, IntPtr filePath);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSImage_initWithContentsOfFile(IntPtr path);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern IntPtr CGImageDestinationCreateWithURL(IntPtr url, IntPtr type, uint count, IntPtr options);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern void CGImageDestinationAddImage(IntPtr dest, IntPtr image, IntPtr properties);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern bool CGImageDestinationFinalize(IntPtr dest);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSString_stringWithUTF8String(string str);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSURL_fileURLWithPath(IntPtr path);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern void CFRelease(IntPtr handle);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSTemporaryDirectory();
|
||||
|
||||
private static readonly string[] SystemFolderPaths =
|
||||
{
|
||||
"/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources",
|
||||
"/System/Library/Extensions",
|
||||
"/System/Library/PrivateFrameworks"
|
||||
};
|
||||
|
||||
private static readonly string[] FolderIconNames = { "GenericFolderIcon.icns", "SidebarDownloadsFolder.icns", "SidebarDocumentsFolder.icns" };
|
||||
private static readonly string[] DriveIconNames = { "GenericHardDiskIcon.icns", "ExternalDiskIcon.icns", "RemovableDiskIcon.icns" };
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return TryGetIconUsingNSWorkspace(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return TryGetIconForExtension(extension);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var iconName in FolderIconNames)
|
||||
{
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconUsingNSWorkspace("/System/Library/CoreServices");
|
||||
}
|
||||
|
||||
public static byte[]? TryGetDriveIconPngBytes()
|
||||
{
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var iconName in DriveIconNames)
|
||||
{
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconUsingNSWorkspace("/");
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconUsingNSWorkspace(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
var script = $@"
|
||||
tell application ""System Events""
|
||||
set theIcon to icon of file ""{filePath}""
|
||||
end tell
|
||||
";
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
Arguments = $"-e 'tell application \"Finder\" to get icon of file \"{filePath}\"'",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
return TryGetIconUsingSips(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconUsingSips(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sips",
|
||||
Arguments = $"-s format png -z {IconSize} {IconSize} \"{filePath}\" --out \"{tempPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
File.Delete(tempPath);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconForExtension(string extension)
|
||||
{
|
||||
var iconName = GetIconNameForExtension(extension);
|
||||
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetIconNameForExtension(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
".txt" => "TextEdit.icns",
|
||||
".md" => "TextEdit.icns",
|
||||
".pdf" => "Preview.icns",
|
||||
".doc" or ".docx" => "Microsoft Word.icns",
|
||||
".xls" or ".xlsx" => "Microsoft Excel.icns",
|
||||
".ppt" or ".pptx" => "Microsoft PowerPoint.icns",
|
||||
".zip" or ".rar" or ".7z" => "Archive Utility.icns",
|
||||
".mp3" or ".wav" or ".flac" or ".aac" => "Music.icns",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" => "QuickTime Player.icns",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" => "Preview.icns",
|
||||
".cs" => "Visual Studio.icns",
|
||||
".js" or ".ts" => "Visual Studio Code.icns",
|
||||
".py" => "IDLE.icns",
|
||||
".json" => "TextEdit.icns",
|
||||
".xml" => "TextEdit.icns",
|
||||
".html" or ".htm" => "Safari.icns",
|
||||
".css" => "TextEdit.icns",
|
||||
".sh" => "Terminal.icns",
|
||||
".app" => "AppIcon.icns",
|
||||
".dmg" => "DiskImage.icns",
|
||||
_ => "GenericDocumentIcon.icns"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[]? TryConvertIcnsToPng(string icnsPath)
|
||||
{
|
||||
if (!File.Exists(icnsPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sips",
|
||||
Arguments = $"-s format png \"{icnsPath}\" --out \"{tempPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
File.Delete(tempPath);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
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) { }
|
||||
}
|
||||
@@ -3244,34 +3244,38 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
|
||||
private async Task<ZhiJiaoHubSnapshot> FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var (owner, repo, path) = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
|
||||
_ => ("ClassIsland", "classisland-hub", "images")
|
||||
};
|
||||
|
||||
var contentsUrl = $"https://api.github.com/repos/{owner}/{repo}/contents/{path}";
|
||||
|
||||
// 如果使用镜像加速,代理 GitHub API 请求
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
var config = ZhiJiaoHubSourceConfig.GetConfig(source);
|
||||
|
||||
try
|
||||
{
|
||||
var images = await FetchImagesFromContentsApi(owner, repo, path, contentsUrl, mirrorSource, cancellationToken);
|
||||
List<ZhiJiaoHubImageItem> images;
|
||||
|
||||
// 如果使用JSON索引模式(Rin's Hub)
|
||||
if (config.UseJsonIndex && !string.IsNullOrEmpty(config.JsonIndexUrl))
|
||||
{
|
||||
images = await FetchImagesFromJsonIndex(config, mirrorSource, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 标准模式(ClassIsland/SECTL)
|
||||
var contentsUrl = config.ApiUrl;
|
||||
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
|
||||
}
|
||||
|
||||
images = await FetchImagesFromContentsApi(config, contentsUrl, mirrorSource, cancellationToken);
|
||||
}
|
||||
|
||||
if (images.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("未找到图片文件");
|
||||
throw new InvalidOperationException($"在 {config.DisplayName} 中未找到图片文件");
|
||||
}
|
||||
|
||||
// 随机打乱图片顺序
|
||||
var random = new Random();
|
||||
var shuffled = images.OrderBy(_ => random.Next()).ToList();
|
||||
|
||||
// 重新设置索引
|
||||
for (int i = 0; i < shuffled.Count; i++)
|
||||
{
|
||||
var item = shuffled[i];
|
||||
@@ -3286,11 +3290,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new HttpRequestException($"获取图片列表失败: {ex.Message}");
|
||||
throw new HttpRequestException($"从 {config.DisplayName} 获取图片列表失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(string owner, string repo, string path, string contentsUrl, string mirrorSource, CancellationToken cancellationToken)
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(
|
||||
ZhiJiaoHubSourceConfig config,
|
||||
string contentsUrl,
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
@@ -3308,7 +3316,17 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
{
|
||||
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
|
||||
}
|
||||
throw new HttpRequestException($"API 返回错误: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||
|
||||
if ((int)response.StatusCode == 404)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"在 {config.DisplayName} 中找不到图片目录。请检查仓库结构和路径配置。\n" +
|
||||
$"仓库: {config.Owner}/{config.Repo}\n" +
|
||||
$"路径: {config.Path}");
|
||||
}
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"从 {config.DisplayName} 获取数据失败: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
|
||||
}
|
||||
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
@@ -3320,9 +3338,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
|
||||
{
|
||||
var errorMessage = messageNode.GetString();
|
||||
throw new InvalidOperationException($"GitHub API 错误: {errorMessage}");
|
||||
throw new InvalidOperationException($"GitHub API 错误 ({config.DisplayName}): {errorMessage}");
|
||||
}
|
||||
throw new InvalidOperationException("Invalid response format from GitHub API.");
|
||||
throw new InvalidOperationException($"从 {config.DisplayName} 返回的数据格式无效");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
@@ -3342,18 +3360,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只处理图片文件
|
||||
var extension = Path.GetExtension(name).ToLowerInvariant();
|
||||
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解码文件名
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
decodedName = Path.GetFileNameWithoutExtension(decodedName);
|
||||
|
||||
// 构造图片 URL
|
||||
string imageUrl;
|
||||
if (!string.IsNullOrWhiteSpace(downloadUrl))
|
||||
{
|
||||
@@ -3361,10 +3376,12 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
}
|
||||
else
|
||||
{
|
||||
imageUrl = $"https://raw.githubusercontent.com/{owner}/{repo}/main/{path}/{Uri.EscapeDataString(name)}";
|
||||
imageUrl = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
config.RawUrlTemplate,
|
||||
Uri.EscapeDataString(name));
|
||||
}
|
||||
|
||||
// 应用镜像加速到图片 URL
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
|
||||
@@ -3374,6 +3391,85 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
||||
return images;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从JSON索引文件获取图片列表(Rin's Hub专用)
|
||||
/// </summary>
|
||||
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromJsonIndex(
|
||||
ZhiJiaoHubSourceConfig config,
|
||||
string mirrorSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var images = new List<ZhiJiaoHubImageItem>();
|
||||
|
||||
// 下载JSON索引文件
|
||||
var jsonUrl = config.JsonIndexUrl!;
|
||||
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
jsonUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + jsonUrl;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, jsonUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var jsonText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(jsonText);
|
||||
var root = document.RootElement;
|
||||
|
||||
// 解析 hub_items 数组
|
||||
if (!root.TryGetProperty("hub_items", out var hubItems) || hubItems.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException($"JSON索引文件格式无效:缺少 hub_items 数组");
|
||||
}
|
||||
|
||||
int index = 0;
|
||||
foreach (var item in hubItems.EnumerateArray())
|
||||
{
|
||||
// 获取图片路径
|
||||
if (!item.TryGetProperty("image", out var imageProp))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var imagePath = imageProp.GetString();
|
||||
if (string.IsNullOrWhiteSpace(imagePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取标题(用于显示名称)
|
||||
string title = string.Empty;
|
||||
if (item.TryGetProperty("title", out var titleProp))
|
||||
{
|
||||
title = titleProp.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
// 如果没有标题,使用文件名
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
title = Path.GetFileNameWithoutExtension(imagePath);
|
||||
}
|
||||
|
||||
// 构建完整的图片URL
|
||||
// imagePath 格式如: "Discord/姐姐好香.png"
|
||||
// 需要拼接为: https://raw.githubusercontent.com/.../updates/images/Discord/姐姐好香.png
|
||||
// 并对路径中的每个部分进行URL编码
|
||||
var pathParts = imagePath.Split('/');
|
||||
var encodedPath = string.Join("/", pathParts.Select(part => Uri.EscapeDataString(part)));
|
||||
var imageUrl = $"https://raw.githubusercontent.com/{config.Owner}/{config.Repo}/main/{config.Path}/{encodedPath}";
|
||||
|
||||
// 应用镜像加速
|
||||
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
|
||||
|
||||
images.Add(new ZhiJiaoHubImageItem(title, imageUrl, index));
|
||||
index++;
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
|
||||
{
|
||||
lock (_cacheGate)
|
||||
|
||||
@@ -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);
|
||||
@@ -41,8 +41,31 @@ public sealed record StatusBarSettingsState(
|
||||
string TaskbarLayoutMode,
|
||||
string ClockDisplayFormat,
|
||||
bool ClockTransparentBackground,
|
||||
string ClockPosition,
|
||||
string ClockFontSize,
|
||||
bool ShowTextCapsule,
|
||||
string TextCapsuleContent,
|
||||
string TextCapsulePosition,
|
||||
bool TextCapsuleTransparentBackground,
|
||||
string TextCapsuleFontSize,
|
||||
bool ShowNetworkSpeed,
|
||||
string NetworkSpeedPosition,
|
||||
string NetworkSpeedDisplayMode,
|
||||
bool NetworkSpeedTransparentBackground,
|
||||
bool ShowNetworkTypeIcon,
|
||||
string NetworkSpeedFontSize,
|
||||
string SpacingMode,
|
||||
int CustomSpacingPercent);
|
||||
int CustomSpacingPercent,
|
||||
bool ShadowEnabled,
|
||||
string ShadowColor,
|
||||
double ShadowOpacity);
|
||||
|
||||
public sealed record TextCapsuleSettingsState(
|
||||
bool ShowTextCapsule,
|
||||
string Content,
|
||||
string Position,
|
||||
bool TransparentBackground);
|
||||
|
||||
public sealed record WeatherSettingsState(
|
||||
string LocationMode,
|
||||
string LocationKey,
|
||||
@@ -274,6 +297,12 @@ public interface IStatusBarSettingsService
|
||||
void Save(StatusBarSettingsState state);
|
||||
}
|
||||
|
||||
public interface ITextCapsuleSettingsService
|
||||
{
|
||||
TextCapsuleSettingsState Get();
|
||||
void Save(TextCapsuleSettingsState state);
|
||||
}
|
||||
|
||||
public interface IWeatherProvider
|
||||
{
|
||||
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
|
||||
@@ -385,6 +414,7 @@ public interface ISettingsFacadeService
|
||||
IWallpaperMediaService WallpaperMedia { get; }
|
||||
IThemeAppearanceService Theme { get; }
|
||||
IStatusBarSettingsService StatusBar { get; }
|
||||
ITextCapsuleSettingsService TextCapsule { get; }
|
||||
IWeatherSettingsService Weather { get; }
|
||||
IRegionSettingsService Region { get; }
|
||||
IPrivacySettingsService Privacy { get; }
|
||||
|
||||
@@ -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))
|
||||
@@ -386,8 +394,24 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.TaskbarLayoutMode,
|
||||
snapshot.ClockDisplayFormat,
|
||||
snapshot.StatusBarClockTransparentBackground,
|
||||
snapshot.ClockPosition,
|
||||
snapshot.ClockFontSize,
|
||||
snapshot.ShowTextCapsule,
|
||||
snapshot.TextCapsuleContent,
|
||||
snapshot.TextCapsulePosition,
|
||||
snapshot.TextCapsuleTransparentBackground,
|
||||
snapshot.TextCapsuleFontSize,
|
||||
snapshot.ShowNetworkSpeed,
|
||||
snapshot.NetworkSpeedPosition,
|
||||
snapshot.NetworkSpeedDisplayMode,
|
||||
snapshot.NetworkSpeedTransparentBackground,
|
||||
snapshot.ShowNetworkTypeIcon,
|
||||
snapshot.NetworkSpeedFontSize,
|
||||
snapshot.StatusBarSpacingMode,
|
||||
snapshot.StatusBarCustomSpacingPercent);
|
||||
snapshot.StatusBarCustomSpacingPercent,
|
||||
snapshot.StatusBarShadowEnabled,
|
||||
snapshot.StatusBarShadowColor,
|
||||
snapshot.StatusBarShadowOpacity);
|
||||
}
|
||||
|
||||
public void Save(StatusBarSettingsState state)
|
||||
@@ -399,8 +423,24 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
|
||||
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
|
||||
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
|
||||
snapshot.ClockPosition = state.ClockPosition;
|
||||
snapshot.ClockFontSize = state.ClockFontSize;
|
||||
snapshot.ShowTextCapsule = state.ShowTextCapsule;
|
||||
snapshot.TextCapsuleContent = state.TextCapsuleContent;
|
||||
snapshot.TextCapsulePosition = state.TextCapsulePosition;
|
||||
snapshot.TextCapsuleTransparentBackground = state.TextCapsuleTransparentBackground;
|
||||
snapshot.TextCapsuleFontSize = state.TextCapsuleFontSize;
|
||||
snapshot.ShowNetworkSpeed = state.ShowNetworkSpeed;
|
||||
snapshot.NetworkSpeedPosition = state.NetworkSpeedPosition;
|
||||
snapshot.NetworkSpeedDisplayMode = state.NetworkSpeedDisplayMode;
|
||||
snapshot.NetworkSpeedTransparentBackground = state.NetworkSpeedTransparentBackground;
|
||||
snapshot.ShowNetworkTypeIcon = state.ShowNetworkTypeIcon;
|
||||
snapshot.NetworkSpeedFontSize = state.NetworkSpeedFontSize;
|
||||
snapshot.StatusBarSpacingMode = state.SpacingMode;
|
||||
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
|
||||
snapshot.StatusBarShadowEnabled = state.ShadowEnabled;
|
||||
snapshot.StatusBarShadowColor = state.ShadowColor;
|
||||
snapshot.StatusBarShadowOpacity = state.ShadowOpacity;
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
@@ -412,8 +452,63 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
||||
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
||||
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.ClockPosition),
|
||||
nameof(AppSettingsSnapshot.ClockFontSize),
|
||||
nameof(AppSettingsSnapshot.ShowTextCapsule),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleContent),
|
||||
nameof(AppSettingsSnapshot.TextCapsulePosition),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleFontSize),
|
||||
nameof(AppSettingsSnapshot.ShowNetworkSpeed),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedPosition),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedDisplayMode),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.ShowNetworkTypeIcon),
|
||||
nameof(AppSettingsSnapshot.NetworkSpeedFontSize),
|
||||
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowEnabled),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowColor),
|
||||
nameof(AppSettingsSnapshot.StatusBarShadowOpacity)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TextCapsuleSettingsService : ITextCapsuleSettingsService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public TextCapsuleSettingsService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
}
|
||||
|
||||
public TextCapsuleSettingsState Get()
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
return new TextCapsuleSettingsState(
|
||||
snapshot.ShowTextCapsule,
|
||||
snapshot.TextCapsuleContent,
|
||||
snapshot.TextCapsulePosition,
|
||||
snapshot.TextCapsuleTransparentBackground);
|
||||
}
|
||||
|
||||
public void Save(TextCapsuleSettingsState state)
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
snapshot.ShowTextCapsule = state.ShowTextCapsule;
|
||||
snapshot.TextCapsuleContent = state.Content;
|
||||
snapshot.TextCapsulePosition = state.Position;
|
||||
snapshot.TextCapsuleTransparentBackground = state.TransparentBackground;
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys:
|
||||
[
|
||||
nameof(AppSettingsSnapshot.ShowTextCapsule),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleContent),
|
||||
nameof(AppSettingsSnapshot.TextCapsulePosition),
|
||||
nameof(AppSettingsSnapshot.TextCapsuleTransparentBackground)
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1198,6 +1293,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
WallpaperMedia = new WallpaperMediaService();
|
||||
Theme = new ThemeAppearanceService(Settings);
|
||||
StatusBar = new StatusBarSettingsService(Settings);
|
||||
TextCapsule = new TextCapsuleSettingsService(Settings);
|
||||
_weatherSettingsService = new WeatherSettingsService(Settings);
|
||||
Weather = _weatherSettingsService;
|
||||
Region = new RegionSettingsService(Settings);
|
||||
@@ -1227,6 +1323,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
|
||||
|
||||
public IStatusBarSettingsService StatusBar { get; }
|
||||
|
||||
public ITextCapsuleSettingsService TextCapsule { get; }
|
||||
|
||||
public IWeatherSettingsService Weather { get; }
|
||||
|
||||
public IRegionSettingsService Region { get; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -93,6 +93,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
private const int WM_WINDOWPOSCHANGING = 0x0046;
|
||||
private const int WM_NCHITTEST = 0x0084;
|
||||
private const int WM_ACTIVATEAPP = 0x001C; // 【新增】应用激活消息
|
||||
private const int HTTRANSPARENT = -1;
|
||||
private const int HTCLIENT = 1;
|
||||
|
||||
@@ -100,8 +101,25 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
private static readonly Dictionary<IntPtr, bool> _bottomMostWindows = new();
|
||||
private static readonly Dictionary<IntPtr, IntPtr> _originalWndProcs = new();
|
||||
private static readonly Dictionary<IntPtr, List<Rect>> _interactiveRegions = new();
|
||||
|
||||
// 记录每个窗口的屏幕原点(窗口左上角的屏幕坐标),用于将 WM_NCHITTEST 屏幕坐标转成窗口相对坐标
|
||||
private static readonly Dictionary<IntPtr, Point> _windowScreenOrigins = new();
|
||||
private static readonly object _staticLock = new();
|
||||
|
||||
// 【修复问题1】静态持有委托引用,防止 GC 回收导致 CallbackOnCollectedDelegate 崩溃
|
||||
private static WndProcDelegate? _wndProcDelegate;
|
||||
|
||||
// 【修复问题2】记录每个窗口的 DPI 缩放比例
|
||||
private static readonly Dictionary<IntPtr, double> _windowDpiScales = new();
|
||||
|
||||
// 【修复问题5】Z 轴竞争优化 - 记录上次置底时间,避免频繁操作
|
||||
private static readonly Dictionary<IntPtr, long> _lastSendToBottomTime = new();
|
||||
private const long MinSendToBottomIntervalMs = 100; // 【修复置底问题】降低到 100ms,提高响应速度
|
||||
|
||||
// 【新增】定时器定期强制置底
|
||||
private static System.Timers.Timer? _keepBottomTimer;
|
||||
private static readonly object _timerLock = new();
|
||||
|
||||
public bool IsBottomMostSupported => true;
|
||||
|
||||
public void SetupBottomMost(Window window)
|
||||
@@ -121,11 +139,13 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
// 设置为桌面子窗口
|
||||
SetAsDesktopChild(handle);
|
||||
|
||||
// 注册置底状态
|
||||
// 注册置底状态 & 记录窗口屏幕原点
|
||||
lock (_staticLock)
|
||||
{
|
||||
_bottomMostWindows[handle] = true;
|
||||
_interactiveRegions[handle] = [];
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
UpdateWindowDpiScale(handle); // 【修复问题2】初始化 DPI 缩放
|
||||
}
|
||||
|
||||
// 注入消息钩子
|
||||
@@ -134,6 +154,9 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
// 初始置底
|
||||
SendToBottomInternal(handle);
|
||||
|
||||
// 【新增】启动定时器定期强制置底
|
||||
StartKeepBottomTimer();
|
||||
|
||||
AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}");
|
||||
};
|
||||
|
||||
@@ -147,6 +170,8 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_bottomMostWindows.Remove(handle);
|
||||
_originalWndProcs.Remove(handle);
|
||||
_interactiveRegions.Remove(handle);
|
||||
_windowScreenOrigins.Remove(handle);
|
||||
_windowDpiScales.Remove(handle); // 【修复问题2】清理 DPI 缩放记录
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -169,21 +194,113 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【新增】启动定时器定期强制置底所有窗口
|
||||
/// </summary>
|
||||
private static void StartKeepBottomTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_keepBottomTimer != null) return;
|
||||
|
||||
_keepBottomTimer = new System.Timers.Timer(200); // 每 200ms 检查一次
|
||||
_keepBottomTimer.Elapsed += (s, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
foreach (var kvp in _bottomMostWindows)
|
||||
{
|
||||
if (kvp.Value) // 如果标记为置底
|
||||
{
|
||||
SendToBottomInternal(kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略定时器错误
|
||||
}
|
||||
};
|
||||
_keepBottomTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【新增】停止定时器
|
||||
/// </summary>
|
||||
private static void StopKeepBottomTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
_keepBottomTimer?.Stop();
|
||||
_keepBottomTimer?.Dispose();
|
||||
_keepBottomTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetAsDesktopChild(IntPtr handle)
|
||||
{
|
||||
// 【修复问题4】增强桌面挂载逻辑,支持 Wallpaper Engine 等动态壁纸软件
|
||||
|
||||
// 方案1: 尝试找到 WorkerW 层(Wallpaper Engine 创建的层)
|
||||
var workerW = IntPtr.Zero;
|
||||
var hDefView = IntPtr.Zero;
|
||||
|
||||
// 枚举所有顶层窗口
|
||||
var windowHandles = new ArrayList();
|
||||
EnumWindows(EnumWindowsCallback, windowHandles);
|
||||
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
var hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
// 查找 WorkerW 窗口(Wallpaper Engine 创建)
|
||||
var className = GetWindowClassName(h);
|
||||
if (className == "WorkerW")
|
||||
{
|
||||
// 在 WorkerW 下查找 SHELLDLL_DefView
|
||||
var defView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (defView != IntPtr.Zero)
|
||||
{
|
||||
workerW = h;
|
||||
hDefView = defView;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了 WorkerW 层,使用它作为父窗口
|
||||
if (workerW != IntPtr.Zero && hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
AppLogger.Info("WindowBottomMost", "Mounted to WorkerW layer (Wallpaper Engine detected)");
|
||||
return;
|
||||
}
|
||||
|
||||
// 方案2: 回退到传统方式,查找 Progman 下的 SHELLDLL_DefView
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
AppLogger.Info("WindowBottomMost", "Mounted to traditional desktop layer");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题4】获取窗口类名
|
||||
/// </summary>
|
||||
private static string GetWindowClassName(IntPtr hWnd)
|
||||
{
|
||||
var buffer = new char[256];
|
||||
var length = GetClassName(hWnd, buffer, buffer.Length);
|
||||
return length > 0 ? new string(buffer, 0, length) : string.Empty;
|
||||
}
|
||||
|
||||
private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles)
|
||||
{
|
||||
handles.Add(handle);
|
||||
@@ -198,13 +315,29 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
lock (_staticLock)
|
||||
{
|
||||
_originalWndProcs[handle] = originalWndProc;
|
||||
|
||||
// 【修复问题1】确保委托实例被静态引用持有,防止 GC 回收
|
||||
_wndProcDelegate ??= SubclassWndProc;
|
||||
}
|
||||
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate<WndProcDelegate>(SubclassWndProc));
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProcDelegate));
|
||||
}
|
||||
|
||||
private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
// 【新增】处理应用激活消息 - 当其他应用激活时立即置底
|
||||
if (msg == WM_ACTIVATEAPP)
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
// 立即置底,不进行频率限制
|
||||
SendToBottomInternal(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 WM_WINDOWPOSCHANGING - 保持置底
|
||||
if (msg == WM_WINDOWPOSCHANGING)
|
||||
{
|
||||
@@ -212,7 +345,19 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
// 【修复问题5】优化 Z 轴竞争 - 限制置底操作频率
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
if (_lastSendToBottomTime.TryGetValue(hWnd, out var lastTime))
|
||||
{
|
||||
if (now - lastTime < MinSendToBottomIntervalMs)
|
||||
{
|
||||
// 跳过过于频繁的置底操作
|
||||
goto CallOriginal;
|
||||
}
|
||||
}
|
||||
|
||||
SendToBottomInternal(hWnd);
|
||||
_lastSendToBottomTime[hWnd] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,15 +365,29 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
// 处理 WM_NCHITTEST - 区域级穿透
|
||||
if (msg == WM_NCHITTEST)
|
||||
{
|
||||
// 从 lParam 解析坐标(低字为 X,高字为 Y)
|
||||
var x = (short)(wParam.ToInt32() & 0xFFFF);
|
||||
var y = (short)((wParam.ToInt32() >> 16) & 0xFFFF);
|
||||
var point = new Point(x, y);
|
||||
// WM_NCHITTEST 的鼠标坐标在 lParam(低16位=X,高16位=Y),且为屏幕坐标
|
||||
var screenX = (short)(lParam.ToInt64() & 0xFFFF);
|
||||
var screenY = (short)((lParam.ToInt64() >> 16) & 0xFFFF);
|
||||
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions))
|
||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
||||
{
|
||||
// 【修复问题2】获取窗口原点和 DPI 缩放比例
|
||||
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
||||
_windowDpiScales.TryGetValue(hWnd, out var dpiScale);
|
||||
if (dpiScale <= 0) dpiScale = 1.0; // 默认缩放为 1.0
|
||||
|
||||
// 将屏幕物理像素坐标转为窗口相对坐标
|
||||
var clientX = screenX - origin.X;
|
||||
var clientY = screenY - origin.Y;
|
||||
|
||||
// 【修复问题2】将物理像素坐标转换为逻辑 DIP 坐标
|
||||
// _interactiveRegions 存储的是 Avalonia UI 的逻辑 DIP 坐标
|
||||
var logicalX = clientX / dpiScale;
|
||||
var logicalY = clientY / dpiScale;
|
||||
var point = new Point(logicalX, logicalY);
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
if (region.Contains(point))
|
||||
@@ -245,6 +404,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
}
|
||||
|
||||
// 调用原始窗口过程
|
||||
CallOriginal:
|
||||
IntPtr originalWndProc;
|
||||
lock (_staticLock)
|
||||
{
|
||||
@@ -265,9 +425,54 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
lock (_staticLock)
|
||||
{
|
||||
_interactiveRegions[handle] = regions;
|
||||
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
UpdateWindowDpiScale(handle); // 【修复问题2】同步更新 DPI 缩放
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新指定窗口的屏幕左上角坐标缓存(用于将 WM_NCHITTEST 屏幕坐标转为窗口相对坐标)
|
||||
/// </summary>
|
||||
private static void UpdateWindowScreenOrigin(IntPtr handle)
|
||||
{
|
||||
if (GetWindowRect(handle, out var rect))
|
||||
{
|
||||
_windowScreenOrigins[handle] = new Point(rect.Left, rect.Top);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题2】更新指定窗口的 DPI 缩放比例
|
||||
/// </summary>
|
||||
private static void UpdateWindowDpiScale(IntPtr handle)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取窗口所在的显示器 DPI
|
||||
var monitor = MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST);
|
||||
if (monitor != IntPtr.Zero)
|
||||
{
|
||||
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out var dpiX, out var _) == 0)
|
||||
{
|
||||
// DPI 缩放比例 = 当前 DPI / 96 (标准 DPI)
|
||||
_windowDpiScales[handle] = dpiX / 96.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果获取失败,使用默认缩放 1.0
|
||||
_windowDpiScales[handle] = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||
|
||||
private delegate IntPtr WndProcDelegate(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
@@ -299,6 +504,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
// 【修复问题2】DPI 相关的 P/Invoke 声明
|
||||
private const int MONITOR_DEFAULTTONEAREST = 2;
|
||||
private const int MDT_EFFECTIVE_DPI = 0;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, int dwFlags);
|
||||
|
||||
[DllImport("shcore.dll")]
|
||||
private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||
|
||||
// 【修复问题4】获取窗口类名的 P/Invoke
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||
private static extern int GetClassName(IntPtr hWnd, char[] lpClassName, int nMaxCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
@@ -696,18 +696,23 @@ internal static class WindowsIconService
|
||||
try
|
||||
{
|
||||
using var source = Image.FromHbitmap(bitmapHandle);
|
||||
using var bitmap = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
|
||||
var width = source.Width;
|
||||
var height = source.Height;
|
||||
|
||||
using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.CompositingMode = CompositingMode.SourceOver;
|
||||
graphics.CompositingMode = CompositingMode.SourceCopy;
|
||||
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
||||
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
graphics.SmoothingMode = SmoothingMode.HighQuality;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
graphics.DrawImage(source, 0, 0, source.Width, source.Height);
|
||||
graphics.DrawImage(source, 0, 0, width, height);
|
||||
}
|
||||
|
||||
FixBitmapAlpha(bitmap);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
@@ -718,6 +723,47 @@ internal static class WindowsIconService
|
||||
}
|
||||
}
|
||||
|
||||
private static void FixBitmapAlpha(Bitmap bitmap)
|
||||
{
|
||||
var width = bitmap.Width;
|
||||
var height = bitmap.Height;
|
||||
var rect = new Rectangle(0, 0, width, height);
|
||||
var data = bitmap.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Math.Abs(data.Stride) * height;
|
||||
var buffer = new byte[bytes];
|
||||
Marshal.Copy(data.Scan0, buffer, 0, bytes);
|
||||
|
||||
for (var i = 0; i < bytes; i += 4)
|
||||
{
|
||||
var b = buffer[i];
|
||||
var g = buffer[i + 1];
|
||||
var r = buffer[i + 2];
|
||||
var a = buffer[i + 3];
|
||||
|
||||
if (a == 0 && (r != 0 || g != 0 || b != 0))
|
||||
{
|
||||
a = (byte)Math.Max(r, Math.Max(g, b));
|
||||
buffer[i + 3] = a;
|
||||
}
|
||||
else if (a > 0 && a < 255)
|
||||
{
|
||||
buffer[i] = (byte)(b * 255 / a);
|
||||
buffer[i + 1] = (byte)(g * 255 / a);
|
||||
buffer[i + 2] = (byte)(r * 255 / a);
|
||||
}
|
||||
}
|
||||
|
||||
Marshal.Copy(buffer, 0, data.Scan0, bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(data);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryInitializeCom(out bool shouldUninitialize)
|
||||
{
|
||||
shouldUninitialize = false;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -21,6 +22,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||
|
||||
ClockFormats = CreateClockFormats();
|
||||
ClockPositions = CreateClockPositions();
|
||||
ClockFontSizes = CreateFontSizes();
|
||||
TextCapsulePositions = CreateTextCapsulePositions();
|
||||
NetworkSpeedPositions = CreateNetworkSpeedPositions();
|
||||
NetworkSpeedDisplayModes = CreateNetworkSpeedDisplayModes();
|
||||
NetworkSpeedFontSizes = CreateFontSizes();
|
||||
SpacingModes = CreateSpacingModes();
|
||||
RefreshLocalizedText();
|
||||
|
||||
@@ -31,8 +38,20 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockFormats { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockPositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> TextCapsulePositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedPositions { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedDisplayModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> SpacingModes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> ClockFontSizes { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption> NetworkSpeedFontSizes { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showClock = true;
|
||||
|
||||
@@ -42,6 +61,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _clockTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedClockPosition = new("Left", "Left");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
|
||||
|
||||
@@ -75,6 +97,81 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _clockTransparentBackgroundDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _clockPositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedClockFontSize = new("Medium", "Medium");
|
||||
|
||||
[ObservableProperty]
|
||||
private string _clockFontSizeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showTextCapsule;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleContent = "**Hello** World!";
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedTextCapsulePosition = new("Right", "Right");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _textCapsuleTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsulePositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleContentLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _textCapsuleTransparentBackgroundLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showNetworkSpeed;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedPosition = new("Right", "Right");
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedDisplayMode = new("Both", "Both");
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _networkSpeedTransparentBackground;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedPositionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedDisplayModeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedTransparentBackgroundLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showNetworkTypeIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _showNetworkTypeIconLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SelectionOption _selectedNetworkSpeedFontSize = new("Medium", "Medium");
|
||||
|
||||
[ObservableProperty]
|
||||
private string _networkSpeedFontSizeLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _spacingHeader = string.Empty;
|
||||
|
||||
@@ -84,6 +181,32 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _customSpacingLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _statusBarShadowEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color _statusBarShadowColor = Colors.Black;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _statusBarShadowOpacity = 30;
|
||||
|
||||
public IBrush StatusBarShadowColorBrush => new SolidColorBrush(StatusBarShadowColor);
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowEnabledLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowColorLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusBarShadowOpacityLabel = string.Empty;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var state = _settingsFacade.StatusBar.Get();
|
||||
@@ -99,12 +222,59 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
?? ClockFormats[1];
|
||||
ClockTransparentBackground = state.ClockTransparentBackground;
|
||||
|
||||
var clockPosition = NormalizeClockPosition(state.ClockPosition);
|
||||
SelectedClockPosition = ClockPositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, clockPosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? ClockPositions[0];
|
||||
|
||||
// 时钟字体大小设置
|
||||
var clockFontSize = NormalizeFontSize(state.ClockFontSize);
|
||||
SelectedClockFontSize = ClockFontSizes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, clockFontSize, StringComparison.OrdinalIgnoreCase))
|
||||
?? ClockFontSizes[1]; // 默认中等
|
||||
|
||||
// 文字胶囊设置
|
||||
ShowTextCapsule = state.ShowTextCapsule;
|
||||
TextCapsuleContent = state.TextCapsuleContent ?? "**Hello** World!";
|
||||
var textCapsulePosition = NormalizeTextCapsulePosition(state.TextCapsulePosition);
|
||||
SelectedTextCapsulePosition = TextCapsulePositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, textCapsulePosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? TextCapsulePositions[2]; // 默认靠右
|
||||
TextCapsuleTransparentBackground = state.TextCapsuleTransparentBackground;
|
||||
|
||||
// 网速设置
|
||||
ShowNetworkSpeed = state.ShowNetworkSpeed;
|
||||
var networkSpeedPosition = NormalizeNetworkSpeedPosition(state.NetworkSpeedPosition);
|
||||
SelectedNetworkSpeedPosition = NetworkSpeedPositions.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedPosition, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedPositions[2]; // 默认靠右
|
||||
var networkSpeedDisplayMode = NormalizeNetworkSpeedDisplayMode(state.NetworkSpeedDisplayMode);
|
||||
SelectedNetworkSpeedDisplayMode = NetworkSpeedDisplayModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedDisplayMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedDisplayModes[0]; // 默认双向
|
||||
NetworkSpeedTransparentBackground = state.NetworkSpeedTransparentBackground;
|
||||
ShowNetworkTypeIcon = state.ShowNetworkTypeIcon;
|
||||
|
||||
// 网速字体大小设置
|
||||
var networkSpeedFontSize = NormalizeFontSize(state.NetworkSpeedFontSize);
|
||||
SelectedNetworkSpeedFontSize = NetworkSpeedFontSizes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, networkSpeedFontSize, StringComparison.OrdinalIgnoreCase))
|
||||
?? NetworkSpeedFontSizes[1]; // 默认中等
|
||||
|
||||
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
|
||||
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
|
||||
string.Equals(option.Value, spacingMode, StringComparison.OrdinalIgnoreCase))
|
||||
?? SpacingModes[1];
|
||||
CustomSpacingPercent = Math.Clamp(state.CustomSpacingPercent, 0, 30);
|
||||
IsCustomSpacingVisible = string.Equals(SelectedSpacingMode.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// 状态栏阴影设置
|
||||
StatusBarShadowEnabled = state.ShadowEnabled;
|
||||
if (Color.TryParse(state.ShadowColor, out var shadowColor))
|
||||
{
|
||||
StatusBarShadowColor = shadowColor;
|
||||
}
|
||||
StatusBarShadowOpacity = Math.Clamp(state.ShadowOpacity * 100, 0, 100);
|
||||
}
|
||||
|
||||
partial void OnShowClockChanged(bool value)
|
||||
@@ -137,6 +307,126 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedClockPositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedClockFontSizeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowTextCapsuleChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnTextCapsuleContentChanged(string value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedTextCapsulePositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnTextCapsuleTransparentBackgroundChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowNetworkSpeedChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedPositionChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedDisplayModeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnNetworkSpeedTransparentBackgroundChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnShowNetworkTypeIconChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedNetworkSpeedFontSizeChanged(SelectionOption value)
|
||||
{
|
||||
if (_isInitializing || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnSelectedSpacingModeChanged(SelectionOption value)
|
||||
{
|
||||
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -165,6 +455,37 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowEnabledChanged(bool value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowColorChanged(Color value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusBarShadowColorBrush));
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
partial void OnStatusBarShadowOpacityChanged(double value)
|
||||
{
|
||||
if (_isInitializing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Save();
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
var state = _settingsFacade.StatusBar.Get();
|
||||
@@ -184,8 +505,24 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
state.TaskbarLayoutMode,
|
||||
SelectedClockFormat.Value,
|
||||
ClockTransparentBackground,
|
||||
SelectedClockPosition.Value,
|
||||
SelectedClockFontSize?.Value ?? "Medium",
|
||||
ShowTextCapsule,
|
||||
TextCapsuleContent ?? "**Hello** World!",
|
||||
SelectedTextCapsulePosition?.Value ?? "Right",
|
||||
TextCapsuleTransparentBackground,
|
||||
"Medium", // TextCapsuleFontSize - 暂时使用默认值
|
||||
ShowNetworkSpeed,
|
||||
SelectedNetworkSpeedPosition?.Value ?? "Right",
|
||||
SelectedNetworkSpeedDisplayMode?.Value ?? "Both",
|
||||
NetworkSpeedTransparentBackground,
|
||||
ShowNetworkTypeIcon,
|
||||
SelectedNetworkSpeedFontSize?.Value ?? "Medium",
|
||||
NormalizeSpacingMode(SelectedSpacingMode.Value),
|
||||
Math.Clamp(CustomSpacingPercent, 0, 30)));
|
||||
Math.Clamp(CustomSpacingPercent, 0, 30),
|
||||
StatusBarShadowEnabled,
|
||||
StatusBarShadowColor.ToString(),
|
||||
StatusBarShadowOpacity / 100.0));
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateClockFormats()
|
||||
@@ -197,6 +534,46 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateClockPositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.clock_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.clock_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.clock_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateTextCapsulePositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.text_capsule_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.text_capsule_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.text_capsule_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateNetworkSpeedPositions()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Left", L("settings.status_bar.network_speed_position.left", "Left")),
|
||||
new SelectionOption("Center", L("settings.status_bar.network_speed_position.center", "Center")),
|
||||
new SelectionOption("Right", L("settings.status_bar.network_speed_position.right", "Right"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateNetworkSpeedDisplayModes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Both", L("settings.status_bar.network_speed_mode.both", "Upload + Download")),
|
||||
new SelectionOption("Upload", L("settings.status_bar.network_speed_mode.upload", "Upload only")),
|
||||
new SelectionOption("Download", L("settings.status_bar.network_speed_mode.download", "Download only"))
|
||||
];
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateSpacingModes()
|
||||
{
|
||||
return
|
||||
@@ -217,9 +594,28 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
|
||||
ClockTransparentBackgroundLabel = L("settings.status_bar.clock_transparent_background_label", "Transparent background");
|
||||
ClockTransparentBackgroundDescription = L("settings.status_bar.clock_transparent_background_desc", "Remove the capsule background and keep only the clock text.");
|
||||
ClockPositionLabel = L("settings.status_bar.clock_position_label", "Clock position");
|
||||
ClockFontSizeLabel = L("settings.status_bar.clock_font_size_label", "Font size");
|
||||
TextCapsuleHeader = L("settings.status_bar.text_capsule_header", "Text Capsule");
|
||||
TextCapsuleDescription = L("settings.status_bar.text_capsule_description", "Display custom text with Markdown support on the status bar.");
|
||||
TextCapsulePositionLabel = L("settings.status_bar.text_capsule_position_label", "Text capsule position");
|
||||
TextCapsuleContentLabel = L("settings.status_bar.text_capsule_content_label", "Text content (Markdown supported)");
|
||||
TextCapsuleTransparentBackgroundLabel = L("settings.status_bar.text_capsule_transparent_background_label", "Transparent background");
|
||||
NetworkSpeedHeader = L("settings.status_bar.network_speed_header", "Network Speed");
|
||||
NetworkSpeedDescription = L("settings.status_bar.network_speed_description", "Display real-time network upload and download speed.");
|
||||
NetworkSpeedPositionLabel = L("settings.status_bar.network_speed_position_label", "Network speed position");
|
||||
NetworkSpeedDisplayModeLabel = L("settings.status_bar.network_speed_mode_label", "Display mode");
|
||||
NetworkSpeedTransparentBackgroundLabel = L("settings.status_bar.network_speed_transparent_background_label", "Transparent background");
|
||||
ShowNetworkTypeIconLabel = L("settings.status_bar.show_network_type_icon_label", "Show network type icon");
|
||||
NetworkSpeedFontSizeLabel = L("settings.status_bar.network_speed_font_size_label", "Font size");
|
||||
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
|
||||
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
|
||||
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||
StatusBarShadowHeader = L("settings.status_bar.shadow_header", "Status Bar Shadow");
|
||||
StatusBarShadowDescription = L("settings.status_bar.shadow_desc", "Add shadow effect to the status bar for better visibility.");
|
||||
StatusBarShadowEnabledLabel = L("settings.status_bar.shadow_enabled_label", "Enable shadow");
|
||||
StatusBarShadowColorLabel = L("settings.status_bar.shadow_color_label", "Shadow color");
|
||||
StatusBarShadowOpacityLabel = L("settings.status_bar.shadow_opacity_label", "Shadow opacity");
|
||||
}
|
||||
|
||||
private string NormalizeSpacingMode(string? value)
|
||||
@@ -232,6 +628,66 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeClockPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ when string.Equals(value, "Right", StringComparison.OrdinalIgnoreCase) => "Right",
|
||||
_ => "Left"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeTextCapsulePosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedPosition(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Left", StringComparison.OrdinalIgnoreCase) => "Left",
|
||||
_ when string.Equals(value, "Center", StringComparison.OrdinalIgnoreCase) => "Center",
|
||||
_ => "Right"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeNetworkSpeedDisplayMode(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Upload", StringComparison.OrdinalIgnoreCase) => "Upload",
|
||||
_ when string.Equals(value, "Download", StringComparison.OrdinalIgnoreCase) => "Download",
|
||||
_ => "Both"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeFontSize(string? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
_ when string.Equals(value, "Small", StringComparison.OrdinalIgnoreCase) => "Small",
|
||||
_ when string.Equals(value, "Large", StringComparison.OrdinalIgnoreCase) => "Large",
|
||||
_ => "Medium"
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<SelectionOption> CreateFontSizes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Small", L("settings.status_bar.font_size.small", "Small")),
|
||||
new SelectionOption("Medium", L("settings.status_bar.font_size.medium", "Medium")),
|
||||
new SelectionOption("Large", L("settings.status_bar.font_size.large", "Large"))
|
||||
];
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
=> _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@
|
||||
<ComboBoxItem x:Name="SectlItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="sectl" />
|
||||
<ComboBoxItem x:Name="RinLitItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="rinlit" />
|
||||
<ComboBoxItem x:Name="JiangtokotoItem"
|
||||
Classes="component-editor-select-item"
|
||||
Tag="jiangtokoto" />
|
||||
</ComboBox>
|
||||
<TextBlock x:Name="SourceDescriptionTextBlock"
|
||||
Classes="component-editor-secondary-text"
|
||||
|
||||
@@ -29,10 +29,12 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
SourceLabelTextBlock.Text = L("zhijiaohub.settings.source", "图片源");
|
||||
ClassIslandItem.Content = L("zhijiaohub.settings.classisland", "ClassIsland 图库");
|
||||
SectlItem.Content = L("zhijiaohub.settings.sectl", "SECTL 图库");
|
||||
RinLitItem.Content = L("zhijiaohub.settings.rinlit", "Rin's 图库");
|
||||
JiangtokotoItem.Content = L("zhijiaohub.settings.jiangtokoto", "Jiangtokoto 表情包");
|
||||
|
||||
// 数据源描述
|
||||
SourceDescriptionTextBlock.Text = L("zhijiaohub.settings.source_desc",
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容。");
|
||||
"选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间,SECTL 图库包含 SECTL 社区的内容,Rin's 图库包含 Rin's 社区的内容,Jiangtokoto 表情包包含丰富的表情包资源。");
|
||||
|
||||
// 镜像加速源
|
||||
MirrorSourceLabelTextBlock.Text = L("zhijiaohub.settings.mirror_source", "镜像加速");
|
||||
@@ -65,6 +67,8 @@ public partial class ZhiJiaoHubComponentEditor : ComponentEditorViewBase
|
||||
SourceComboBox.SelectedItem = source switch
|
||||
{
|
||||
ZhiJiaoHubSources.Sectl => SectlItem,
|
||||
ZhiJiaoHubSources.RinLit => RinLitItem,
|
||||
ZhiJiaoHubSources.Jiangtokoto => JiangtokotoItem,
|
||||
_ => ClassIslandItem
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -220,7 +221,7 @@ public partial class ComponentLibraryWindow : Window
|
||||
|
||||
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Apps;
|
||||
return Symbol.Info;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -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 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
@@ -25,6 +25,7 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
|
||||
private bool _transparentBackground;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private string _fontSize = "Medium"; // Small, Medium, Large
|
||||
|
||||
public ClockWidget()
|
||||
{
|
||||
@@ -72,6 +73,21 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
TransparentBackground = transparentBackground;
|
||||
}
|
||||
|
||||
public string WidgetFontSize
|
||||
{
|
||||
get => _fontSize;
|
||||
set
|
||||
{
|
||||
_fontSize = value;
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFontSize(string fontSize)
|
||||
{
|
||||
WidgetFontSize = fontSize;
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
{
|
||||
ClearTimeZoneService();
|
||||
@@ -138,7 +154,14 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
|
||||
|
||||
// 3. 核心:满盈字阶 (Filled Typography)
|
||||
// 使主时间文字占据容器高度的 ~68%,产生饱满的视觉张力
|
||||
var mainFontSize = targetHeight * 0.68;
|
||||
// 根据字体大小设置调整基础大小
|
||||
var fontSizeMultiplier = _fontSize switch
|
||||
{
|
||||
"Small" => 0.55,
|
||||
"Large" => 0.85,
|
||||
_ => 0.68 // Medium (default)
|
||||
};
|
||||
var mainFontSize = targetHeight * fontSizeMultiplier;
|
||||
MainTimeTextBlock.FontSize = mainFontSize;
|
||||
MainTimeTextBlock.FontWeight = FontWeight.SemiBold;
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -475,7 +471,19 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
"component.zhijiao_hub",
|
||||
() => new ZhiJiaoHubWidget())
|
||||
() => new ZhiJiaoHubWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"component.file_manager",
|
||||
() => new FileManagerWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"component.notification_box",
|
||||
() => new NotificationBoxWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopShortcut,
|
||||
"component.shortcut",
|
||||
() => new ShortcutWidget())
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
138
LanMountainDesktop/Views/Components/FileManagerWidget.axaml
Normal file
138
LanMountainDesktop/Views/Components/FileManagerWidget.axaml
Normal file
@@ -0,0 +1,138 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="320"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.FileManagerWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="12,10"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<!-- 导航栏 -->
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="Auto,Auto,*,Auto"
|
||||
ColumnSpacing="6">
|
||||
<!-- 返回按钮 -->
|
||||
<Button x:Name="BackButton"
|
||||
Grid.Column="0"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnBackButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowLeft"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
|
||||
<!-- 主页/盘符按钮 -->
|
||||
<Button x:Name="HomeButton"
|
||||
Grid.Column="1"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnHomeButtonClick">
|
||||
<fi:SymbolIcon Symbol="Home"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
|
||||
<!-- 路径显示 -->
|
||||
<Border Grid.Column="2"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="10,0"
|
||||
VerticalAlignment="Center"
|
||||
Height="32">
|
||||
<TextBlock x:Name="PathTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="13"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding $self.Text}"
|
||||
Text="此电脑" />
|
||||
</Border>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="3"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<Border Grid.Row="1"
|
||||
Height="1"
|
||||
Margin="0,10"
|
||||
Background="{DynamicResource AdaptiveDividerBrush}" />
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="FileItemsControl">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<StackPanel x:Name="EmptyStatePanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="FolderOpen"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
<TextBlock x:Name="EmptyStateTextBlock"
|
||||
Text="文件夹为空"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<StackPanel x:Name="ErrorStatePanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="ErrorCircle"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource AdaptiveErrorBrush}" />
|
||||
<TextBlock x:Name="ErrorStateTextBlock"
|
||||
Text="无法访问此文件夹"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveErrorBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
819
LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs
Normal file
819
LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs
Normal file
@@ -0,0 +1,819 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using FluentIcons.Avalonia;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class FileManagerWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
IDesktopPageVisibilityAwareComponentWidget,
|
||||
IComponentPlacementContextAware,
|
||||
IDisposable
|
||||
{
|
||||
private readonly List<string> _navigationHistory = new();
|
||||
private int _currentHistoryIndex = -1;
|
||||
private string _currentPath = string.Empty;
|
||||
private string _componentId = BuiltInComponentIds.DesktopFileManager;
|
||||
private string _placementId = string.Empty;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isOnActivePage;
|
||||
private bool _isEditMode;
|
||||
private bool _isAttached;
|
||||
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,
|
||||
FileSystemItem Item,
|
||||
Border Border
|
||||
);
|
||||
|
||||
public FileManagerWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
NavigateToDrives();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
|
||||
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
|
||||
RootBorder.CornerRadius = mainRectangleCornerRadius;
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(_currentCellSize * 0.25, 10, 20),
|
||||
Math.Clamp(_currentCellSize * 0.20, 8, 16));
|
||||
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_isOnActivePage = isOnActivePage;
|
||||
_isEditMode = isEditMode;
|
||||
|
||||
if (_isOnActivePage && _isAttached && !string.IsNullOrEmpty(_currentPath))
|
||||
{
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopFileManager
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
|
||||
_gestureStates.Clear();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = true;
|
||||
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = false;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
private void ApplyLayoutMetrics()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4;
|
||||
|
||||
var buttonSize = Math.Clamp(32 * scale, 28, 40);
|
||||
var iconSize = Math.Clamp(14 * scale, 12, 18);
|
||||
var pathFontSize = Math.Clamp(13 * scale, 11, 16);
|
||||
|
||||
BackButton.Width = buttonSize;
|
||||
BackButton.Height = buttonSize;
|
||||
BackButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
HomeButton.Width = buttonSize;
|
||||
HomeButton.Height = buttonSize;
|
||||
HomeButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
RefreshButton.Width = buttonSize;
|
||||
RefreshButton.Height = buttonSize;
|
||||
RefreshButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
PathTextBlock.FontSize = pathFontSize;
|
||||
|
||||
if (BackButton.Content is SymbolIcon backIcon)
|
||||
{
|
||||
backIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (HomeButton.Content is SymbolIcon homeIcon)
|
||||
{
|
||||
homeIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (RefreshButton.Content is SymbolIcon refreshIcon)
|
||||
{
|
||||
refreshIcon.FontSize = iconSize;
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 280d, 0.72, 2.4) : 1;
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 280d, 0.72, 2.4) : 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
|
||||
}
|
||||
|
||||
private void OnBackButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (_currentHistoryIndex > 0)
|
||||
{
|
||||
_currentHistoryIndex--;
|
||||
var path = _navigationHistory[_currentHistoryIndex];
|
||||
LoadDirectory(path, addToHistory: false);
|
||||
}
|
||||
else if (_currentHistoryIndex == 0 && _navigationHistory.Count > 0)
|
||||
{
|
||||
NavigateToDrives();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHomeButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
NavigateToDrives();
|
||||
}
|
||||
|
||||
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
|
||||
private void OnItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is not Border border || border.DataContext is not FileSystemItem item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointer = e.GetCurrentPoint(border);
|
||||
var pointerId = e.Pointer.Id;
|
||||
var position = pointer.Position;
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
_gestureStates[pointerId] = new PointerGestureState(position, timestamp, item, border);
|
||||
|
||||
e.Pointer.Capture(border);
|
||||
}
|
||||
|
||||
private void OnItemPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (sender is not Border border)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.TryGetValue(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(border);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (sender is not Border border)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.Remove(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Pointer.Capture(null);
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(border);
|
||||
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)
|
||||
{
|
||||
if (state.Item.IsDirectory)
|
||||
{
|
||||
LoadDirectory(state.Item.FullPath, addToHistory: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenFile(state.Item.FullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateToDrives()
|
||||
{
|
||||
_navigationHistory.Clear();
|
||||
_currentHistoryIndex = -1;
|
||||
_currentPath = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var drives = new List<FileSystemItem>();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!drive.IsReady)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = FileSystemItem.FromDriveInfo(drive);
|
||||
drives.Add(item);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to access drive: {drive?.Name}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "根目录",
|
||||
FullPath = "/",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "主目录",
|
||||
FullPath = homePath,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
|
||||
var linuxMountPoints = new[] { "/mnt", "/media", "/run/media" };
|
||||
foreach (var mount in linuxMountPoints)
|
||||
{
|
||||
if (Directory.Exists(mount))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = mount,
|
||||
FullPath = mount,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "根目录",
|
||||
FullPath = "/",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "用户",
|
||||
FullPath = "/Users",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "应用程序",
|
||||
FullPath = "/Applications",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "个人",
|
||||
FullPath = homePath,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
|
||||
if (Directory.Exists("/Volumes"))
|
||||
{
|
||||
foreach (var volume in Directory.GetDirectories("/Volumes"))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = Path.GetFileName(volume),
|
||||
FullPath = volume,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RenderFileItems(drives);
|
||||
PathTextBlock.Text = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
||||
|
||||
UpdateEmptyState(drives.Count == 0, "没有可用的位置");
|
||||
ErrorStatePanel.IsVisible = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", "Failed to load drives.", ex);
|
||||
ShowError("无法加载位置列表");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadDirectory(string path, bool addToHistory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
NavigateToDrives();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directoryInfo = new DirectoryInfo(path);
|
||||
|
||||
if (!directoryInfo.Exists)
|
||||
{
|
||||
ShowError("文件夹不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
var items = new List<FileSystemItem>();
|
||||
|
||||
// 添加子文件夹
|
||||
try
|
||||
{
|
||||
var directories = directoryInfo.GetDirectories()
|
||||
.Where(d => (d.Attributes & FileAttributes.Hidden) == 0)
|
||||
.OrderBy(d => d.Name)
|
||||
.Select(FileSystemItem.FromDirectoryInfo);
|
||||
items.AddRange(directories);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 忽略无权限访问的文件夹
|
||||
}
|
||||
|
||||
// 添加文件
|
||||
try
|
||||
{
|
||||
var files = directoryInfo.GetFiles()
|
||||
.Where(f => (f.Attributes & FileAttributes.Hidden) == 0)
|
||||
.OrderBy(f => f.Name)
|
||||
.Select(FileSystemItem.FromFileInfo);
|
||||
items.AddRange(files);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 忽略无权限访问的文件
|
||||
}
|
||||
|
||||
RenderFileItems(items);
|
||||
_currentPath = path;
|
||||
PathTextBlock.Text = FormatPathForDisplay(path);
|
||||
|
||||
if (addToHistory)
|
||||
{
|
||||
// 移除当前位置之后的历史记录
|
||||
if (_currentHistoryIndex < _navigationHistory.Count - 1)
|
||||
{
|
||||
_navigationHistory.RemoveRange(_currentHistoryIndex + 1, _navigationHistory.Count - _currentHistoryIndex - 1);
|
||||
}
|
||||
|
||||
_navigationHistory.Add(path);
|
||||
_currentHistoryIndex = _navigationHistory.Count - 1;
|
||||
}
|
||||
|
||||
UpdateEmptyState(items.Count == 0, "文件夹为空");
|
||||
ErrorStatePanel.IsVisible = false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
ShowError("没有权限访问此文件夹");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to load directory: {path}", ex);
|
||||
ShowError("无法加载文件夹内容");
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderFileItems(List<FileSystemItem> items)
|
||||
{
|
||||
FileItemsControl.ItemsSource = null;
|
||||
FileItemsControl.Items.Clear();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var itemControl = CreateFileItemControl(item);
|
||||
FileItemsControl.Items.Add(itemControl);
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreateFileItemControl(FileSystemItem item)
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var itemWidth = Math.Clamp(72 * scale, 64, 96);
|
||||
var itemHeight = Math.Clamp(80 * scale, 72, 108);
|
||||
var iconSize = Math.Clamp(32 * scale, 24, 40);
|
||||
var fontSize = Math.Clamp(11 * scale, 10, 14);
|
||||
|
||||
var textBrush = this.FindResource("AdaptiveTextPrimaryBrush") as IBrush ?? new SolidColorBrush(Colors.White);
|
||||
|
||||
var border = new Border
|
||||
{
|
||||
Width = itemWidth,
|
||||
Height = itemHeight,
|
||||
Margin = new Thickness(4),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = new SolidColorBrush(Colors.Transparent),
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
DataContext = item
|
||||
};
|
||||
|
||||
var grid = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("*,Auto"),
|
||||
Margin = new Thickness(4)
|
||||
};
|
||||
|
||||
var iconImage = CreateSystemIconImage(item, iconSize);
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = item.Name,
|
||||
FontSize = fontSize,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = textBrush
|
||||
};
|
||||
|
||||
if (iconImage is not null)
|
||||
{
|
||||
grid.Children.Add(iconImage);
|
||||
Grid.SetRow(iconImage, 0);
|
||||
}
|
||||
|
||||
grid.Children.Add(textBlock);
|
||||
Grid.SetRow(textBlock, 1);
|
||||
|
||||
border.Child = grid;
|
||||
|
||||
ToolTip.SetTip(border, item.Name);
|
||||
|
||||
border.PointerPressed += OnItemPointerPressed;
|
||||
border.PointerMoved += OnItemPointerMoved;
|
||||
border.PointerReleased += OnItemPointerReleased;
|
||||
|
||||
return border;
|
||||
}
|
||||
|
||||
private Control? CreateSystemIconImage(FileSystemItem item, double iconSize)
|
||||
{
|
||||
byte[]? pngBytes = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => GetDriveIconBytes(item.FullPath),
|
||||
FileSystemItemType.Directory => WindowsIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => WindowsIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => LinuxIconService.TryGetDriveIconPngBytes(),
|
||||
FileSystemItemType.Directory => LinuxIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => LinuxIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => MacIconService.TryGetDriveIconPngBytes(),
|
||||
FileSystemItemType.Directory => MacIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => MacIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pngBytes = null;
|
||||
}
|
||||
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(pngBytes);
|
||||
var bitmap = new Bitmap(stream);
|
||||
return new Image
|
||||
{
|
||||
Source = bitmap,
|
||||
Width = iconSize,
|
||||
Height = iconSize,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
Stretch = Stretch.Uniform
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return CreateFallbackIconImage(item, iconSize);
|
||||
}
|
||||
|
||||
private static byte[]? GetDriveIconBytes(string drivePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(drivePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (Directory.Exists(drivePath))
|
||||
{
|
||||
return WindowsIconService.TryGetIconPngBytes(drivePath);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxIconService.TryGetDriveIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return MacIconService.TryGetDriveIconPngBytes();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return WindowsIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return MacIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Control CreateFallbackIconImage(FileSystemItem item, double iconSize)
|
||||
{
|
||||
var symbol = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => FluentIcons.Common.Symbol.HardDrive,
|
||||
FileSystemItemType.Directory => FluentIcons.Common.Symbol.Folder,
|
||||
_ => FluentIcons.Common.Symbol.Document
|
||||
};
|
||||
|
||||
var iconBrush = item.ItemType == FileSystemItemType.File
|
||||
? this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray)
|
||||
: this.FindResource("AdaptiveAccentBrush") as IBrush ?? new SolidColorBrush(Colors.DodgerBlue);
|
||||
|
||||
return new SymbolIcon
|
||||
{
|
||||
Symbol = symbol,
|
||||
FontSize = iconSize,
|
||||
Foreground = iconBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
private void RefreshCurrentDirectory()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentPath))
|
||||
{
|
||||
NavigateToDrives();
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadDirectory(_currentPath, addToHistory: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEmptyState(bool isEmpty, string message)
|
||||
{
|
||||
EmptyStatePanel.IsVisible = isEmpty;
|
||||
EmptyStateTextBlock.Text = message;
|
||||
FileItemsControl.IsVisible = !isEmpty;
|
||||
}
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
ErrorStatePanel.IsVisible = true;
|
||||
ErrorStateTextBlock.Text = message;
|
||||
FileItemsControl.IsVisible = false;
|
||||
EmptyStatePanel.IsVisible = false;
|
||||
}
|
||||
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(filePath)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
Process.Start("xdg-open", filePath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to open file: {filePath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatPathForDisplay(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
||||
}
|
||||
|
||||
var separator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
if (path.Length <= 3 && path.EndsWith(":\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var driveInfo = new DriveInfo(path.Substring(0, 1));
|
||||
if (!string.IsNullOrWhiteSpace(driveInfo.VolumeLabel))
|
||||
{
|
||||
return $"{driveInfo.VolumeLabel} ({path.Substring(0, 2)})";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (path == "/")
|
||||
{
|
||||
return "根目录";
|
||||
}
|
||||
|
||||
if (path == Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||
{
|
||||
return "主目录";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
if (path == "/Applications")
|
||||
{
|
||||
return "应用程序";
|
||||
}
|
||||
|
||||
if (path == "/Users")
|
||||
{
|
||||
return "用户";
|
||||
}
|
||||
|
||||
if (path.StartsWith("/Volumes/"))
|
||||
{
|
||||
return Path.GetFileName(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parts = path.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length <= 3)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return $"{parts[0]}{separator}...{separator}{parts[^2]}{separator}{parts[^1]}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
72
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml
Normal file
72
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml
Normal file
@@ -0,0 +1,72 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="160"
|
||||
d:DesignHeight="48"
|
||||
x:Class="LanMountainDesktop.Views.Components.NetworkSpeedWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="surface-translucent-panel"
|
||||
Padding="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,0">
|
||||
<!-- 上传速度 -->
|
||||
<StackPanel x:Name="UploadPanel"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="↑"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock x:Name="UploadSpeedTextBlock"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Margin="2,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 分隔符 -->
|
||||
<Rectangle x:Name="Separator"
|
||||
Width="1"
|
||||
Height="16"
|
||||
Margin="8,0"
|
||||
Opacity="0.3"
|
||||
Fill="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
|
||||
<!-- 下载速度 -->
|
||||
<StackPanel x:Name="DownloadPanel"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="↓"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
<TextBlock x:Name="DownloadSpeedTextBlock"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Margin="2,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 网络类型图标 -->
|
||||
<fi:SymbolIcon x:Name="NetworkTypeIcon"
|
||||
Symbol="Globe"
|
||||
FontSize="14"
|
||||
Margin="8,0,0,0"
|
||||
Opacity="0.8"
|
||||
IsVisible="False"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
451
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml.cs
Normal file
451
LanMountainDesktop/Views/Components/NetworkSpeedWidget.axaml.cs
Normal file
@@ -0,0 +1,451 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.NetworkInformation;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using FluentIcons.Avalonia;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Services;
|
||||
using Symbol = FluentIcons.Common.Symbol;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class NetworkSpeedWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private readonly DispatcherTimer _timer = new();
|
||||
private readonly DispatcherTimer _networkTypeTimer = new();
|
||||
private NetworkInterface? _selectedInterface;
|
||||
private long _lastBytesReceived;
|
||||
private long _lastBytesSent;
|
||||
private bool _isFirstUpdate = true;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private bool _transparentBackground;
|
||||
private string _displayMode = "Both"; // "Upload", "Download", "Both"
|
||||
private bool _showNetworkTypeIcon;
|
||||
private string _fontSize = "Medium"; // Small, Medium, Large
|
||||
|
||||
public NetworkSpeedWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
SetupTimer();
|
||||
SelectBestInterface();
|
||||
UpdateDisplayMode();
|
||||
UpdateNetworkTypeIcon();
|
||||
}
|
||||
|
||||
public string DisplayMode
|
||||
{
|
||||
get => _displayMode;
|
||||
set
|
||||
{
|
||||
if (_displayMode == value) return;
|
||||
_displayMode = value;
|
||||
UpdateDisplayMode();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TransparentBackground
|
||||
{
|
||||
get => _transparentBackground;
|
||||
set
|
||||
{
|
||||
if (_transparentBackground == value) return;
|
||||
_transparentBackground = value;
|
||||
ApplyChrome();
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowNetworkTypeIcon
|
||||
{
|
||||
get => _showNetworkTypeIcon;
|
||||
set
|
||||
{
|
||||
if (_showNetworkTypeIcon == value) return;
|
||||
_showNetworkTypeIcon = value;
|
||||
UpdateNetworkTypeIcon();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDisplayMode(string mode)
|
||||
{
|
||||
DisplayMode = mode;
|
||||
}
|
||||
|
||||
public void SetTransparentBackground(bool transparent)
|
||||
{
|
||||
TransparentBackground = transparent;
|
||||
}
|
||||
|
||||
public void SetShowNetworkTypeIcon(bool show)
|
||||
{
|
||||
ShowNetworkTypeIcon = show;
|
||||
}
|
||||
|
||||
public string WidgetFontSize
|
||||
{
|
||||
get => _fontSize;
|
||||
set
|
||||
{
|
||||
_fontSize = value;
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFontSize(string fontSize)
|
||||
{
|
||||
WidgetFontSize = fontSize;
|
||||
}
|
||||
|
||||
private void SetupTimer()
|
||||
{
|
||||
// 网速更新定时器(每秒)
|
||||
_timer.Interval = TimeSpan.FromSeconds(1);
|
||||
_timer.Tick += (_, _) => UpdateSpeed();
|
||||
_timer.Start();
|
||||
|
||||
// 网络类型检测定时器(每500ms,满足响应延迟要求)
|
||||
_networkTypeTimer.Interval = TimeSpan.FromMilliseconds(500);
|
||||
_networkTypeTimer.Tick += (_, _) => UpdateNetworkTypeIcon();
|
||||
_networkTypeTimer.Start();
|
||||
}
|
||||
|
||||
private void SelectBestInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel)
|
||||
.Where(ni => !ni.Description.Contains("Virtual", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(ni => !ni.Description.Contains("VPN", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
// 优先选择有流量的物理网卡
|
||||
_selectedInterface = interfaces
|
||||
.OrderByDescending(ni => ni.GetIPv4Statistics().BytesReceived + ni.GetIPv4Statistics().BytesSent)
|
||||
.FirstOrDefault();
|
||||
|
||||
// 如果没有找到,选择第一个活动的非虚拟网卡
|
||||
_selectedInterface ??= interfaces.FirstOrDefault();
|
||||
|
||||
if (_selectedInterface != null)
|
||||
{
|
||||
var stats = _selectedInterface.GetIPv4Statistics();
|
||||
_lastBytesReceived = stats.BytesReceived;
|
||||
_lastBytesSent = stats.BytesSent;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略错误,下次重试
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSpeed()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 如果当前网卡不可用,尝试重新选择
|
||||
if (_selectedInterface == null ||
|
||||
_selectedInterface.OperationalStatus != OperationalStatus.Up)
|
||||
{
|
||||
SelectBestInterface();
|
||||
}
|
||||
|
||||
if (_selectedInterface == null)
|
||||
{
|
||||
UploadSpeedTextBlock.Text = "--";
|
||||
DownloadSpeedTextBlock.Text = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
var stats = _selectedInterface.GetIPv4Statistics();
|
||||
var currentBytesReceived = stats.BytesReceived;
|
||||
var currentBytesSent = stats.BytesSent;
|
||||
|
||||
if (_isFirstUpdate)
|
||||
{
|
||||
_lastBytesReceived = currentBytesReceived;
|
||||
_lastBytesSent = currentBytesSent;
|
||||
_isFirstUpdate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算速度(每秒字节数)
|
||||
var downloadBytes = currentBytesReceived - _lastBytesReceived;
|
||||
var uploadBytes = currentBytesSent - _lastBytesSent;
|
||||
|
||||
// 处理计数器重置的情况
|
||||
if (downloadBytes < 0) downloadBytes = 0;
|
||||
if (uploadBytes < 0) uploadBytes = 0;
|
||||
|
||||
UploadSpeedTextBlock.Text = FormatSpeed(uploadBytes);
|
||||
DownloadSpeedTextBlock.Text = FormatSpeed(downloadBytes);
|
||||
|
||||
_lastBytesReceived = currentBytesReceived;
|
||||
_lastBytesSent = currentBytesSent;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 错误时显示 --
|
||||
UploadSpeedTextBlock.Text = "--";
|
||||
DownloadSpeedTextBlock.Text = "--";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNetworkTypeIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_showNetworkTypeIcon || NetworkTypeIcon == null)
|
||||
{
|
||||
if (NetworkTypeIcon != null)
|
||||
NetworkTypeIcon.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前活动的网络接口
|
||||
var activeInterface = GetActiveNetworkInterface();
|
||||
|
||||
if (activeInterface == null)
|
||||
{
|
||||
// 无网络连接
|
||||
NetworkTypeIcon.Symbol = Symbol.DismissCircle;
|
||||
NetworkTypeIcon.IsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据网络类型设置图标
|
||||
switch (activeInterface.NetworkInterfaceType)
|
||||
{
|
||||
case NetworkInterfaceType.Wireless80211:
|
||||
// WiFi
|
||||
NetworkTypeIcon.Symbol = Symbol.WiFi;
|
||||
break;
|
||||
|
||||
case NetworkInterfaceType.Ethernet:
|
||||
// 有线网络 - 检查是否是移动网络热点
|
||||
if (IsLikelyMobileHotspot(activeInterface))
|
||||
{
|
||||
NetworkTypeIcon.Symbol = Symbol.Phone;
|
||||
}
|
||||
else
|
||||
{
|
||||
NetworkTypeIcon.Symbol = Symbol.PlugConnected;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 其他类型,尝试根据描述判断
|
||||
var symbol = GetSymbolFromDescription(activeInterface.Description);
|
||||
NetworkTypeIcon.Symbol = symbol;
|
||||
break;
|
||||
}
|
||||
|
||||
NetworkTypeIcon.IsVisible = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 错误时隐藏图标
|
||||
if (NetworkTypeIcon != null)
|
||||
NetworkTypeIcon.IsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private NetworkInterface? GetActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 优先使用当前选中的网卡
|
||||
if (_selectedInterface != null &&
|
||||
_selectedInterface.OperationalStatus == OperationalStatus.Up)
|
||||
{
|
||||
return _selectedInterface;
|
||||
}
|
||||
|
||||
// 否则查找最佳网卡
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(ni => ni.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.Where(ni => ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel)
|
||||
.ToList();
|
||||
|
||||
// 优先返回有流量的网卡
|
||||
return interfaces
|
||||
.OrderByDescending(ni => ni.GetIPv4Statistics().BytesReceived + ni.GetIPv4Statistics().BytesSent)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLikelyMobileHotspot(NetworkInterface ni)
|
||||
{
|
||||
// 通过描述判断是否是移动热点
|
||||
var desc = ni.Description.ToLowerInvariant();
|
||||
return desc.Contains("mobile") ||
|
||||
desc.Contains("cellular") ||
|
||||
desc.Contains("phone") ||
|
||||
desc.Contains("tether");
|
||||
}
|
||||
|
||||
private static Symbol GetSymbolFromDescription(string description)
|
||||
{
|
||||
var desc = description.ToLowerInvariant();
|
||||
|
||||
if (desc.Contains("wifi") || desc.Contains("wi-fi") || desc.Contains("wireless"))
|
||||
return Symbol.WiFi;
|
||||
|
||||
if (desc.Contains("ethernet") || desc.Contains("lan") || desc.Contains("wired"))
|
||||
return Symbol.PlugConnected;
|
||||
|
||||
if (desc.Contains("cellular") || desc.Contains("mobile") || desc.Contains("lte") || desc.Contains("5g") || desc.Contains("4g"))
|
||||
return Symbol.Phone;
|
||||
|
||||
if (desc.Contains("bluetooth"))
|
||||
return Symbol.Bluetooth;
|
||||
|
||||
// 默认使用 Globe 图标
|
||||
return Symbol.Globe;
|
||||
}
|
||||
|
||||
private static string FormatSpeed(long bytesPerSecond)
|
||||
{
|
||||
// 根据数值大小决定显示格式,始终保持3个字符宽度
|
||||
// 例如: 1.23, 12.3, 123
|
||||
return bytesPerSecond switch
|
||||
{
|
||||
>= 1024 * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0 * 1024.0), "G"),
|
||||
>= 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0), "M"),
|
||||
>= 1024 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"),
|
||||
_ => FormatWithThreeDigits(bytesPerSecond, "B")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 格式化数字,始终保持3个有效数字的显示宽度
|
||||
/// </summary>
|
||||
private static string FormatWithThreeDigits(double value, string unit)
|
||||
{
|
||||
// 根据数值大小决定小数位数,确保总宽度一致
|
||||
// < 10: 显示两位小数 (如 1.23)
|
||||
// 10-99: 显示一位小数 (如 12.3)
|
||||
// >= 100: 显示整数 (如 123)
|
||||
string formatted = value switch
|
||||
{
|
||||
< 10 => $"{value:F2}",
|
||||
< 100 => $"{value:F1}",
|
||||
_ => $"{value:F0}"
|
||||
};
|
||||
|
||||
return formatted + unit;
|
||||
}
|
||||
|
||||
private void UpdateDisplayMode()
|
||||
{
|
||||
switch (_displayMode)
|
||||
{
|
||||
case "Upload":
|
||||
UploadPanel.IsVisible = true;
|
||||
DownloadPanel.IsVisible = false;
|
||||
Separator.IsVisible = false;
|
||||
break;
|
||||
case "Download":
|
||||
UploadPanel.IsVisible = false;
|
||||
DownloadPanel.IsVisible = true;
|
||||
Separator.IsVisible = false;
|
||||
break;
|
||||
case "Both":
|
||||
default:
|
||||
UploadPanel.IsVisible = true;
|
||||
DownloadPanel.IsVisible = true;
|
||||
Separator.IsVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_lastAppliedCellSize = cellSize;
|
||||
|
||||
// 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
||||
var targetHeight = Math.Clamp(cellSize * 0.74, 34, 74);
|
||||
RootBorder.Height = targetHeight;
|
||||
|
||||
// 主矩形统一到主题主档圆角
|
||||
RootBorder.CornerRadius = ResolveUnifiedMainRectangle();
|
||||
RootBorder.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center;
|
||||
|
||||
// 根据单元格大小和字体大小设置调整字体大小
|
||||
var fontSizeMultiplier = _fontSize switch
|
||||
{
|
||||
"Small" => 0.32,
|
||||
"Large" => 0.48,
|
||||
_ => 0.4 // Medium (default)
|
||||
};
|
||||
var fontSize = Math.Clamp(targetHeight * fontSizeMultiplier, 11, 22);
|
||||
UploadSpeedTextBlock.FontSize = fontSize;
|
||||
DownloadSpeedTextBlock.FontSize = fontSize;
|
||||
|
||||
// 调整图标大小
|
||||
if (NetworkTypeIcon != null)
|
||||
{
|
||||
NetworkTypeIcon.FontSize = Math.Clamp(targetHeight * 0.35, 10, 18);
|
||||
}
|
||||
|
||||
// 设置最小和最大宽度
|
||||
RootBorder.MinWidth = cellSize * 1.5;
|
||||
RootBorder.MaxWidth = cellSize * 5;
|
||||
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.MinWidth = 0;
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保清除可能存在的固定 Padding,由代码控制"紧密感"
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.Classes.Remove("glass-panel");
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
RootBorder.BoxShadow = default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RootBorder.Classes.Contains("glass-panel"))
|
||||
{
|
||||
RootBorder.Classes.Add("glass-panel");
|
||||
}
|
||||
|
||||
RootBorder.ClearValue(Border.BackgroundProperty);
|
||||
RootBorder.ClearValue(Border.BorderBrushProperty);
|
||||
RootBorder.ClearValue(Border.BorderThicknessProperty);
|
||||
RootBorder.ClearValue(Border.BoxShadowProperty);
|
||||
}
|
||||
|
||||
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
_timer?.Stop();
|
||||
_networkTypeTimer?.Stop();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
22
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml
Normal file
22
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml
Normal file
@@ -0,0 +1,22 @@
|
||||
<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"
|
||||
xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="200"
|
||||
d:DesignHeight="48"
|
||||
x:Class="LanMountainDesktop.Views.Components.TextCapsuleWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="surface-translucent-panel"
|
||||
Padding="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}">
|
||||
<md:MarkdownScrollViewer x:Name="MarkdownViewer"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="12,6"
|
||||
MaxWidth="400" />
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
167
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml.cs
Normal file
167
LanMountainDesktop/Views/Components/TextCapsuleWidget.axaml.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using Markdown.Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class TextCapsuleWidget : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private string _text = string.Empty;
|
||||
private bool _transparentBackground;
|
||||
private double _lastAppliedCellSize = 100;
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
|
||||
public TextCapsuleWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
UpdateDisplay();
|
||||
}
|
||||
|
||||
public string Text
|
||||
{
|
||||
get => _text;
|
||||
set
|
||||
{
|
||||
if (_text == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_text = value;
|
||||
DebouncedUpdateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TransparentBackground
|
||||
{
|
||||
get => _transparentBackground;
|
||||
set
|
||||
{
|
||||
if (_transparentBackground == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_transparentBackground = value;
|
||||
ApplyChrome();
|
||||
ApplyCellSize(_lastAppliedCellSize);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetText(string text)
|
||||
{
|
||||
Text = text;
|
||||
}
|
||||
|
||||
public void SetTransparentBackground(bool transparentBackground)
|
||||
{
|
||||
TransparentBackground = transparentBackground;
|
||||
}
|
||||
|
||||
private void DebouncedUpdateDisplay()
|
||||
{
|
||||
// 取消之前的延迟任务
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts?.Dispose();
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
|
||||
var token = _debounceCts.Token;
|
||||
|
||||
// 延迟 150ms 后更新显示,避免频繁输入时过度渲染
|
||||
Dispatcher.UIThread.Post(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(150, token);
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
UpdateDisplay();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 忽略取消异常
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateDisplay()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_text))
|
||||
{
|
||||
MarkdownViewer.Markdown = "*Empty*";
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 Markdown 引擎渲染文本
|
||||
MarkdownViewer.Markdown = _text;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 错误处理:显示错误信息而不是崩溃
|
||||
MarkdownViewer.Markdown = $"*Error: {ex.Message}*";
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_lastAppliedCellSize = cellSize;
|
||||
|
||||
// 计算组件高度:保持与任务栏核心比例一致 (0.74x)
|
||||
var targetHeight = Math.Clamp(cellSize * 0.74, 34, 74);
|
||||
RootBorder.Height = targetHeight;
|
||||
|
||||
// 主矩形统一到主题主档圆角
|
||||
RootBorder.CornerRadius = ResolveUnifiedMainRectangle();
|
||||
RootBorder.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center;
|
||||
|
||||
// 设置最小和最大宽度
|
||||
RootBorder.MinWidth = cellSize * 1.5;
|
||||
RootBorder.MaxWidth = cellSize * 6;
|
||||
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.MinWidth = 0;
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保清除可能存在的固定 Padding,由代码控制"紧密感"
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
|
||||
}
|
||||
|
||||
private void ApplyChrome()
|
||||
{
|
||||
if (_transparentBackground)
|
||||
{
|
||||
RootBorder.Classes.Remove("glass-panel");
|
||||
RootBorder.Background = Brushes.Transparent;
|
||||
RootBorder.BorderBrush = Brushes.Transparent;
|
||||
RootBorder.BorderThickness = new Thickness(0);
|
||||
RootBorder.BoxShadow = default;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RootBorder.Classes.Contains("glass-panel"))
|
||||
{
|
||||
RootBorder.Classes.Add("glass-panel");
|
||||
}
|
||||
|
||||
RootBorder.ClearValue(Border.BackgroundProperty);
|
||||
RootBorder.ClearValue(Border.BorderBrushProperty);
|
||||
RootBorder.ClearValue(Border.BorderThicknessProperty);
|
||||
RootBorder.ClearValue(Border.BoxShadowProperty);
|
||||
}
|
||||
|
||||
private CornerRadius ResolveUnifiedMainRectangle() => new(ResolveUnifiedMainRadiusValue());
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
LanMountainDesktop/Views/DesktopWidgetWindow.axaml
Normal file
23
LanMountainDesktop/Views/DesktopWidgetWindow.axaml
Normal file
@@ -0,0 +1,23 @@
|
||||
<Window 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"
|
||||
x:Class="LanMountainDesktop.Views.DesktopWidgetWindow"
|
||||
Title="Desktop Component"
|
||||
ShowInTaskbar="False"
|
||||
SystemDecorations="None"
|
||||
Background="Transparent"
|
||||
Topmost="False"
|
||||
SizeToContent="WidthAndHeight"
|
||||
TransparencyLevelHint="Transparent"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
CanResize="False">
|
||||
|
||||
<Border x:Name="ComponentContainer"
|
||||
Background="Transparent"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True">
|
||||
<!-- Component control will be injected here -->
|
||||
</Border>
|
||||
</Window>
|
||||
61
LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
Normal file
61
LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.Services;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个独立的组件挂载窗口。它不含有任何自己的边窗,仅仅负责包裹组件并将自身植入系统最底层。
|
||||
/// </summary>
|
||||
public partial class DesktopWidgetWindow : Window
|
||||
{
|
||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||
|
||||
public DesktopWidgetWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public DesktopWidgetWindow(Control componentContent) : this()
|
||||
{
|
||||
ComponentContainer.Child = componentContent;
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// 通过现有的置底服务将独立的小窗口锁定到底层
|
||||
_bottomMostService.SetupBottomMost(this);
|
||||
_bottomMostService.SendToBottom(this);
|
||||
|
||||
// 当窗口展示完毕且有了尺寸后,更新可交互区域,使得整个组件都能被点击
|
||||
Dispatcher.UIThread.Post(UpdateInteractiveRegion, DispatcherPriority.Render);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnSizeChanged(SizeChangedEventArgs e)
|
||||
{
|
||||
base.OnSizeChanged(e);
|
||||
|
||||
if (OperatingSystem.IsWindows() && IsVisible)
|
||||
{
|
||||
UpdateInteractiveRegion();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateInteractiveRegion()
|
||||
{
|
||||
// 既然是一个完全紧贴在组件身上的小窗,它的全部都是可交互的
|
||||
_regionPassthroughService.SetInteractiveRegions(this, new List<Rect>
|
||||
{
|
||||
new(0, 0, Bounds.Width, Bounds.Height)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,204 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
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"
|
||||
Width="400" Height="500">
|
||||
<!--
|
||||
融合桌面组件库 - 专门用于添加组件到系统桌面(负一屏)
|
||||
注意:此窗口只能添加组件到融合桌面,不能添加到阑山桌面
|
||||
-->
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- 标题栏 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
Padding="16,12">
|
||||
<StackPanel>
|
||||
<TextBlock Text="融合桌面组件"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="16" />
|
||||
<TextBlock Text="选择组件添加到系统桌面"
|
||||
Opacity="0.7"
|
||||
FontSize="12"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
x:DataType="vm:ComponentLibraryWindowViewModel">
|
||||
|
||||
<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="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="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"/>
|
||||
|
||||
<!-- 预览区域 -->
|
||||
<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 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 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 SelectedComponent.PreviewStatusText}"/>
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding SelectedComponent.PreviewErrorMessage}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</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>
|
||||
|
||||
<!-- 组件列表 -->
|
||||
<ScrollViewer Grid.Row="1"
|
||||
Padding="12">
|
||||
<WrapPanel x:Name="ComponentPanel" Orientation="Horizontal" />
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,78 +1,219 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 融合桌面组件库控件 - 专门用于添加组件到系统桌面(负一屏)
|
||||
/// </summary>
|
||||
public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// 添加组件到融合桌面事件
|
||||
/// </summary>
|
||||
public event EventHandler<string>? AddComponentRequested;
|
||||
|
||||
|
||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||
private List<DesktopComponentDefinition> _allDefinitions = new();
|
||||
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private readonly IWeatherInfoService _weatherDataService;
|
||||
private readonly TimeZoneService _timeZoneService;
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
public FusedDesktopComponentLibraryControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
LoadComponents();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载可用组件列表
|
||||
/// </summary>
|
||||
private void LoadComponents()
|
||||
{
|
||||
var registry = ComponentRegistry.CreateDefault();
|
||||
|
||||
foreach (var definition in registry.GetAll())
|
||||
DataContext = _viewModel;
|
||||
|
||||
_weatherDataService = _settingsFacade.Weather.GetWeatherInfoService();
|
||||
_timeZoneService = _settingsFacade.Region.GetTimeZoneService();
|
||||
|
||||
LoadRegistry();
|
||||
LoadCategories();
|
||||
|
||||
// 为 ListBoxItem 添加 category-item 样式类
|
||||
CategoryListBox.ContainerPrepared += OnCategoryListBoxContainerPrepared;
|
||||
|
||||
// 默认选择第一个分类
|
||||
if (_viewModel.Categories.Count > 0)
|
||||
{
|
||||
if (!definition.AllowDesktopPlacement)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Width = 100,
|
||||
Height = 100,
|
||||
Margin = new Thickness(4),
|
||||
Padding = new Thickness(8),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Tag = definition.Id
|
||||
};
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = definition.DisplayName,
|
||||
FontSize = 11,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
button.Content = textBlock;
|
||||
button.Click += OnAddComponentClick;
|
||||
|
||||
ComponentPanel.Children.Add(button);
|
||||
CategoryListBox.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加组件按钮点击
|
||||
/// </summary>
|
||||
|
||||
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;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService,
|
||||
_settingsFacade);
|
||||
|
||||
_allDefinitions = _componentRegistry.GetAll()
|
||||
.Where(d => d.AllowDesktopPlacement)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void LoadCategories()
|
||||
{
|
||||
_viewModel.Categories.Clear();
|
||||
|
||||
// 添加"全部组件"分类
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
"all",
|
||||
"全部组件",
|
||||
Symbol.Apps,
|
||||
Array.Empty<ComponentLibraryItemViewModel>()));
|
||||
|
||||
var categoryMap = new Dictionary<string, (string Display, Symbol Icon)>
|
||||
{
|
||||
{ "clock", ("时钟", Symbol.Clock) },
|
||||
{ "date", ("日历", Symbol.CalendarDate) },
|
||||
{ "weather", ("天气", Symbol.WeatherSunny) },
|
||||
{ "board", ("画板", Symbol.Edit) },
|
||||
{ "media", ("媒体", Symbol.Play) },
|
||||
{ "info", ("资讯", Symbol.News) },
|
||||
{ "calculator", ("工具", Symbol.Calculator) },
|
||||
{ "study", ("学习", Symbol.Hourglass) },
|
||||
{ "file", ("文件", Symbol.Folder) }
|
||||
};
|
||||
|
||||
var usedCategories = _allDefinitions
|
||||
.Select(d => d.Category)
|
||||
.Distinct()
|
||||
.Where(c => !string.IsNullOrEmpty(c));
|
||||
|
||||
foreach (var cat in usedCategories)
|
||||
{
|
||||
if (categoryMap.TryGetValue(cat.ToLower(), out var info))
|
||||
{
|
||||
var categoryComponents = _allDefinitions
|
||||
.Where(d => string.Equals(d.Category, cat, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(d => d.DisplayName)
|
||||
.Select(d => CreateComponentItem(d))
|
||||
.ToArray();
|
||||
|
||||
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
|
||||
cat,
|
||||
info.Display,
|
||||
info.Icon,
|
||||
categoryComponents));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
|
||||
{
|
||||
var previewKey = ComponentPreviewKey.ForComponentType(
|
||||
definition.Id,
|
||||
definition.MinWidthCells,
|
||||
definition.MinHeightCells);
|
||||
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
ComponentPreviewImageEntry? previewEntry = null;
|
||||
|
||||
if (mainWindow is not null)
|
||||
{
|
||||
previewEntry = mainWindow.GetPreviewEntry(previewKey);
|
||||
}
|
||||
|
||||
var item = new ComponentLibraryItemViewModel(
|
||||
definition.Id,
|
||||
definition.DisplayName,
|
||||
previewKey,
|
||||
description: null,
|
||||
"正在加载预览...",
|
||||
"预览不可用",
|
||||
previewEntry);
|
||||
|
||||
if (mainWindow is not null && (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending))
|
||||
{
|
||||
mainWindow.RequestDetachedLibraryPreview(previewKey);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
foreach (var category in _viewModel.Categories)
|
||||
{
|
||||
foreach (var component in category.Components)
|
||||
{
|
||||
if (component.PreviewKey.Equals(entry.Key))
|
||||
{
|
||||
component.UpdatePreviewImageEntry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
UpdateSelectedComponent();
|
||||
}
|
||||
|
||||
private void UpdateSelectedComponent()
|
||||
{
|
||||
var selectedCategory = CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel;
|
||||
if (selectedCategory is null)
|
||||
{
|
||||
_viewModel.SelectedComponent = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取该分类下的组件列表
|
||||
IEnumerable<DesktopComponentDefinition> filtered;
|
||||
if (selectedCategory.Id == "all")
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAddComponentClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button button && button.Tag is string componentId)
|
||||
|
||||
@@ -1,41 +1,69 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:LanMountainDesktop.Views"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
|
||||
Width="420" Height="560"
|
||||
MinWidth="380" MinHeight="400"
|
||||
Width="860" Height="620"
|
||||
MinWidth="600" MinHeight="500"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
CanResize="True"
|
||||
Title="融合桌面组件">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!-- 标题栏 -->
|
||||
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
|
||||
BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="16,12">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="融合桌面设置"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="18" />
|
||||
<TextBlock Text="选择组件添加到系统桌面(负一屏)"
|
||||
Opacity="0.7"
|
||||
FontSize="12"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="20"
|
||||
Padding="8"
|
||||
Click="OnCloseClick">
|
||||
<TextBlock Text="✕"
|
||||
FontSize="16" />
|
||||
SystemDecorations="Full"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="NoChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Mica"
|
||||
Title="添加小组件">
|
||||
|
||||
<Panel>
|
||||
<!-- 背景磨砂效果 -->
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceLowBrush}"
|
||||
Opacity="0.85" />
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- 自定义标题栏 -->
|
||||
<Border Background="Transparent"
|
||||
IsHitTestVisible="True"
|
||||
Padding="20,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Text="添加小组件"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="20"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock Text="将精美组件放置在您的系统桌面上(负一屏)"
|
||||
Opacity="0.6"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Width="36" Height="36"
|
||||
Padding="0"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
BorderThickness="0"
|
||||
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>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 组件库控件 -->
|
||||
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
|
||||
Grid.Row="1" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Window>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
@@ -13,13 +17,20 @@ namespace LanMountainDesktop.Views;
|
||||
public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
{
|
||||
private readonly IFusedDesktopLayoutService _layoutService = FusedDesktopLayoutServiceProvider.GetOrCreate();
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private TransparentOverlayWindow? _overlayWindow;
|
||||
|
||||
// 与 TransparentOverlayWindow 保持一致的默认 cellSize
|
||||
private const double DefaultCellSize = 100;
|
||||
|
||||
public FusedDesktopComponentLibraryWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
LibraryControl.AddComponentRequested += OnAddComponentRequested;
|
||||
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.RegisterFusedLibraryWindow(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -31,7 +42,7 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加组件请求处理
|
||||
/// 添加组件请求处理 - 将组件放置在屏幕(覆盖层画布)中央
|
||||
/// </summary>
|
||||
private void OnAddComponentRequested(object? sender, string componentId)
|
||||
{
|
||||
@@ -41,21 +52,86 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
// 在屏幕中央添加组件
|
||||
var screenBounds = _overlayWindow.Bounds;
|
||||
var x = screenBounds.Width / 2 - 100; // 居中
|
||||
var y = screenBounds.Height / 2 - 100;
|
||||
// 计算组件的像素尺寸
|
||||
var (componentWidth, componentHeight) = ResolveComponentSize(componentId);
|
||||
|
||||
_overlayWindow.AddComponent(componentId, x, y, 200, 200);
|
||||
// 取覆盖层画布的中心点,减去组件半尺寸,使组件出现在屏幕正中央
|
||||
var overlayBounds = _overlayWindow.Bounds;
|
||||
var centerX = overlayBounds.Width / 2.0 - componentWidth / 2.0;
|
||||
var centerY = overlayBounds.Height / 2.0 - componentHeight / 2.0;
|
||||
|
||||
AppLogger.Info("FusedDesktopLibrary", $"Added component {componentId} to fused desktop.");
|
||||
// 边界保护:确保组件不超出屏幕边界
|
||||
centerX = Math.Max(0, Math.Min(centerX, overlayBounds.Width - componentWidth));
|
||||
centerY = Math.Max(0, Math.Min(centerY, overlayBounds.Height - componentHeight));
|
||||
|
||||
_overlayWindow.AddComponent(componentId, centerX, centerY, componentWidth, componentHeight);
|
||||
|
||||
AppLogger.Info("FusedDesktopLibrary",
|
||||
$"Added component '{componentId}' at center ({centerX:F0}, {centerY:F0}) size ({componentWidth}x{componentHeight}).");
|
||||
|
||||
// 关闭窗口
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析组件的默认像素尺寸(基于组件定义的 MinCells * DefaultCellSize)
|
||||
/// </summary>
|
||||
private (double Width, double Height) ResolveComponentSize(string componentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
var registry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
if (registry.TryGetDefinition(componentId, out var definition))
|
||||
{
|
||||
var w = Math.Max(1, definition.MinWidthCells) * DefaultCellSize;
|
||||
var h = Math.Max(1, definition.MinHeightCells) * DefaultCellSize;
|
||||
return (w, h);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FusedDesktopLibrary", $"Failed to resolve component size for '{componentId}'.", ex);
|
||||
}
|
||||
|
||||
// 回退为 2×2 格子的默认尺寸
|
||||
return (DefaultCellSize * 2, DefaultCellSize * 2);
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
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)
|
||||
{
|
||||
base.OnClosed(e);
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.UnregisterFusedLibraryWindow(this);
|
||||
}
|
||||
|
||||
public void UpdatePreviewImage(ComponentPreviewImageEntry entry)
|
||||
{
|
||||
LibraryControl.UpdatePreviewImage(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public partial class MainWindow
|
||||
private readonly IComponentPreviewImageService _componentPreviewImageService = new ComponentPreviewImageService();
|
||||
private readonly Dictionary<ComponentPreviewKey, List<ComponentLibraryPreviewVisualTarget>> _componentLibraryPreviewVisualTargets = new(ComponentPreviewKeyComparer.Instance);
|
||||
private bool _componentLibraryPreviewWarmupStarted;
|
||||
private FusedDesktopComponentLibraryWindow? _fusedLibraryWindow;
|
||||
|
||||
private sealed record ComponentLibraryPreviewVisualTarget(Image Image, Control Fallback);
|
||||
|
||||
@@ -294,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)
|
||||
@@ -519,6 +520,7 @@ public partial class MainWindow
|
||||
{
|
||||
ApplyPreviewEntryToEmbeddedVisuals(entry.Key);
|
||||
_detachedComponentLibraryWindow?.UpdatePreviewImage(entry);
|
||||
_fusedLibraryWindow?.UpdatePreviewImage(entry);
|
||||
|
||||
if (entry.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
|
||||
{
|
||||
@@ -597,4 +599,30 @@ public partial class MainWindow
|
||||
action: "DetachedLibraryRender",
|
||||
forceRefresh: false);
|
||||
}
|
||||
|
||||
// FusedDesktop 支持
|
||||
|
||||
public void RegisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
|
||||
{
|
||||
_fusedLibraryWindow = window;
|
||||
}
|
||||
|
||||
public void UnregisterFusedLibraryWindow(FusedDesktopComponentLibraryWindow window)
|
||||
{
|
||||
if (ReferenceEquals(_fusedLibraryWindow, window))
|
||||
{
|
||||
_fusedLibraryWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentPreviewImageEntry? GetPreviewEntry(ComponentPreviewKey key)
|
||||
{
|
||||
return ResolvePreviewEntry(key);
|
||||
}
|
||||
|
||||
public void RequestDetachedLibraryPreview(ComponentPreviewKey key)
|
||||
{
|
||||
RequestDetachedLibraryPreviewWarm(key);
|
||||
RequestDetachedLibraryPreviewRender(key);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -269,12 +272,6 @@ public partial class MainWindow
|
||||
LauncherPagePanel.MaxWidth = pageWidth - launcherMargin * 2;
|
||||
LauncherPagePanel.MaxHeight = pageHeight - launcherMargin * 2;
|
||||
|
||||
if (LauncherFolderPanel is not null)
|
||||
{
|
||||
LauncherFolderPanel.MaxWidth = Math.Max(320, pageWidth - 96);
|
||||
LauncherFolderPanel.MaxHeight = Math.Max(220, pageHeight - 96);
|
||||
}
|
||||
|
||||
// 更新启动台图标布局
|
||||
UpdateLauncherTileLayout();
|
||||
|
||||
@@ -331,19 +328,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
// 同样更新文件夹视图的图标尺寸
|
||||
if (LauncherFolderTilePanel is not null)
|
||||
{
|
||||
LauncherFolderTilePanel.Width = availableWidth;
|
||||
foreach (var child in LauncherFolderTilePanel.Children)
|
||||
{
|
||||
if (child is Button button)
|
||||
{
|
||||
button.Width = tileWidth;
|
||||
button.Height = tileHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ClampSurfaceIndex()
|
||||
@@ -630,8 +614,12 @@ public partial class MainWindow
|
||||
|
||||
foreach (var node in button.GetSelfAndVisualAncestors())
|
||||
{
|
||||
if (node is WrapPanel panel &&
|
||||
(panel.Name == "LauncherRootTilePanel" || panel.Name == "LauncherFolderTilePanel"))
|
||||
if (node is WrapPanel panel && panel.Name == "LauncherRootTilePanel")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node is Grid grid && grid.Name == "LauncherFolderGridPanel")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -719,8 +707,7 @@ public partial class MainWindow
|
||||
return false;
|
||||
}
|
||||
|
||||
return scrollViewer.Name == "LauncherRootScrollViewer" ||
|
||||
scrollViewer.Name == "LauncherFolderScrollViewer";
|
||||
return scrollViewer.Name == "LauncherRootScrollViewer";
|
||||
}
|
||||
|
||||
private bool TryGetPointerPositionInDesktopViewport(PointerEventArgs e, out Point point)
|
||||
@@ -1153,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,
|
||||
@@ -1162,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)
|
||||
@@ -1561,18 +1557,17 @@ public partial class MainWindow
|
||||
LauncherFolderOverlay.IsVisible = false;
|
||||
}
|
||||
|
||||
if (LauncherFolderTilePanel is not null)
|
||||
if (LauncherFolderGridPanel is not null)
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
LauncherFolderGridPanel.Children.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderLauncherFolderFromStack()
|
||||
{
|
||||
if (LauncherFolderOverlay is null ||
|
||||
LauncherFolderTilePanel is null ||
|
||||
LauncherFolderTitleTextBlock is null ||
|
||||
LauncherFolderBackButton is null)
|
||||
LauncherFolderGridPanel is null ||
|
||||
LauncherFolderTitleTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1587,38 +1582,250 @@ public partial class MainWindow
|
||||
var folder = _launcherFolderStack.Peek();
|
||||
LauncherFolderOverlay.IsVisible = true;
|
||||
LauncherFolderTitleTextBlock.Text = folder.Name;
|
||||
LauncherFolderBackButton.IsVisible = _launcherFolderStack.Count > 1;
|
||||
|
||||
LauncherFolderTilePanel.Children.Clear();
|
||||
foreach (var subFolder in folder.Folders)
|
||||
LauncherFolderGridPanel.Children.Clear();
|
||||
|
||||
const int maxCols = 4;
|
||||
const int maxRows = 3;
|
||||
const int maxItems = maxCols * maxRows;
|
||||
|
||||
var visibleFolders = folder.Folders.Where(IsLauncherFolderVisible).ToList();
|
||||
var visibleApps = folder.Apps.Where(IsLauncherAppVisible).ToList();
|
||||
|
||||
if (visibleFolders.Count == 0 && visibleApps.Count == 0)
|
||||
{
|
||||
if (!IsLauncherFolderVisible(subFolder))
|
||||
LauncherFolderGridPanel.Children.Add(CreateLauncherFolderGridHintCell(
|
||||
L("launcher.empty_folder", "This folder is empty.")));
|
||||
return;
|
||||
}
|
||||
|
||||
var allItems = new List<(StartMenuFolderNode? Folder, StartMenuAppEntry? App)>();
|
||||
foreach (var f in visibleFolders)
|
||||
{
|
||||
allItems.Add((f, null));
|
||||
}
|
||||
foreach (var a in visibleApps)
|
||||
{
|
||||
allItems.Add((null, a));
|
||||
}
|
||||
|
||||
var displayCount = Math.Min(allItems.Count, maxItems);
|
||||
for (var i = 0; i < displayCount; i++)
|
||||
{
|
||||
var col = i % maxCols;
|
||||
var row = i / maxCols;
|
||||
var (itemFolder, itemApp) = allItems[i];
|
||||
|
||||
Control cell;
|
||||
if (itemFolder is not null)
|
||||
{
|
||||
var capturedFolder = itemFolder;
|
||||
cell = CreateLauncherFolderGridTile(itemFolder.Name, GetLauncherFolderIconBitmap(), () => OpenLauncherFolder(capturedFolder));
|
||||
}
|
||||
else if (itemApp is not null)
|
||||
{
|
||||
var capturedApp = itemApp;
|
||||
cell = CreateLauncherFolderGridTile(capturedApp, () => LaunchStartMenuEntry(capturedApp));
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherFolderTile(subFolder));
|
||||
Grid.SetColumn(cell, col);
|
||||
Grid.SetRow(cell, row);
|
||||
LauncherFolderGridPanel.Children.Add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
private Button CreateLauncherFolderGridTile(StartMenuAppEntry app, Action clickAction)
|
||||
{
|
||||
var iconBitmap = GetLauncherIconBitmap(app);
|
||||
var monogram = BuildMonogram(app.DisplayName);
|
||||
|
||||
Control iconControl = iconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
Source = iconBitmap,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = monogram,
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
content.Children.Add(iconControl);
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = app.DisplayName,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(8, 8, 8, 6),
|
||||
Content = content
|
||||
};
|
||||
|
||||
// 根据设置决定是否显示背景
|
||||
if (_showLauncherTileBackground)
|
||||
{
|
||||
button.Classes.Add("glass-panel");
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Background = Brushes.Transparent;
|
||||
}
|
||||
|
||||
foreach (var app in folder.Apps)
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (!IsLauncherAppVisible(app))
|
||||
if (_isComponentLibraryOpen)
|
||||
{
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherAppTile(app));
|
||||
}
|
||||
clickAction();
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
if (LauncherFolderTilePanel.Children.Count == 0)
|
||||
private Button CreateLauncherFolderGridTile(string folderName, Bitmap? iconBitmap, Action clickAction)
|
||||
{
|
||||
var monogram = "DIR";
|
||||
|
||||
Control iconControl = iconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
Source = iconBitmap,
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 32,
|
||||
Height = 32,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = monogram,
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StackPanel
|
||||
{
|
||||
LauncherFolderTilePanel.Children.Add(CreateLauncherHintTile(
|
||||
L("launcher.empty_folder", "This folder is empty."),
|
||||
string.Empty));
|
||||
Spacing = 6,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
content.Children.Add(iconControl);
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = folderName,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
});
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(8, 8, 8, 6),
|
||||
Content = content
|
||||
};
|
||||
|
||||
// 根据设置决定是否显示背景
|
||||
if (_showLauncherTileBackground)
|
||||
{
|
||||
button.Classes.Add("glass-panel");
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Background = Brushes.Transparent;
|
||||
}
|
||||
|
||||
// 在图标渲染完成后,应用布局计算
|
||||
Dispatcher.UIThread.Post(() => UpdateLauncherTileLayout(), DispatcherPriority.Background);
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
clickAction();
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
private Control CreateLauncherFolderGridHintCell(string message)
|
||||
{
|
||||
return CreateLauncherFolderGridHintCell(message, 0, 0);
|
||||
}
|
||||
|
||||
private Control CreateLauncherFolderGridHintCell(string message, int col, int row)
|
||||
{
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = message,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Opacity = 0.6
|
||||
};
|
||||
|
||||
var cell = new Border
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Child = textBlock
|
||||
};
|
||||
|
||||
Grid.SetColumn(cell, col);
|
||||
Grid.SetRow(cell, row);
|
||||
return cell;
|
||||
}
|
||||
|
||||
private static string BuildMonogram(string text)
|
||||
@@ -1689,18 +1896,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLauncherFolderBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_launcherFolderStack.Count <= 1)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
_launcherFolderStack.Pop();
|
||||
RenderLauncherFolderFromStack();
|
||||
}
|
||||
|
||||
private void OnLauncherFolderOverlayPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (LauncherFolderPanel is null)
|
||||
@@ -1721,11 +1916,6 @@ public partial class MainWindow
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLauncherFolderCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
CloseLauncherFolderOverlay();
|
||||
}
|
||||
|
||||
private void DisposeLauncherResources()
|
||||
{
|
||||
foreach (var bitmap in _launcherIconCache.Values)
|
||||
|
||||
@@ -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)
|
||||
@@ -650,8 +668,24 @@ public partial class MainWindow
|
||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
||||
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
|
||||
ClockPosition = _clockPosition,
|
||||
ClockFontSize = _clockFontSize,
|
||||
ShowTextCapsule = _showTextCapsule,
|
||||
TextCapsuleContent = _textCapsuleContent,
|
||||
TextCapsulePosition = _textCapsulePosition,
|
||||
TextCapsuleTransparentBackground = _textCapsuleTransparentBackground,
|
||||
TextCapsuleFontSize = _textCapsuleFontSize,
|
||||
ShowNetworkSpeed = _showNetworkSpeed,
|
||||
NetworkSpeedPosition = _networkSpeedPosition,
|
||||
NetworkSpeedDisplayMode = _networkSpeedDisplayMode,
|
||||
NetworkSpeedTransparentBackground = _networkSpeedTransparentBackground,
|
||||
ShowNetworkTypeIcon = _showNetworkTypeIcon,
|
||||
NetworkSpeedFontSize = _networkSpeedFontSize,
|
||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent,
|
||||
StatusBarShadowEnabled = _statusBarShadowEnabled,
|
||||
StatusBarShadowColor = _statusBarShadowColor,
|
||||
StatusBarShadowOpacity = _statusBarShadowOpacity,
|
||||
DisabledPluginIds = existingSnapshot.DisabledPluginIds,
|
||||
StudyFrameMs = existingSnapshot.StudyFrameMs,
|
||||
StudyScoreThresholdDbfs = existingSnapshot.StudyScoreThresholdDbfs,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user