From 93758fc08355d1f523180aa22ab8f3b40b080ed4 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 18 May 2026 08:30:40 +0800 Subject: [PATCH] =?UTF-8?q?feat.=E6=95=B0=E5=AD=97=E6=97=B6=E9=92=9F?= =?UTF-8?q?=EF=BC=8C=E7=99=BD=E6=9D=BF=E5=8A=9F=E8=83=BD=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .comate/specs/standby-digital-clock/doc.md | 291 +++++++++++ .../specs/standby-digital-clock/summary.md | 52 ++ .comate/specs/standby-digital-clock/tasks.md | 25 + LanMountainDesktop.AirAppHost/AirApp.axaml | 52 +- .../AirAppLaunchOptions.cs | 19 +- .../AirAppWindow.axaml.cs | 42 ++ .../AirAppWindowDescriptor.cs | 6 +- .../LanMountainDesktop.AirAppHost.csproj | 1 + LanMountainDesktop.AirAppHost/Program.cs | 36 +- .../WorldClockAirAppView.axaml | 10 +- LanMountainDesktop.Launcher/App.axaml.cs | 8 +- .../Services/AirApp/IAirAppProcessStarter.cs | 32 +- .../AirAppLauncherServiceTests.cs | 15 + .../WhiteboardWidgetLayoutSyncTests.cs | 155 ++++++ .../WindowLayerIsolationTests.cs | 69 +++ .../ComponentSystem/BuiltInComponentIds.cs | 1 + .../ComponentSystem/ComponentRegistry.cs | 9 + .../DesktopEditing/DesktopEditGhostView.cs | 20 +- .../DesktopEditOverlayPresenter.cs | 30 +- .../Services/AirAppLauncherService.cs | 18 +- .../Services/AppDataPathProvider.cs | 10 +- .../Components/AnalogClockWidget.axaml.cs | 31 +- .../DesktopComponentRuntimeRegistry.cs | 4 + .../StandbyDigitalClockWidget.axaml | 149 ++++++ .../StandbyDigitalClockWidget.axaml.cs | 489 ++++++++++++++++++ .../Components/WhiteboardWidget.axaml.cs | 202 +++++++- .../Components/WorldClockWidget.axaml.cs | 5 +- .../Views/MainWindow.ComponentSystem.cs | 29 +- 28 files changed, 1729 insertions(+), 81 deletions(-) create mode 100644 .comate/specs/standby-digital-clock/doc.md create mode 100644 .comate/specs/standby-digital-clock/summary.md create mode 100644 .comate/specs/standby-digital-clock/tasks.md create mode 100644 LanMountainDesktop.Tests/WhiteboardWidgetLayoutSyncTests.cs create mode 100644 LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs 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(" 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);