diff --git a/.comate/specs/standby-digital-clock/doc.md b/.comate/specs/standby-digital-clock/doc.md
new file mode 100644
index 0000000..d2bc6fe
--- /dev/null
+++ b/.comate/specs/standby-digital-clock/doc.md
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 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 比例规则)
diff --git a/.comate/specs/standby-digital-clock/summary.md b/.comate/specs/standby-digital-clock/summary.md
new file mode 100644
index 0000000..a502339
--- /dev/null
+++ b/.comate/specs/standby-digital-clock/summary.md
@@ -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 基准
diff --git a/.comate/specs/standby-digital-clock/tasks.md b/.comate/specs/standby-digital-clock/tasks.md
new file mode 100644
index 0000000..6cb7dab
--- /dev/null
+++ b/.comate/specs/standby-digital-clock/tasks.md
@@ -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)
diff --git a/LanMountainDesktop.AirAppHost/AirApp.axaml b/LanMountainDesktop.AirAppHost/AirApp.axaml
index 57f2ac7..858ce71 100644
--- a/LanMountainDesktop.AirAppHost/AirApp.axaml
+++ b/LanMountainDesktop.AirAppHost/AirApp.axaml
@@ -1,9 +1,59 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
index fe1a8fc..7094b9d 100644
--- a/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
+++ b/LanMountainDesktop.AirAppHost/AirAppLaunchOptions.cs
@@ -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 values, string key, string fallback)
diff --git a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
index 3df352f..d811a28 100644
--- a/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
+++ b/LanMountainDesktop.AirAppHost/AirAppWindow.axaml.cs
@@ -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))
diff --git a/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
index 3ee33e3..1178154 100644
--- a/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
+++ b/LanMountainDesktop.AirAppHost/AirAppWindowDescriptor.cs
@@ -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))
diff --git a/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj b/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj
index 463f4d8..1de68b5 100644
--- a/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj
+++ b/LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj
@@ -25,5 +25,6 @@
+
diff --git a/LanMountainDesktop.AirAppHost/Program.cs b/LanMountainDesktop.AirAppHost/Program.cs
index e86d13f..4c2757e 100644
--- a/LanMountainDesktop.AirAppHost/Program.cs
+++ b/LanMountainDesktop.AirAppHost/Program.cs
@@ -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();
+ };
+ }
}
diff --git a/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
index d41d263..f9d27f2 100644
--- a/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
+++ b/LanMountainDesktop.AirAppHost/WorldClockAirAppView.axaml
@@ -2,26 +2,26 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.AirAppHost.WorldClockAirAppView">
+ Margin="18,0,18,16">
+ Spacing="8">
diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs
index 3117fbc..60e4668 100644
--- a/LanMountainDesktop.Launcher/App.axaml.cs
+++ b/LanMountainDesktop.Launcher/App.axaml.cs
@@ -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(
diff --git a/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs
index 031e050..6e00ea2 100644
--- a/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs
+++ b/LanMountainDesktop.Launcher/Services/AirApp/IAirAppProcessStarter.cs
@@ -12,15 +12,18 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
private readonly AirAppHostLocator _locator;
private readonly Func _packageRootProvider;
private readonly Func _hostPathProvider;
+ private readonly Func _dataRootProvider;
public AirAppProcessStarter(
AirAppHostLocator locator,
Func packageRootProvider,
- Func hostPathProvider)
+ Func hostPathProvider,
+ Func 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)
diff --git a/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs b/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs
index acbefa2..101ccd4 100644
--- a/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs
+++ b/LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs
@@ -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()
{
diff --git a/LanMountainDesktop.Tests/WhiteboardWidgetLayoutSyncTests.cs b/LanMountainDesktop.Tests/WhiteboardWidgetLayoutSyncTests.cs
new file mode 100644
index 0000000..bbd7db8
--- /dev/null
+++ b/LanMountainDesktop.Tests/WhiteboardWidgetLayoutSyncTests.cs
@@ -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}'.");
+ }
+}
diff --git a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs
index 639f64e..245e60e 100644
--- a/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs
+++ b/LanMountainDesktop.Tests/WindowLayerIsolationTests.cs
@@ -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(" _root;
diff --git a/LanMountainDesktop/Services/AirAppLauncherService.cs b/LanMountainDesktop/Services/AirAppLauncherService.cs
index 2778474..f204534 100644
--- a/LanMountainDesktop/Services/AirAppLauncherService.cs
+++ b/LanMountainDesktop/Services/AirAppLauncherService.cs
@@ -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);
}
diff --git a/LanMountainDesktop/Services/AppDataPathProvider.cs b/LanMountainDesktop/Services/AppDataPathProvider.cs
index 55d6f5a..e1b7f12 100644
--- a/LanMountainDesktop/Services/AppDataPathProvider.cs
+++ b/LanMountainDesktop/Services/AppDataPathProvider.cs
@@ -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;
diff --git a/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs
index caff4b8..3779307 100644
--- a/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/AnalogClockWidget.axaml.cs
@@ -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 ZhCityNames =
new Dictionary(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)
diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
index 77002a0..5d561a3 100644
--- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
+++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs
@@ -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",
diff --git a/LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml b/LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml
new file mode 100644
index 0000000..830aec1
--- /dev/null
+++ b/LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs
new file mode 100644
index 0000000..2d11043
--- /dev/null
+++ b/LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs
@@ -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 setCurrentTextBlock,
+ Action 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(SettingsScope.App);
+ var componentSnapshot = _settingsService.LoadSnapshot(
+ 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)
+ }
+ };
+ }
+}
diff --git a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
index 6ace53e..cb988b2 100644
--- a/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/WhiteboardWidget.axaml.cs
@@ -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);
diff --git a/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs
index 5f92ca5..4b3aa53 100644
--- a/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs
+++ b/LanMountainDesktop/Views/Components/WorldClockWidget.axaml.cs
@@ -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;
}
diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
index efd4f9b..9fd030b 100644
--- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
+++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs
@@ -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);