mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 15:44:25 +08:00
feat.数字时钟,白板功能修复
This commit is contained in:
291
.comate/specs/standby-digital-clock/doc.md
Normal file
291
.comate/specs/standby-digital-clock/doc.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# StandBy Digital Clock - iPhone 待机风格大数字时钟组件
|
||||
|
||||
## 1. 需求场景与处理逻辑
|
||||
|
||||
### 1.1 需求描述
|
||||
新增一个 4×2 尺寸的数字时钟桌面组件,视觉风格参考 iPhone 横屏充电时的 StandBy 待机显示——大面积、粗体、圆润的数字显示当前时间(HH:MM),数字采用不规则的自由排版(有微妙的垂直偏移,不在一条直线上),颜色使用 Monet 主题色而非纯黑/白,伴随数字切换时的流畅垂直滚动/翻转动画,下方显示日期信息。
|
||||
|
||||
### 1.2 用户体验目标
|
||||
- 大字号、圆润粗体的数字时间,远距离一目了然
|
||||
- 数字采用不规则自由排版(微妙垂直偏移),营造 iPhone StandBy 那种有机、散漫的视觉节奏
|
||||
- 数字使用 Monet 主题色(跟随壁纸/用户选色的强调色),而非死板的纯黑/白
|
||||
- 数字变化时执行垂直滑动动画(旧数字向上滑出,新数字从下方滑入),类似翻页时钟效果
|
||||
- 冒号(:)有呼吸闪烁效果
|
||||
- 支持夜间/日间模式自动切换
|
||||
- 点击组件可打开世界时钟 AirApp
|
||||
- 支持时区配置(与现有桌面时钟共享设置体系)
|
||||
|
||||
### 1.3 处理逻辑
|
||||
1. 组件加载时读取时区设置和秒针模式设置
|
||||
2. `DispatcherTimer` 每秒触发一次更新
|
||||
3. 当检测到分钟数变化时,触发分钟数字的垂直滑动动画
|
||||
4. 当检测到小时数变化时,触发小时数字的垂直滑动动画
|
||||
5. 冒号以 1 秒周期做透明度脉冲动画
|
||||
6. 每 tick 检查是否需要切换日间/夜间视觉模式
|
||||
|
||||
## 2. 架构与技术方案
|
||||
|
||||
### 2.1 组件架构
|
||||
遵循现有桌面组件架构模式:
|
||||
- 继承 `UserControl`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware`
|
||||
- AXAML 定义根布局结构,代码后置处理动画逻辑
|
||||
- 通过 `DesktopComponentDefinition` 注册到组件系统
|
||||
|
||||
### 2.2 数字滚动动画技术方案
|
||||
采用 Avalonia `RenderTransform` + `DoubleTransition` 实现数字滚动:
|
||||
|
||||
**核心思路**:每个数位(共 4 位:H1, H2, M1, M2)使用 `ClipToBounds` 的容器,内含一个垂直排列的 `StackPanel`,包含当前数字和下一个数字。切换时通过 `TranslateTransform.Y` 的 `DoubleTransition` 实现平滑滚动。
|
||||
|
||||
```
|
||||
每位数字的结构:
|
||||
┌─ DigitClip (ClipToBounds=true) ──────────┐
|
||||
│ ┌─ DigitStack (TranslateTransform.Y) ──┐ │
|
||||
│ │ [当前数字 TextBlock] │ │
|
||||
│ │ [新数字 TextBlock] │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
当数字变化时:
|
||||
1. 在 StackPanel 底部添加新数字的 TextBlock
|
||||
2. 将 `TranslateTransform.Y` 从 0 动画过渡到 `-digitHeight`
|
||||
3. 动画完成后移除旧数字,重置 Y 为 0
|
||||
|
||||
### 2.3 动画参数
|
||||
- 使用项目 `FluttermotionToken` 体系:滚动动画时长 `FluttermotionToken.Standard`(200ms)
|
||||
- 缓动函数:`CubicEaseOut`(与项目现有动画风格一致)
|
||||
- 冒号呼吸动画:透明度 1.0 → 0.3 → 1.0,周期 2 秒,使用 `DoubleTransition`
|
||||
|
||||
### 2.4 尺寸与布局
|
||||
- 组件定义:`MinWidthCells = 4, MinHeightCells = 2`
|
||||
- 缩放规则:2:1 比例(与 WorldClock 一致)
|
||||
- 内部布局采用 `Viewbox` 包裹,确保在不同 cellSize 下自适应缩放
|
||||
- 数字字体大小:基准设计为 130px(在 Viewbox 内),实际显示由 Viewbox 缩放
|
||||
|
||||
### 2.5 布局风格——不规则自由排版(iPhone StandBy 风格)
|
||||
iPhone StandBy 的数字不是规矩地排成一条直线,而是有微妙的垂直偏移和大小差异,营造出自由散漫、有机的视觉节奏:
|
||||
|
||||
```
|
||||
H1 H2 : M1 M2
|
||||
↗↘ ↘↗ ↗↘ ↘↗
|
||||
↕+6 ↕+2 : ↕+4 ↕+2
|
||||
↖ -3° ↗ +4° : ↖ -1° ↗ +5°
|
||||
←+6,↑-10 ←-2,↓+10 →+4,↑-3 ←-2,↓+12
|
||||
```
|
||||
|
||||
每个数字有三个自由度:
|
||||
- **垂直偏移 (Y)**:H1=-10, H2=+10, 冒号=+8, M1=-3, M2=+12
|
||||
- **水平偏移 (X)**:H1=+6, H2=-2, 冒号=0, M1=+4, M2=-2
|
||||
- **旋转角度 (Z)**:H1=-4°, H2=+3°, 冒号=-1°, M1=-2°, M2=+5°
|
||||
|
||||
### 2.6 视觉风格——圆润粗体 + Monet 主题色
|
||||
- **字体**:`FontWeight.Bold`,配合较大的字号,视觉上圆润饱满
|
||||
- **颜色**:使用项目 Monet 主题色系统,数字颜色跟随 `AdaptiveAccentBrush` / `SystemAccentColor`,而非纯黑/白
|
||||
- 数字颜色通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
|
||||
- 跟随系统:使用 `AdaptiveAccentBrush`(Monet 提取的强调色)
|
||||
- 原生模式:使用组件自带的特色色彩
|
||||
- 夜间模式:深色渐变背景 + 主题色数字(亮色调)
|
||||
- 日间模式:浅色渐变背景 + 主题色数字(深色调)
|
||||
- 夜间暗光环境:数字过渡到柔和的红色调(`#FF6B4A`),模拟 iPhone StandBy 夜间红色调
|
||||
- **冒号颜色**:与数字同色,但有呼吸动画
|
||||
- **日期行**:使用 `AdaptiveTextMutedBrush`(跟随主题的弱化文字色),字号约 14-16px 基准
|
||||
- **根容器圆角**:`DesignCornerRadiusComponent`(遵循圆角规范)
|
||||
|
||||
## 3. 受影响文件
|
||||
|
||||
### 3.1 新增文件
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | 新增 | 组件 AXAML 布局 |
|
||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 新增 | 组件代码后置(动画逻辑、时间更新、模式切换) |
|
||||
|
||||
### 3.2 修改文件
|
||||
| 文件 | 修改类型 | 受影响函数/区域 |
|
||||
|------|----------|-----------------|
|
||||
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增常量 | 新增 `DesktopStandbyDigitalClock` 常量 |
|
||||
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 新增定义 | `CreateDefault()` 中新增组件定义 |
|
||||
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增运行时注册 | `GetDefaultRegistrations()` 中新增运行时注册项 |
|
||||
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | 新增缩放规则 | `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 |
|
||||
|
||||
## 4. 实现细节
|
||||
|
||||
### 4.1 BuiltInComponentIds 新增常量
|
||||
```csharp
|
||||
public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock";
|
||||
```
|
||||
|
||||
### 4.2 ComponentRegistry 新增定义
|
||||
```csharp
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopStandbyDigitalClock,
|
||||
"StandBy Clock",
|
||||
"Clock",
|
||||
"Clock",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
```
|
||||
|
||||
### 4.3 DesktopComponentRuntimeRegistry 新增注册
|
||||
```csharp
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopStandbyDigitalClock,
|
||||
"component.standby_digital_clock",
|
||||
() => new StandbyDigitalClockWidget()),
|
||||
```
|
||||
|
||||
### 4.4 NormalizeAspectRatioForComponent 缩放规则
|
||||
在 `case BuiltInComponentIds.DesktopWorldClock:` 的同一分支中添加 `BuiltInComponentIds.DesktopStandbyDigitalClock`,使用 2:1 比例规则。
|
||||
|
||||
### 4.5 AXAML 布局结构
|
||||
```xml
|
||||
<UserControl x:Class="LanMountainDesktop.Views.Components.StandbyDigitalClockWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Padding="14">
|
||||
<!-- 背景在代码后置中设置(渐变,与AnalogClockWidget一致) -->
|
||||
<Viewbox Stretch="Uniform">
|
||||
<Grid Width="400" Height="200">
|
||||
<StackPanel VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Orientation="Horizontal">
|
||||
<!-- H1 数位 -->
|
||||
<Border x:Name="H1Clip" ClipToBounds="True" ...>
|
||||
<Panel x:Name="H1Stack" ...>
|
||||
<TextBlock x:Name="H1Text" Text="0" ... />
|
||||
</Panel>
|
||||
</Border>
|
||||
<!-- H2 数位 -->
|
||||
<Border x:Name="H2Clip" ClipToBounds="True" ...>
|
||||
<Panel x:Name="H2Stack" ...>
|
||||
<TextBlock x:Name="H2Text" Text="0" ... />
|
||||
</Panel>
|
||||
</Border>
|
||||
<!-- 冒号 -->
|
||||
<TextBlock x:Name="ColonText" Text=":" ... />
|
||||
<!-- M1 数位 -->
|
||||
<Border x:Name="M1Clip" ClipToBounds="True" ...>
|
||||
<Panel x:Name="M1Stack" ...>
|
||||
<TextBlock x:Name="M1Text" Text="0" ... />
|
||||
</Panel>
|
||||
</Border>
|
||||
<!-- M2 数位 -->
|
||||
<Border x:Name="M2Clip" ClipToBounds="True" ...>
|
||||
<Panel x:Name="M2Stack" ...>
|
||||
<TextBlock x:Name="M2Text" Text="0" ... />
|
||||
</Panel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- 日期行 -->
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Center" ... />
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
</Border>
|
||||
</UserControl>
|
||||
```
|
||||
|
||||
### 4.6 数字滚动动画核心代码(伪代码)
|
||||
```csharp
|
||||
private void AnimateDigit(Border clip, Panel stack, TextBlock currentText, char newDigit, double digitHeight)
|
||||
{
|
||||
var oldText = currentText;
|
||||
var newTextBlock = new TextBlock
|
||||
{
|
||||
Text = newDigit.ToString(),
|
||||
FontSize = oldText.FontSize,
|
||||
FontWeight = oldText.FontWeight,
|
||||
Foreground = oldText.Foreground,
|
||||
Width = oldText.Width,
|
||||
Height = digitHeight,
|
||||
// 复制旧文本的所有样式属性
|
||||
};
|
||||
stack.Children.Add(newTextBlock);
|
||||
|
||||
// 应用 TranslateTransform 过渡动画
|
||||
var transform = new TranslateTransform { Y = 0 };
|
||||
stack.RenderTransform = transform;
|
||||
stack.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition(TranslateTransform.YProperty, FluttermotionToken.Standard, new CubicEaseOut())
|
||||
};
|
||||
|
||||
// 触发动画:从当前位置滑到 -digitHeight
|
||||
transform.Y = -digitHeight;
|
||||
|
||||
// 动画完成后清理
|
||||
_ = DispatcherTimer.RunOnce(() =>
|
||||
{
|
||||
stack.Children.Remove(oldText);
|
||||
transform.Y = 0;
|
||||
stack.Transitions = null; // 移除过渡,避免重置时再次动画
|
||||
// 更新引用
|
||||
UpdateCurrentTextReference(newTextBlock);
|
||||
}, FluttermotionToken.Standard);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 冒号呼吸动画
|
||||
使用 `DispatcherTimer` 每秒切换冒号透明度:
|
||||
```csharp
|
||||
private void ToggleColonOpacity()
|
||||
{
|
||||
_colonVisible = !_colonVisible;
|
||||
ColonText.Opacity = _colonVisible ? 1.0 : 0.3;
|
||||
}
|
||||
```
|
||||
配合 `DoubleTransition` 使透明度变化平滑过渡。
|
||||
|
||||
### 4.8 日间/夜间模式
|
||||
与 `AnalogClockWidget` 使用完全相同的判断逻辑:
|
||||
- 检查 `ActualThemeVariant`
|
||||
- 回退到 `AdaptiveSurfaceBaseBrush` 亮度计算
|
||||
- 夜间模式:深色渐变背景 + 浅色数字
|
||||
- 日间模式:浅色渐变背景 + 深色数字
|
||||
|
||||
### 4.9 时区与设置
|
||||
- 复用 `AnalogClockWidget` 的时区解析和设置加载逻辑
|
||||
- 使用 `ComponentSettingsSnapshot.DesktopClockTimeZoneId` 读取时区配置
|
||||
- 点击打开世界时钟 AirApp
|
||||
|
||||
## 5. 边界条件与异常处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| 组件首次加载时数字尚未初始化 | 在构造函数中初始化所有数字为当前时间,不触发动画 |
|
||||
| 快速连续触发数字变化(如时间同步导致跳变) | 在动画完成前忽略新的变化请求,或中断当前动画立即跳转到目标值 |
|
||||
| cellSize 极小或极大 | `ApplyCellSize` 中 clamp 缩放因子(0.58-1.95,与 AnalogClockWidget 一致) |
|
||||
| 时区切换 | 重新加载设置并更新所有数字(无动画,直接设置) |
|
||||
| 主题切换 | 通过 `ApplyModeVisualIfNeeded()` 在下一个 tick 自动检测并切换 |
|
||||
| 组件被销毁 | `DetachedFromVisualTree` 停止 timer,清理资源 |
|
||||
| 冒号动画在组件不可见时 | timer 仍在运行但 Opacity 变化无性能开销;若需要可结合 `IDesktopPageVisibilityAwareComponentWidget` |
|
||||
|
||||
## 6. 数据流路径
|
||||
|
||||
```
|
||||
DispatcherTimer (1s interval)
|
||||
→ OnTimerTick
|
||||
→ 计算当前时间 (TimeZoneInfo.ConvertTimeFromUtc)
|
||||
→ 比较新旧时间数字
|
||||
→ 若有变化: AnimateDigit() 执行滚动动画
|
||||
→ ToggleColonOpacity() 切换冒号
|
||||
→ ApplyModeVisualIfNeeded() 检查日/夜间切换
|
||||
→ UpdateDateText() 更新日期文本
|
||||
|
||||
用户点击 → OnPointerReleased → AirAppLauncherServiceProvider.OpenWorldClock()
|
||||
|
||||
时区变更 → TimeZoneChanged event → RefreshFromSettings() → 无动画更新所有数字
|
||||
```
|
||||
|
||||
## 7. 预期成果
|
||||
|
||||
- 在桌面组件选择器中新增 "StandBy Clock" 组件,位于 Clock 分类
|
||||
- 拖放到桌面后显示 4×2 大数字时钟
|
||||
- 数字切换时有流畅的垂直滑动动画
|
||||
- 冒号有呼吸闪烁效果
|
||||
- 支持日间/夜间自动切换
|
||||
- 支持时区配置
|
||||
- 支持组件缩放(2:1 比例规则)
|
||||
52
.comate/specs/standby-digital-clock/summary.md
Normal file
52
.comate/specs/standby-digital-clock/summary.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# StandBy Digital Clock 实现总结
|
||||
|
||||
## 完成状态
|
||||
全部任务已完成,构建通过(0 错误)。
|
||||
|
||||
## 变更清单
|
||||
|
||||
### 新增文件
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | AXAML 布局:不规则自由排版数字 + 冒号 + 日期,Monet 主题色绑定 |
|
||||
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 代码后置:数字滚动动画、冒号呼吸、Monet 主题色、日/夜模式、时区支持 |
|
||||
|
||||
### 修改文件
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增 `DesktopStandbyDigitalClock` 常量 |
|
||||
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 在 `CreateDefault()` 中新增 4×2 Clock 分类组件定义 |
|
||||
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增 `StandbyDigitalClockWidget` 运行时注册 |
|
||||
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | `NormalizeAspectRatioForComponent` 将 StandbyDigitalClock 加入 2:1 缩放规则 |
|
||||
|
||||
## 核心设计要点
|
||||
|
||||
### 不规则自由排版(iPhone StandBy 风格)
|
||||
- 每个数字有独立的垂直 Margin 偏移(H1 上移10, H2 下移8, M1 上移5, M2 下移10)
|
||||
- 冒号比数字中心略低(下移6)
|
||||
- 数字间距不等,营造自由散漫的视觉节奏
|
||||
|
||||
### Monet 主题色
|
||||
- 数字和冒号使用 `AdaptiveAccentBrush` / `SystemAccentColor`,跟随壁纸/用户选色的强调色
|
||||
- 通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
|
||||
- 跟随系统:使用 Monet 提取的强调色
|
||||
- 原生模式:使用暖橙红色(`#E84530` 日间 / `#FF8A65` 夜间),灵感来自 iPhone StandBy
|
||||
- 日期文本使用 `AdaptiveTextMutedBrush`
|
||||
|
||||
### 数字滚动动画
|
||||
- `TranslateTransform.Y` + `DoubleTransition`(200ms CubicEaseOut)
|
||||
- 动画完成后清理旧 TextBlock 并重置 transform
|
||||
|
||||
### 冒号呼吸
|
||||
- 每秒切换 Opacity(1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡
|
||||
|
||||
### 日/夜模式
|
||||
- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算
|
||||
- 夜间:深色渐变背景 + 亮调强调色数字
|
||||
- 日间:浅色渐变背景 + 深调强调色数字
|
||||
|
||||
### 组件规格
|
||||
- 尺寸:4×2 (MinWidthCells=4, MinHeightCells=2)
|
||||
- 分类:Clock
|
||||
- 缩放:2:1 比例 (Proportional)
|
||||
- 字体:FontWeight.Bold, 120px 基准
|
||||
25
.comate/specs/standby-digital-clock/tasks.md
Normal file
25
.comate/specs/standby-digital-clock/tasks.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# StandBy Digital Clock 任务计划
|
||||
|
||||
- [x] Task 1: 注册组件定义与运行时
|
||||
- 1.1: 在 `BuiltInComponentIds.cs` 中新增 `DesktopStandbyDigitalClock` 常量
|
||||
- 1.2: 在 `ComponentRegistry.cs` 的 `CreateDefault()` 中新增 `DesktopComponentDefinition`(4×2, Clock 分类, Proportional)
|
||||
- 1.3: 在 `DesktopComponentRuntimeRegistry.cs` 的 `GetDefaultRegistrations()` 中新增运行时注册项
|
||||
- 1.4: 在 `MainWindow.ComponentSystem.cs` 的 `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则
|
||||
|
||||
- [x] Task 2: 创建 StandbyDigitalClockWidget AXAML 布局
|
||||
- 2.1: 创建 `StandbyDigitalClockWidget.axaml`,定义 RootBorder(DesignCornerRadiusComponent)、Viewbox、时间数字区域(4 个 ClipToBounds 数位容器 + 冒号)、日期文本
|
||||
- 2.2: 确保 Viewbox 内基准设计尺寸为 400×200,数字使用 FontWeight.Bold,冒号和日期布局合理
|
||||
|
||||
- [x] Task 3: 实现组件代码后置(核心逻辑与动画)
|
||||
- 3.1: 创建 `StandbyDigitalClockWidget.axaml.cs`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware` 接口
|
||||
- 3.2: 实现 DispatcherTimer 每秒更新逻辑,比较新旧时间数字,触发数位滚动动画
|
||||
- 3.3: 实现数字垂直滚动动画:每位数字使用 TranslateTransform.Y + DoubleTransition,旧数字上滑出新数字滑入,动画完成后清理
|
||||
- 3.4: 实现冒号呼吸动画:每秒切换透明度,配合 DoubleTransition 平滑过渡
|
||||
- 3.5: 实现日间/夜间模式切换:检测 ActualThemeVariant 和亮度,切换背景渐变和数字颜色;夜间暗光环境过渡到红色调
|
||||
- 3.6: 实现 ApplyCellSize 缩放逻辑,clamp 缩放因子,更新圆角和间距
|
||||
- 3.7: 实现时区设置加载(复用 AnalogClockWidget 逻辑),点击打开世界时钟 AirApp
|
||||
- 3.8: 实现日期文本更新逻辑,显示完整日期和星期
|
||||
|
||||
- [x] Task 4: 构建验证与调试
|
||||
- 4.1: 执行 `dotnet build` 确保编译通过,修复所有错误
|
||||
- 4.2: 检查圆角规范合规性(根容器使用 DesignCornerRadiusComponent)
|
||||
@@ -1,9 +1,59 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sty="using:FluentAvalonia.Styling"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
x:Class="LanMountainDesktop.AirAppHost.AirApp"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<sty:FluentAvaloniaTheme />
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="UserControl">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="FontFeatures" Value="tnum" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="SelectableTextBlock">
|
||||
<Setter Property="FontFeatures" Value="tnum" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ScrollViewer">
|
||||
<Setter Property="ScrollViewer.IsScrollInertiaEnabled" Value="False" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="fi|SymbolIcon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="fi|FluentIcon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="fi|SymbolIcon.icon-s, fi|FluentIcon.icon-s">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="fi|SymbolIcon.icon-m, fi|FluentIcon.icon-m">
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="fi|SymbolIcon.icon-l, fi|FluentIcon.icon-l">
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
<Application.Resources>
|
||||
|
||||
@@ -6,7 +6,8 @@ public sealed record AirAppLaunchOptions(
|
||||
string? SourceComponentId,
|
||||
string? SourcePlacementId,
|
||||
string? LauncherPipeName,
|
||||
string? InstanceKey)
|
||||
string? InstanceKey,
|
||||
string? DataRoot)
|
||||
{
|
||||
public const string WorldClockAppId = "world-clock";
|
||||
public const string WhiteboardAppId = "whiteboard";
|
||||
@@ -28,6 +29,19 @@ public sealed record AirAppLaunchOptions(
|
||||
continue;
|
||||
}
|
||||
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex > 0)
|
||||
{
|
||||
var inlineValue = key[(equalsIndex + 1)..];
|
||||
key = key[..equalsIndex].Trim();
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
values[key] = inlineValue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
values[key] = args[index + 1];
|
||||
@@ -45,7 +59,8 @@ public sealed record AirAppLaunchOptions(
|
||||
GetOptionalValue(values, "source-component-id"),
|
||||
GetOptionalValue(values, "source-placement-id"),
|
||||
GetOptionalValue(values, "launcher-pipe"),
|
||||
GetOptionalValue(values, "instance-key"));
|
||||
GetOptionalValue(values, "instance-key"),
|
||||
GetOptionalValue(values, "data-root"));
|
||||
}
|
||||
|
||||
private static string GetValue(IReadOnlyDictionary<string, string> values, string key, string fallback)
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed partial class AirAppWindow : Window
|
||||
{
|
||||
private readonly AirAppLaunchOptions _options;
|
||||
private readonly AirAppWindowDescriptor _descriptor;
|
||||
private WhiteboardWidget? _whiteboardWidget;
|
||||
private string _instanceKey = string.Empty;
|
||||
|
||||
public AirAppWindow()
|
||||
@@ -117,6 +118,7 @@ public sealed partial class AirAppWindow : Window
|
||||
? 4
|
||||
: 2;
|
||||
var widget = new WhiteboardWidget(baseWidthCells);
|
||||
_whiteboardWidget = widget;
|
||||
widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId);
|
||||
widget.SetSurfaceMode(
|
||||
WhiteboardWidgetSurfaceMode.AirApp,
|
||||
@@ -127,6 +129,9 @@ public sealed partial class AirAppWindow : Window
|
||||
});
|
||||
|
||||
ContentHost.Content = widget;
|
||||
AppLogger.Info(
|
||||
"AirAppWindow",
|
||||
$"Whiteboard content created. ComponentId='{componentId}'; PlacementId='{_options.SourcePlacementId ?? string.Empty}'.");
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
@@ -144,6 +149,7 @@ public sealed partial class AirAppWindow : Window
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
SaveAndDisposeWhiteboard();
|
||||
_ = UnregisterWithLauncherAsync();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
@@ -158,9 +164,45 @@ public sealed partial class AirAppWindow : Window
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
SaveWhiteboard();
|
||||
Close();
|
||||
}
|
||||
|
||||
private void SaveAndDisposeWhiteboard()
|
||||
{
|
||||
var widget = _whiteboardWidget;
|
||||
if (widget is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveWhiteboard();
|
||||
if (ContentHost.Content == widget)
|
||||
{
|
||||
ContentHost.Content = null;
|
||||
}
|
||||
|
||||
widget.Dispose();
|
||||
_whiteboardWidget = null;
|
||||
}
|
||||
|
||||
private void SaveWhiteboard()
|
||||
{
|
||||
if (_whiteboardWidget is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_whiteboardWidget.ForceSaveNote();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AirAppWindow", "Failed to force-save whiteboard before closing Air APP.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RegisterWithLauncherAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.LauncherPipeName))
|
||||
|
||||
@@ -25,7 +25,11 @@ public sealed record AirAppWindowDescriptor(
|
||||
return Standard(
|
||||
"World Clock - Air APP",
|
||||
"World Clock",
|
||||
"Air APP");
|
||||
"Air APP",
|
||||
width: 360,
|
||||
height: 220,
|
||||
minWidth: 320,
|
||||
minHeight: 220);
|
||||
}
|
||||
|
||||
if (string.Equals(options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="FluentAvaloniaUI" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.AirAppHost;
|
||||
|
||||
@@ -7,8 +8,22 @@ internal static class Program
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Initialize();
|
||||
AppDataPathProvider.Initialize(args);
|
||||
RegisterGlobalExceptionLogging();
|
||||
AppLogger.Info("AirAppHost", $"Starting. Args='{string.Join(" ", args)}'.");
|
||||
|
||||
try
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Info("AirAppHost", "Exited normally.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Critical("AirAppHost", "Unhandled startup exception.", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static AppBuilder BuildAvaloniaApp()
|
||||
@@ -18,4 +33,21 @@ internal static class Program
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
|
||||
private static void RegisterGlobalExceptionLogging()
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
AppLogger.Critical(
|
||||
"AirAppHost",
|
||||
"Unhandled AppDomain exception.",
|
||||
e.ExceptionObject as Exception);
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
AppLogger.Error("AirAppHost", "Unobserved task exception.", e.Exception);
|
||||
e.SetObserved();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LanMountainDesktop.AirAppHost.WorldClockAirAppView">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
Margin="24,8,24,24">
|
||||
Margin="18,0,18,16">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="10">
|
||||
Spacing="8">
|
||||
<TextBlock x:Name="TimeTextBlock"
|
||||
Text="00:00:00"
|
||||
FontSize="58"
|
||||
FontSize="42"
|
||||
FontWeight="SemiBold"
|
||||
LetterSpacing="0"
|
||||
Foreground="{DynamicResource AirAppTitleTextBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
Text="0000-00-00"
|
||||
FontSize="17"
|
||||
FontSize="14"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource AirAppSecondaryTextBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="TimeZoneTextBlock"
|
||||
Text="Local Time"
|
||||
FontSize="13"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AirAppSecondaryTextBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -104,6 +104,7 @@ public partial class App : Application
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var requesterPid = context.GetIntOption("requester-pid", 0);
|
||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
||||
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
|
||||
|
||||
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
|
||||
@@ -111,7 +112,8 @@ public partial class App : Application
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null)));
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
|
||||
@@ -280,6 +282,7 @@ public partial class App : Application
|
||||
LauncherResult result;
|
||||
SplashWindow? currentSplashWindow = splashWindow;
|
||||
var appRoot = Commands.ResolveAppRoot(context);
|
||||
var dataLocationResolver = new DataLocationResolver(appRoot);
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
|
||||
@@ -308,7 +311,8 @@ public partial class App : Application
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => appRoot,
|
||||
() => null)));
|
||||
() => null,
|
||||
() => dataLocationResolver.ResolveDataRoot())));
|
||||
airAppIpcHost.Start();
|
||||
|
||||
using var coordinatorServer = new LauncherCoordinatorIpcServer(
|
||||
|
||||
@@ -12,15 +12,18 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
private readonly AirAppHostLocator _locator;
|
||||
private readonly Func<string?> _packageRootProvider;
|
||||
private readonly Func<string?> _hostPathProvider;
|
||||
private readonly Func<string?> _dataRootProvider;
|
||||
|
||||
public AirAppProcessStarter(
|
||||
AirAppHostLocator locator,
|
||||
Func<string?> packageRootProvider,
|
||||
Func<string?> hostPathProvider)
|
||||
Func<string?> hostPathProvider,
|
||||
Func<string?> dataRootProvider)
|
||||
{
|
||||
_locator = locator;
|
||||
_packageRootProvider = packageRootProvider;
|
||||
_hostPathProvider = hostPathProvider;
|
||||
_dataRootProvider = dataRootProvider;
|
||||
}
|
||||
|
||||
public Process? Start(
|
||||
@@ -52,6 +55,11 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
AddArgument(startInfo, "--session-id", sessionId);
|
||||
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
|
||||
var dataRoot = _dataRootProvider();
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
AddArgument(startInfo, "--data-root", Path.GetFullPath(dataRoot));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourceComponentId))
|
||||
{
|
||||
@@ -63,7 +71,27 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||
}
|
||||
|
||||
return Process.Start(startInfo);
|
||||
LanMountainDesktop.Launcher.Services.Logger.Info(
|
||||
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is not null)
|
||||
{
|
||||
process.EnableRaisingEvents = true;
|
||||
process.Exited += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
LanMountainDesktop.Launcher.Services.Logger.Info(
|
||||
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LanMountainDesktop.Launcher.Services.Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||
|
||||
@@ -21,6 +21,21 @@ public sealed class AirAppLauncherServiceTests
|
||||
Assert.Equal(42, request.RequesterProcessId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildOpenRequest_IncludesAnalogClockSourceContext()
|
||||
{
|
||||
var request = AirAppLauncherService.BuildOpenRequest(
|
||||
AirAppLauncherService.WorldClockAppId,
|
||||
BuiltInComponentIds.DesktopClock,
|
||||
"analog-placement",
|
||||
43);
|
||||
|
||||
Assert.Equal("world-clock", request.AppId);
|
||||
Assert.Equal(BuiltInComponentIds.DesktopClock, request.SourceComponentId);
|
||||
Assert.Equal("analog-placement", request.SourcePlacementId);
|
||||
Assert.Equal(43, request.RequesterProcessId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildOpenRequest_NormalizesEmptyOptionalContext()
|
||||
{
|
||||
|
||||
155
LanMountainDesktop.Tests/WhiteboardWidgetLayoutSyncTests.cs
Normal file
155
LanMountainDesktop.Tests/WhiteboardWidgetLayoutSyncTests.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardWidgetLayoutSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveViewportSize_PrefersViewportRootSize()
|
||||
{
|
||||
var resolution = WhiteboardWidget.ResolveViewportSize(
|
||||
viewportRootSize: new Size(320, 240),
|
||||
canvasBorderSize: new Size(200, 160),
|
||||
widgetSize: new Size(100, 80),
|
||||
currentCellSize: 48,
|
||||
baseWidthCells: 2);
|
||||
|
||||
Assert.Equal(new Size(320, 240), resolution.Size);
|
||||
Assert.Equal("ViewportRoot", resolution.Source);
|
||||
Assert.False(resolution.IsFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveViewportSize_FallsBackToCanvasBorderBeforeCellSize()
|
||||
{
|
||||
var resolution = WhiteboardWidget.ResolveViewportSize(
|
||||
viewportRootSize: new Size(0, 0),
|
||||
canvasBorderSize: new Size(260, 180),
|
||||
widgetSize: new Size(100, 80),
|
||||
currentCellSize: 48,
|
||||
baseWidthCells: 2);
|
||||
|
||||
Assert.Equal(new Size(260, 180), resolution.Size);
|
||||
Assert.Equal("CanvasBorder", resolution.Source);
|
||||
Assert.False(resolution.IsFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveViewportSize_UsesCellSizeFallbackOnlyWhenLayoutIsUnavailable()
|
||||
{
|
||||
var resolution = WhiteboardWidget.ResolveViewportSize(
|
||||
viewportRootSize: new Size(0, 0),
|
||||
canvasBorderSize: new Size(1, 1),
|
||||
widgetSize: new Size(0, 0),
|
||||
currentCellSize: 48,
|
||||
baseWidthCells: 2);
|
||||
|
||||
Assert.Equal(new Size(96, 96), resolution.Size);
|
||||
Assert.Equal("Fallback", resolution.Source);
|
||||
Assert.True(resolution.IsFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToOpaqueInkColor_ForcesColorPickerAlphaToVisibleInk()
|
||||
{
|
||||
var color = Color.FromArgb(0, 20, 40, 60);
|
||||
|
||||
var inkColor = WhiteboardWidget.ToOpaqueInkColor(color);
|
||||
|
||||
Assert.Equal((byte)255, inkColor.Alpha);
|
||||
Assert.Equal((byte)20, inkColor.Red);
|
||||
Assert.Equal((byte)40, inkColor.Green);
|
||||
Assert.Equal((byte)60, inkColor.Blue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteboardWidget_DefinesDeferredViewportLayoutSynchronization()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
|
||||
var synchronizeSource = ExtractMethodSource(source, "SynchronizeViewportLayout");
|
||||
|
||||
Assert.Contains("ViewportRoot.SizeChanged += OnViewportRootSizeChanged", source);
|
||||
Assert.Contains("QueueViewportLayoutSync(\"attached-loaded\")", source);
|
||||
Assert.Contains("DispatcherPriority.Loaded", source);
|
||||
Assert.Contains("ResolveViewportSize(", source);
|
||||
Assert.DoesNotContain("QueueNoteSave(", synchronizeSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteboardWidget_RestoresInkInputAfterColorPopupCloses()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
|
||||
var restoreSource = ExtractMethodSource(source, "RestoreInkInputAfterToolPopup");
|
||||
|
||||
Assert.Contains("ColorPickerPopup.Closed += OnColorPickerPopupClosed", source);
|
||||
Assert.Contains("ColorPickerPopup.Closed -= OnColorPickerPopupClosed", source);
|
||||
Assert.Contains("ToOpaqueInkColor(e.NewColor)", source);
|
||||
Assert.Contains("SetToolMode(WhiteboardToolMode.Pen)", restoreSource);
|
||||
Assert.Contains("SynchronizeViewportLayout(reason)", restoreSource);
|
||||
Assert.Contains("InkCanvas.Focus", restoreSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteboardWidget_ColorPickerDoesNotPersistTransparentInk()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
|
||||
var colorChangedSource = ExtractMethodSource(source, "OnColorPickerColorChanged");
|
||||
|
||||
Assert.DoesNotContain("color.A", colorChangedSource);
|
||||
Assert.DoesNotContain("e.NewColor.A", colorChangedSource);
|
||||
Assert.Contains("byte.MaxValue", source);
|
||||
}
|
||||
|
||||
private static string ReadRepositoryFile(params string[] segments)
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return File.ReadAllText(candidate);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
|
||||
}
|
||||
|
||||
private static string ExtractMethodSource(string source, string methodName)
|
||||
{
|
||||
var methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal);
|
||||
Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'.");
|
||||
|
||||
var braceIndex = source.IndexOf('{', methodIndex);
|
||||
Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'.");
|
||||
|
||||
var depth = 0;
|
||||
for (var i = braceIndex; i < source.Length; i++)
|
||||
{
|
||||
if (source[i] == '{')
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
else if (source[i] == '}')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
return source.Substring(methodIndex, i - methodIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Could not extract method '{methodName}'.");
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,79 @@ public sealed class WindowLayerIsolationTests
|
||||
|
||||
Assert.Contains("AirAppLaunchOptions.WorldClockAppId", source);
|
||||
Assert.Contains("AirAppWindowChromeMode.Standard", source);
|
||||
Assert.Contains("width: 360", source);
|
||||
Assert.Contains("height: 220", source);
|
||||
Assert.Contains("AirAppLaunchOptions.WhiteboardAppId", source);
|
||||
Assert.Contains("AirAppWindowChromeMode.FullScreen", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesktopComponentHost_DoesNotInterceptLivePointerInputForAirApps()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "MainWindow.ComponentSystem.cs");
|
||||
var handlerSource = ExtractMethodSource(source, "OnDesktopComponentHostPointerPressed");
|
||||
|
||||
Assert.DoesNotContain("TryOpenAirAppFromDesktopComponent", source);
|
||||
Assert.DoesNotContain("OpenWorldClock(placement.PlacementId", source);
|
||||
Assert.DoesNotContain("OpenWhiteboard(", handlerSource);
|
||||
Assert.DoesNotContain("OpenWorldClock(", handlerSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalogClockWidget_OpensWorldClockOnlyInLiveMode()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "AnalogClockWidget.axaml.cs");
|
||||
|
||||
Assert.Contains("IComponentRuntimeContextAware", source);
|
||||
Assert.Contains("DesktopComponentRenderMode.Live", source);
|
||||
Assert.Contains("OpenWorldClock(_componentId, _placementId)", source);
|
||||
Assert.Contains("BuiltInComponentIds.DesktopClock", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppWindow_WhiteboardBranchReusesWidgetAndSavesOnClose()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml.cs");
|
||||
|
||||
Assert.Contains("new WhiteboardWidget(baseWidthCells)", source);
|
||||
Assert.Contains("SetComponentPlacementContext(componentId, _options.SourcePlacementId)", source);
|
||||
Assert.Contains("SetSurfaceMode(", source);
|
||||
Assert.Contains("WhiteboardWidgetSurfaceMode.AirApp", source);
|
||||
Assert.Contains("ForceSaveNote()", source);
|
||||
Assert.Contains("widget.Dispose()", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppHost_LoadsHostThemeForWhiteboardToolFlyouts()
|
||||
{
|
||||
var appXaml = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirApp.axaml");
|
||||
var projectFile = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "LanMountainDesktop.AirAppHost.csproj");
|
||||
|
||||
Assert.Contains("<sty:FluentAvaloniaTheme", appXaml);
|
||||
Assert.DoesNotContain("<FluentTheme", appXaml);
|
||||
Assert.Contains("Style Selector=\"fi|SymbolIcon\"", appXaml);
|
||||
Assert.Contains("Style Selector=\"ScrollViewer\"", appXaml);
|
||||
Assert.Contains("AppFontFamily", appXaml);
|
||||
Assert.Contains("FluentIcons.Avalonia", projectFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppHost_ParsesAndReceivesSharedDataRoot()
|
||||
{
|
||||
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
|
||||
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
|
||||
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "Services", "AirApp", "IAirAppProcessStarter.cs");
|
||||
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
|
||||
|
||||
Assert.Contains("DataRoot", optionsSource);
|
||||
Assert.Contains("IndexOf('=')", optionsSource);
|
||||
Assert.Contains("data-root", optionsSource);
|
||||
Assert.Contains("AppDataPathProvider.Initialize(args)", programSource);
|
||||
Assert.Contains("--data-root", starterSource);
|
||||
Assert.Contains("Path.GetFullPath(dataRoot)", starterSource);
|
||||
Assert.Contains("string.Equals(arg, \"--data-root\"", dataPathSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopClock = "DesktopClock";
|
||||
public const string DesktopWeatherClock = "DesktopWeatherClock";
|
||||
public const string DesktopWorldClock = "DesktopWorldClock";
|
||||
public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock";
|
||||
public const string DesktopTimer = "DesktopTimer";
|
||||
public const string DesktopWeather = "DesktopWeather";
|
||||
public const string DesktopHourlyWeather = "DesktopHourlyWeather";
|
||||
|
||||
@@ -57,6 +57,15 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopStandbyDigitalClock,
|
||||
"StandBy Clock",
|
||||
"Clock",
|
||||
"Clock",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopTimer,
|
||||
"Timer",
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace LanMountainDesktop.DesktopEditing;
|
||||
|
||||
@@ -51,15 +52,18 @@ internal sealed class DesktopEditGhostView : Border
|
||||
ClipToBounds = true;
|
||||
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
|
||||
RenderTransform = _scaleTransform;
|
||||
Transitions = new Transitions
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
CreateOpacityTransition(FastDuration)
|
||||
};
|
||||
_scaleTransform.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||
};
|
||||
Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(FastDuration)
|
||||
};
|
||||
_scaleTransform.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||
};
|
||||
}
|
||||
|
||||
_accentDot = new Border
|
||||
{
|
||||
|
||||
@@ -66,8 +66,11 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
CornerRadius = new CornerRadius(22),
|
||||
Opacity = 0,
|
||||
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative),
|
||||
RenderTransform = _candidateScale,
|
||||
Transitions = new Transitions
|
||||
RenderTransform = _candidateScale
|
||||
};
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
_candidateOutline.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
@@ -75,13 +78,13 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
Duration = FastDuration,
|
||||
Easing = StandardEasing
|
||||
}
|
||||
}
|
||||
};
|
||||
_candidateScale.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||
};
|
||||
};
|
||||
_candidateScale.Transitions = new Transitions
|
||||
{
|
||||
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
|
||||
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
|
||||
};
|
||||
}
|
||||
|
||||
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
|
||||
_ghostView.SetValue(Panel.ZIndexProperty, 1);
|
||||
@@ -99,10 +102,13 @@ internal sealed class DesktopEditOverlayPresenter
|
||||
}
|
||||
};
|
||||
|
||||
_root.Transitions = new Transitions
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
CreateOpacityTransition(FastDuration)
|
||||
};
|
||||
_root.Transitions = new Transitions
|
||||
{
|
||||
CreateOpacityTransition(FastDuration)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Control Root => _root;
|
||||
|
||||
@@ -12,6 +12,8 @@ public interface IAirAppLauncherService
|
||||
{
|
||||
void OpenWorldClock(string? sourcePlacementId);
|
||||
|
||||
void OpenWorldClock(string sourceComponentId, string? sourcePlacementId);
|
||||
|
||||
void OpenWhiteboard(string componentId, string? sourcePlacementId);
|
||||
}
|
||||
|
||||
@@ -24,11 +26,25 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
|
||||
|
||||
public void OpenWorldClock(string? sourcePlacementId)
|
||||
{
|
||||
_ = OpenAsync(WorldClockAppId, BuiltInComponentIds.DesktopWorldClock, sourcePlacementId);
|
||||
OpenWorldClock(BuiltInComponentIds.DesktopWorldClock, sourcePlacementId);
|
||||
}
|
||||
|
||||
public void OpenWorldClock(string sourceComponentId, string? sourcePlacementId)
|
||||
{
|
||||
var componentId = string.IsNullOrWhiteSpace(sourceComponentId)
|
||||
? BuiltInComponentIds.DesktopWorldClock
|
||||
: sourceComponentId.Trim();
|
||||
AppLogger.Info(
|
||||
"AirAppLauncher",
|
||||
$"World Clock Air APP requested. ComponentId='{componentId}'; PlacementId='{sourcePlacementId ?? string.Empty}'.");
|
||||
_ = OpenAsync(WorldClockAppId, componentId, sourcePlacementId);
|
||||
}
|
||||
|
||||
public void OpenWhiteboard(string componentId, string? sourcePlacementId)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"AirAppLauncher",
|
||||
$"Whiteboard Air APP requested. ComponentId='{componentId}'; PlacementId='{sourcePlacementId ?? string.Empty}'.");
|
||||
_ = OpenAsync(WhiteboardAppId, componentId, sourcePlacementId);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,20 @@ public static class AppDataPathProvider
|
||||
private static string? ResolveDataRootFromArgs(string[] args)
|
||||
{
|
||||
const string prefix = "--data-root=";
|
||||
foreach (var arg in args)
|
||||
for (var index = 0; index < args.Length; index++)
|
||||
{
|
||||
var arg = args[index];
|
||||
if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return arg[prefix.Length..];
|
||||
}
|
||||
|
||||
if (string.Equals(arg, "--data-root", StringComparison.OrdinalIgnoreCase) &&
|
||||
index + 1 < args.Length &&
|
||||
!args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return args[index + 1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
@@ -15,7 +16,7 @@ using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware
|
||||
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentRuntimeContextAware
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -60,6 +61,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
private const double Center = DialSize / 2;
|
||||
private string _componentId = BuiltInComponentIds.DesktopClock;
|
||||
private string _placementId = string.Empty;
|
||||
private DesktopComponentRenderMode _renderMode = DesktopComponentRenderMode.Live;
|
||||
|
||||
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
@@ -83,6 +85,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
PointerReleased += OnPointerReleased;
|
||||
|
||||
InitializeDialIfNeeded();
|
||||
InitializeHandsIfNeeded();
|
||||
@@ -126,6 +129,15 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(context.ComponentId)
|
||||
? BuiltInComponentIds.DesktopClock
|
||||
: context.ComponentId.Trim();
|
||||
_placementId = context.PlacementId?.Trim() ?? string.Empty;
|
||||
_renderMode = context.RenderMode;
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
InitializeDialIfNeeded();
|
||||
@@ -156,6 +168,23 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (e.InitialPressMouseButton != MouseButton.Left ||
|
||||
_renderMode != DesktopComponentRenderMode.Live ||
|
||||
!string.Equals(_componentId, BuiltInComponentIds.DesktopClock, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"AirAppLauncher",
|
||||
$"Analog clock component clicked. ComponentId='{_componentId}'; PlacementId='{_placementId}'.");
|
||||
AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_componentId, _placementId);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void InitializeDialIfNeeded()
|
||||
{
|
||||
if (_dialInitialized)
|
||||
|
||||
@@ -340,6 +340,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
BuiltInComponentIds.DesktopWorldClock,
|
||||
"component.world_clock",
|
||||
() => new WorldClockWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopStandbyDigitalClock,
|
||||
"component.standby_digital_clock",
|
||||
() => new StandbyDigitalClockWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopTimer,
|
||||
"component.desktop_timer",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<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="384"
|
||||
d:DesignHeight="192"
|
||||
x:Class="LanMountainDesktop.Views.Components.StandbyDigitalClockWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
ClipToBounds="True"
|
||||
Padding="16">
|
||||
<Viewbox Stretch="Uniform">
|
||||
<Grid Width="420"
|
||||
Height="210">
|
||||
<!-- Irregular free-form digit layout: each digit has unique vertical offset,
|
||||
horizontal offset, and rotation — like stickers casually placed -->
|
||||
<StackPanel VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="0">
|
||||
|
||||
<!-- H1 digit — tilted left, shifted right and up -->
|
||||
<Border x:Name="H1Clip"
|
||||
ClipToBounds="True"
|
||||
Width="88"
|
||||
Height="130"
|
||||
Margin="6,-10,0,0">
|
||||
<Border.RenderTransform>
|
||||
<RotateTransform Angle="-4" />
|
||||
</Border.RenderTransform>
|
||||
<StackPanel x:Name="H1Stack"
|
||||
Orientation="Vertical">
|
||||
<TextBlock x:Name="H1Text"
|
||||
Text="0"
|
||||
FontSize="120"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource AdaptiveAccentBrush}"
|
||||
Width="88"
|
||||
Height="130"
|
||||
TextAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
LineHeight="130" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- H2 digit — tilted right, shifted left and down -->
|
||||
<Border x:Name="H2Clip"
|
||||
ClipToBounds="True"
|
||||
Width="88"
|
||||
Height="130"
|
||||
Margin="-2,10,0,0">
|
||||
<Border.RenderTransform>
|
||||
<RotateTransform Angle="3" />
|
||||
</Border.RenderTransform>
|
||||
<StackPanel x:Name="H2Stack"
|
||||
Orientation="Vertical">
|
||||
<TextBlock x:Name="H2Text"
|
||||
Text="0"
|
||||
FontSize="120"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource AdaptiveAccentBrush}"
|
||||
Width="88"
|
||||
Height="130"
|
||||
TextAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
LineHeight="130" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Colon — slightly rotated, a bit lower -->
|
||||
<TextBlock x:Name="ColonText"
|
||||
Text=":"
|
||||
FontSize="100"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource AdaptiveAccentBrush}"
|
||||
Width="36"
|
||||
Height="130"
|
||||
TextAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
LineHeight="130"
|
||||
Margin="0,8,0,0">
|
||||
<TextBlock.RenderTransform>
|
||||
<RotateTransform Angle="-1" />
|
||||
</TextBlock.RenderTransform>
|
||||
</TextBlock>
|
||||
|
||||
<!-- M1 digit — slightly tilted left, shifted right and slightly up -->
|
||||
<Border x:Name="M1Clip"
|
||||
ClipToBounds="True"
|
||||
Width="88"
|
||||
Height="130"
|
||||
Margin="4,-3,0,0">
|
||||
<Border.RenderTransform>
|
||||
<RotateTransform Angle="-2" />
|
||||
</Border.RenderTransform>
|
||||
<StackPanel x:Name="M1Stack"
|
||||
Orientation="Vertical">
|
||||
<TextBlock x:Name="M1Text"
|
||||
Text="0"
|
||||
FontSize="120"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource AdaptiveAccentBrush}"
|
||||
Width="88"
|
||||
Height="130"
|
||||
TextAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
LineHeight="130" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- M2 digit — tilted right more, shifted left and down -->
|
||||
<Border x:Name="M2Clip"
|
||||
ClipToBounds="True"
|
||||
Width="88"
|
||||
Height="130"
|
||||
Margin="-2,12,0,0">
|
||||
<Border.RenderTransform>
|
||||
<RotateTransform Angle="5" />
|
||||
</Border.RenderTransform>
|
||||
<StackPanel x:Name="M2Stack"
|
||||
Orientation="Vertical">
|
||||
<TextBlock x:Name="M2Text"
|
||||
Text="0"
|
||||
FontSize="120"
|
||||
FontWeight="Bold"
|
||||
Foreground="{DynamicResource AdaptiveAccentBrush}"
|
||||
Width="88"
|
||||
Height="130"
|
||||
TextAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
LineHeight="130" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Date line -->
|
||||
<TextBlock x:Name="DateTextBlock"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,6" />
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,489 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
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;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class StandbyDigitalClockWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
ITimeZoneAwareComponentWidget,
|
||||
IComponentPlacementContextAware,
|
||||
IComponentRuntimeContextAware
|
||||
{
|
||||
private const double BaseCellSize = 48d;
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
private const double DigitHeight = 130d;
|
||||
|
||||
private readonly DispatcherTimer _timer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
private string _componentId = BuiltInComponentIds.DesktopStandbyDigitalClock;
|
||||
private string _placementId = string.Empty;
|
||||
private DesktopComponentRenderMode _renderMode = DesktopComponentRenderMode.Live;
|
||||
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private double _currentCellSize = 48;
|
||||
private TimeZoneInfo _clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal("China Standard Time");
|
||||
private string _languageCode = "zh-CN";
|
||||
private string? _componentColorScheme;
|
||||
|
||||
// Track previous digit chars to detect changes
|
||||
private char _prevH1, _prevH2, _prevM1, _prevM2;
|
||||
private bool _colonVisible = true;
|
||||
private bool? _isNightModeApplied;
|
||||
|
||||
// Digit state: track the current TextBlock for each digit position
|
||||
private TextBlock _h1Current, _h2Current, _m1Current, _m2Current;
|
||||
private bool _isAnimatingH1, _isAnimatingH2, _isAnimatingM1, _isAnimatingM2;
|
||||
|
||||
public StandbyDigitalClockWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_h1Current = H1Text;
|
||||
_h2Current = H2Text;
|
||||
_m1Current = M1Text;
|
||||
_m2Current = M2Text;
|
||||
|
||||
_timer.Tick += OnTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
PointerReleased += OnPointerReleased;
|
||||
|
||||
LoadClockSettings();
|
||||
InitializeDigitsWithoutAnimation();
|
||||
}
|
||||
|
||||
// ─── Interface implementations ───────────────────────────────
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
|
||||
RootBorder.CornerRadius = mainRectangleCornerRadius;
|
||||
|
||||
var scale = ResolveScale();
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 6, 28));
|
||||
ApplyModeVisualIfNeeded();
|
||||
}
|
||||
|
||||
public void SetTimeZoneService(TimeZoneService timeZoneService)
|
||||
{
|
||||
ClearTimeZoneService();
|
||||
_timeZoneService = timeZoneService;
|
||||
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
public void ClearTimeZoneService()
|
||||
{
|
||||
if (_timeZoneService is null) return;
|
||||
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
|
||||
_timeZoneService = null;
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopStandbyDigitalClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadClockSettings();
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(context.ComponentId)
|
||||
? BuiltInComponentIds.DesktopStandbyDigitalClock
|
||||
: context.ComponentId.Trim();
|
||||
_placementId = context.PlacementId?.Trim() ?? string.Empty;
|
||||
_renderMode = context.RenderMode;
|
||||
}
|
||||
|
||||
// ─── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
LoadClockSettings();
|
||||
InitializeDigitsWithoutAnimation();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_timer.Stop();
|
||||
}
|
||||
|
||||
private void OnTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
private void OnTimeZoneChanged(object? sender, EventArgs e)
|
||||
{
|
||||
LoadClockSettings();
|
||||
InitializeDigitsWithoutAnimation();
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (e.InitialPressMouseButton != MouseButton.Left ||
|
||||
_renderMode != DesktopComponentRenderMode.Live)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"AirAppLauncher",
|
||||
$"StandBy digital clock clicked. ComponentId='{_componentId}'; PlacementId='{_placementId}'.");
|
||||
AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_componentId, _placementId);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// ─── Clock update ───────────────────────────────────────────
|
||||
|
||||
private void InitializeDigitsWithoutAnimation()
|
||||
{
|
||||
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clockTimeZone);
|
||||
var h = now.Hour.ToString("D2", CultureInfo.InvariantCulture);
|
||||
var m = now.Minute.ToString("D2", CultureInfo.InvariantCulture);
|
||||
|
||||
_prevH1 = h[0]; _prevH2 = h[1];
|
||||
_prevM1 = m[0]; _prevM2 = m[1];
|
||||
|
||||
H1Text.Text = h[0].ToString();
|
||||
H2Text.Text = h[1].ToString();
|
||||
M1Text.Text = m[0].ToString();
|
||||
M2Text.Text = m[1].ToString();
|
||||
|
||||
_h1Current = H1Text;
|
||||
_h2Current = H2Text;
|
||||
_m1Current = M1Text;
|
||||
_m2Current = M2Text;
|
||||
|
||||
UpdateDateText(now);
|
||||
ApplyModeVisualIfNeeded();
|
||||
}
|
||||
|
||||
private void UpdateClock()
|
||||
{
|
||||
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clockTimeZone);
|
||||
var h = now.Hour.ToString("D2", CultureInfo.InvariantCulture);
|
||||
var m = now.Minute.ToString("D2", CultureInfo.InvariantCulture);
|
||||
|
||||
ApplyModeVisualIfNeeded();
|
||||
|
||||
// Detect digit changes and animate
|
||||
if (h[0] != _prevH1) AnimateDigit(H1Stack, _h1Current, h[0], _isAnimatingH1, value => _h1Current = value, value => _isAnimatingH1 = value);
|
||||
if (h[1] != _prevH2) AnimateDigit(H2Stack, _h2Current, h[1], _isAnimatingH2, value => _h2Current = value, value => _isAnimatingH2 = value);
|
||||
if (m[0] != _prevM1) AnimateDigit(M1Stack, _m1Current, m[0], _isAnimatingM1, value => _m1Current = value, value => _isAnimatingM1 = value);
|
||||
if (m[1] != _prevM2) AnimateDigit(M2Stack, _m2Current, m[1], _isAnimatingM2, value => _m2Current = value, value => _isAnimatingM2 = value);
|
||||
|
||||
_prevH1 = h[0]; _prevH2 = h[1];
|
||||
_prevM1 = m[0]; _prevM2 = m[1];
|
||||
|
||||
// Colon breathing
|
||||
ToggleColonOpacity();
|
||||
|
||||
// Date
|
||||
UpdateDateText(now);
|
||||
}
|
||||
|
||||
// ─── Digit scroll animation ─────────────────────────────────
|
||||
|
||||
private void AnimateDigit(
|
||||
StackPanel stack,
|
||||
TextBlock currentTextBlock,
|
||||
char newDigit,
|
||||
bool isAnimating,
|
||||
Action<TextBlock> setCurrentTextBlock,
|
||||
Action<bool> setAnimating)
|
||||
{
|
||||
if (isAnimating)
|
||||
{
|
||||
// If still animating, just set the text directly and skip animation
|
||||
currentTextBlock.Text = newDigit.ToString();
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimating(true);
|
||||
var oldText = currentTextBlock;
|
||||
|
||||
var newTextBlock = CreateDigitTextBlock(newDigit);
|
||||
stack.Children.Add(newTextBlock);
|
||||
|
||||
// Apply TranslateTransform with transition for smooth animation
|
||||
var transform = new TranslateTransform { Y = 0 };
|
||||
stack.RenderTransform = transform;
|
||||
stack.RenderTransformOrigin = new RelativePoint(0, 0, RelativeUnit.Relative);
|
||||
|
||||
stack.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = TranslateTransform.YProperty,
|
||||
Duration = FluttermotionToken.Standard,
|
||||
Easing = new CubicEaseOut()
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger the animation: slide up by one digit height
|
||||
transform.Y = -DigitHeight;
|
||||
|
||||
// After animation completes, clean up
|
||||
var cleanupTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = FluttermotionToken.Standard + TimeSpan.FromMilliseconds(20)
|
||||
};
|
||||
cleanupTimer.Tick += (_, _) =>
|
||||
{
|
||||
cleanupTimer.Stop();
|
||||
|
||||
// Remove transitions to prevent re-animation on reset
|
||||
stack.Transitions = null;
|
||||
|
||||
// Remove the old TextBlock and reset position
|
||||
stack.Children.Remove(oldText);
|
||||
transform.Y = 0;
|
||||
|
||||
setCurrentTextBlock(newTextBlock);
|
||||
setAnimating(false);
|
||||
};
|
||||
cleanupTimer.Start();
|
||||
}
|
||||
|
||||
private TextBlock CreateDigitTextBlock(char digit)
|
||||
{
|
||||
var accentBrush = ResolveAccentBrush();
|
||||
return new TextBlock
|
||||
{
|
||||
Text = digit.ToString(),
|
||||
FontSize = 120,
|
||||
FontWeight = FontWeight.Bold,
|
||||
Foreground = accentBrush,
|
||||
Width = 88,
|
||||
Height = DigitHeight,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
LineHeight = DigitHeight
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Colon breathing ────────────────────────────────────────
|
||||
|
||||
private void ToggleColonOpacity()
|
||||
{
|
||||
_colonVisible = !_colonVisible;
|
||||
|
||||
if (ColonText.Transitions is null)
|
||||
{
|
||||
ColonText.Transitions = new Transitions
|
||||
{
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = OpacityProperty,
|
||||
Duration = TimeSpan.FromMilliseconds(400),
|
||||
Easing = new CubicEaseInOut()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ColonText.Opacity = _colonVisible ? 1.0 : 0.25;
|
||||
}
|
||||
|
||||
// ─── Color system ───────────────────────────────────────────
|
||||
|
||||
private IBrush ResolveAccentBrush()
|
||||
{
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
var isNight = ResolveIsNightMode();
|
||||
|
||||
if (useMonetColor)
|
||||
{
|
||||
// Use the Monet accent brush from dynamic resources
|
||||
if (this.TryFindResource("AdaptiveAccentBrush", out var accentRes) && accentRes is IBrush accentBrush)
|
||||
{
|
||||
return accentBrush;
|
||||
}
|
||||
|
||||
// Fallback: compute from SystemAccentColor
|
||||
if (this.TryFindResource("SystemAccentColor", out var sysAccent) && sysAccent is Color sysColor)
|
||||
{
|
||||
return new SolidColorBrush(isNight ? Lighten(sysColor, 0.3) : sysColor);
|
||||
}
|
||||
}
|
||||
|
||||
// Native / fallback: warm orange-red accent (iPhone StandBy inspired)
|
||||
return isNight
|
||||
? CreateBrush("#FF8A65")
|
||||
: CreateBrush("#E84530");
|
||||
}
|
||||
|
||||
private static Color Lighten(Color color, double amount)
|
||||
{
|
||||
var r = (byte)Math.Min(255, color.R + (255 - color.R) * amount);
|
||||
var g = (byte)Math.Min(255, color.G + (255 - color.G) * amount);
|
||||
var b = (byte)Math.Min(255, color.B + (255 - color.B) * amount);
|
||||
return new Color(color.A, r, g, b);
|
||||
}
|
||||
|
||||
// ─── Night / Day mode ───────────────────────────────────────
|
||||
|
||||
private void ApplyModeVisualIfNeeded()
|
||||
{
|
||||
var isNightMode = ResolveIsNightMode();
|
||||
if (_isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
|
||||
return;
|
||||
|
||||
_isNightModeApplied = isNightMode;
|
||||
ApplyModeVisual(isNightMode);
|
||||
}
|
||||
|
||||
private void ApplyModeVisual(bool isNightMode)
|
||||
{
|
||||
RootBorder.Background = isNightMode
|
||||
? CreateLinearGradientBrush("#1F2C4B", "#131B33")
|
||||
: CreateLinearGradientBrush("#EEF2FA", "#E7ECF6");
|
||||
|
||||
var accentBrush = ResolveAccentBrush();
|
||||
|
||||
// Update current digit TextBlocks with accent color
|
||||
foreach (var tb in new[] { _h1Current, _h2Current, _m1Current, _m2Current })
|
||||
{
|
||||
if (tb is not null) tb.Foreground = accentBrush;
|
||||
}
|
||||
|
||||
// Also update the named XAML TextBlocks (in case they haven't been replaced yet)
|
||||
H1Text.Foreground = accentBrush;
|
||||
H2Text.Foreground = accentBrush;
|
||||
M1Text.Foreground = accentBrush;
|
||||
M2Text.Foreground = accentBrush;
|
||||
|
||||
ColonText.Foreground = accentBrush;
|
||||
|
||||
// Date text uses muted brush from dynamic resource
|
||||
if (this.TryFindResource("AdaptiveTextMutedBrush", out var mutedRes) && mutedRes is IBrush mutedBrush)
|
||||
{
|
||||
DateTextBlock.Foreground = mutedBrush;
|
||||
}
|
||||
else
|
||||
{
|
||||
DateTextBlock.Foreground = CreateBrush(isNightMode ? "#7E8593" : "#7E8593");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ResolveIsNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark) return true;
|
||||
if (ActualThemeVariant == ThemeVariant.Light) return false;
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush solidBrush)
|
||||
{
|
||||
return CalculateRelativeLuminance(solidBrush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Date text ──────────────────────────────────────────────
|
||||
|
||||
private void UpdateDateText(DateTime now)
|
||||
{
|
||||
var culture = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
|
||||
? new CultureInfo("zh-CN")
|
||||
: CultureInfo.CurrentUICulture;
|
||||
|
||||
var dateStr = now.ToString("M", culture);
|
||||
var dayStr = now.ToString("dddd", culture);
|
||||
DateTextBlock.Text = $"{dateStr} {dayStr}";
|
||||
}
|
||||
|
||||
// ─── Settings ───────────────────────────────────────────────
|
||||
|
||||
private void LoadClockSettings()
|
||||
{
|
||||
var appSnapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var componentSnapshot = _settingsService.LoadSnapshot<ComponentSettingsSnapshot>(
|
||||
SettingsScope.ComponentInstance,
|
||||
_componentId,
|
||||
_placementId);
|
||||
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||
? "China Standard Time"
|
||||
: componentSnapshot.DesktopClockTimeZoneId.Trim();
|
||||
|
||||
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
|
||||
_componentColorScheme = componentSnapshot.ColorSchemeSource;
|
||||
}
|
||||
|
||||
// ─── Scaling ────────────────────────────────────────────────
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.60, 1.90);
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / (BaseCellSize * BaseHeightCells), 0.58, 2.0) : 1;
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / (BaseCellSize * BaseWidthCells), 0.58, 2.0) : 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
|
||||
}
|
||||
|
||||
// ─── Brush helpers ──────────────────────────────────────────
|
||||
|
||||
private static IBrush CreateBrush(string colorHex)
|
||||
{
|
||||
return new SolidColorBrush(Color.Parse(colorHex));
|
||||
}
|
||||
|
||||
private static IBrush CreateLinearGradientBrush(string fromColorHex, string toColorHex)
|
||||
{
|
||||
return new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops = new GradientStops
|
||||
{
|
||||
new GradientStop(Color.Parse(fromColorHex), 0),
|
||||
new GradientStop(Color.Parse(toColorHex), 1)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ public enum WhiteboardWidgetSurfaceMode
|
||||
AirApp
|
||||
}
|
||||
|
||||
internal readonly record struct WhiteboardViewportSizeResolution(Size Size, string Source, bool IsFallback);
|
||||
|
||||
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
|
||||
{
|
||||
private enum WhiteboardToolMode
|
||||
@@ -73,6 +75,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
private int _noteLoadRevision;
|
||||
private WhiteboardWidgetSurfaceMode _surfaceMode = WhiteboardWidgetSurfaceMode.Component;
|
||||
private Action? _airAppCloseAction;
|
||||
private bool _isViewportLayoutSyncQueued;
|
||||
private Size _lastSynchronizedViewportSize = default;
|
||||
private string _lastViewportSizeSource = string.Empty;
|
||||
private bool _disposed;
|
||||
|
||||
public WhiteboardWidget()
|
||||
@@ -95,6 +100,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ViewportRoot.SizeChanged += OnViewportRootSizeChanged;
|
||||
ColorPickerPopup.Closed += OnColorPickerPopupClosed;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
_noteSaveTimer.Tick += OnNoteSaveTimerTick;
|
||||
|
||||
@@ -114,7 +121,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
if (InkColorPicker is not null)
|
||||
{
|
||||
InkColorPicker.Color = new Color(
|
||||
_selectedInkColor.Alpha,
|
||||
byte.MaxValue,
|
||||
_selectedInkColor.Red,
|
||||
_selectedInkColor.Green,
|
||||
_selectedInkColor.Blue);
|
||||
@@ -131,8 +138,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
ApplyThemeVisual(force: true);
|
||||
EnsureLogicalCanvasSize(expandToViewport: true);
|
||||
SetViewportState(_viewportState, queueSave: false);
|
||||
SynchronizeViewportLayout("attached");
|
||||
QueueViewportLayoutSync("attached-loaded");
|
||||
SchedulePersistedNoteLoad();
|
||||
}
|
||||
|
||||
@@ -144,8 +151,14 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyCellSize(_currentCellSize);
|
||||
EnsureLogicalCanvasSize(expandToViewport: true);
|
||||
SetViewportState(_viewportState, queueSave: false);
|
||||
QueueViewportLayoutSync("widget-size-changed");
|
||||
}
|
||||
|
||||
private void OnViewportRootSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
QueueViewportLayoutSync("viewport-root-size-changed");
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
@@ -253,7 +266,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
if (InkColorPicker is not null)
|
||||
{
|
||||
InkColorPicker.Color = new Color(
|
||||
_selectedInkColor.Alpha,
|
||||
byte.MaxValue,
|
||||
_selectedInkColor.Red,
|
||||
_selectedInkColor.Green,
|
||||
_selectedInkColor.Blue);
|
||||
@@ -350,6 +363,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
_disposed = true;
|
||||
_noteSaveTimer.Stop();
|
||||
_noteSaveTimer.Tick -= OnNoteSaveTimerTick;
|
||||
ViewportRoot.SizeChanged -= OnViewportRootSizeChanged;
|
||||
ColorPickerPopup.Closed -= OnColorPickerPopupClosed;
|
||||
InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected;
|
||||
InkCanvas.StrokeErased -= OnInkCanvasStrokeErased;
|
||||
InkCanvas.PointerReleased -= OnInkCanvasPointerReleased;
|
||||
@@ -461,7 +476,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
|
||||
private void SetInkColor(SKColor color)
|
||||
{
|
||||
_selectedInkColor = color;
|
||||
_selectedInkColor = NormalizeInkColor(color);
|
||||
if (_toolMode == WhiteboardToolMode.Pen)
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
||||
@@ -478,6 +493,16 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
}
|
||||
}
|
||||
|
||||
internal static SKColor ToOpaqueInkColor(Color color)
|
||||
{
|
||||
return new SKColor(color.R, color.G, color.B, byte.MaxValue);
|
||||
}
|
||||
|
||||
private static SKColor NormalizeInkColor(SKColor color)
|
||||
{
|
||||
return new SKColor(color.Red, color.Green, color.Blue, byte.MaxValue);
|
||||
}
|
||||
|
||||
private void RefreshToolButtonVisuals()
|
||||
{
|
||||
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
||||
@@ -543,12 +568,53 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
|
||||
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
|
||||
{
|
||||
var color = e.NewColor;
|
||||
var skColor = new SKColor(color.R, color.G, color.B, color.A);
|
||||
var skColor = ToOpaqueInkColor(e.NewColor);
|
||||
_isUserCustomColor = skColor != SKColors.Black && skColor != SKColors.White;
|
||||
SetInkColor(skColor);
|
||||
}
|
||||
|
||||
private void OnColorPickerPopupClosed(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
RestoreInkInputAfterToolPopup("color-popup-closed");
|
||||
}
|
||||
|
||||
private void RestoreInkInputAfterToolPopup(string reason, int attempt = 0)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClearPanZoomPointers();
|
||||
try
|
||||
{
|
||||
SetToolMode(WhiteboardToolMode.Pen);
|
||||
SynchronizeViewportLayout(reason);
|
||||
InkCanvas.Focus(NavigationMethod.Unspecified);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
if (attempt >= 3)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Whiteboard",
|
||||
$"Ink input restore gave up because the ink canvas stayed in input processing. Reason='{reason}'.",
|
||||
ex);
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"Whiteboard",
|
||||
$"Ink input restore was deferred because the ink canvas is still processing input. Reason='{reason}'.",
|
||||
ex);
|
||||
Dispatcher.UIThread.Post(
|
||||
() => RestoreInkInputAfterToolPopup($"{reason}-deferred", attempt + 1),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||
{
|
||||
SetInkThickness((float)e.NewValue);
|
||||
@@ -1188,6 +1254,54 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
SetLogicalCanvasSize(normalizedCanvasSize);
|
||||
}
|
||||
|
||||
private void QueueViewportLayoutSync(string reason)
|
||||
{
|
||||
if (_disposed || _isViewportLayoutSyncQueued)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isViewportLayoutSyncQueued = true;
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
_isViewportLayoutSyncQueued = false;
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SynchronizeViewportLayout(reason);
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void SynchronizeViewportLayout(string reason)
|
||||
{
|
||||
var resolution = ResolveCurrentViewportSize();
|
||||
var previousCanvasSize = _logicalCanvasSize;
|
||||
|
||||
EnsureLogicalCanvasSize(expandToViewport: true);
|
||||
SetViewportState(_viewportState, queueSave: false);
|
||||
|
||||
var canvasExpanded =
|
||||
_logicalCanvasSize.Width > previousCanvasSize.Width + 0.5d ||
|
||||
_logicalCanvasSize.Height > previousCanvasSize.Height + 0.5d;
|
||||
var sourceChanged = !string.Equals(_lastViewportSizeSource, resolution.Source, StringComparison.Ordinal);
|
||||
var viewportChanged = !AreSizesClose(_lastSynchronizedViewportSize, resolution.Size);
|
||||
if (canvasExpanded || sourceChanged || viewportChanged)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"Whiteboard",
|
||||
$"Viewport synchronized. ComponentId='{_componentId}'; PlacementId='{_placementId}'; Reason='{reason}'; " +
|
||||
$"ViewportSize='{resolution.Size.Width:0.##}x{resolution.Size.Height:0.##}'; ViewportSource='{resolution.Source}'; " +
|
||||
$"CanvasSize='{_logicalCanvasSize.Width:0.##}x{_logicalCanvasSize.Height:0.##}'; SurfaceMode='{_surfaceMode}'.");
|
||||
}
|
||||
|
||||
_lastSynchronizedViewportSize = resolution.Size;
|
||||
_lastViewportSizeSource = resolution.Source;
|
||||
}
|
||||
|
||||
private void SetLogicalCanvasSize(Size canvasSize)
|
||||
{
|
||||
_logicalCanvasSize = WhiteboardViewportHelper.NormalizeSize(canvasSize);
|
||||
@@ -1197,14 +1311,53 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
|
||||
private Size GetViewportSize()
|
||||
{
|
||||
var width = CanvasBorder.Bounds.Width > 1d
|
||||
? CanvasBorder.Bounds.Width
|
||||
: Math.Max(1d, _currentCellSize * _baseWidthCells);
|
||||
var height = CanvasBorder.Bounds.Height > 1d
|
||||
? CanvasBorder.Bounds.Height
|
||||
: Math.Max(1d, Bounds.Height > 1d ? Bounds.Height : _currentCellSize * Math.Max(2, _baseWidthCells));
|
||||
return ResolveCurrentViewportSize().Size;
|
||||
}
|
||||
|
||||
return WhiteboardViewportHelper.NormalizeSize(new Size(width, height));
|
||||
private WhiteboardViewportSizeResolution ResolveCurrentViewportSize()
|
||||
{
|
||||
return ResolveViewportSize(
|
||||
ViewportRoot.Bounds.Size,
|
||||
CanvasBorder.Bounds.Size,
|
||||
Bounds.Size,
|
||||
_currentCellSize,
|
||||
_baseWidthCells);
|
||||
}
|
||||
|
||||
internal static WhiteboardViewportSizeResolution ResolveViewportSize(
|
||||
Size viewportRootSize,
|
||||
Size canvasBorderSize,
|
||||
Size widgetSize,
|
||||
double currentCellSize,
|
||||
int baseWidthCells)
|
||||
{
|
||||
if (HasUsableSize(viewportRootSize))
|
||||
{
|
||||
return new WhiteboardViewportSizeResolution(
|
||||
WhiteboardViewportHelper.NormalizeSize(viewportRootSize),
|
||||
"ViewportRoot",
|
||||
IsFallback: false);
|
||||
}
|
||||
|
||||
if (HasUsableSize(canvasBorderSize))
|
||||
{
|
||||
return new WhiteboardViewportSizeResolution(
|
||||
WhiteboardViewportHelper.NormalizeSize(canvasBorderSize),
|
||||
"CanvasBorder",
|
||||
IsFallback: false);
|
||||
}
|
||||
|
||||
var normalizedCellSize = Math.Max(1d, currentCellSize);
|
||||
var normalizedBaseWidthCells = Math.Max(1, baseWidthCells);
|
||||
var width = normalizedCellSize * normalizedBaseWidthCells;
|
||||
var height = HasUsableLength(widgetSize.Height)
|
||||
? widgetSize.Height
|
||||
: normalizedCellSize * Math.Max(2, normalizedBaseWidthCells);
|
||||
|
||||
return new WhiteboardViewportSizeResolution(
|
||||
WhiteboardViewportHelper.NormalizeSize(new Size(width, height)),
|
||||
"Fallback",
|
||||
IsFallback: true);
|
||||
}
|
||||
|
||||
private void SetViewportState(WhiteboardViewportState nextState, bool queueSave)
|
||||
@@ -1238,6 +1391,23 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
|
||||
Math.Abs(first.Offset.Y - second.Offset.Y) <= tolerance;
|
||||
}
|
||||
|
||||
private static bool AreSizesClose(Size first, Size second)
|
||||
{
|
||||
const double tolerance = 0.5d;
|
||||
return Math.Abs(first.Width - second.Width) <= tolerance &&
|
||||
Math.Abs(first.Height - second.Height) <= tolerance;
|
||||
}
|
||||
|
||||
private static bool HasUsableSize(Size size)
|
||||
{
|
||||
return HasUsableLength(size.Width) && HasUsableLength(size.Height);
|
||||
}
|
||||
|
||||
private static bool HasUsableLength(double value)
|
||||
{
|
||||
return double.IsFinite(value) && value > 1d;
|
||||
}
|
||||
|
||||
private WhiteboardNoteSnapshot BuildNoteSnapshot()
|
||||
{
|
||||
EnsureLogicalCanvasSize(expandToViewport: true);
|
||||
|
||||
@@ -342,7 +342,10 @@ public partial class WorldClockWidget : UserControl,
|
||||
return;
|
||||
}
|
||||
|
||||
AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_placementId);
|
||||
AppLogger.Info(
|
||||
"AirAppLauncher",
|
||||
$"World clock component clicked. ComponentId='{_componentId}'; PlacementId='{_placementId}'.");
|
||||
AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_componentId, _placementId);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -2391,9 +2391,10 @@ public partial class MainWindow : Window
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
}
|
||||
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(componentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(componentId, BuiltInComponentIds.DesktopStandbyDigitalClock, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Keep world clock widget at 2:1 ratio: 4x2, 6x3, 8x4...
|
||||
// Keep world clock / StandBy digital clock widget at 2:1 ratio: 4x2, 6x3, 8x4...
|
||||
return SnapSpanToScaleRules(
|
||||
span,
|
||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
||||
@@ -2875,7 +2876,6 @@ public partial class MainWindow : Window
|
||||
{
|
||||
if (!_isComponentLibraryOpen)
|
||||
{
|
||||
TryOpenAirAppFromDesktopComponent(sender, e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2923,29 +2923,6 @@ public partial class MainWindow : Window
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void TryOpenAirAppFromDesktopComponent(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (HasActiveDesktopEditSession ||
|
||||
DesktopPagesViewport is null ||
|
||||
sender is not Border host ||
|
||||
host.Tag is not string placementId ||
|
||||
!e.GetCurrentPoint(host).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var placement = _desktopComponentPlacements.FirstOrDefault(p =>
|
||||
string.Equals(p.PlacementId, placementId, StringComparison.OrdinalIgnoreCase));
|
||||
if (placement is null ||
|
||||
!string.Equals(placement.ComponentId, BuiltInComponentIds.DesktopWorldClock, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_airAppLauncherService.OpenWorldClock(placement.PlacementId);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void SetSelectedDesktopComponent(Border? host)
|
||||
{
|
||||
ClearSelectedLauncherTile(refreshTaskbar: false);
|
||||
|
||||
Reference in New Issue
Block a user