Compare commits

...

19 Commits

Author SHA1 Message Date
lincube
692ca3de3d Update CHANGELOG.md 2026-04-12 20:20:15 +08:00
lincube
d62226ffa0 fix. 试验性的修复了轻量版的Dotnet问题 2026-04-12 17:28:33 +08:00
lincube
91ab52ce8b change.插件sdk更新 2026-04-12 13:52:52 +08:00
lincube
4a89c2388b feat.便签组件 2026-04-12 12:14:25 +08:00
lincube
cb96180118 feat.白板笔色自适应主题 2026-04-12 01:10:12 +08:00
lincube
cf4b8e2132 fix.央广网新闻组件第二行显示修复,课程表显示修复。 2026-04-11 03:43:41 +08:00
lincube
e8ba847328 fix.我又改了一下融合桌面的设置窗口。 2026-04-11 00:35:27 +08:00
lincube
2156922039 feat.试验性地改了一下融合桌面的组件库,反正还是不能用。 2026-04-10 22:13:53 +08:00
lincube
e795e9964e feat.增加了无.net10的安装包版本,实验性的修改了融合桌面设置下的组件库样式。 2026-04-10 12:20:05 +08:00
lincube
11130cfdb3 feat.更新界面多标题修复。支持了,应用启动台不显示应用卡片背景。。。 2026-04-09 19:15:06 +08:00
lincube
66ae0b0270 fix.课表组件日间模式字体颜色修复 2026-04-09 00:53:28 +08:00
lincube
a671db8b69 更新 README.md 2026-04-08 23:32:39 +08:00
lincube
8c94253f92 fix.快捷方式组件的透明问题修复。顺便修了一下电源菜单。 2026-04-08 17:39:19 +08:00
lincube
6849a467d6 fead.快捷方式组件。fix.优化了噪音检测组件与白板组件的性能 2026-04-08 16:22:32 +08:00
lincube
e69bbf8b19 feat.加入快捷方式组件 2026-04-08 02:09:17 +08:00
lincube
d30af21317 docs.加入changelog 2026-04-08 01:45:26 +08:00
lincube
8583465a67 fead.圆角,终于统一 2026-04-08 00:55:10 +08:00
lincube
e1d5a0c6de fead.添加了电源菜单 2026-04-07 12:18:15 +08:00
lincube
5fa2031ad6 fead.消息盒子组件 2026-04-07 00:49:33 +08:00
96 changed files with 6969 additions and 813 deletions

View File

@@ -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,21 +106,42 @@ jobs:
- name: Publish
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/windows-${{ matrix.arch }} `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:SelfContained=true `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
if ($selfContained) {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
} else {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-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 +152,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 +221,8 @@ jobs:
"/DPublishDir=$publishDir",
"/DMyOutputDir=$outputDir",
"/DMyAppArch=$arch",
"/DMyAppSuffix=$suffix",
"/DIsSelfContained=$selfContained",
$installerScript
)
@@ -213,7 +249,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 +584,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 }}

View File

@@ -0,0 +1,112 @@
---
name: "refactoring-insight"
description: "Analyzes codebase for refactoring opportunities: large files, code duplication, god classes, naming inconsistencies, tight coupling, and missing abstractions. Invoke when user asks for refactoring insight/analysis or wants to improve code architecture."
---
# Refactoring Insight
Deep codebase analysis skill that identifies structural problems and produces prioritized refactoring recommendations.
## When to Invoke
- User asks for "refactoring insight", "refactoring analysis", "code quality analysis", "architecture review"
- User wants to understand what should be refactored in the codebase
- User asks "where are the code smells?" or "what needs refactoring?"
## Analysis Dimensions
Run all 6 dimensions in parallel where possible. For each dimension, use search agents to gather data, then synthesize findings.
### 1. Large Files / God Classes
- Find all .cs files over 300 lines, sorted by line count descending
- Identify partial classes and sum their total line count across files
- Flag classes with 15+ methods or constructors taking 8+ parameters
- Focus on: Views/, ViewModels/, Services/, plugins/
**Output**: Table of files with line counts and responsibility summary.
### 2. Code Duplication
Search for these specific duplication patterns:
- **Service boilerplate**: Repeated DI registration, `new` instantiation instead of DI
- **Data service pattern**: Services that fetch/parse/transform data similarly (Load → Map → Save)
- **Localization pattern**: `private readonly LocalizationService _localizationService = new();` and `L()` helper method repetitions
- **Helper method duplication**: Methods like `ResolveUnifiedMainRadiusValue`, `NormalizeConfig`, `ParticleState` classes copied across files
- **Error handling pattern**: Identical try-catch blocks repeated in multiple methods
- **Settings snapshot pattern**: `_settingsFacade.Settings.LoadSnapshot<T>(scope)` call sites
**Output**: List of duplicated patterns with file locations and line numbers.
### 3. Tight Coupling
- Services instantiated via `new` instead of DI injection
- ViewModels directly accessing infrastructure-layer APIs (e.g., `LoadSnapshot/SaveSnapshot`)
- Hard-coded dependencies (GitHub repo owner/name, default values)
- `Application.Current` upcasting to access services: `(Application.Current as App)?.SomeService`
- Platform-specific code embedded in cross-platform services without interface abstraction
**Output**: Table of coupling violations with severity (high/medium/low).
### 4. Naming Inconsistencies
- Service suffix inconsistency: `Service` vs `Store` vs `Helper` vs `Provider` vs `Manager` vs `Factory` for similar responsibilities
- Model suffix inconsistency: `Snapshot` vs `State` vs `Types` for similar concepts
- Platform prefix inconsistency: `Windows`/`Linux` full name vs `Mac` abbreviation
- Confusing names: services with similar names but different responsibilities (e.g., `NotificationService` vs `NotificationListenerService`)
**Output**: Categorized list of naming inconsistencies.
### 5. Missing Abstractions
- Services without corresponding interfaces (check for `I<ServiceName>` pattern)
- Common patterns that could be extracted into base classes:
- `SettingsPageViewModelBase` for shared ViewModel boilerplate
- `JsonFileSettingsService<TSnapshot>` for repeated settings persistence
- `SettingsDomainServiceBase<TState>` for Load-Map-Save pattern
- `DesktopComponentWidgetBase` for shared Widget code
- `ComponentEditorViewBase` enhancements (e.g., `_suppressEvents` pattern)
- Static singleton/Factory providers repeating thread-safe lazy-load boilerplate
**Output**: List of missing abstractions with proposed base class/interface names.
### 6. Misplaced Responsibilities
- Files in wrong directories (e.g., data access in Settings/, UI services mixed with data services)
- ViewModels containing business logic or file system operations
- Widget code-behind files with excessive logic (>200 lines)
- Platform-specific services not organized into subdirectories
**Output**: List of misplaced files/classes with recommended new locations.
## Output Format
Produce a structured report with:
1. **Summary table**: Total metrics (file count, duplication count, etc.)
2. **Priority-ranked findings**: P0 (must fix), P1 (should fix), P2 (recommended), P3 (nice to have)
3. **Each finding includes**: Problem description, affected files with links, specific line numbers, recommended action, estimated impact
### Priority Criteria
- **P0**: Files over 1000 lines with mixed responsibilities; patterns duplicated 10+ times; god classes with 20+ dependencies
- **P1**: Patterns duplicated 5-9 times; services without interfaces that are widely used; DI bypass affecting testability
- **P2**: Patterns duplicated 3-4 times; naming inconsistencies affecting readability; misplaced files
- **P3**: Minor naming variations; single-instance duplications; organizational improvements
## Project-Specific Context
This skill is aware of the LanMountainDesktop project structure:
- `LanMountainDesktop/Services/` — Business and infrastructure services
- `LanMountainDesktop/Services/Settings/` — Settings subsystem
- `LanMountainDesktop/ViewModels/` — View models
- `LanMountainDesktop/Views/Components/` — Desktop widget components
- `LanMountainDesktop/Views/ComponentEditors/` — Widget editor views
- `LanMountainDesktop/plugins/` — Plugin runtime
- `LanMountainDesktop.PluginSdk/` — Plugin SDK public API
- `LanMountainDesktop.Shared.Contracts/` — Host/plugin shared contracts
- `LanMountainDesktop.Appearance/` — Appearance and corner radius infrastructure
When analyzing, respect the project's architectural boundaries documented in `docs/ARCHITECTURE.md` and `docs/ECOSYSTEM_BOUNDARIES.md`.

View File

@@ -0,0 +1,166 @@
# 融合桌面组件库窗口重设计规格
## 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小组件面板设计暂不提供搜索功能

View 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 依赖于所有前置任务完成

View File

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

154
CHANGELOG.md Normal file
View File

@@ -0,0 +1,154 @@
# 更新日志 / Changelog
## [0.8.3.3](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.3) - 2026-04-12
### 新增 (Added)
-**便签组件**: 全新便签组件上线,支持 Markdown 语法
- 支持丰富的 Markdown 格式:标题、列表、加粗、斜体、代码块等
- 便签内容自动保存,方便记录和管理日常备忘。丰富信息展示途径,让作业布置也可在阑山桌面完成。
-**白板主题自适应笔色**: 白板功能新增主题自适应笔色支持
- 根据当前主题自动调整画笔颜色,确保在不同主题下都有良好的书写体验
- 深色主题下自动切换为浅色笔迹,浅色主题下使用深色笔迹
### 变更 (Changed)
- 🎨 **融合桌面设置组件库样式更新**: 优化融合桌面设置页面的组件库样式
- 提升视觉一致性和用户体验
- 统一组件风格,与整体设计语言保持协调
### 修复 (Fixed)
- 🐛 **白板无法使用问题**: 修复了白板功能无法正常使用的问题
- 问题原因: 相关依赖或初始化逻辑异常导致白板功能失效
- 修复方案: 修复了白板的依赖加载和初始化流程,恢复正常使用
- 🐛 **央官网新闻组件显示问题**: 修复了央官网新闻组件的显示异常
- 优化组件渲染逻辑,确保新闻内容正确展示
- 🐛 **课程表组件显示问题**: 修复了课程表组件的显示异常
- 优化组件布局和渲染,确保课程信息正确显示
- 🐛 **轻量版 .NET 10 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 10 环境下的依赖问题
- 问题原因: 轻量版与 .NET 10 的依赖兼容性存在冲突
- 修复方案: 调整依赖配置,提升与 .NET 10 的兼容性(实验性修复,持续观察中)
### 移除 (Removed)
-
***
## [0.8.3.2](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2) - 2026-04-09
### 新增 (Added)
-**应用启动台图标卡片显示选项**: 新增应用启动台图标卡片显示设置
- 用户可在设置中选择是否显示应用图标的专属卡片背景
- 关闭后仅显示应用图标本身,更加简洁
- 支持动态切换,实时预览效果
### 变更 (Changed)
-
### 修复 (Fixed)
- 🐛 **应用启动台文件夹应用数量限制**: 修复了应用启动台文件夹无法查看超过12个应用的问题
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
- 🐛 **电源菜单重启导致关机问题**: 修复了点击电源菜单"重启"选项却触发关机的问题
- 问题原因: `SlideToShutDown.exe` 仅支持关机操作,不支持重启,错误地将其用于重启功能
- 修复方案: 重启操作改为使用标准的二次确认对话框(所有平台统一),仅关机操作使用 SlideToShutDown 滑动界面
- 🐛 **课表组件字体显示问题**: 修复了日间模式下课表组件字体颜色与背景色相近导致看不清的问题
- 问题原因: 主题切换时增量更新逻辑未同步更新文字颜色
- 修复方案: 在 `IncrementalUpdateItems()` 方法中同步更新课程项的文字颜色
### 移除 (Removed)
- 🗑️ **更新页面重复标题**: 移除了更新页面中重复的更新标题,优化页面布局
***
## [0.8.3.1](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1) - 2026-04-08
### 新增 (Added)
-**快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
- 支持创建快捷方式,统一管理应用和文件
- 提供单击打开和双击打开两种交互模式
- 支持配置是否显示背景
- 📝 初始化更新日志文档,为后续版本发布建立基础
### 变更 (Changed)
-
### 修复 (Fixed)
-
### 移除 (Removed)
-
***
所有重要的更改都将记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
***
## \[格式示例]
### 新增 (Added)
- 待发布的新功能
### 变更 (Changed)
- 待发布的变更
### 修复 (Fixed)
- 待发布的修复
### 移除 (Removed)
- 待发布的移除项
***
## 版本说明
### 版本号规则
本项目采用语义化版本号 `MAJOR.MINOR.PATCH.BUILD`:
- **MAJOR (主版本号)**: 不兼容的 API 修改
- **MINOR (次版本号)**: 向下兼容的功能性新增
- **PATCH (修订号)**: 向下兼容的问题修正
- **BUILD (构建号)**: 内部构建版本,用于区分同一 PATCH 版本的不同构建
### 分类说明
- **新增 (Added)**: 新功能、新特性
- **变更 (Changed)**: 对现有功能的变更
- **修复 (Fixed)**: Bug 修复
- **移除 (Removed)**: 移除的功能或特性
### 图例
- 🎉 **重大更新**: 重要功能或里程碑
-**新功能**: 新增功能特性
- 🐛 **Bug修复**: 问题修复
- 🔧 **配置**: 配置相关变更
- 📝 **文档**: 文档更新
- 🎨 **样式**: UI/UX 改进
- ♻️ **重构**: 代码重构
-**性能**: 性能优化
- 🔒 **安全**: 安全相关
- 🌐 **国际化**: 国际化/本地化
***
## 链接

View File

@@ -6,23 +6,48 @@ namespace LanMountainDesktop.Appearance;
public static class AppearanceCornerRadiusTokenFactory
{
public static AppearanceCornerRadiusTokens Create(double scale)
public static AppearanceCornerRadiusTokens Create(string style)
{
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
return new AppearanceCornerRadiusTokens(
Radius(6, normalizedScale),
Radius(12, normalizedScale),
Radius(14, normalizedScale),
Radius(20, normalizedScale),
Radius(28, normalizedScale),
Radius(32, normalizedScale),
Radius(36, normalizedScale),
Radius(18, normalizedScale));
}
private static CornerRadius Radius(double value, double scale)
{
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
return new CornerRadius(scaled);
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(style);
return normalized switch
{
GlobalAppearanceSettings.CornerRadiusStyleSharp => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(4),
Xs: new CornerRadius(8),
Sm: new CornerRadius(10),
Md: new CornerRadius(14),
Lg: new CornerRadius(20),
Xl: new CornerRadius(24),
Island: new CornerRadius(28),
Component: new CornerRadius(20)),
GlobalAppearanceSettings.CornerRadiusStyleRounded => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(8),
Xs: new CornerRadius(14),
Sm: new CornerRadius(16),
Md: new CornerRadius(24),
Lg: new CornerRadius(32),
Xl: new CornerRadius(36),
Island: new CornerRadius(40),
Component: new CornerRadius(28)),
GlobalAppearanceSettings.CornerRadiusStyleOpen => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(10),
Xs: new CornerRadius(16),
Sm: new CornerRadius(20),
Md: new CornerRadius(28),
Lg: new CornerRadius(36),
Xl: new CornerRadius(40),
Island: new CornerRadius(44),
Component: new CornerRadius(32)),
// Balanced (default)
_ => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(6),
Xs: new CornerRadius(12),
Sm: new CornerRadius(14),
Md: new CornerRadius(20),
Lg: new CornerRadius(28),
Xl: new CornerRadius(32),
Island: new CornerRadius(36),
Component: new CornerRadius(24))
};
}
}

View File

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

View File

@@ -0,0 +1,109 @@
namespace LanMountainDesktop.PluginSdk;
/// <summary>
/// 外观变更事件参数,当主题、圆角或其他外观属性变化时触发。
/// </summary>
public sealed class AppearanceChangedEvent : EventArgs
{
/// <summary>
/// 创建外观变更事件实例。
/// </summary>
/// <param name="snapshot">当前外观快照</param>
/// <param name="changedProperties">变更的属性集合</param>
public AppearanceChangedEvent(
PluginAppearanceSnapshot snapshot,
IReadOnlyCollection<AppearanceProperty> changedProperties)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(changedProperties);
Snapshot = snapshot;
ChangedProperties = changedProperties;
}
/// <summary>
/// 当前外观快照。
/// </summary>
public PluginAppearanceSnapshot Snapshot { get; }
/// <summary>
/// 变更的属性集合。
/// </summary>
public IReadOnlyCollection<AppearanceProperty> ChangedProperties { get; }
/// <summary>
/// 圆角是否发生变化。
/// </summary>
public bool CornerRadiusChanged => ChangedProperties.Contains(AppearanceProperty.CornerRadius);
/// <summary>
/// 主题变体(亮色/暗色)是否发生变化。
/// </summary>
public bool ThemeVariantChanged => ChangedProperties.Contains(AppearanceProperty.ThemeVariant);
/// <summary>
/// 强调色是否发生变化。
/// </summary>
public bool AccentColorChanged => ChangedProperties.Contains(AppearanceProperty.AccentColor);
/// <summary>
/// 圆角风格是否发生变化。
/// </summary>
public bool CornerRadiusStyleChanged => ChangedProperties.Contains(AppearanceProperty.CornerRadiusStyle);
/// <summary>
/// 检查指定属性是否发生变化。
/// </summary>
/// <param name="property">要检查的属性</param>
/// <returns>如果属性发生变化则返回 true</returns>
public bool HasChanged(AppearanceProperty property)
{
return ChangedProperties.Contains(property);
}
/// <summary>
/// 检查是否有任何外观属性发生变化。
/// </summary>
public bool HasAnyChanges => ChangedProperties.Count > 0;
}
/// <summary>
/// 可变更的外观属性枚举。
/// </summary>
public enum AppearanceProperty
{
/// <summary>
/// 圆角Token值发生变化。
/// </summary>
CornerRadius,
/// <summary>
/// 主题变体(亮色/暗色)发生变化。
/// </summary>
ThemeVariant,
/// <summary>
/// 强调色发生变化。
/// </summary>
AccentColor,
/// <summary>
/// 圆角风格Sharp/Balanced/Rounded/Open发生变化。
/// </summary>
CornerRadiusStyle,
/// <summary>
/// 壁纸发生变化。
/// </summary>
Wallpaper,
/// <summary>
/// 系统材质模式发生变化。
/// </summary>
SystemMaterialMode,
/// <summary>
/// 所有外观属性(用于批量更新)。
/// </summary>
All
}

View File

@@ -1,10 +1,35 @@
namespace LanMountainDesktop.PluginSdk;
/// <summary>
/// 插件外观上下文接口,提供主题、圆角等外观资源的访问和变更通知。
/// </summary>
public interface IPluginAppearanceContext
{
/// <summary>
/// 当前外观快照。
/// </summary>
PluginAppearanceSnapshot Snapshot { get; }
/// <summary>
/// 外观变更事件。当主题、圆角或其他外观属性发生变化时触发。
/// </summary>
event EventHandler<AppearanceChangedEvent>? Changed;
/// <summary>
/// 解析带缩放的圆角半径。
/// </summary>
/// <param name="baseRadius">基础圆角半径</param>
/// <param name="minimum">最小值(可选)</param>
/// <param name="maximum">最大值(可选)</param>
/// <returns>解析后的圆角半径</returns>
double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null);
/// <summary>
/// 根据预设解析圆角半径。
/// </summary>
/// <param name="preset">圆角预设</param>
/// <param name="minimum">最小值(可选)</param>
/// <param name="maximum">最大值(可选)</param>
/// <returns>解析后的圆角半径</returns>
double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null);
}

View File

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

View File

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

View File

@@ -1,44 +1,84 @@
namespace LanMountainDesktop.PluginSdk;
/// <summary>
/// 插件外观上下文实现,提供主题、圆角等外观资源的访问和变更通知。
/// </summary>
public sealed class PluginAppearanceContext : IPluginAppearanceContext
{
private PluginAppearanceSnapshot _snapshot;
/// <summary>
/// 创建插件外观上下文实例。
/// </summary>
/// <param name="snapshot">初始外观快照</param>
public PluginAppearanceContext(PluginAppearanceSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(snapshot.CornerRadiusTokens);
Snapshot = snapshot with
_snapshot = snapshot with
{
GlobalCornerRadiusScale = Math.Max(0d, snapshot.GlobalCornerRadiusScale),
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
? "Unknown"
: snapshot.ThemeVariant.Trim()
};
}
public PluginAppearanceSnapshot Snapshot { get; }
/// <inheritdoc />
public PluginAppearanceSnapshot Snapshot => _snapshot;
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
/// <inheritdoc />
public event EventHandler<AppearanceChangedEvent>? Changed;
/// <summary>
/// 更新外观快照并触发变更事件。
/// 此方法由宿主调用,用于在主题、圆角等外观属性变化时通知插件。
/// </summary>
/// <param name="newSnapshot">新的外观快照</param>
/// <param name="changedProperties">变更的属性集合</param>
public void UpdateSnapshot(PluginAppearanceSnapshot newSnapshot, IReadOnlyCollection<AppearanceProperty> changedProperties)
{
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;
ArgumentNullException.ThrowIfNull(newSnapshot);
ArgumentNullException.ThrowIfNull(changedProperties);
_snapshot = newSnapshot with
{
ThemeVariant = string.IsNullOrWhiteSpace(newSnapshot.ThemeVariant)
? "Unknown"
: newSnapshot.ThemeVariant.Trim()
};
if (changedProperties.Count > 0)
{
Changed?.Invoke(this, new AppearanceChangedEvent(_snapshot, changedProperties));
}
}
/// <inheritdoc />
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
{
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);
}
/// <inheritdoc />
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
{
var resolved = Math.Max(0d, Snapshot.CornerRadiusTokens.Get(preset));
var resolved = Math.Max(0d, _snapshot.CornerRadiusTokens.Get(preset));
if (!minimum.HasValue && !maximum.HasValue)
{
return resolved;
}
var clampedMin = minimum ?? resolved;
var clampedMax = maximum ?? resolved;
var clampedMin = minimum ?? 0d;
var clampedMax = maximum ?? double.MaxValue;
if (clampedMin > clampedMax)
{
(clampedMin, clampedMax) = (clampedMax, clampedMin);

View File

@@ -0,0 +1,137 @@
using Avalonia;
namespace LanMountainDesktop.PluginSdk;
/// <summary>
/// 插件外观辅助方法,提供统一的圆角和主题资源访问。
/// </summary>
public static class PluginAppearanceHelper
{
/// <summary>
/// 获取桌面组件主外壳圆角半径。
/// 这是组件最外层边框应该使用的圆角值,对应 DesignCornerRadiusComponent 资源。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>主外壳圆角半径(像素)</returns>
public static double GetShellCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
}
/// <summary>
/// 获取内部卡片圆角半径。
/// 用于组件内部的次级卡片、内容区块等。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>内部卡片圆角半径(像素)</returns>
public static double GetCardCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Sm);
}
/// <summary>
/// 获取控件圆角半径。
/// 用于按钮、输入框、标签等交互控件。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>控件圆角半径(像素)</returns>
public static double GetControlCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Xs);
}
/// <summary>
/// 获取徽章/标签圆角半径。
/// 用于小徽章、标签、角标等微元素。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>徽章圆角半径(像素)</returns>
public static double GetBadgeCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Micro);
}
/// <summary>
/// 获取中等面板圆角半径。
/// 用于悬浮菜单、小提示框、子面板等。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>中等面板圆角半径(像素)</returns>
public static double GetMediumPanelCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Md);
}
/// <summary>
/// 获取大面板圆角半径。
/// 用于对话框、设置面板等大型容器(非桌面组件)。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>大面板圆角半径(像素)</returns>
public static double GetLargePanelCornerRadius(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.ResolveCornerRadius(PluginCornerRadiusPreset.Lg);
}
/// <summary>
/// 将圆角预设转换为 Avalonia CornerRadius。
/// </summary>
/// <param name="context">外观上下文</param>
/// <param name="preset">圆角预设</param>
/// <returns>Avalonia CornerRadius 结构</returns>
public static CornerRadius ToCornerRadius(this IPluginAppearanceContext context, PluginCornerRadiusPreset preset)
{
ArgumentNullException.ThrowIfNull(context);
var radius = context.ResolveCornerRadius(preset);
return new CornerRadius(radius);
}
/// <summary>
/// 获取当前主题变体(亮色/暗色)。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>是否为暗色主题</returns>
public static bool IsDarkTheme(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return string.Equals(context.Snapshot.ThemeVariant, "Dark", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 获取当前主题变体字符串。
/// </summary>
/// <param name="context">外观上下文</param>
/// <returns>主题变体字符串("Light" 或 "Dark"</returns>
public static string GetThemeVariant(this IPluginAppearanceContext context)
{
ArgumentNullException.ThrowIfNull(context);
return context.Snapshot.ThemeVariant;
}
}
/// <summary>
/// 内部元素层级,用于区分不同层级的圆角需求。
/// </summary>
public enum InnerElementLevel
{
/// <summary>
/// 内部卡片:使用 Sm token14px @ 1.0x
/// </summary>
Card,
/// <summary>
/// 交互控件:使用 Xs token12px @ 1.0x
/// </summary>
Control,
/// <summary>
/// 微元素徽章:使用 Micro token6px @ 1.0x
/// </summary>
Badge
}

View File

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

View File

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

View File

@@ -72,14 +72,11 @@ public sealed class PluginDesktopComponentRegistration
var resolved = CornerRadiusResolver is not null
? CornerRadiusResolver(appearance, Math.Max(1d, cellSize))
: CornerRadiusPreset == PluginCornerRadiusPreset.Default
? appearance.ResolveScaledCornerRadius(
Math.Clamp(Math.Max(1d, cellSize) * 0.22, 8, 18),
8,
18)
? appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Component)
: appearance.ResolveCornerRadius(CornerRadiusPreset);
return double.IsFinite(resolved)
? Math.Max(0d, resolved)
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Default);
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
using Avalonia;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class CornerRadiusScaleTests
{
[Theory]
[InlineData(-1d, 0d)]
[InlineData(0d, 0d)]
[InlineData(0.33d, 0.33d)]
[InlineData(1.234d, 1.234d)]
[InlineData(2.5d, 2.5d)]
[InlineData(3d, 2.5d)]
public void NormalizeCornerRadiusScale_ClampsWithoutSnapping(double input, double expected)
{
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusScale(input), 3);
}
[Fact]
public void NormalizeCornerRadiusScale_UsesDefaultForInvalidValues()
{
Assert.Equal(
GlobalAppearanceSettings.DefaultCornerRadiusScale,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.NaN),
3);
Assert.Equal(
GlobalAppearanceSettings.DefaultCornerRadiusScale,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.PositiveInfinity),
3);
}
[Fact]
public void PluginDesktopComponentContext_AllowsZeroRadiusScaling()
{
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: 0d,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8))),
ThemeVariant: "Unknown"));
var context = new PluginDesktopComponentContext(
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
"C:\\Plugins\\plugin.id",
"C:\\Data\\plugin.id",
new NullServiceProvider(),
new Dictionary<string, object?>(),
"component-1",
null,
96d,
appearanceContext);
Assert.Equal(0d, context.GlobalCornerRadiusScale, 3);
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d), 3);
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
}
[Fact]
public void PluginAppearanceContext_ResolveCornerRadius_DoesNotDoubleScalePresetTokens()
{
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: 2d,
CornerRadiusTokens: new PluginCornerRadiusTokens(
Micro: 12d,
Xs: 20d,
Sm: 28d,
Md: 36d,
Lg: 48d,
Xl: 60d,
Island: 72d,
Component: 16d),
ThemeVariant: "Light"));
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 40d), 3);
Assert.Equal(36d, context.ResolveScaledCornerRadius(18d), 3);
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}

View File

@@ -0,0 +1,76 @@
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
Assert.Equal(20d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
Assert.Equal(15d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 15d), 3);
// ResolveScaledCornerRadius returns baseRadius as-is when no min/max specified
Assert.Equal(18d, 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);
// ResolveScaledCornerRadius returns baseRadius as-is when no min/max specified
Assert.Equal(12d, context.ResolveScaledCornerRadius(12d), 3);
// When min/max specified, value is clamped
Assert.Equal(12d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
// Component token access
Assert.Equal(24d, context.CornerRadiusTokens.Component, 3);
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
using System;
using System.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class StudyAnalyticsServiceTests
{
[Fact]
public void SnapshotUpdated_UsesUiPublishThrottle()
{
using var recorder = new FakeAudioRecorderService();
using var service = new StudyAnalyticsService(recorder);
service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
var updateCount = 0;
service.SnapshotUpdated += (_, _) => Interlocked.Increment(ref updateCount);
Assert.True(service.StartOrResumeMonitoring());
Thread.Sleep(280);
Assert.True(service.PauseMonitoring());
var totalUpdates = Volatile.Read(ref updateCount);
Assert.InRange(totalUpdates, 2, 6);
}
[Fact]
public void GetSnapshot_ReusesRealtimeBufferSnapshot_WhenNoNewFramesArrive()
{
using var recorder = new FakeAudioRecorderService();
using var service = new StudyAnalyticsService(recorder);
service.UpdateConfig(new StudyAnalyticsConfig(FrameMs: 20, UiPublishIntervalMs: 120));
using var firstUpdate = new ManualResetEventSlim(false);
service.SnapshotUpdated += (_, args) =>
{
if (args.Snapshot.RealtimeBuffer.Count > 0)
{
firstUpdate.Set();
}
};
Assert.True(service.StartOrResumeMonitoring());
Assert.True(firstUpdate.Wait(TimeSpan.FromSeconds(2)));
Assert.True(service.PauseMonitoring());
var firstSnapshot = service.GetSnapshot();
var secondSnapshot = service.GetSnapshot();
Assert.NotEmpty(firstSnapshot.RealtimeBuffer);
Assert.Same(firstSnapshot.RealtimeBuffer, secondSnapshot.RealtimeBuffer);
}
private sealed class FakeAudioRecorderService : IAudioRecorderService
{
private readonly object _syncRoot = new();
private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Ready;
public AudioRecorderSnapshot GetSnapshot()
{
lock (_syncRoot)
{
return new AudioRecorderSnapshot(
State: _state,
Duration: TimeSpan.Zero,
InputLevel: _state == AudioRecorderRuntimeState.Recording ? 0.55 : 0,
LastSavedFilePath: string.Empty,
LastError: string.Empty);
}
}
public bool StartOrResume()
{
lock (_syncRoot)
{
_state = AudioRecorderRuntimeState.Recording;
return true;
}
}
public bool Pause()
{
lock (_syncRoot)
{
_state = AudioRecorderRuntimeState.Paused;
return true;
}
}
public string? StopAndSave(string? outputPath = null)
{
lock (_syncRoot)
{
_state = AudioRecorderRuntimeState.Ready;
return outputPath;
}
}
public void Discard()
{
lock (_syncRoot)
{
_state = AudioRecorderRuntimeState.Ready;
}
}
public void Dispose()
{
}
}
}

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,12 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
</PropertyGroup>
<!-- Keep Release defaults compatibility-first for desktop dependencies (WebView/interop/reflection). -->
@@ -76,6 +76,7 @@
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
</ItemGroup>
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,70 @@ 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
#region Sticky Note Component Settings (便)
public string StickyNoteContent { get; set; } = string.Empty;
#endregion
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,32 @@
namespace LanMountainDesktop.ViewModels;
using CommunityToolkit.Mvvm.Input;
using System.Diagnostics;
using System.IO;
namespace LanMountainDesktop.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
public string Greeting { get; } = "A modern desktop shell powered by FluentAvalonia.";
[RelayCommand]
private void OpenDesignSpec(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return;
var fullPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "docs", fileName);
if (!File.Exists(fullPath))
{
// Try relative to project root in dev
fullPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "docs", fileName));
}
if (File.Exists(fullPath))
{
Process.Start(new ProcessStartInfo
{
FileName = fullPath,
UseShellExecute = true
});
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.ViewModels;
public sealed partial class ShortcutEditorViewModel : ViewModelBase
{
private readonly DesktopComponentEditorContext? _context;
private bool _isInitializing;
public ShortcutEditorViewModel(DesktopComponentEditorContext? context)
{
_context = context;
ClickModeOptions = new ObservableCollection<SelectionOption>
{
new("Double", "双击打开"),
new("Single", "单击打开")
};
LoadSettings();
}
private void LoadSettings()
{
var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>()
?? new ComponentSettingsSnapshot();
_isInitializing = true;
TargetPath = snapshot.ShortcutTargetPath ?? string.Empty;
SelectedClickMode = ClickModeOptions.FirstOrDefault(o => o.Value == snapshot.ShortcutClickMode)
?? ClickModeOptions[0];
ShowBackground = snapshot.ShortcutShowBackground;
_isInitializing = false;
}
private void SaveSettings()
{
if (_isInitializing || _context == null) return;
var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
snapshot.ShortcutTargetPath = string.IsNullOrWhiteSpace(TargetPath) ? null : TargetPath;
snapshot.ShortcutClickMode = SelectedClickMode?.Value ?? "Double";
snapshot.ShortcutShowBackground = ShowBackground;
_context.ComponentSettingsAccessor.SaveSnapshot(snapshot);
_context.HostContext.RequestRefresh();
}
[ObservableProperty] private string _descriptionText = "配置此快捷方式组件的目标路径和打开方式。这些设置仅作用于当前组件实例。";
[ObservableProperty] private string _targetPathLabel = "目标路径";
[ObservableProperty] private string _targetPathPlaceholder = "未选择目标";
[ObservableProperty] private string _browseButtonText = "浏览...";
[ObservableProperty] private string _clearButtonText = "清除";
[ObservableProperty] private string _clickModeLabel = "打开方式";
[ObservableProperty] private string _backgroundLabel = "显示背景";
[ObservableProperty] private string _backgroundDescription = "关闭后组件背景将变为透明。";
[ObservableProperty] private string _targetPath = string.Empty;
[ObservableProperty] private SelectionOption? _selectedClickMode;
[ObservableProperty] private bool _showBackground = true;
public ObservableCollection<SelectionOption> ClickModeOptions { get; }
public void SetTargetPath(string? path)
{
TargetPath = path ?? string.Empty;
SaveSettings();
}
public void ClearTargetPath()
{
TargetPath = string.Empty;
SaveSettings();
}
partial void OnSelectedClickModeChanged(SelectionOption? value) => SaveSettings();
partial void OnShowBackgroundChanged(bool value) => SaveSettings();
}

View File

@@ -0,0 +1,88 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
x:Class="LanMountainDesktop.Views.ComponentEditors.NotificationBoxComponentEditor"
x:DataType="vm:NotificationBoxEditorViewModel">
<StackPanel Spacing="16">
<!-- 说明卡片 -->
<Border Classes="component-editor-card" Padding="20">
<TextBlock Text="{Binding DescriptionText}"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</Border>
<!-- 最大显示数量 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding MaxDisplayCountLabel}"
Classes="component-editor-section-title" />
<TextBlock Text="{Binding MaxDisplayCountDescription}"
Classes="component-editor-secondary-text" />
<ComboBox ItemsSource="{Binding MaxDisplayCountOptions}"
SelectedItem="{Binding SelectedMaxDisplayCount}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
<!-- 排序方式 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding SortOrderLabel}"
Classes="component-editor-section-title" />
<ComboBox ItemsSource="{Binding SortOrderOptions}"
SelectedItem="{Binding SelectedSortOrder}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
<!-- 显示选项 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="16">
<TextBlock Text="{Binding DisplayOptionsLabel}"
Classes="component-editor-section-title" />
<CheckBox IsChecked="{Binding ShowAppIcon}"
Content="{Binding ShowAppIconLabel}" />
<CheckBox IsChecked="{Binding ShowTimestamp}"
Content="{Binding ShowTimestampLabel}" />
<CheckBox IsChecked="{Binding GroupByApp}"
Content="{Binding GroupByAppLabel}" />
<CheckBox IsChecked="{Binding ShowClearButton}"
Content="{Binding ShowClearButtonLabel}" />
</StackPanel>
</Border>
<!-- 时间格式 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding TimeFormatLabel}"
Classes="component-editor-section-title" />
<ComboBox ItemsSource="{Binding TimeFormatOptions}"
SelectedItem="{Binding SelectedTimeFormat}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

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

View File

@@ -0,0 +1,66 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
x:Class="LanMountainDesktop.Views.ComponentEditors.ShortcutComponentEditor"
x:DataType="vm:ShortcutEditorViewModel">
<StackPanel Spacing="16">
<!-- 说明卡片 -->
<Border Classes="component-editor-card" Padding="20">
<TextBlock Text="{Binding DescriptionText}"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</Border>
<!-- 目标路径 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding TargetPathLabel}"
Classes="component-editor-section-title" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Text="{Binding TargetPath}"
IsReadOnly="True"
Watermark="{Binding TargetPathPlaceholder}"
Grid.Column="0" />
<Button Content="{Binding BrowseButtonText}"
Click="OnBrowseClick"
Grid.Column="1"
Margin="8,0,0,0" />
</Grid>
<Button Content="{Binding ClearButtonText}"
Click="OnClearClick"
HorizontalAlignment="Stretch" />
</StackPanel>
</Border>
<!-- 打开方式 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding ClickModeLabel}"
Classes="component-editor-section-title" />
<ComboBox ItemsSource="{Binding ClickModeOptions}"
SelectedItem="{Binding SelectedClickMode}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
<!-- 背景设置 -->
<Border Classes="component-editor-card" Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding BackgroundLabel}"
Classes="component-editor-section-title" />
<TextBlock Text="{Binding BackgroundDescription}"
Classes="component-editor-secondary-text" />
<CheckBox IsChecked="{Binding ShowBackground}"
Content="{Binding BackgroundLabel}" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,77 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ShortcutComponentEditor : ComponentEditorViewBase
{
private ShortcutEditorViewModel? _viewModel;
public ShortcutComponentEditor()
: this(null)
{
}
public ShortcutComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
_viewModel = new ShortcutEditorViewModel(context);
DataContext = _viewModel;
}
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.StorageProvider is not { } storageProvider)
{
return;
}
var options = new FilePickerOpenOptions
{
Title = "选择目标文件",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("可执行文件")
{
Patterns = ["*.exe", "*.lnk", "*.bat", "*.cmd"]
},
new FilePickerFileType("所有文件")
{
Patterns = ["*.*"]
}
]
};
var files = await storageProvider.OpenFilePickerAsync(options);
var localPath = files.FirstOrDefault()?.TryGetLocalPath();
if (string.IsNullOrWhiteSpace(localPath))
{
var folderOptions = new FolderPickerOpenOptions
{
Title = "选择目标文件夹",
AllowMultiple = false
};
var folders = await storageProvider.OpenFolderPickerAsync(folderOptions);
localPath = folders.FirstOrDefault()?.TryGetLocalPath();
}
if (!string.IsNullOrWhiteSpace(localPath))
{
_viewModel?.SetTargetPath(localPath);
}
}
private void OnClearClick(object? sender, RoutedEventArgs e)
{
_viewModel?.ClearTargetPath();
}
}

View File

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

View File

@@ -34,11 +34,13 @@
<TextBlock x:Name="WeekdayTextBlock"
Text="周一"
TextAlignment="Right"
FontWeight="SemiBold" />
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="ClassCountTextBlock"
Text="0节课"
TextAlignment="Right"
FontWeight="SemiBold" />
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>

View File

@@ -725,6 +725,8 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
? CreateBrush("#FF4FC3F7")
: CreateBrush("#FF4D5A");
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
for (var i = 0; i < _courseItems.Count && i < CourseListPanel.Children.Count; i++)
{
@@ -746,19 +748,31 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var timeText = textStack.Children[1] as TextBlock;
var detailText = textStack.Children[2] as TextBlock;
if (titleText != null && titleText.Text != item.Name)
if (titleText != null)
{
titleText.Text = item.Name;
if (titleText.Text != item.Name)
{
titleText.Text = item.Name;
}
titleText.Foreground = primaryBrush;
}
if (timeText != null && timeText.Text != item.TimeRange)
if (timeText != null)
{
timeText.Text = item.TimeRange;
if (timeText.Text != item.TimeRange)
{
timeText.Text = item.TimeRange;
}
timeText.Foreground = secondaryBrush;
}
if (detailText != null && detailText.Text != item.Detail)
if (detailText != null)
{
detailText.Text = item.Detail;
if (detailText.Text != item.Detail)
{
detailText.Text = item.Detail;
}
detailText.Foreground = secondaryBrush;
}
}
}
@@ -914,7 +928,28 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
MetaStack.Spacing = Math.Clamp(6 * scale, 3, 10);
CourseListPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
var dateFont = Math.Clamp(66 * scale, 26, 82);
var dateFontByScale = Math.Clamp(66 * scale, 26, 82);
var weekdayFontByScale = Math.Clamp(34 * scale, 13, 32);
var classCountFontByScale = Math.Clamp(40 * scale, 14, 36);
// 宽度感知:当头部内容总需求超过可用宽度时,按比例缩小日期字体
var availableWidth = Math.Max(1, Bounds.Width - rootPadding.Left - rootPadding.Right);
var dateGroupEstimatedWidth = dateFontByScale * 0.6 * 3 + DateGroup.Spacing * 2;
var metaStackEstimatedWidth = classCountFontByScale * 0.6 * 4 + MetaStack.Spacing;
var headerColumnSpacing = Math.Clamp(10 * scale, 4, 16);
var totalHeaderNeed = dateGroupEstimatedWidth + headerColumnSpacing + metaStackEstimatedWidth;
var dateFont = dateFontByScale;
if (totalHeaderNeed > availableWidth)
{
var shrinkRatio = availableWidth / totalHeaderNeed;
dateFont = Math.Max(20, dateFontByScale * shrinkRatio);
}
// 为 HeaderGrid 左列设置最小宽度,防止被压缩至零
var minDateColumnWidth = dateFont * 0.6 * 3 + DateGroup.Spacing * 2;
HeaderGrid.ColumnDefinitions[0].MinWidth = minDateColumnWidth;
MonthTextBlock.FontSize = dateFont;
DayTextBlock.FontSize = dateFont;
SlashTextBlock.FontSize = dateFont;
@@ -926,8 +961,8 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");
WeekdayTextBlock.FontSize = Math.Clamp(34 * scale, 13, 32);
ClassCountTextBlock.FontSize = Math.Clamp(40 * scale, 14, 36);
WeekdayTextBlock.FontSize = weekdayFontByScale;
ClassCountTextBlock.FontSize = classCountFontByScale;
StatusTextBlock.FontSize = Math.Clamp(30 * scale, 12, 30);
WeekdayTextBlock.FontWeight = ToVariableWeight(Lerp(560, 700, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -552,7 +552,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
{
Width = 160,
Height = 90,
CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16, 8, 22),
CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16, 8, 22),
ClipToBounds = true,
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
IsHitTestVisible = false
@@ -647,8 +647,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
News1ImageHost.Height = imageHeight;
News2ImageHost.Width = imageWidth;
News2ImageHost.Height = imageHeight;
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
News1ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
News2ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
@@ -691,7 +691,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
row.ImageHost.Width = imageWidth;
row.ImageHost.Height = imageHeight;
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(16 * scale, 8, 22);
row.ImageHost.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#E6E6E6"));
row.TitleTextBlock.MaxWidth = availableTextWidth;
@@ -704,6 +704,24 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10);
ApplyNightModeVisual();
var headerHeight = refreshHeight;
var newsItemHeight = Math.Max(imageHeight, mainNewsMinHeight);
var requiredHeight = verticalPadding * 2
+ headerHeight
+ rowSpacing
+ newsItemHeight
+ rowSpacing
+ newsItemHeight;
if (_extraNewsRows.Count > 0)
{
var extraSpacing = ExtraNewsItemsPanel.Spacing * (_extraNewsRows.Count - 1);
requiredHeight += rowSpacing + extraSpacing + _extraNewsRows.Count * newsItemHeight;
}
this.MinHeight = requiredHeight;
}
private void UpdateRefreshButtonState()
@@ -842,6 +860,11 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
oldBitmap?.Dispose();
_newsBitmaps[index] = bitmap;
imageControl.Source = bitmap;
if (bitmap != null)
{
InvalidateMeasure();
}
}
private void DisposeNewsBitmaps()

View File

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

View File

@@ -39,8 +39,7 @@ public sealed class DesktopComponentRuntimeRegistration
_ => controlFactory(),
cornerRadiusResolver is null
? null
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) *
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
: chromeContext => cornerRadiusResolver(chromeContext.CellSize))
{
}
@@ -55,8 +54,7 @@ public sealed class DesktopComponentRuntimeRegistration
controlFactory,
cornerRadiusResolver is null
? null
: chromeContext => cornerRadiusResolver(chromeContext.CellSize) *
Math.Max(GlobalAppearanceSettings.MinimumCornerRadiusScale, chromeContext.GlobalCornerRadiusScale))
: chromeContext => cornerRadiusResolver(chromeContext.CellSize))
{
}
@@ -131,7 +129,6 @@ public sealed class DesktopComponentRuntimeDescriptor
Definition.Id,
placementId,
cellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
appearanceSnapshot.CornerRadiusTokens);
var control = _controlFactory(new DesktopComponentControlFactoryContext(
Definition,
@@ -226,8 +223,7 @@ public sealed class DesktopComponentRuntimeDescriptor
Definition.Id,
null,
Math.Max(1, cellSize),
1d,
AppearanceCornerRadiusTokenFactory.Create(1d)));
AppearanceCornerRadiusTokenFactory.Create(GlobalAppearanceSettings.DefaultCornerRadiusStyle)));
}
private static void ApplySettingsDependencies(
@@ -456,6 +452,10 @@ public sealed class DesktopComponentRuntimeRegistry
BuiltInComponentIds.DesktopBlackboardLandscape,
"component.blackboard_landscape",
() => new WhiteboardWidget(baseWidthCells: 4)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopStickyNote,
"component.sticky_note",
() => new StickyNoteWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBrowser,
"component.browser",
@@ -479,7 +479,15 @@ public sealed class DesktopComponentRuntimeRegistry
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopFileManager,
"component.file_manager",
() => new FileManagerWidget())
() => new FileManagerWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopNotificationBox,
"component.notification_box",
() => new NotificationBoxWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopShortcut,
"component.shortcut",
() => new ShortcutWidget())
];
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@@ -730,7 +730,7 @@ public partial class IfengNewsWidget : UserControl, IDesktopComponentWidget, IRe
_imageHost.Width = imageWidth;
_imageHost.Height = imageHeight;
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(imageHeight * 0.15, 8, 16);
_imageHost.CornerRadius = ComponentChromeCornerRadiusHelper.ScaleRadius(imageHeight * 0.15, 8, 16);
var textWidth = Math.Max(84, innerWidth - imageWidth - columnGap);
_titleTextBlock.MaxWidth = textWidth;

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="96"
d:DesignHeight="96"
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid RowDefinitions="*,Auto"
x:Name="ContentGrid">
<Border x:Name="IconHost"
Grid.Row="0"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Panel>
<Image x:Name="IconImage"
Stretch="Uniform"
IsVisible="False" />
<ContentControl x:Name="SymbolIconHost"
IsVisible="False" />
</Panel>
</Border>
<TextBlock x:Name="NameTextBlock"
Grid.Row="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="2"
TextWrapping="Wrap"
Margin="4,0,4,4"
FontSize="11"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,396 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IComponentSettingsContextAware, IDisposable
{
private string _componentId = BuiltInComponentIds.DesktopShortcut;
private string _placementId = string.Empty;
private string? _targetPath;
private string _clickMode = "Double";
private bool _showBackground = true;
private double _currentCellSize = 48;
private bool _isDisposed;
private const double TapMovementThreshold = 10;
private const long TapTimeThresholdMs = 500;
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
private record PointerGestureState(
Point StartPosition,
long StartTime
);
public ShortcutWidget()
{
InitializeComponent();
DoubleTapped += OnDoubleTapped;
UpdateDisplay();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopShortcut
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
}
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
{
var snapshot = context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
ApplySettings(snapshot);
}
public void ApplySettings(ComponentSettingsSnapshot snapshot)
{
_targetPath = snapshot.ShortcutTargetPath;
_clickMode = string.Equals(snapshot.ShortcutClickMode, "Single", StringComparison.OrdinalIgnoreCase)
? "Single"
: "Double";
_showBackground = snapshot.ShortcutShowBackground;
UpdateDisplay();
ApplyChrome();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = cellSize;
// 图标大小:从 cellSize 的 50% 计算,最小 24px最大 128px
var iconSize = Math.Clamp(cellSize * 0.5, 24, 128);
IconImage.Width = iconSize;
IconImage.Height = iconSize;
// 字体大小:从 cellSize 的 18% 计算,最小 10px最大 24px
var fontSize = Math.Clamp(cellSize * 0.18, 10, 24);
NameTextBlock.FontSize = fontSize;
// 更新符号图标的大小(如果当前显示的是符号图标)
if (SymbolIconHost.Content is SymbolIcon symbolIcon)
{
symbolIcon.FontSize = iconSize;
}
}
private void UpdateDisplay()
{
if (string.IsNullOrWhiteSpace(_targetPath))
{
ShowEmptyState();
return;
}
try
{
var name = GetDisplayName(_targetPath);
NameTextBlock.Text = name;
// 文字颜色由 XAML 中的 DynamicResource 自动适配主题
LoadIcon(_targetPath);
}
catch
{
ShowEmptyState();
}
}
private void ShowEmptyState()
{
NameTextBlock.Text = "添加快捷方式";
// 使用次要文字颜色(由主题自动适配)
NameTextBlock.Foreground = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
var iconBrush = this.FindResource("AdaptiveTextSecondaryBrush") as IBrush;
// 隐藏图片图标,显示符号图标
IconImage.IsVisible = false;
IconImage.Source = null;
// 计算图标大小
var iconSize = Math.Clamp(_currentCellSize * 0.5, 24, 128);
var iconHostContent = new SymbolIcon
{
Symbol = FluentIcons.Common.Symbol.Add,
FontSize = iconSize,
Foreground = iconBrush,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
SymbolIconHost.Content = iconHostContent;
SymbolIconHost.IsVisible = true;
}
private static string GetDisplayName(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return "快捷方式";
}
try
{
if (Directory.Exists(path))
{
return Path.GetFileName(path.TrimEnd('\\', '/'));
}
var fileName = Path.GetFileNameWithoutExtension(path);
return string.IsNullOrWhiteSpace(fileName) ? path : fileName;
}
catch
{
return path;
}
}
private void LoadIcon(string path)
{
byte[]? pngBytes = null;
try
{
if (OperatingSystem.IsWindows())
{
if (Directory.Exists(path))
{
pngBytes = WindowsIconService.TryGetSystemFolderIconPngBytes();
}
else if (File.Exists(path))
{
pngBytes = WindowsIconService.TryGetIconPngBytes(path);
}
}
else if (OperatingSystem.IsLinux())
{
if (Directory.Exists(path))
{
pngBytes = LinuxIconService.TryGetSystemFolderIconPngBytes();
}
else if (File.Exists(path))
{
pngBytes = LinuxIconService.TryGetIconPngBytes(path);
}
}
else if (OperatingSystem.IsMacOS())
{
if (Directory.Exists(path))
{
pngBytes = MacIconService.TryGetSystemFolderIconPngBytes();
}
else if (File.Exists(path))
{
pngBytes = MacIconService.TryGetIconPngBytes(path);
}
}
}
catch
{
pngBytes = null;
}
if (pngBytes is not null)
{
try
{
using var stream = new MemoryStream(pngBytes);
IconImage.Source = new Bitmap(stream);
IconImage.IsVisible = true;
SymbolIconHost.IsVisible = false;
return;
}
catch
{
}
}
LoadFallbackIcon(path);
}
private void LoadFallbackIcon(string path)
{
var symbol = Directory.Exists(path)
? FluentIcons.Common.Symbol.Folder
: FluentIcons.Common.Symbol.Document;
// 使用强调色(由主题自动适配)
var iconBrush = this.FindResource("AdaptiveAccentBrush") as IBrush;
// 隐藏图片图标,显示符号图标
IconImage.IsVisible = false;
IconImage.Source = null;
// 计算图标大小
var iconSize = Math.Clamp(_currentCellSize * 0.5, 24, 128);
var iconHostContent = new SymbolIcon
{
Symbol = symbol,
FontSize = iconSize,
Foreground = iconBrush,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
SymbolIconHost.Content = iconHostContent;
SymbolIconHost.IsVisible = true;
}
private void ApplyChrome()
{
if (!_showBackground)
{
RootBorder.Background = Brushes.Transparent;
RootBorder.BorderBrush = Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(0);
return;
}
// 恢复默认的实心背景样式
RootBorder.Background = this.FindResource("AdaptiveSurfaceRaisedBrush") as IBrush ?? Brushes.Transparent;
RootBorder.BorderBrush = this.FindResource("AdaptiveButtonBorderBrush") as IBrush ?? Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(1);
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (string.IsNullOrWhiteSpace(_targetPath))
{
return;
}
var pointer = e.GetCurrentPoint(this);
var pointerId = e.Pointer.Id;
var position = pointer.Position;
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_gestureStates[pointerId] = new PointerGestureState(position, timestamp);
e.Pointer.Capture(this);
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var pointerId = e.Pointer.Id;
if (!_gestureStates.TryGetValue(pointerId, out var state))
{
return;
}
var currentPoint = e.GetCurrentPoint(this);
var distance = Math.Sqrt(
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
);
if (distance > TapMovementThreshold)
{
_gestureStates.Remove(pointerId);
e.Pointer.Capture(null);
}
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
var pointerId = e.Pointer.Id;
if (!_gestureStates.Remove(pointerId, out var state))
{
return;
}
e.Pointer.Capture(null);
var currentPoint = e.GetCurrentPoint(this);
var distance = Math.Sqrt(
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
);
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
if (distance > TapMovementThreshold || elapsed > TapTimeThresholdMs)
{
return;
}
if (_clickMode == "Single")
{
OpenTarget();
}
}
private void OnDoubleTapped(object? sender, TappedEventArgs e)
{
if (string.IsNullOrWhiteSpace(_targetPath))
{
return;
}
if (_clickMode == "Double")
{
OpenTarget();
}
}
private void OpenTarget()
{
if (string.IsNullOrWhiteSpace(_targetPath))
{
return;
}
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Process.Start(new ProcessStartInfo(_targetPath)
{
UseShellExecute = true
});
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Process.Start("xdg-open", _targetPath);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", _targetPath);
}
}
catch (Exception ex)
{
AppLogger.Warn("ShortcutWidget", $"Failed to open target: {_targetPath}", ex);
}
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
_gestureStates.Clear();
}
}

View File

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

View File

@@ -0,0 +1,51 @@
<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"
xmlns:md="clr-namespace:Markdown.Avalonia;assembly=Markdown.Avalonia"
mc:Ignorable="d"
d:DesignWidth="200"
d:DesignHeight="200"
x:Class="LanMountainDesktop.Views.Components.StickyNoteWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
BorderBrush="#E0C878"
BorderThickness="1">
<Grid>
<md:MarkdownScrollViewer x:Name="MarkdownViewer"
Margin="14,14,14,10"
IsVisible="True" />
<TextBox x:Name="NoteTextBox"
AcceptsReturn="True"
TextWrapping="Wrap"
IsVisible="False"
Background="Transparent"
BorderThickness="0"
Margin="14,14,14,10"
FontFamily="Consolas,Cascadia Code,Courier New,monospace"
Foreground="#5D4E37" />
<Button x:Name="ToggleButton"
Width="28"
Height="28"
CornerRadius="14"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="4,4,4,0"
Padding="0"
Background="#00000010"
BorderThickness="0"
Click="OnToggleButtonClick">
<fi:SymbolIcon x:Name="ToggleIcon"
Symbol="Edit"
IconVariant="Regular"
FontSize="13"
Foreground="#8B7D5A" />
</Button>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,371 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentIcons.Common;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Views.Components;
public partial class StickyNoteWidget : UserControl,
IDesktopComponentWidget,
IComponentPlacementContextAware,
IComponentSettingsContextAware,
IDesktopPageVisibilityAwareComponentWidget,
IDisposable
{
private static readonly Color LightNoteYellow = Color.FromRgb(0xFF, 0xF9, 0xC4);
private static readonly Color LightNoteBorder = Color.FromRgb(0xE0, 0xC8, 0x78);
private static readonly Color LightNoteForeground = Color.FromRgb(0x5D, 0x4E, 0x37);
private static readonly Color LightNoteHint = Color.FromRgb(0x8B, 0x7D, 0x5A);
private static readonly Color DarkNoteYellow = Color.FromRgb(0x5D, 0x52, 0x29);
private static readonly Color DarkNoteBorder = Color.FromRgb(0x7A, 0x6D, 0x3A);
private static readonly Color DarkNoteForeground = Color.FromRgb(0xE8, 0xE0, 0xC8);
private static readonly Color DarkNoteHint = Color.FromRgb(0xA0, 0x96, 0x70);
private string _componentId = BuiltInComponentIds.DesktopStickyNote;
private string _placementId = string.Empty;
private IComponentSettingsAccessor? _settingsAccessor;
private string _markdownContent = string.Empty;
private bool _isEditing;
private bool _isDirty;
private bool _isOnActivePage = true;
private bool _isEditMode;
private bool _disposed;
private bool _isApplyingPersistedContent;
private readonly DispatcherTimer _autoSaveTimer = new()
{
Interval = TimeSpan.FromSeconds(30)
};
private CancellationTokenSource? _renderDebounceCts;
public StickyNoteWidget()
{
InitializeComponent();
_autoSaveTimer.Tick += OnAutoSaveTimerTick;
NoteTextBox.TextChanged += OnNoteTextBoxTextChanged;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
ApplyNoteColors();
UpdateDisplay();
}
public void ApplyCellSize(double cellSize)
{
var scale = Math.Clamp(cellSize / 48d, 0.82, 2.2);
RootBorder.CornerRadius = new CornerRadius(
ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadiusValue(
new ComponentChromeContext(
_componentId,
_placementId,
Math.Max(1, cellSize),
Appearance.AppearanceCornerRadiusTokenFactory.Create(
Settings.Core.GlobalAppearanceSettings.DefaultCornerRadiusStyle))));
RootBorder.Padding = new Thickness(
Math.Clamp(2 * scale, 1, 4),
Math.Clamp(2 * scale, 1, 4));
var contentMargin = Math.Clamp(12 * scale, 6, 20);
MarkdownViewer.Margin = new Thickness(contentMargin, contentMargin, contentMargin, contentMargin - 2);
NoteTextBox.Margin = new Thickness(contentMargin, contentMargin, contentMargin, contentMargin - 2);
NoteTextBox.FontSize = Math.Clamp(13 * scale, 10, 22);
var buttonSize = Math.Clamp(28 * scale, 22, 40);
ToggleButton.Width = buttonSize;
ToggleButton.Height = buttonSize;
ToggleButton.CornerRadius = new CornerRadius(buttonSize / 2d);
ToggleButton.Margin = new Thickness(Math.Clamp(4 * scale, 2, 8), Math.Clamp(4 * scale, 2, 8), Math.Clamp(4 * scale, 2, 8), 0);
ToggleIcon.FontSize = Math.Clamp(13 * scale, 10, 18);
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
if (_isDirty && !string.IsNullOrWhiteSpace(_placementId))
{
PersistNoteImmediately();
}
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopStickyNote
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
if (_isEditing)
{
ExitEditMode();
}
LoadPersistedContent();
}
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
{
_settingsAccessor = context.ComponentSettingsAccessor;
LoadPersistedContent();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_isOnActivePage = isOnActivePage;
_isEditMode = isEditMode;
ToggleButton.IsHitTestVisible = !isEditMode;
NoteTextBox.IsReadOnly = isEditMode;
if (isEditMode && _isEditing)
{
ExitEditMode();
}
}
private void OnToggleButtonClick(object? sender, RoutedEventArgs e)
{
if (_isEditing)
{
ExitEditMode();
}
else
{
EnterEditMode();
}
}
private void EnterEditMode()
{
_isEditing = true;
NoteTextBox.Text = _markdownContent;
MarkdownViewer.IsVisible = false;
NoteTextBox.IsVisible = true;
ToggleIcon.Symbol = Symbol.Checkmark;
Dispatcher.UIThread.Post(() => NoteTextBox.Focus(), DispatcherPriority.Input);
}
private void ExitEditMode()
{
_isEditing = false;
var editedContent = NoteTextBox.Text ?? string.Empty;
if (editedContent != _markdownContent)
{
_markdownContent = editedContent;
_isDirty = true;
}
NoteTextBox.IsVisible = false;
MarkdownViewer.IsVisible = true;
ToggleIcon.Symbol = Symbol.Edit;
UpdateDisplay();
if (_isDirty)
{
PersistNoteImmediately();
}
}
private void OnNoteTextBoxTextChanged(object? sender, TextChangedEventArgs e)
{
if (_isApplyingPersistedContent || !_isEditing)
{
return;
}
_isDirty = true;
if (!_autoSaveTimer.IsEnabled)
{
_autoSaveTimer.Start();
}
}
private void OnAutoSaveTimerTick(object? sender, EventArgs e)
{
_autoSaveTimer.Stop();
if (_isDirty && _isEditing)
{
_markdownContent = NoteTextBox.Text ?? string.Empty;
PersistNoteImmediately();
}
}
private void UpdateDisplay()
{
try
{
if (string.IsNullOrWhiteSpace(_markdownContent))
{
MarkdownViewer.Markdown = "*Click ✏️ to write a note...*";
return;
}
_renderDebounceCts?.Cancel();
_renderDebounceCts?.Dispose();
_renderDebounceCts = new CancellationTokenSource();
var token = _renderDebounceCts.Token;
Dispatcher.UIThread.Post(async () =>
{
try
{
await Task.Delay(150, token);
if (!token.IsCancellationRequested)
{
MarkdownViewer.Markdown = _markdownContent;
}
}
catch (OperationCanceledException) { }
});
}
catch (Exception ex)
{
MarkdownViewer.Markdown = $"*Error: {ex.Message}*";
}
}
private void LoadPersistedContent()
{
if (_settingsAccessor is null)
{
return;
}
try
{
var snapshot = _settingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
_isApplyingPersistedContent = true;
_markdownContent = snapshot.StickyNoteContent ?? string.Empty;
_isDirty = false;
UpdateDisplay();
}
catch
{
_markdownContent = string.Empty;
UpdateDisplay();
}
finally
{
_isApplyingPersistedContent = false;
}
}
private void PersistNoteImmediately()
{
if (_settingsAccessor is null || _disposed)
{
return;
}
try
{
var snapshot = _settingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
snapshot.StickyNoteContent = _markdownContent;
_settingsAccessor.SaveSnapshot(snapshot,
[nameof(ComponentSettingsSnapshot.StickyNoteContent)]);
_isDirty = false;
}
catch
{
}
}
private void ApplyNoteColors()
{
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
if (isDark)
{
RootBorder.Background = new SolidColorBrush(DarkNoteYellow);
RootBorder.BorderBrush = new SolidColorBrush(DarkNoteBorder);
NoteTextBox.Foreground = new SolidColorBrush(DarkNoteForeground);
ToggleIcon.Foreground = new SolidColorBrush(DarkNoteHint);
}
else
{
RootBorder.Background = new SolidColorBrush(LightNoteYellow);
RootBorder.BorderBrush = new SolidColorBrush(LightNoteBorder);
NoteTextBox.Foreground = new SolidColorBrush(LightNoteForeground);
ToggleIcon.Foreground = new SolidColorBrush(LightNoteHint);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
Application.Current!.ActualThemeVariantChanged += OnThemeVariantChanged;
ApplyNoteColors();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
Application.Current!.ActualThemeVariantChanged -= OnThemeVariantChanged;
if (_isDirty)
{
if (_isEditing)
{
_markdownContent = NoteTextBox.Text ?? string.Empty;
}
PersistNoteImmediately();
}
_autoSaveTimer.Stop();
}
private void OnThemeVariantChanged(object? sender, EventArgs e)
{
Dispatcher.UIThread.Post(ApplyNoteColors);
}
public void ForceSave()
{
if (_isEditing)
{
_markdownContent = NoteTextBox.Text ?? string.Empty;
}
if (_isDirty || _isEditing)
{
PersistNoteImmediately();
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_autoSaveTimer.Stop();
_renderDebounceCts?.Cancel();
_renderDebounceCts?.Dispose();
if (_isDirty)
{
if (_isEditing)
{
_markdownContent = NoteTextBox.Text ?? string.Empty;
}
PersistNoteImmediately();
}
}
}

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied;
private SKColor _selectedInkColor = SKColors.Black;
private bool _isUserCustomColor;
private float _selectedInkThickness = 2.5f;
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
private string _placementId = string.Empty;
@@ -167,6 +168,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return;
}
var wasNightMode = _isNightModeApplied;
_isNightModeApplied = isNightMode;
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
@@ -175,9 +177,39 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
ApplyThemeDefaultInkColor(isNightMode, wasNightMode);
RefreshToolButtonVisuals();
}
private void ApplyThemeDefaultInkColor(bool isNightMode, bool? wasNightMode)
{
if (_isUserCustomColor || wasNightMode == isNightMode)
{
return;
}
var oldDefault = wasNightMode == true ? SKColors.White : SKColors.Black;
var newDefault = isNightMode ? SKColors.White : SKColors.Black;
if (_selectedInkColor == oldDefault)
{
_selectedInkColor = newDefault;
if (_toolMode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
}
if (InkColorPicker is not null)
{
InkColorPicker.Color = new Color(
_selectedInkColor.Alpha,
_selectedInkColor.Red,
_selectedInkColor.Green,
_selectedInkColor.Blue);
}
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
@@ -429,7 +461,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
{
var color = e.NewColor;
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
var skColor = new SKColor(color.R, color.G, color.B, color.A);
_isUserCustomColor = skColor != SKColors.Black && skColor != SKColors.White;
SetInkColor(skColor);
}
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)

View File

@@ -1,160 +1,227 @@
<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:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
xmlns:converters="using:Avalonia.Data.Converters"
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryControl"
x:DataType="vm:ComponentLibraryWindowViewModel">
<Grid ColumnDefinitions="240,*"
ColumnSpacing="12"
<UserControl.Styles>
<!-- 分类列表项样式 - 遵循 Fluent NavigationView 风格 -->
<Style Selector="ListBoxItem.category-item">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}"/>
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00"/>
</Transitions>
</Setter>
</Style>
<Style Selector="ListBoxItem.category-item:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}"/>
</Style>
<!-- 分类项图标和文字 -->
<Style Selector="ListBoxItem.category-item fi|FluentIcon.category-icon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</Style>
<Style Selector="ListBoxItem.category-item:selected fi|FluentIcon.category-icon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}"/>
</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 AdaptiveTextPrimaryBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="0"
Margin="0">
<!-- 分类列表 (左侧) -->
<Border Classes="surface-translucent-panel"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="10">
<Grid RowDefinitions="Auto,*">
<TextBox x:Name="SearchBox"
Watermark="搜索组件..."
Margin="0,0,0,12"
Classes="clear"
Background="{DynamicResource AdaptiveSurfaceLowBrush}"
CornerRadius="12"
Padding="12,8">
<TextBox.InnerLeftContent>
<fi:SymbolIcon Symbol="Search" FontSize="16" Margin="10,0,0,0" Opacity="0.5" />
</TextBox.InnerLeftContent>
</TextBox>
<!-- 左侧导航列 - 分类列表 + 底部"查找更多组件" -->
<Border Width="280"
Background="Transparent">
<Grid RowDefinitions="*,Auto">
<!-- 分类列表 -->
<ListBox x:Name="CategoryListBox"
Grid.Row="1"
Grid.Row="0"
Background="Transparent"
BorderThickness="0"
Margin="8,8,4,0"
SelectionChanged="OnCategorySelectionChanged"
ItemsSource="{Binding Categories}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
<Border Padding="10"
Margin="0,0,0,6"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveNavItemBackgroundBrush}">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<fi:SymbolIcon Symbol="{Binding Icon}"
IconVariant="Regular"
FontSize="16" />
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding Title}" />
</Grid>
</Border>
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="12"
Margin="12,10">
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Regular"
FontSize="18"
Classes="category-icon"/>
<TextBlock Grid.Column="1"
VerticalAlignment="Center"
FontSize="14"
Classes="category-text"
Text="{Binding Title}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- 底部"查找更多组件" - 在左侧导航列底部 -->
<StackPanel Grid.Row="1"
Margin="12,8,8,12">
<Border Height="1"
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.4"
Margin="0,0,0,8"/>
<Button Classes="hyperlink"
HorizontalAlignment="Left"
Click="OnFindMoreComponentsClick">
<StackPanel Orientation="Horizontal" Spacing="6">
<fi:FluentIcon Icon="Globe" IconVariant="Regular" FontSize="14"/>
<TextBlock Text="查找更多组件"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- 组件网格 (右侧) -->
<!-- 右侧内容区与左侧的分隔线 -->
<Border Grid.Column="1"
Classes="surface-translucent-strong"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="10">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl x:Name="ComponentItemsControl"
ItemsSource="{Binding Components}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
Width="1"
HorizontalAlignment="Left"
Background="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Opacity="0.5"/>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ComponentLibraryItemViewModel">
<Border Width="240"
Height="220"
Margin="6"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="10"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1">
<Grid RowDefinitions="*,Auto,Auto"
RowSpacing="8">
<!-- 预览区域 -->
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
BorderThickness="1"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
Padding="8">
<Grid>
<Image Source="{Binding PreviewBitmap}"
Stretch="Uniform"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
RenderOptions.BitmapInterpolationMode="HighQuality"
IsVisible="{Binding IsPreviewReady}" />
<!-- 组件预览区 (右侧) -->
<ScrollViewer Grid.Column="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="16,8,12,8"
Spacing="0">
<!-- 加载中状态 -->
<Border IsVisible="{Binding IsPreviewPending}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<ProgressBar Width="96"
IsIndeterminate="True" />
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="12"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding PreviewStatusText}" />
</StackPanel>
</Border>
<!-- 有选中组件时的显示 -->
<Panel IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNotNull}}">
<!-- 失败状态 -->
<Border IsVisible="{Binding IsPreviewFailed}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding PreviewStatusText}" />
<TextBlock HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding PreviewErrorMessage}" />
</StackPanel>
</Border>
</Grid>
<!-- 组件展示面板 - 有独立背景色,与窗口背景形成层级分界 -->
<Border Classes="surface-translucent-panel"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="20">
<StackPanel Spacing="16">
<!-- 组件标题 -->
<TextBlock FontSize="28"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding SelectedComponent.DisplayName}"/>
<!-- 固定大小的预览卡片 -->
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
Width="420"
Height="300"
HorizontalAlignment="Center">
<Grid Margin="16">
<!-- 预览图片 -->
<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>
<!-- 组件名称 -->
<TextBlock Grid.Row="1"
HorizontalAlignment="Center"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Text="{Binding DisplayName}" />
<!-- 添加按钮 -->
<Button Grid.Row="2"
HorizontalAlignment="Center"
Padding="12,6"
Tag="{Binding ComponentId}"
Click="OnAddComponentClick">
<TextBlock Text="添加到桌面" />
</Button>
<!-- 失败状态 -->
<Border IsVisible="{Binding SelectedComponent.IsPreviewFailed}"
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<fi:FluentIcon Icon="ImageOff"
IconVariant="Regular"
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>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- "添加小组件"按钮 - 在面板内居中,使用主题强调色 -->
<Button HorizontalAlignment="Center"
Classes="accent"
Padding="24,10"
Tag="{Binding SelectedComponent.ComponentId}"
Click="OnAddComponentClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="Add" IconVariant="Regular" FontSize="16"/>
<TextBlock Text="添加小组件" FontWeight="SemiBold"/>
</StackPanel>
</Button>
</StackPanel>
</Border>
</Panel>
<!-- 空状态 -->
<Grid IsVisible="{Binding SelectedComponent, Converter={x:Static converters:ObjectConverters.IsNull}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MinHeight="400">
<StackPanel Spacing="16" HorizontalAlignment="Center"
VerticalAlignment="Center">
<fi:FluentIcon Icon="Apps"
IconVariant="Regular"
FontSize="64"
Opacity="0.3"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"/>
<TextBlock HorizontalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="请从左侧选择一个组件"/>
</StackPanel>
</Grid>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View File

@@ -4,6 +4,7 @@ using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using FluentIcons.Common;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
@@ -29,6 +30,8 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private static readonly LocalizationService _localizationService = new();
public FusedDesktopComponentLibraryControl()
{
InitializeComponent();
@@ -39,7 +42,9 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
LoadRegistry();
LoadCategories();
SearchBox.KeyUp += (s, e) => FilterComponents();
// 为 ListBoxItem 添加 category-item 样式类
CategoryListBox.ContainerPrepared += OnCategoryListBoxContainerPrepared;
// 默认选择第一个分类
if (_viewModel.Categories.Count > 0)
@@ -48,6 +53,14 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
}
}
private void OnCategoryListBoxContainerPrepared(object? sender, ContainerPreparedEventArgs e)
{
if (e.Container is ListBoxItem listBoxItem)
{
listBoxItem.Classes.Add("category-item");
}
}
private void LoadRegistry()
{
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
@@ -65,28 +78,16 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private void LoadCategories()
{
_viewModel.Categories.Clear();
_viewModel.Components.Clear();
var languageCode = _settingsFacade.Region.Get().LanguageCode;
// 添加"全部组件"分类
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
"all",
"全部组件",
L(languageCode, "component_category.all", "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()
@@ -94,23 +95,62 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
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();
var icon = ResolveCategoryIcon(cat);
var title = GetLocalizedCategoryTitle(languageCode, cat);
_viewModel.Categories.Add(new ComponentLibraryCategoryViewModel(
cat,
info.Display,
info.Icon,
categoryComponents));
}
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,
title,
icon,
categoryComponents));
}
}
/// <summary>
/// 分类图标映射 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
/// </summary>
private static Symbol ResolveCategoryIcon(string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return Symbol.Clock;
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) return Symbol.CalendarDate;
if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return Symbol.WeatherSunny;
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return Symbol.Edit;
if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return Symbol.Play;
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return Symbol.Apps;
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return Symbol.Calculator;
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return Symbol.Hourglass;
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return Symbol.Folder;
return Symbol.Apps;
}
/// <summary>
/// 分类本地化标题 - 与阑山桌面 Dock 栏组件库 (MainWindow.ComponentSystem) 保持一致
/// </summary>
private string GetLocalizedCategoryTitle(string languageCode, string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.clock", "Clock");
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.date", "Calendar");
if (string.Equals(categoryId, "Weather", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.weather", "Weather");
if (string.Equals(categoryId, "Board", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.board", "Board");
if (string.Equals(categoryId, "Media", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.media", "Media");
if (string.Equals(categoryId, "Info", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.info", "Info");
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.calculator", "Calculator");
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.study", "Study");
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase)) return L(languageCode, "component_category.file", "File");
return categoryId;
}
private string L(string languageCode, string key, string fallback)
{
return _localizationService.GetString(languageCode, key, fallback);
}
private ComponentLibraryItemViewModel CreateComponentItem(DesktopComponentDefinition definition)
{
var previewKey = ComponentPreviewKey.ForComponentType(
@@ -130,10 +170,11 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
definition.Id,
definition.DisplayName,
previewKey,
description: null,
"正在加载预览...",
"预览不可用",
previewEntry);
if (mainWindow is not null && (previewEntry is null || previewEntry.State == ComponentPreviewImageState.Pending))
{
mainWindow.RequestDetachedLibraryPreview(previewKey);
@@ -158,25 +199,49 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private void OnCategorySelectionChanged(object? sender, SelectionChangedEventArgs e)
{
FilterComponents();
UpdateSelectedComponent();
}
private void FilterComponents()
private void UpdateSelectedComponent()
{
var selectedCategory = (CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel)?.Id;
var searchText = SearchBox.Text?.ToLower() ?? "";
var filtered = _allDefinitions.Where(d =>
var selectedCategory = CategoryListBox.SelectedItem as ComponentLibraryCategoryViewModel;
if (selectedCategory is null)
{
var matchesCategory = selectedCategory == "all" || string.Equals(d.Category, selectedCategory, StringComparison.OrdinalIgnoreCase);
var matchesSearch = string.IsNullOrEmpty(searchText) || d.DisplayName.ToLower().Contains(searchText) || d.Id.ToLower().Contains(searchText);
return matchesCategory && matchesSearch;
});
_viewModel.SelectedComponent = null;
return;
}
_viewModel.Components.Clear();
foreach (var def in filtered)
// 获取该分类下的组件列表
IEnumerable<DesktopComponentDefinition> filtered;
if (selectedCategory.Id == "all")
{
_viewModel.Components.Add(CreateComponentItem(def));
filtered = _allDefinitions.OrderBy(d => d.DisplayName);
}
else
{
filtered = _allDefinitions
.Where(d => string.Equals(d.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
.OrderBy(d => d.DisplayName);
}
// 选择该分类下的第一个组件作为默认选中
var firstComponent = filtered.FirstOrDefault();
if (firstComponent is not null)
{
// 查找或创建对应的 ViewModel
var existingComponent = selectedCategory.Components.FirstOrDefault(c => c.ComponentId == firstComponent.Id);
if (existingComponent is not null)
{
_viewModel.SelectedComponent = existingComponent;
}
else
{
_viewModel.SelectedComponent = CreateComponentItem(firstComponent);
}
}
else
{
_viewModel.SelectedComponent = null;
}
}
@@ -187,4 +252,22 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
AddComponentRequested?.Invoke(this, componentId);
}
}
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
{
// 打开设置窗口并导航到插件目录页面
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);
}
// 关闭所在窗口
var window = this.FindAncestorOfType<Window>();
window?.Close();
}
}

View File

@@ -1,57 +1,73 @@
<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"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.FusedDesktopComponentLibraryWindow"
Width="860" Height="620"
MinWidth="600" MinHeight="500"
CanResize="True"
WindowStartupLocation="CenterScreen"
SystemDecorations="Full"
SystemDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="NoChrome"
ExtendClientAreaTitleBarHeightHint="-1"
ExtendClientAreaTitleBarHeightHint="48"
Background="Transparent"
TransparencyLevelHint="Mica"
Title="融合桌面组件库">
<Panel>
<!-- 背景磨砂效果 -->
<Border Background="{DynamicResource AdaptiveSurfaceLowBrush}"
Opacity="0.85" />
<Grid RowDefinitions="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"
Classes="accent"
Width="36" Height="36"
Padding="0"
CornerRadius="18"
BorderThickness="0"
Background="{DynamicResource AdaptiveButtonHoverBackgroundBrush}"
Click="OnCloseClick">
<fi:SymbolIcon Symbol="Dismiss" FontSize="18" />
</Button>
</Grid>
</Border>
<!-- 组件库控件 -->
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
Grid.Row="1" />
</Grid>
</Panel>
Title="添加小组件">
<Grid x:Name="RootGrid"
Classes="settings-scope"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
RowDefinitions="Auto,*">
<!-- 自定义标题栏 - 与 SettingsWindow 风格一致 -->
<Border x:Name="WindowTitleBarHost"
Height="48"
Padding="12,0,12,0"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveSettingsWindowBorderBrush}"
BorderThickness="0,0,0,1"
PointerPressed="OnWindowTitleBarPointerPressed">
<Grid ColumnDefinitions="Auto,Auto,*,Auto"
ColumnSpacing="8"
VerticalAlignment="Center">
<fi:FluentIcon x:Name="WindowBrandIcon"
Icon="Apps"
IconVariant="Filled"
FontSize="16"
IsHitTestVisible="False"
VerticalAlignment="Center" />
<TextBlock x:Name="WindowTitleTextBlock"
Grid.Column="1"
FontSize="12"
FontWeight="SemiBold"
IsHitTestVisible="False"
Text="添加小组件" />
<TextBlock Grid.Column="2"
FontSize="12"
Opacity="0.6"
IsHitTestVisible="False"
VerticalAlignment="Center"
Text="将精美组件放置在您的系统桌面上(负一屏)" />
<Button x:Name="CloseWindowButton"
Grid.Column="3"
Width="40"
Height="32"
Padding="0"
Background="Transparent"
BorderThickness="0"
Click="OnCloseClick">
<fi:FluentIcon Icon="Dismiss"
IconVariant="Regular"
FontSize="16" />
</Button>
</Grid>
</Border>
<!-- 组件库控件 -->
<controls:FusedDesktopComponentLibraryControl x:Name="LibraryControl"
Grid.Row="1"
Margin="12,8,16,8" />
</Grid>
</Window>

View File

@@ -1,6 +1,7 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
@@ -102,6 +103,14 @@ public partial class FusedDesktopComponentLibraryWindow : Window
{
Close();
}
private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
BeginMoveDrag(e);
}
}
protected override void OnClosed(EventArgs e)
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,14 @@
#define MyAppArch "x64"
#endif
#ifndef MyAppSuffix
#define MyAppSuffix ""
#endif
#ifndef IsSelfContained
#define IsSelfContained "true"
#endif
[Setup]
AppId={#MyAppId}
AppName={#MyAppName}
@@ -34,7 +42,7 @@ LanguageDetectionMethod=uilanguage
DefaultGroupName={cm:AppShortcutName}
UninstallDisplayIcon={app}\{#MyAppExeName}
OutputDir={#MyOutputDir}
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}{#MyAppSuffix}
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
@@ -93,7 +101,17 @@ chinesesimplified.UpgradeCleanupMissingUninstaller=安装程序发现了现有
english.UpgradeCleanupFailedPrefix=Setup could not remove the existing installation automatically. Error code:
chinesesimplified.UpgradeCleanupFailedPrefix=安装程序无法自动移除现有安装。错误代码:
english.UpgradeCleanupFailedSuffix=Please close LanMountainDesktop, uninstall the current version manually, and then run this installer again.
chinesesimplified.UpgradeCleanupFailedSuffix=请关闭 LanMountainDesktop手动卸载当前版本然后重新运行此安装程序。
chinesesimplified.UpgradeCleanupFailedSuffix=请关闭 LanMountain Desktop手动卸载当前版本然后重新运行此安装程序。
english.DotNetRuntimeMissingTitle=.NET Desktop Runtime Required
chinesesimplified.DotNetRuntimeMissingTitle=需要 .NET Desktop Runtime
english.DotNetRuntimeMissingMessage=This application requires .NET 10.0 Desktop Runtime to run.
chinesesimplified.DotNetRuntimeMissingMessage=此应用程序需要 .NET 10.0 Desktop Runtime 才能运行。
english.DotNetRuntimeMissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again.
chinesesimplified.DotNetRuntimeMissingAction=单击"是"打开官方下载页面。请先完成安装,然后重新运行此安装程序。
english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automatically.
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
chinesesimplified.DotNetRuntimeOpenFailedAction=请手动打开以下链接:
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
@@ -127,6 +145,7 @@ const
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
DotNetRuntimeDownloadUrl = 'https://dotnet.microsoft.com/download/dotnet/10.0';
UpgradeChoiceInPlace = 0;
UpgradeChoiceRelocate = 1;
@@ -435,10 +454,110 @@ begin
RegQueryStringValue(HKCU32, WebView2RuntimeKeyPath, 'pv', VersionValue);
end;
// Checks whether a .NET 10.x shared framework is installed under the given
// base path by enumerating version sub-directories and looking for one that
// starts with '10.'.
function IsDotNet10RuntimePresent(const BasePath: String): Boolean;
var
FindRec: TFindRec;
begin
Result := False;
if not DirExists(BasePath) then
begin
exit;
end;
if FindFirst(BasePath + '\*', FindRec) then
begin
try
repeat
if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) and
(Length(FindRec.Name) >= 3) and
(Copy(FindRec.Name, 1, 3) = '10.') then
begin
Result := True;
exit;
end;
until not FindNext(FindRec);
finally
FindClose(FindRec);
end;
end;
end;
// Returns True when the .NET 10 Desktop Runtime (or the .NET 10 Core Runtime
// which is sufficient for Avalonia apps) is found on the system.
// We check both Microsoft.WindowsDesktop.App and Microsoft.NETCore.App because
// the runtimeconfig.json may reference either framework depending on the
// publish mode and the app only needs the one it actually references.
function IsDotNetDesktopRuntimeInstalled(): Boolean;
var
BasePath: String;
begin
Result := False;
// Check 64-bit Program Files
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
if IsDotNet10RuntimePresent(BasePath) then
begin
Result := True;
exit;
end;
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.NETCore.App');
if IsDotNet10RuntimePresent(BasePath) then
begin
Result := True;
exit;
end;
// Check 32-bit Program Files
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
if IsDotNet10RuntimePresent(BasePath) then
begin
Result := True;
exit;
end;
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.NETCore.App');
if IsDotNet10RuntimePresent(BasePath) then
begin
Result := True;
exit;
end;
end;
function InitializeSetup(): Boolean;
var
ErrorCode: Integer;
IsSelfContainedBuild: Boolean;
begin
IsSelfContainedBuild := ('{#IsSelfContained}' = 'true');
if not IsSelfContainedBuild then
begin
if not IsDotNetDesktopRuntimeInstalled() then
begin
if MsgBox(
CustomMessage('DotNetRuntimeMissingMessage') + #13#10#13#10 +
CustomMessage('DotNetRuntimeMissingAction'),
mbConfirmation,
MB_YESNO) = IDYES then
begin
if not ShellExec('open', DotNetRuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
begin
MsgBox(
CustomMessage('DotNetRuntimeOpenFailedMessage') + #13#10 +
CustomMessage('DotNetRuntimeOpenFailedAction') + #13#10 + DotNetRuntimeDownloadUrl,
mbError,
MB_OK);
end;
end;
Result := False;
exit;
end;
end;
if IsWebView2RuntimeInstalled() then
begin
Result := True;

View File

@@ -339,8 +339,7 @@ public sealed class PluginLoader
private static PluginAppearanceSnapshot BuildAppearanceSnapshot(IServiceProvider? hostServices)
{
var defaultSnapshot = new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: 1d,
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 18),
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
ThemeVariant: "Unknown");
if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService)
@@ -352,7 +351,6 @@ public sealed class PluginLoader
{
var hostSnapshot = appearanceThemeService.GetCurrent();
return new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: Math.Max(0d, hostSnapshot.GlobalCornerRadiusScale),
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(hostSnapshot.CornerRadiusTokens),
ThemeVariant: hostSnapshot.IsNightMode ? "Dark" : "Light");
}
@@ -850,6 +848,8 @@ public sealed class PluginLoader
private sealed class PluginRuntimeContext : IPluginRuntimeContext
{
private readonly PluginAppearanceContext _appearanceContext;
public PluginRuntimeContext(
PluginManifest manifest,
string pluginDirectory,
@@ -861,7 +861,8 @@ public sealed class PluginLoader
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Properties = properties;
Appearance = new PluginAppearanceContext(appearanceSnapshot);
_appearanceContext = new PluginAppearanceContext(appearanceSnapshot);
Appearance = _appearanceContext;
Services = NullServiceProvider.Instance;
}
@@ -900,6 +901,14 @@ public sealed class PluginLoader
{
Services = services ?? throw new ArgumentNullException(nameof(services));
}
/// <summary>
/// 更新外观快照并通知插件。
/// </summary>
internal void UpdateAppearanceSnapshot(PluginAppearanceSnapshot newSnapshot, IReadOnlyCollection<AppearanceProperty> changedProperties)
{
_appearanceContext.UpdateSnapshot(newSnapshot, changedProperties);
}
}
private sealed class PluginMessageBus : IPluginMessageBus, IDisposable

View File

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

683
design.md Normal file
View File

@@ -0,0 +1,683 @@
# UI Design System Guide (design.md)
> **目标**: 让 AI 正确使用 Fluent Avalonia / Fluent Icons / Material Avalonia避免窗口套窗口、容器套容器
>
> **最后更新**: 2026-04-11
---
## 一句话总结
**主界面用 Fluent + FluentIcon编辑器用 Material + MaterialIcon永远不要混用保持扁平结构。**
---
## 1. 技术栈与职责
### 1.1 库清单
| 库 | 包名 | 什么时候用 |
|---|------|----------|
| **FluentAvaloniaUI** | `FluentAvaloniaUI` | 主界面、设置页、导航 |
| **FluentIcons.Avalonia.Fluent** | `FluentIcons.Avalonia.Fluent` | 主界面图标(**首选**|
| **FluentIcons.Avalonia** | `FluentIcons.Avalonia` | 旧图标兼容SymbolIcon|
| **Material.Icons.Avalonia** | `Material.Icons.Avalonia` | 编辑器图标(**仅限 ComponentEditorWindow**|
| **Material.Avalonia** | `Material.Avalonia` | MD3 主题(**仅限 ComponentEditorWindow**|
### 1.2 初始化顺序App.axaml
```xml
<Application.Styles>
<sty:FluentAvaloniaTheme /> <!-- 第 1 位:基础 Win11 风格 -->
<mi:MaterialIconStyles /> <!-- 第 2 位:全局注册 Material Icons 样式 -->
<!-- 项目自定义样式 -->
<StyleInclude Source="avares://LanMountainDesktop/Styles/FlutermotionToken.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/NavigationStyles.axaml" />
</Application.Styles>
```
---
## 2. 命名空间速查表
**复制粘贴用:**
```xml
<!-- 必需 -->
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
<!-- FluentAvaloniaUI 控件(主界面/设置页必需)-->
xmlns:ui="using:FluentAvalonia.UI.Controls"
<!-- Fluent Icons - 新版推荐(主界面首选)-->
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
<!-- Fluent Icons - 旧版兼容(已有代码维护用)-->
<!-- xmlns:fi-legacy="using:FluentIcons.Avalonia" -->
<!-- Material Icons仅 ComponentEditorWindow 使用)-->
<!-- xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" -->
<!-- Material Theme仅 ComponentEditorWindow 使用)-->
<!-- xmlns:themes="clr-namespace:Material.Styles.Themes;assembly=Material.Styles" -->
<!-- 项目控件 -->
xmlns:local="using:LanMountainDesktop.Controls"
xmlns:comp="using:LanMountainDesktop.Views.Components"
```
---
## 3. 图标系统
### 3.1 选择决策树
```
你在写什么?
├─ 设置页面 / 主界面 / 桌面组件?
│ └─ 用 FluentIconfi:FluentIcon
│ ├─ 需要 Filled/Regular 切换?→ IconVariant="Filled" 或 "Regular"
│ └─ 简单静态图标?→ 也用 FluentIcon不用 SymbolIcon
├─ ComponentEditorWindow 及其子页面?
│ └─ 用 MaterialIconmi:MaterialIcon
└─ 其他情况?
└─ 默认 FluentIcon
```
### 3.2 FluentIcon 使用方法
```xml
<fi:FluentIcon Icon="Settings" <!-- -->
IconVariant="Filled" <!-- Filled | Regular -->
Classes="icon-m" /> <!-- icon-s(12px) | icon-m(16px) | icon-l(20px) -->
```
**常用图标名称:**
- 导航类:`Home`, `Settings`, `Navigation`, `ArrowLeft`, `ChevronRight`, `Dismiss`
- 操作类:`Add`, `Delete`, `Edit`, `Save`, `Refresh`, `Sync`, `ArrowSync`
- 状态类:`Info`, `Warning`, `ErrorBadge`, `CheckmarkCircle`
- 外观类:`ThemeLightDark`, `ColorBackground`, `Appearance`
### 3.3 MaterialIcon 使用方法(仅限编辑器)
```xml
<mi:MaterialIcon Kind="Close" <!-- -->
Width="24"
Height="24"
Foreground="{DynamicResource EditorPrimaryBrush}" />
```
**常用 Kind 值:**
- 操作:`Close`, `Check`, `Pencil`, `Delete`, `Settings`, `Plus`
- 导航:`ArrowLeft`, `ArrowRight`, `Home`, `Menu`
- 系统:`Power`, `Lock`, `ExitToApp`, `Refresh`, `WeatherNight`
### 3.4 ❌ 禁止事项
```xml
<!-- ❌ 错误:同一区域混用两种图标库 -->
<StackPanel>
<fi:FluentIcon Icon="Home" /> <!-- Fluent -->
<mi:MaterialIcon Kind="Settings" /> <!-- Material -->
</StackPanel>
<!-- ❌ 错误:硬编码尺寸 -->
<fi:FluentIcon Icon="Settings" FontSize="18" />
<!-- ✅ 正确:使用预定义 class -->
<fi:FluentIcon Icon="Settings" Classes="icon-m" />
```
---
## 4. 容器嵌套规范(核心!)
### 4.1 最大深度限制
| 场景 | 最大层数 | 从哪里开始数 |
|-----|---------|------------|
| 普通页面 | **≤ 4 层** | Window/UserControl → ... → 叶子节点 |
| Popup/Dialog | **≤ 3 层** | Border → Content |
| 列表项/DataTemplate | **≤ 3 层** | Root → ... → 元素 |
| MainWindow 桌面布局 | **≤ 6 层** | 特殊允许(多层叠加需求)|
### 4.2 如何数层级?
从根元素到目标元素经过的容器标签数:
```xml
<UserControl> <!-- 层级 0-->
<Border> <!-- 层级 1 -->
<Grid> <!-- 层级 2 -->
<StackPanel> <!-- 层级 3 -->
<Button> <!-- 层级 4叶子节点✅ OK -->
</Button>
</StackPanel>
</Grid>
</Border>
</UserControl>
```
### 4.3 推荐的标准结构
#### 结构 A标准设置页面3-4 层)
```xml
<UserControl>
<ScrollViewer> <!-- 层 1 -->
<StackPanel Spacing="24"> <!-- 层 2 -->
<Border Classes="settings-section-card"> <!-- 层 3 -->
<StackPanel Spacing="16"> <!-- 层 4 ✅ -->
<!-- 内容 -->
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
```
#### 结构 B卡片/面板组件3 层)
```xml
<Border CornerRadius="..." Padding="..."> <!-- 层 1-->
<Grid RowDefinitions="Auto,*"> <!-- 层 2 -->
<Grid ColumnDefinitions="Auto,*,Auto"> <!-- 层 3a: Header -->
<fi:FluentIcon Classes="icon-l" />
<TextBlock Grid.Column="1" />
<ContentPresenter Grid.Column="2" />
</Grid>
<ContentControl Grid.Row="1" /> <!-- 层 3b: Body ✅ -->
</Grid>
</Border>
```
#### 结构 C列表项2-3 层)
```xml
<Border Classes="list-item"> <!-- 层 1 -->
<Grid ColumnDefinitions="Auto,*,Auto"> <!-- 层 2 -->
<Border Classes="icon-host"> <!-- 层 3a -->
<fi:FluentIcon Classes="icon-m" />
</Border>
<StackPanel Grid.Column="1" Spacing="4"> <!-- 层 3b -->
<TextBlock Classes="title" />
<TextBlock Classes="subtitle" />
</StackPanel>
<Button Grid.Column="2">...</Button> <!-- 层 3c ✅ -->
</Grid>
</Border>
```
### 4.4 ❌ 反模式:过度嵌套
```xml
<!-- ❌ 错误7 层嵌套(实际项目中出现的反面教材)-->
<Grid> <!-- 1 -->
<Grid> <!-- 2 -->
<Border> <!-- 3 -->
<Grid> <!-- 4 -->
<Border> <!-- 5 -->
<Grid> <!-- 6 -->
<Border> <!-- 7 ❌ 太深了!-->
<Button Content="Click" />
</Border>
</Grid>
</Border>
</Grid>
</Border>
</Grid>
</Grid>
<!-- ✅ 重构后2 层 -->
<Grid HorizontalAlignment="Center"
VerticalAlignment="Center">
<Button Content="Click"
Padding="16,8" />
</Grid>
```
### 4.5 何时可以超过限制?
只有这 3 种情况:
1. **MainWindow 桌面布局**(壁纸层 + 组件层 + 拖拽层 + 任务栏)
2. **需要独立动画层**Transform/Opacity 动画需要单独容器)
3. **复杂 Popup 内部**
**必须加注释说明原因:**
```xml
<!--
允许深层嵌套原因:桌面渲染需要支持以下视觉层级
- DesktopWallpaperLayer壁纸背景
- DesktopPagesContainer桌面分页
- LauncherPagePanel启动器面板
- Canvas 拖拽层
- BottomTaskbarContainer任务栏
-->
<Grid x:Name="DesktopHost">
...
</Grid>
```
---
## 5. 窗口 vs UserControl vs Border
### 5.1 什么时候用什么?
| 需求 | 用什么 | 示例 |
|-----|-------|------|
| 独立窗口(有标题栏、可拖动、任务栏可见)| **Window** | SettingsWindow, ComponentEditorWindow |
| 可复用的 UI 组件块 | **UserControl** | SettingsOptionCard, ClockWidget |
| 视觉上的卡片/面板/容器 | **Border** | 设置分区卡片、弹出面板 |
### 5.2 ❌ 禁止:窗口套窗口
```csharp
// ❌ 错误:在 XAML 中实例化另一个 Window
// <local:SettingsWindow Visibility="Visible" />
// ✅ 正确:通过代码显示独立窗口
var settings = new SettingsWindow { Owner = this };
settings.Show();
```
### 5.3 ❌ 禁止:把 Window 当 UserControl 用
```xml
<!-- ❌ 错误MainWindow 内部嵌入了一个本应是独立窗口的东西 -->
<Window x:Class="MainWindow">
<Grid>
<Border x:Name="ComponentLibraryWindow" <!-- Window -->
Width="620"
Height="320"
CornerRadius="36">
<!-- 组件库内容 -->
</Border>
</Grid>
</Window>
```
**如果它不是真正的操作系统窗口,就用 Border 或 UserControl。**
---
## 6. 颜色与资源使用规范
### 6.1 必须使用 DynamicResource
```xml
<!-- ❌ 错误:硬编码颜色 -->
<TextBlock Foreground="#FF1D1B20" />
<Border Background="#FFF3EDF7" />
<Button Background="#FF6750A4" />
<!-- ✅ 正确:使用动态资源 -->
<TextBlock Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
<Button Background="{DynamicResource AccentBrush}" />
```
### 6.2 常用资源键速查
| 资源键 | 用途 | 示例场景 |
|--------|------|---------|
| `AdaptiveTextPrimaryBrush` | 主要文本 | 标题、正文 |
| `AdaptiveTextSecondaryBrush` | 次要文本 | 描述文字、提示 |
| `AdaptiveSurfaceRaisedBrush` | 抬高表面 | 卡片背景、面板 |
| `AdaptiveSurfaceOverlayBrush` | 覆盖层 | 遮罩、弹窗背景 |
| `AccentBrush` | 强调色 | 主按钮、选中态 |
| `AppFontFamily` | 应用字体 | 全局字体设置 |
### 6.3 圆角 Token强制
```xml
<!-- ❌ 错误:硬编码圆角 -->
<Border CornerRadius="8" />
<Button CornerRadius="12" />
<!-- ❌ 错误:桌面组件根容器用了非组件级 Token -->
<Border CornerRadius="{DynamicResource DesignCornerRadiusMd}" />
<!-- ✅ 正确 -->
<!-- 桌面组件Widget根容器必须且只能用这个 -->
<Border CornerRadius="{DynamicResource DesignCornerRadiusComponent}" />
<!-- 内部元素按层级选择 -->
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}" /> <!---->
<Border CornerRadius="{DynamicResource DesignCornerRadiusMd}" /> <!---->
<Border CornerRadius="{DynamicResource DesignCornerRadiusLg}" /> <!---->
```
---
## 7. FluentAvaloniaUI 控件用法
### 7.1 NavigationView设置页导航
```xml
<ui:NavigationView x:Name="RootNav"
PaneDisplayMode="Auto" <!-- -->
OpenPaneLength="283" <!-- Win11 标准宽度 -->
IsSettingsVisible="False" <!-- 隐藏默认设置按钮 -->
IsBackButtonVisible="False"
SelectionChanged="OnNavChanged">
<ui:NavigationView.Resources>
<!-- 移除默认背景色 -->
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewContentGridBorderBrush" Color="Transparent" />
</ui:NavigationView.Resources>
<Grid Margin="12,0,16,16">
<ui:Frame x:Name="ContentFrame" />
</Grid>
</ui:NavigationView>
```
### 7.2 Frame页面导航容器
```xml
<ui:Frame x:Name="ContentFrame" />
```
```csharp
// C# 代码中导航
ContentFrame.Navigate(typeof(SettingsHomePage));
// 或绑定
ContentFrame.SourcePageType = typeof(SettingsHomePage);
```
### 7.3 InfoBar内联通知条
```xml
<ui:InfoBar Title="发现新版本"
Message="v2.0.0 可用"
Severity="Informational" <!-- Informational | Warning | Error | Success -->
IsOpen="True"
ActionButtonText="立即更新"
ActionButtonClick="OnUpdateClick"
CloseButtonClick="OnDismissClick" />
```
### 7.4 ContentDialog模态对话框
```csharp
var dialog = new ContentDialog
{
Title = "确认删除",
Content = "确定要删除吗?此操作不可撤销。",
PrimaryButtonText = "删除",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Primary
};
var result = await dialog.ShowAsync(this);
if (result == ContentDialogResult.Primary)
{
}
```
---
## 8. Material.Avalonia 使用规范(严格限制!)
### 8.1 ⚠️ 只能在这里用
**✅ 允许:**
- `ComponentEditorWindow.axaml`
- ComponentEditorWindow 的子编辑页面
**❌ 禁止:**
- MainWindow
- SettingsWindow
- NotificationWindow
- 任何桌面组件Widget
- 任何其他地方
### 8.2 如何在 ComponentEditorWindow 中启用
```xml
<Window ...>
<Window.Resources>
<!-- MD3 色板(仅此窗口有效)-->
<SolidColorBrush x:Key="EditorPrimaryBrush" Color="#FF6750A4" />
<SolidColorBrush x:Key="EditorSurfaceContainerBrush" Color="#FFF3EDF7" />
<SolidColorBrush x:Key="EditorOnPrimaryBrush" Color="#FFFFFFFF" />
</Window.Resources>
<Window.Styles>
<!-- 加载 Material 主题(只影响此窗口)-->
<themes:CustomMaterialTheme BaseTheme="Light"
PrimaryColor="#6750A4"
SecondaryColor="#625B71" />
<!-- 项目自定义覆盖 -->
<StyleInclude Source="avares://LanMountainDesktop/Styles/ComponentEditorThemeResources.axaml" />
</Window.Styles>
</Window>
```
### 8.3 MD3 组件示例
#### FAB 按钮(浮动操作按钮)
```xml
<Button x:Name="SaveFAB"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="28"
Width="64" Height="64"
Background="{DynamicResource EditorPrimaryBrush}"
Foreground="{DynamicResource EditorOnPrimaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Click="OnSaveClick">
<mi:MaterialIcon Kind="Check" Width="32" Height="32" />
</Button>
```
#### Top App Bar顶栏
```xml
<Border Background="{DynamicResource EditorTopAppBarBackgroundBrush}"
Padding="24,16">
<Grid ColumnDefinitions="Auto,*,Auto">
<mi:MaterialIcon Kind="Widgets"
Width="28" Height="28"
Foreground="{DynamicResource EditorPrimaryBrush}" />
<TextBlock Grid.Column="1"
FontSize="20" FontWeight="SemiBold"
Text="组件编辑器"
Margin="16,0,0,0" />
<Button Grid.Column="2" Click="OnCloseClick">
<mi:MaterialIcon Kind="Close" Width="24" Height="24" />
</Button>
</Grid>
</Border>
```
---
## 9. 实战代码模板
### 模板 1新建设置页面
文件位置:`Views/Settings/YourPageName.axaml`
```xml
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
xmlns:local="using:LanMountainDesktop.Controls"
x:Class="LanMountainDesktop.Views.Settings.YourPageName">
<ScrollViewer Padding="24,0,24,24">
<StackPanel Spacing="24">
<!-- 分区卡片 -->
<Border Classes="settings-section-card">
<StackPanel Spacing="16">
<!-- 分区标题 -->
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<fi:FluentIcon Icon="YourSectionIcon"
IconVariant="Filled"
Classes="icon-l" />
<TextBlock Grid.Column="1"
Classes="settings-section-title"
Text="分区标题" />
</Grid>
<!-- 选项卡 1 -->
<local:SettingsOptionCard Icon="OptionIcon1"
Title="选项标题"
Title="选项描述">
<local:SettingsOptionCard.ActionContent>
<ToggleSwitch IsChecked="{Binding YourProperty, Mode=TwoWay}" />
</local:SettingsOptionCard.ActionContent>
</local:SettingsOptionCard>
<!-- 选项卡 2 -->
<local:SettingsOptionCard Icon="OptionIcon2"
Title="另一个选项"
Title="描述信息" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
```
**嵌套检查:** ScrollViewer(1) > StackPanel(2) > Border(3) > StackPanel(4) > Items(5) ✅ (因为有 ScrollViewer 容器5 层可接受)
### 模板 2新建桌面小组件
文件位置:`Views/Components/YourWidget.axaml`
```xml
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Components.YourWidget">
<Border Classes="desktop-widget-root"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Padding="16,12">
<Grid RowDefinitions="Auto,*" RowSpacing="8">
<!-- 标题区 -->
<TextBlock x:Name="TitleTextBlock"
FontSize="14"
FontWeight="SemiBold" />
<!-- 内容区 -->
<ContentControl Grid.Row="1" />
</Grid>
</Border>
</UserControl>
```
**嵌套检查:** Border(1) > Grid(2) > Elements(3) ✅ 完美!
### 模板 3新建独立窗口
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.YourWindow"
Width="800"
Height="600"
MinWidth="400"
MinHeight="300"
CanResize="True"
SystemDecorations="BorderOnly"
Background="Transparent"
Title="窗口标题">
<Grid RowDefinitions="Auto,*">
<!-- 自定义标题栏 -->
<Border Height="48"
Padding="12,0"
PointerPressed="OnTitleBarPressed">
<Grid ColumnDefinitions="Auto,*,Auto">
<fi:FluentIcon Icon="WindowIcon"
IconVariant="Filled"
Classes="icon-m" />
<TextBlock Grid.Column="1"
Text="{Binding Title}"
VerticalAlignment="Center" />
<Button Grid.Column="2"
Click="OnCloseClick">
<fi:FluentIcon Icon="Dismiss" IconVariant="Regular" />
</Button>
</Grid>
</Border>
<!-- 主内容区 -->
<ContentControl Grid.Row="1"
Content="{Binding Content}" />
</Grid>
</Window>
```
**嵌套检查:** Grid(1) > Border(2) > Grid(3) > Elements(4) ✅
---
## 10. AI 编码检查清单
### 写代码前问自己
- [ ] 这个文件是设置页/主界面?→ 用 Fluent + FluentIcon
- [ ] 这个文件是 ComponentEditorWindow→ 用 Material + MaterialIcon
- [ ] 我用了正确的命名空间吗?(见第 2 节速查表)
- [ ] 图标用了 Classes="icon-s/m/l" 而非硬编码 FontSize 吗?
### 写完代码后检查
- [ ] 数一下最大嵌套深度(见第 4.2 节)
- [ ] 有没有硬编码颜色值?(应该都用 DynamicResource
- [ ] 有没有硬编码 CornerRadius应该用 DesignCornerRadiusXxx
- [ ] 有没有在同一区域混用 FluentIcon 和 MaterialIcon
- [ ] 是不是不小心写了 `<local:SomeWindow>` 在另一个 Window 里?
- [ ] 是不是连续写了 Border > Grid > Border > Grid 可以合并?
### 如果审查别人代码
- [ ] 发现窗口套窗口了吗?
- [ ] 发现超过 4 层的无意义嵌套了吗?(没有注释说明原因的话)
- [ ] 发现 Fluent 和 Material 控件混在同一区域了吗?
- [ ] 发现应该用 DynamicResource 的地方硬编码了吗?
---
## 附录:常见错误快速修复
| 错误现象 | 问题原因 | 修复方法 |
|---------|---------|---------|
| 图标不显示或大小不对 | 用了错误的命名空间或硬编码尺寸 | 改用 `fi:FluentIcon` + `Classes="icon-m"` |
| 圆角在设置里改了但没生效 | 硬编码了 CornerRadius | 改用 `{DynamicResource DesignCornerRadiusComponent}` |
| 深色模式下颜色刺眼 | 硬编码了颜色值 | 改用 `{DynamicResource AdaptiveTextPrimaryBrush}` 等 |
| 设置页风格和其他窗口不一致 | 混用了 Material 控件 | 统一用 FluentAvaloniaUI 控件 |
| 性能差/渲染慢 | 嵌套太深(>6 层)| 扁平化结构,合并多余容器 |
| 弹窗显示位置/大小异常 | 把 Window 当成 UserControl 嵌套了 | 改为代码中 `.Show()` 显示 |
---
**相关文档:**
- [VISUAL_SPEC.md](./VISUAL_SPEC.md) - 视觉规范总纲
- [CORNER_RADIUS_SPEC.md](./CORNER_RADIUS_SPEC.md) - 圆角详细规范
- AGENTS.md - AI 强制规则

View File

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

62
docs/TYPOGRAPHY_SPEC.md Normal file
View File

@@ -0,0 +1,62 @@
# 字体排版设计规范 (Typography Specification)
## 中文
本规范用于统一阑山桌面各组件Widget及页面的字体样式解决目前组件间字体不协调、厚度不一的问题。通过引入标准化的设计 Token确保在不同 DPI 和设备上呈现一致的高级感Premium Look
### 1. 字体家族 (Font Family)
- **默认字体**:优先使用内置的 `MiSans VF` (Variable Font)。
- **回退顺序**`MiSans VF` -> `MiSans` -> `Microsoft YaHei` -> `Sans-serif`
### 2. 字重标准 (Font Weights)
为了达到“不粗不细”的协调感,我们采用 `Medium (500)` 作为默认正文字重,以应对复杂的背景环境。
| 角色 | Token | MiSans 权重 | 说明 |
| --- | --- | --- | --- |
| **Caption/Secondary** | `DesignFontWeightCaption` | `Normal (400)` | 用于不重要的补充说明信息 |
| **Body (Default)** | `DesignFontWeightBody` | `Medium (500)` | **核心全局字重**,用于所有常规正文 |
| **Title/Header** | `DesignFontWeightTitle` | `SemiBold (600)` | 用于卡片标题、分类标题 |
| **Display (Large)** | `DesignFontWeightDisplay` | `SemiBold (600)` | 用于超大号文本(如温度数字) |
> **注意**:除非极特殊艺术需求,应避免使用 `Thin`, `ExtraLight`, `Light` 或 `Bold (700)`, `Heavy`。
### 3. 字号标准 (Font Sizes)
| 角色 | Token | 数值 (px) | 典型应用场景 |
| --- | --- | --- | --- |
| **Caption** | `DesignFontSizeCaption` | 12 | 底部说明、状态提示 |
| **BodySmall** | `DesignFontSizeBodySmall` | 13 | 设置项描述、次要标签 |
| **Body** | `DesignFontSizeBody` | 14 | 标准文本、正文内容 |
| **BodyLarge** | `DesignFontSizeBodyLarge` | 16 | 加大正文、菜单项 |
| **Subtitle** | `DesignFontSizeSubtitle` | 18 | 小节标题、大按钮文字 |
| **Title** | `DesignFontSizeTitle` | 24 | 组件标题、大卡片标题 |
| **Headline** | `DesignFontSizeHeadline` | 32 | 重要数据指标 |
| **Display** | `DesignFontSizeDisplay` | 48 | 天气温度、时间分钟 |
| **DisplayLarge** | `DesignFontSizeDisplayLarge` | 54 | 诗词正文、欢迎语 |
### 4. 行高标准 (Line Heights)
统一行高可以增强视觉节奏感。
| Token | 数值 (倍率) | 应用场景 |
| --- | --- | --- |
| `DesignLineHeightStandard` | 1.2 | 单行标签、紧凑卡片 |
| `DesignLineHeightLoose` | 1.5 | 多行诗词、新闻摘要、说明文档 |
### 5. 使用规范
1. **禁止硬编码**:严禁在 `.axaml` 中直接写入 `FontSize="18"``FontWeight="Bold"`
2. **动态资源绑定**:始终使用 `{DynamicResource DesignFontSize...}` 进行绑定。
3. **全局样式继承**`App.axaml` 已经设置了 `TextBlock` 的默认 `FontWeight``Medium`,除非是 `Caption``Title`,否则无需重复声明。
---
## English (Summary)
- **Default Font**: MiSans VF.
- **Base Weight**: `Medium (500)` for better readability on glass/dark backgrounds.
- **Header Weight**: `SemiBold (600)` for a modern premium feel.
- **Line Height**: Standardized to 1.2x and 1.5x.
- **Tokens**: All components must use `DesignFontSize...` and `DesignFontWeight...` resource keys.

View File

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