feat.数字时钟,白板功能修复

This commit is contained in:
lincube
2026-05-18 08:30:40 +08:00
parent 9404a0b347
commit 93758fc083
28 changed files with 1729 additions and 81 deletions

View File

@@ -0,0 +1,291 @@
# StandBy Digital Clock - iPhone 待机风格大数字时钟组件
## 1. 需求场景与处理逻辑
### 1.1 需求描述
新增一个 4×2 尺寸的数字时钟桌面组件,视觉风格参考 iPhone 横屏充电时的 StandBy 待机显示——大面积、粗体、圆润的数字显示当前时间HH:MM数字采用不规则的自由排版有微妙的垂直偏移不在一条直线上颜色使用 Monet 主题色而非纯黑/白,伴随数字切换时的流畅垂直滚动/翻转动画,下方显示日期信息。
### 1.2 用户体验目标
- 大字号、圆润粗体的数字时间,远距离一目了然
- 数字采用不规则自由排版(微妙垂直偏移),营造 iPhone StandBy 那种有机、散漫的视觉节奏
- 数字使用 Monet 主题色(跟随壁纸/用户选色的强调色),而非死板的纯黑/白
- 数字变化时执行垂直滑动动画(旧数字向上滑出,新数字从下方滑入),类似翻页时钟效果
- 冒号(:)有呼吸闪烁效果
- 支持夜间/日间模式自动切换
- 点击组件可打开世界时钟 AirApp
- 支持时区配置(与现有桌面时钟共享设置体系)
### 1.3 处理逻辑
1. 组件加载时读取时区设置和秒针模式设置
2. `DispatcherTimer` 每秒触发一次更新
3. 当检测到分钟数变化时,触发分钟数字的垂直滑动动画
4. 当检测到小时数变化时,触发小时数字的垂直滑动动画
5. 冒号以 1 秒周期做透明度脉冲动画
6. 每 tick 检查是否需要切换日间/夜间视觉模式
## 2. 架构与技术方案
### 2.1 组件架构
遵循现有桌面组件架构模式:
- 继承 `UserControl`,实现 `IDesktopComponentWidget`, `ITimeZoneAwareComponentWidget`, `IComponentPlacementContextAware`, `IComponentRuntimeContextAware`
- AXAML 定义根布局结构,代码后置处理动画逻辑
- 通过 `DesktopComponentDefinition` 注册到组件系统
### 2.2 数字滚动动画技术方案
采用 Avalonia `RenderTransform` + `DoubleTransition` 实现数字滚动:
**核心思路**:每个数位(共 4 位H1, H2, M1, M2使用 `ClipToBounds` 的容器,内含一个垂直排列的 `StackPanel`,包含当前数字和下一个数字。切换时通过 `TranslateTransform.Y``DoubleTransition` 实现平滑滚动。
```
每位数字的结构:
┌─ DigitClip (ClipToBounds=true) ──────────┐
│ ┌─ DigitStack (TranslateTransform.Y) ──┐ │
│ │ [当前数字 TextBlock] │ │
│ │ [新数字 TextBlock] │ │
│ └───────────────────────────────────────┘ │
└───────────────────────────────────────────┘
```
当数字变化时:
1. 在 StackPanel 底部添加新数字的 TextBlock
2.`TranslateTransform.Y` 从 0 动画过渡到 `-digitHeight`
3. 动画完成后移除旧数字,重置 Y 为 0
### 2.3 动画参数
- 使用项目 `FluttermotionToken` 体系:滚动动画时长 `FluttermotionToken.Standard`200ms
- 缓动函数:`CubicEaseOut`(与项目现有动画风格一致)
- 冒号呼吸动画:透明度 1.0 → 0.3 → 1.0,周期 2 秒,使用 `DoubleTransition`
### 2.4 尺寸与布局
- 组件定义:`MinWidthCells = 4, MinHeightCells = 2`
- 缩放规则2:1 比例(与 WorldClock 一致)
- 内部布局采用 `Viewbox` 包裹,确保在不同 cellSize 下自适应缩放
- 数字字体大小:基准设计为 130px在 Viewbox 内),实际显示由 Viewbox 缩放
### 2.5 布局风格——不规则自由排版iPhone StandBy 风格)
iPhone StandBy 的数字不是规矩地排成一条直线,而是有微妙的垂直偏移和大小差异,营造出自由散漫、有机的视觉节奏:
```
H1 H2 : M1 M2
↗↘ ↘↗ ↗↘ ↘↗
↕+6 ↕+2 : ↕+4 ↕+2
↖ -3° ↗ +4° : ↖ -1° ↗ +5°
←+6,↑-10 ←-2,↓+10 →+4,↑-3 ←-2,↓+12
```
每个数字有三个自由度:
- **垂直偏移 (Y)**H1=-10, H2=+10, 冒号=+8, M1=-3, M2=+12
- **水平偏移 (X)**H1=+6, H2=-2, 冒号=0, M1=+4, M2=-2
- **旋转角度 (Z)**H1=-4°, H2=+3°, 冒号=-1°, M1=-2°, M2=+5°
### 2.6 视觉风格——圆润粗体 + Monet 主题色
- **字体**`FontWeight.Bold`,配合较大的字号,视觉上圆润饱满
- **颜色**:使用项目 Monet 主题色系统,数字颜色跟随 `AdaptiveAccentBrush` / `SystemAccentColor`,而非纯黑/白
- 数字颜色通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
- 跟随系统:使用 `AdaptiveAccentBrush`Monet 提取的强调色)
- 原生模式:使用组件自带的特色色彩
- 夜间模式:深色渐变背景 + 主题色数字(亮色调)
- 日间模式:浅色渐变背景 + 主题色数字(深色调)
- 夜间暗光环境:数字过渡到柔和的红色调(`#FF6B4A`),模拟 iPhone StandBy 夜间红色调
- **冒号颜色**:与数字同色,但有呼吸动画
- **日期行**:使用 `AdaptiveTextMutedBrush`(跟随主题的弱化文字色),字号约 14-16px 基准
- **根容器圆角**`DesignCornerRadiusComponent`(遵循圆角规范)
## 3. 受影响文件
### 3.1 新增文件
| 文件 | 类型 | 说明 |
|------|------|------|
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | 新增 | 组件 AXAML 布局 |
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 新增 | 组件代码后置(动画逻辑、时间更新、模式切换) |
### 3.2 修改文件
| 文件 | 修改类型 | 受影响函数/区域 |
|------|----------|-----------------|
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增常量 | 新增 `DesktopStandbyDigitalClock` 常量 |
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 新增定义 | `CreateDefault()` 中新增组件定义 |
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增运行时注册 | `GetDefaultRegistrations()` 中新增运行时注册项 |
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | 新增缩放规则 | `NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则 |
## 4. 实现细节
### 4.1 BuiltInComponentIds 新增常量
```csharp
public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock";
```
### 4.2 ComponentRegistry 新增定义
```csharp
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStandbyDigitalClock,
"StandBy Clock",
"Clock",
"Clock",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
```
### 4.3 DesktopComponentRuntimeRegistry 新增注册
```csharp
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopStandbyDigitalClock,
"component.standby_digital_clock",
() => new StandbyDigitalClockWidget()),
```
### 4.4 NormalizeAspectRatioForComponent 缩放规则
`case BuiltInComponentIds.DesktopWorldClock:` 的同一分支中添加 `BuiltInComponentIds.DesktopStandbyDigitalClock`,使用 2:1 比例规则。
### 4.5 AXAML 布局结构
```xml
<UserControl x:Class="LanMountainDesktop.Views.Components.StandbyDigitalClockWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="14">
<!-- 背景在代码后置中设置渐变与AnalogClockWidget一致 -->
<Viewbox Stretch="Uniform">
<Grid Width="400" Height="200">
<StackPanel VerticalAlignment="Center"
HorizontalAlignment="Center"
Orientation="Horizontal">
<!-- H1 数位 -->
<Border x:Name="H1Clip" ClipToBounds="True" ...>
<Panel x:Name="H1Stack" ...>
<TextBlock x:Name="H1Text" Text="0" ... />
</Panel>
</Border>
<!-- H2 数位 -->
<Border x:Name="H2Clip" ClipToBounds="True" ...>
<Panel x:Name="H2Stack" ...>
<TextBlock x:Name="H2Text" Text="0" ... />
</Panel>
</Border>
<!-- 冒号 -->
<TextBlock x:Name="ColonText" Text=":" ... />
<!-- M1 数位 -->
<Border x:Name="M1Clip" ClipToBounds="True" ...>
<Panel x:Name="M1Stack" ...>
<TextBlock x:Name="M1Text" Text="0" ... />
</Panel>
</Border>
<!-- M2 数位 -->
<Border x:Name="M2Clip" ClipToBounds="True" ...>
<Panel x:Name="M2Stack" ...>
<TextBlock x:Name="M2Text" Text="0" ... />
</Panel>
</Border>
</StackPanel>
<!-- 日期行 -->
<TextBlock x:Name="DateTextBlock"
VerticalAlignment="Bottom"
HorizontalAlignment="Center" ... />
</Grid>
</Viewbox>
</Border>
</UserControl>
```
### 4.6 数字滚动动画核心代码(伪代码)
```csharp
private void AnimateDigit(Border clip, Panel stack, TextBlock currentText, char newDigit, double digitHeight)
{
var oldText = currentText;
var newTextBlock = new TextBlock
{
Text = newDigit.ToString(),
FontSize = oldText.FontSize,
FontWeight = oldText.FontWeight,
Foreground = oldText.Foreground,
Width = oldText.Width,
Height = digitHeight,
// 复制旧文本的所有样式属性
};
stack.Children.Add(newTextBlock);
// 应用 TranslateTransform 过渡动画
var transform = new TranslateTransform { Y = 0 };
stack.RenderTransform = transform;
stack.Transitions = new Transitions
{
new DoubleTransition(TranslateTransform.YProperty, FluttermotionToken.Standard, new CubicEaseOut())
};
// 触发动画:从当前位置滑到 -digitHeight
transform.Y = -digitHeight;
// 动画完成后清理
_ = DispatcherTimer.RunOnce(() =>
{
stack.Children.Remove(oldText);
transform.Y = 0;
stack.Transitions = null; // 移除过渡,避免重置时再次动画
// 更新引用
UpdateCurrentTextReference(newTextBlock);
}, FluttermotionToken.Standard);
}
```
### 4.7 冒号呼吸动画
使用 `DispatcherTimer` 每秒切换冒号透明度:
```csharp
private void ToggleColonOpacity()
{
_colonVisible = !_colonVisible;
ColonText.Opacity = _colonVisible ? 1.0 : 0.3;
}
```
配合 `DoubleTransition` 使透明度变化平滑过渡。
### 4.8 日间/夜间模式
`AnalogClockWidget` 使用完全相同的判断逻辑:
- 检查 `ActualThemeVariant`
- 回退到 `AdaptiveSurfaceBaseBrush` 亮度计算
- 夜间模式:深色渐变背景 + 浅色数字
- 日间模式:浅色渐变背景 + 深色数字
### 4.9 时区与设置
- 复用 `AnalogClockWidget` 的时区解析和设置加载逻辑
- 使用 `ComponentSettingsSnapshot.DesktopClockTimeZoneId` 读取时区配置
- 点击打开世界时钟 AirApp
## 5. 边界条件与异常处理
| 场景 | 处理方式 |
|------|----------|
| 组件首次加载时数字尚未初始化 | 在构造函数中初始化所有数字为当前时间,不触发动画 |
| 快速连续触发数字变化(如时间同步导致跳变) | 在动画完成前忽略新的变化请求,或中断当前动画立即跳转到目标值 |
| cellSize 极小或极大 | `ApplyCellSize` 中 clamp 缩放因子0.58-1.95,与 AnalogClockWidget 一致) |
| 时区切换 | 重新加载设置并更新所有数字(无动画,直接设置) |
| 主题切换 | 通过 `ApplyModeVisualIfNeeded()` 在下一个 tick 自动检测并切换 |
| 组件被销毁 | `DetachedFromVisualTree` 停止 timer清理资源 |
| 冒号动画在组件不可见时 | timer 仍在运行但 Opacity 变化无性能开销;若需要可结合 `IDesktopPageVisibilityAwareComponentWidget` |
## 6. 数据流路径
```
DispatcherTimer (1s interval)
→ OnTimerTick
→ 计算当前时间 (TimeZoneInfo.ConvertTimeFromUtc)
→ 比较新旧时间数字
→ 若有变化: AnimateDigit() 执行滚动动画
→ ToggleColonOpacity() 切换冒号
→ ApplyModeVisualIfNeeded() 检查日/夜间切换
→ UpdateDateText() 更新日期文本
用户点击 → OnPointerReleased → AirAppLauncherServiceProvider.OpenWorldClock()
时区变更 → TimeZoneChanged event → RefreshFromSettings() → 无动画更新所有数字
```
## 7. 预期成果
- 在桌面组件选择器中新增 "StandBy Clock" 组件,位于 Clock 分类
- 拖放到桌面后显示 4×2 大数字时钟
- 数字切换时有流畅的垂直滑动动画
- 冒号有呼吸闪烁效果
- 支持日间/夜间自动切换
- 支持时区配置
- 支持组件缩放2:1 比例规则)

View File

@@ -0,0 +1,52 @@
# StandBy Digital Clock 实现总结
## 完成状态
全部任务已完成构建通过0 错误)。
## 变更清单
### 新增文件
| 文件 | 说明 |
|------|------|
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml` | AXAML 布局:不规则自由排版数字 + 冒号 + 日期Monet 主题色绑定 |
| `LanMountainDesktop/Views/Components/StandbyDigitalClockWidget.axaml.cs` | 代码后置数字滚动动画、冒号呼吸、Monet 主题色、日/夜模式、时区支持 |
### 修改文件
| 文件 | 改动 |
|------|------|
| `LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs` | 新增 `DesktopStandbyDigitalClock` 常量 |
| `LanMountainDesktop/ComponentSystem/ComponentRegistry.cs` | 在 `CreateDefault()` 中新增 4×2 Clock 分类组件定义 |
| `LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs` | 新增 `StandbyDigitalClockWidget` 运行时注册 |
| `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs` | `NormalizeAspectRatioForComponent` 将 StandbyDigitalClock 加入 2:1 缩放规则 |
## 核心设计要点
### 不规则自由排版iPhone StandBy 风格)
- 每个数字有独立的垂直 Margin 偏移H1 上移10, H2 下移8, M1 上移5, M2 下移10
- 冒号比数字中心略低下移6
- 数字间距不等,营造自由散漫的视觉节奏
### Monet 主题色
- 数字和冒号使用 `AdaptiveAccentBrush` / `SystemAccentColor`,跟随壁纸/用户选色的强调色
- 通过 `ComponentColorSchemeHelper.ShouldUseMonetColor()` 判断:
- 跟随系统:使用 Monet 提取的强调色
- 原生模式:使用暖橙红色(`#E84530` 日间 / `#FF8A65` 夜间),灵感来自 iPhone StandBy
- 日期文本使用 `AdaptiveTextMutedBrush`
### 数字滚动动画
- `TranslateTransform.Y` + `DoubleTransition`200ms CubicEaseOut
- 动画完成后清理旧 TextBlock 并重置 transform
### 冒号呼吸
- 每秒切换 Opacity1.0 ↔ 0.25),配合 400ms CubicEaseInOut 平滑过渡
### 日/夜模式
- 检测 `ActualThemeVariant` + `AdaptiveSurfaceBaseBrush` 亮度计算
- 夜间:深色渐变背景 + 亮调强调色数字
- 日间:浅色渐变背景 + 深调强调色数字
### 组件规格
- 尺寸4×2 (MinWidthCells=4, MinHeightCells=2)
- 分类Clock
- 缩放2:1 比例 (Proportional)
- 字体FontWeight.Bold, 120px 基准

View File

@@ -0,0 +1,25 @@
# StandBy Digital Clock 任务计划
- [x] Task 1: 注册组件定义与运行时
- 1.1: 在 `BuiltInComponentIds.cs` 中新增 `DesktopStandbyDigitalClock` 常量
- 1.2: 在 `ComponentRegistry.cs``CreateDefault()` 中新增 `DesktopComponentDefinition`4×2, Clock 分类, Proportional
- 1.3: 在 `DesktopComponentRuntimeRegistry.cs``GetDefaultRegistrations()` 中新增运行时注册项
- 1.4: 在 `MainWindow.ComponentSystem.cs``NormalizeAspectRatioForComponent()` 中为 StandbyDigitalClock 添加 2:1 缩放规则
- [x] Task 2: 创建 StandbyDigitalClockWidget AXAML 布局
- 2.1: 创建 `StandbyDigitalClockWidget.axaml`,定义 RootBorderDesignCornerRadiusComponent、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

View File

@@ -1,9 +1,59 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.AirAppHost.AirApp" x:Class="LanMountainDesktop.AirAppHost.AirApp"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<Application.Styles> <Application.Styles>
<FluentTheme /> <sty:FluentAvaloniaTheme />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="UserControl">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="TextBlock">
<Setter Property="FontFeatures" Value="tnum" />
<Setter Property="FontWeight" Value="Normal" />
</Style>
<Style Selector="SelectableTextBlock">
<Setter Property="FontFeatures" Value="tnum" />
<Setter Property="FontWeight" Value="Normal" />
</Style>
<Style Selector="ScrollViewer">
<Setter Property="ScrollViewer.IsScrollInertiaEnabled" Value="False" />
</Style>
<Style Selector="fi|SymbolIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="fi|SymbolIcon.icon-s, fi|FluentIcon.icon-s">
<Setter Property="FontSize" Value="12" />
</Style>
<Style Selector="fi|SymbolIcon.icon-m, fi|FluentIcon.icon-m">
<Setter Property="FontSize" Value="16" />
</Style>
<Style Selector="fi|SymbolIcon.icon-l, fi|FluentIcon.icon-l">
<Setter Property="FontSize" Value="20" />
</Style>
</Application.Styles> </Application.Styles>
<Application.Resources> <Application.Resources>

View File

@@ -6,7 +6,8 @@ public sealed record AirAppLaunchOptions(
string? SourceComponentId, string? SourceComponentId,
string? SourcePlacementId, string? SourcePlacementId,
string? LauncherPipeName, string? LauncherPipeName,
string? InstanceKey) string? InstanceKey,
string? DataRoot)
{ {
public const string WorldClockAppId = "world-clock"; public const string WorldClockAppId = "world-clock";
public const string WhiteboardAppId = "whiteboard"; public const string WhiteboardAppId = "whiteboard";
@@ -28,6 +29,19 @@ public sealed record AirAppLaunchOptions(
continue; 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)) if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
{ {
values[key] = args[index + 1]; values[key] = args[index + 1];
@@ -45,7 +59,8 @@ public sealed record AirAppLaunchOptions(
GetOptionalValue(values, "source-component-id"), GetOptionalValue(values, "source-component-id"),
GetOptionalValue(values, "source-placement-id"), GetOptionalValue(values, "source-placement-id"),
GetOptionalValue(values, "launcher-pipe"), GetOptionalValue(values, "launcher-pipe"),
GetOptionalValue(values, "instance-key")); GetOptionalValue(values, "instance-key"),
GetOptionalValue(values, "data-root"));
} }
private static string GetValue(IReadOnlyDictionary<string, string> values, string key, string fallback) private static string GetValue(IReadOnlyDictionary<string, string> values, string key, string fallback)

View File

@@ -14,6 +14,7 @@ public sealed partial class AirAppWindow : Window
{ {
private readonly AirAppLaunchOptions _options; private readonly AirAppLaunchOptions _options;
private readonly AirAppWindowDescriptor _descriptor; private readonly AirAppWindowDescriptor _descriptor;
private WhiteboardWidget? _whiteboardWidget;
private string _instanceKey = string.Empty; private string _instanceKey = string.Empty;
public AirAppWindow() public AirAppWindow()
@@ -117,6 +118,7 @@ public sealed partial class AirAppWindow : Window
? 4 ? 4
: 2; : 2;
var widget = new WhiteboardWidget(baseWidthCells); var widget = new WhiteboardWidget(baseWidthCells);
_whiteboardWidget = widget;
widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId); widget.SetComponentPlacementContext(componentId, _options.SourcePlacementId);
widget.SetSurfaceMode( widget.SetSurfaceMode(
WhiteboardWidgetSurfaceMode.AirApp, WhiteboardWidgetSurfaceMode.AirApp,
@@ -127,6 +129,9 @@ public sealed partial class AirAppWindow : Window
}); });
ContentHost.Content = widget; ContentHost.Content = widget;
AppLogger.Info(
"AirAppWindow",
$"Whiteboard content created. ComponentId='{componentId}'; PlacementId='{_options.SourcePlacementId ?? string.Empty}'.");
} }
protected override void OnOpened(EventArgs e) protected override void OnOpened(EventArgs e)
@@ -144,6 +149,7 @@ public sealed partial class AirAppWindow : Window
protected override void OnClosed(EventArgs e) protected override void OnClosed(EventArgs e)
{ {
SaveAndDisposeWhiteboard();
_ = UnregisterWithLauncherAsync(); _ = UnregisterWithLauncherAsync();
base.OnClosed(e); base.OnClosed(e);
} }
@@ -158,9 +164,45 @@ public sealed partial class AirAppWindow : Window
private void OnCloseClick(object? sender, RoutedEventArgs e) private void OnCloseClick(object? sender, RoutedEventArgs e)
{ {
SaveWhiteboard();
Close(); 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() private async Task RegisterWithLauncherAsync()
{ {
if (string.IsNullOrWhiteSpace(_options.LauncherPipeName)) if (string.IsNullOrWhiteSpace(_options.LauncherPipeName))

View File

@@ -25,7 +25,11 @@ public sealed record AirAppWindowDescriptor(
return Standard( return Standard(
"World Clock - Air APP", "World Clock - Air APP",
"World Clock", "World Clock",
"Air APP"); "Air APP",
width: 360,
height: 220,
minWidth: 320,
minHeight: 220);
} }
if (string.Equals(options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase)) if (string.Equals(options.AppId, AirAppLaunchOptions.WhiteboardAppId, StringComparison.OrdinalIgnoreCase))

View File

@@ -25,5 +25,6 @@
<PackageReference Include="Avalonia.Fonts.Inter" /> <PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Themes.Fluent" /> <PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="FluentAvaloniaUI" /> <PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="FluentIcons.Avalonia" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,5 @@
using Avalonia; using Avalonia;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.AirAppHost; namespace LanMountainDesktop.AirAppHost;
@@ -7,8 +8,22 @@ internal static class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
BuildAvaloniaApp() AppLogger.Initialize();
.StartWithClassicDesktopLifetime(args); 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() private static AppBuilder BuildAvaloniaApp()
@@ -18,4 +33,21 @@ internal static class Program
.WithInterFont() .WithInterFont()
.LogToTrace(); .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();
};
}
} }

View File

@@ -2,26 +2,26 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.AirAppHost.WorldClockAirAppView"> x:Class="LanMountainDesktop.AirAppHost.WorldClockAirAppView">
<Grid RowDefinitions="*,Auto" <Grid RowDefinitions="*,Auto"
Margin="24,8,24,24"> Margin="18,0,18,16">
<StackPanel HorizontalAlignment="Center" <StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Spacing="10"> Spacing="8">
<TextBlock x:Name="TimeTextBlock" <TextBlock x:Name="TimeTextBlock"
Text="00:00:00" Text="00:00:00"
FontSize="58" FontSize="42"
FontWeight="SemiBold" FontWeight="SemiBold"
LetterSpacing="0" LetterSpacing="0"
Foreground="{DynamicResource AirAppTitleTextBrush}" Foreground="{DynamicResource AirAppTitleTextBrush}"
HorizontalAlignment="Center" /> HorizontalAlignment="Center" />
<TextBlock x:Name="DateTextBlock" <TextBlock x:Name="DateTextBlock"
Text="0000-00-00" Text="0000-00-00"
FontSize="17" FontSize="14"
FontWeight="Medium" FontWeight="Medium"
Foreground="{DynamicResource AirAppSecondaryTextBrush}" Foreground="{DynamicResource AirAppSecondaryTextBrush}"
HorizontalAlignment="Center" /> HorizontalAlignment="Center" />
<TextBlock x:Name="TimeZoneTextBlock" <TextBlock x:Name="TimeZoneTextBlock"
Text="Local Time" Text="Local Time"
FontSize="13" FontSize="12"
Foreground="{DynamicResource AirAppSecondaryTextBrush}" Foreground="{DynamicResource AirAppSecondaryTextBrush}"
HorizontalAlignment="Center" /> HorizontalAlignment="Center" />
</StackPanel> </StackPanel>

View File

@@ -104,6 +104,7 @@ public partial class App : Application
{ {
var appRoot = Commands.ResolveAppRoot(context); var appRoot = Commands.ResolveAppRoot(context);
var requesterPid = context.GetIntOption("requester-pid", 0); var requesterPid = context.GetIntOption("requester-pid", 0);
var dataLocationResolver = new DataLocationResolver(appRoot);
Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}."); Logger.Info($"Air APP broker starting. AppRoot='{appRoot}'; RequesterPid={requesterPid}.");
using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost( using var airAppIpcHost = new LauncherAirAppLifecycleIpcHost(
@@ -111,7 +112,8 @@ public partial class App : Application
new AirAppProcessStarter( new AirAppProcessStarter(
new AirAppHostLocator(), new AirAppHostLocator(),
() => appRoot, () => appRoot,
() => null))); () => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start(); airAppIpcHost.Start();
await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false); await WaitForAirAppBrokerExitAsync(requesterPid, airAppIpcHost.LifecycleService).ConfigureAwait(false);
@@ -280,6 +282,7 @@ public partial class App : Application
LauncherResult result; LauncherResult result;
SplashWindow? currentSplashWindow = splashWindow; SplashWindow? currentSplashWindow = splashWindow;
var appRoot = Commands.ResolveAppRoot(context); var appRoot = Commands.ResolveAppRoot(context);
var dataLocationResolver = new DataLocationResolver(appRoot);
var startupAttemptRegistry = new StartupAttemptRegistry(); var startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context); var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
@@ -308,7 +311,8 @@ public partial class App : Application
new AirAppProcessStarter( new AirAppProcessStarter(
new AirAppHostLocator(), new AirAppHostLocator(),
() => appRoot, () => appRoot,
() => null))); () => null,
() => dataLocationResolver.ResolveDataRoot())));
airAppIpcHost.Start(); airAppIpcHost.Start();
using var coordinatorServer = new LauncherCoordinatorIpcServer( using var coordinatorServer = new LauncherCoordinatorIpcServer(

View File

@@ -12,15 +12,18 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
private readonly AirAppHostLocator _locator; private readonly AirAppHostLocator _locator;
private readonly Func<string?> _packageRootProvider; private readonly Func<string?> _packageRootProvider;
private readonly Func<string?> _hostPathProvider; private readonly Func<string?> _hostPathProvider;
private readonly Func<string?> _dataRootProvider;
public AirAppProcessStarter( public AirAppProcessStarter(
AirAppHostLocator locator, AirAppHostLocator locator,
Func<string?> packageRootProvider, Func<string?> packageRootProvider,
Func<string?> hostPathProvider) Func<string?> hostPathProvider,
Func<string?> dataRootProvider)
{ {
_locator = locator; _locator = locator;
_packageRootProvider = packageRootProvider; _packageRootProvider = packageRootProvider;
_hostPathProvider = hostPathProvider; _hostPathProvider = hostPathProvider;
_dataRootProvider = dataRootProvider;
} }
public Process? Start( public Process? Start(
@@ -52,6 +55,11 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
AddArgument(startInfo, "--session-id", sessionId); AddArgument(startInfo, "--session-id", sessionId);
AddArgument(startInfo, "--instance-key", instanceKey); AddArgument(startInfo, "--instance-key", instanceKey);
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName); 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)) if (!string.IsNullOrWhiteSpace(sourceComponentId))
{ {
@@ -63,7 +71,27 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim()); 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) private static void AddArgument(ProcessStartInfo startInfo, string name, string value)

View File

@@ -21,6 +21,21 @@ public sealed class AirAppLauncherServiceTests
Assert.Equal(42, request.RequesterProcessId); 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] [Fact]
public void BuildOpenRequest_NormalizesEmptyOptionalContext() public void BuildOpenRequest_NormalizesEmptyOptionalContext()
{ {

View File

@@ -0,0 +1,155 @@
using Avalonia;
using Avalonia.Media;
using LanMountainDesktop.Views.Components;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class WhiteboardWidgetLayoutSyncTests
{
[Fact]
public void ResolveViewportSize_PrefersViewportRootSize()
{
var resolution = WhiteboardWidget.ResolveViewportSize(
viewportRootSize: new Size(320, 240),
canvasBorderSize: new Size(200, 160),
widgetSize: new Size(100, 80),
currentCellSize: 48,
baseWidthCells: 2);
Assert.Equal(new Size(320, 240), resolution.Size);
Assert.Equal("ViewportRoot", resolution.Source);
Assert.False(resolution.IsFallback);
}
[Fact]
public void ResolveViewportSize_FallsBackToCanvasBorderBeforeCellSize()
{
var resolution = WhiteboardWidget.ResolveViewportSize(
viewportRootSize: new Size(0, 0),
canvasBorderSize: new Size(260, 180),
widgetSize: new Size(100, 80),
currentCellSize: 48,
baseWidthCells: 2);
Assert.Equal(new Size(260, 180), resolution.Size);
Assert.Equal("CanvasBorder", resolution.Source);
Assert.False(resolution.IsFallback);
}
[Fact]
public void ResolveViewportSize_UsesCellSizeFallbackOnlyWhenLayoutIsUnavailable()
{
var resolution = WhiteboardWidget.ResolveViewportSize(
viewportRootSize: new Size(0, 0),
canvasBorderSize: new Size(1, 1),
widgetSize: new Size(0, 0),
currentCellSize: 48,
baseWidthCells: 2);
Assert.Equal(new Size(96, 96), resolution.Size);
Assert.Equal("Fallback", resolution.Source);
Assert.True(resolution.IsFallback);
}
[Fact]
public void ToOpaqueInkColor_ForcesColorPickerAlphaToVisibleInk()
{
var color = Color.FromArgb(0, 20, 40, 60);
var inkColor = WhiteboardWidget.ToOpaqueInkColor(color);
Assert.Equal((byte)255, inkColor.Alpha);
Assert.Equal((byte)20, inkColor.Red);
Assert.Equal((byte)40, inkColor.Green);
Assert.Equal((byte)60, inkColor.Blue);
}
[Fact]
public void WhiteboardWidget_DefinesDeferredViewportLayoutSynchronization()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
var synchronizeSource = ExtractMethodSource(source, "SynchronizeViewportLayout");
Assert.Contains("ViewportRoot.SizeChanged += OnViewportRootSizeChanged", source);
Assert.Contains("QueueViewportLayoutSync(\"attached-loaded\")", source);
Assert.Contains("DispatcherPriority.Loaded", source);
Assert.Contains("ResolveViewportSize(", source);
Assert.DoesNotContain("QueueNoteSave(", synchronizeSource);
}
[Fact]
public void WhiteboardWidget_RestoresInkInputAfterColorPopupCloses()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
var restoreSource = ExtractMethodSource(source, "RestoreInkInputAfterToolPopup");
Assert.Contains("ColorPickerPopup.Closed += OnColorPickerPopupClosed", source);
Assert.Contains("ColorPickerPopup.Closed -= OnColorPickerPopupClosed", source);
Assert.Contains("ToOpaqueInkColor(e.NewColor)", source);
Assert.Contains("SetToolMode(WhiteboardToolMode.Pen)", restoreSource);
Assert.Contains("SynchronizeViewportLayout(reason)", restoreSource);
Assert.Contains("InkCanvas.Focus", restoreSource);
}
[Fact]
public void WhiteboardWidget_ColorPickerDoesNotPersistTransparentInk()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
var colorChangedSource = ExtractMethodSource(source, "OnColorPickerColorChanged");
Assert.DoesNotContain("color.A", colorChangedSource);
Assert.DoesNotContain("e.NewColor.A", colorChangedSource);
Assert.Contains("byte.MaxValue", source);
}
private static string ReadRepositoryFile(params string[] segments)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
{
break;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
}
private static string ExtractMethodSource(string source, string methodName)
{
var methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal);
Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'.");
var braceIndex = source.IndexOf('{', methodIndex);
Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'.");
var depth = 0;
for (var i = braceIndex; i < source.Length; i++)
{
if (source[i] == '{')
{
depth++;
}
else if (source[i] == '}')
{
depth--;
if (depth == 0)
{
return source.Substring(methodIndex, i - methodIndex + 1);
}
}
}
throw new InvalidOperationException($"Could not extract method '{methodName}'.");
}
}

View File

@@ -36,10 +36,79 @@ public sealed class WindowLayerIsolationTests
Assert.Contains("AirAppLaunchOptions.WorldClockAppId", source); Assert.Contains("AirAppLaunchOptions.WorldClockAppId", source);
Assert.Contains("AirAppWindowChromeMode.Standard", source); Assert.Contains("AirAppWindowChromeMode.Standard", source);
Assert.Contains("width: 360", source);
Assert.Contains("height: 220", source);
Assert.Contains("AirAppLaunchOptions.WhiteboardAppId", source); Assert.Contains("AirAppLaunchOptions.WhiteboardAppId", source);
Assert.Contains("AirAppWindowChromeMode.FullScreen", source); Assert.Contains("AirAppWindowChromeMode.FullScreen", source);
} }
[Fact]
public void DesktopComponentHost_DoesNotInterceptLivePointerInputForAirApps()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "MainWindow.ComponentSystem.cs");
var handlerSource = ExtractMethodSource(source, "OnDesktopComponentHostPointerPressed");
Assert.DoesNotContain("TryOpenAirAppFromDesktopComponent", source);
Assert.DoesNotContain("OpenWorldClock(placement.PlacementId", source);
Assert.DoesNotContain("OpenWhiteboard(", handlerSource);
Assert.DoesNotContain("OpenWorldClock(", handlerSource);
}
[Fact]
public void AnalogClockWidget_OpensWorldClockOnlyInLiveMode()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "AnalogClockWidget.axaml.cs");
Assert.Contains("IComponentRuntimeContextAware", source);
Assert.Contains("DesktopComponentRenderMode.Live", source);
Assert.Contains("OpenWorldClock(_componentId, _placementId)", source);
Assert.Contains("BuiltInComponentIds.DesktopClock", source);
}
[Fact]
public void AirAppWindow_WhiteboardBranchReusesWidgetAndSavesOnClose()
{
var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml.cs");
Assert.Contains("new WhiteboardWidget(baseWidthCells)", source);
Assert.Contains("SetComponentPlacementContext(componentId, _options.SourcePlacementId)", source);
Assert.Contains("SetSurfaceMode(", source);
Assert.Contains("WhiteboardWidgetSurfaceMode.AirApp", source);
Assert.Contains("ForceSaveNote()", source);
Assert.Contains("widget.Dispose()", source);
}
[Fact]
public void AirAppHost_LoadsHostThemeForWhiteboardToolFlyouts()
{
var appXaml = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirApp.axaml");
var projectFile = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "LanMountainDesktop.AirAppHost.csproj");
Assert.Contains("<sty:FluentAvaloniaTheme", appXaml);
Assert.DoesNotContain("<FluentTheme", appXaml);
Assert.Contains("Style Selector=\"fi|SymbolIcon\"", appXaml);
Assert.Contains("Style Selector=\"ScrollViewer\"", appXaml);
Assert.Contains("AppFontFamily", appXaml);
Assert.Contains("FluentIcons.Avalonia", projectFile);
}
[Fact]
public void AirAppHost_ParsesAndReceivesSharedDataRoot()
{
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "Services", "AirApp", "IAirAppProcessStarter.cs");
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
Assert.Contains("DataRoot", optionsSource);
Assert.Contains("IndexOf('=')", optionsSource);
Assert.Contains("data-root", optionsSource);
Assert.Contains("AppDataPathProvider.Initialize(args)", programSource);
Assert.Contains("--data-root", starterSource);
Assert.Contains("Path.GetFullPath(dataRoot)", starterSource);
Assert.Contains("string.Equals(arg, \"--data-root\"", dataPathSource);
}
[Fact] [Fact]
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary() public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
{ {

View File

@@ -6,6 +6,7 @@ public static class BuiltInComponentIds
public const string DesktopClock = "DesktopClock"; public const string DesktopClock = "DesktopClock";
public const string DesktopWeatherClock = "DesktopWeatherClock"; public const string DesktopWeatherClock = "DesktopWeatherClock";
public const string DesktopWorldClock = "DesktopWorldClock"; public const string DesktopWorldClock = "DesktopWorldClock";
public const string DesktopStandbyDigitalClock = "DesktopStandbyDigitalClock";
public const string DesktopTimer = "DesktopTimer"; public const string DesktopTimer = "DesktopTimer";
public const string DesktopWeather = "DesktopWeather"; public const string DesktopWeather = "DesktopWeather";
public const string DesktopHourlyWeather = "DesktopHourlyWeather"; public const string DesktopHourlyWeather = "DesktopHourlyWeather";

View File

@@ -57,6 +57,15 @@ public sealed class ComponentRegistry
MinHeightCells: 2, MinHeightCells: 2,
AllowStatusBarPlacement: false, AllowStatusBarPlacement: false,
AllowDesktopPlacement: true), AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStandbyDigitalClock,
"StandBy Clock",
"Clock",
"Clock",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition( new DesktopComponentDefinition(
BuiltInComponentIds.DesktopTimer, BuiltInComponentIds.DesktopTimer,
"Timer", "Timer",

View File

@@ -5,6 +5,7 @@ using Avalonia.Animation.Easings;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading;
namespace LanMountainDesktop.DesktopEditing; namespace LanMountainDesktop.DesktopEditing;
@@ -51,15 +52,18 @@ internal sealed class DesktopEditGhostView : Border
ClipToBounds = true; ClipToBounds = true;
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative); RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
RenderTransform = _scaleTransform; RenderTransform = _scaleTransform;
Transitions = new Transitions if (Dispatcher.UIThread.CheckAccess())
{ {
CreateOpacityTransition(FastDuration) Transitions = new Transitions
}; {
_scaleTransform.Transitions = new Transitions CreateOpacityTransition(FastDuration)
{ };
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration), _scaleTransform.Transitions = new Transitions
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration) {
}; CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
};
}
_accentDot = new Border _accentDot = new Border
{ {

View File

@@ -66,8 +66,11 @@ internal sealed class DesktopEditOverlayPresenter
CornerRadius = new CornerRadius(22), CornerRadius = new CornerRadius(22),
Opacity = 0, Opacity = 0,
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative), RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative),
RenderTransform = _candidateScale, RenderTransform = _candidateScale
Transitions = new Transitions };
if (Dispatcher.UIThread.CheckAccess())
{
_candidateOutline.Transitions = new Transitions
{ {
new DoubleTransition new DoubleTransition
{ {
@@ -75,13 +78,13 @@ internal sealed class DesktopEditOverlayPresenter
Duration = FastDuration, Duration = FastDuration,
Easing = StandardEasing Easing = StandardEasing
} }
} };
}; _candidateScale.Transitions = new Transitions
_candidateScale.Transitions = new Transitions {
{ CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration), CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration) };
}; }
_candidateOutline.SetValue(Panel.ZIndexProperty, 0); _candidateOutline.SetValue(Panel.ZIndexProperty, 0);
_ghostView.SetValue(Panel.ZIndexProperty, 1); _ghostView.SetValue(Panel.ZIndexProperty, 1);
@@ -99,10 +102,13 @@ internal sealed class DesktopEditOverlayPresenter
} }
}; };
_root.Transitions = new Transitions if (Dispatcher.UIThread.CheckAccess())
{ {
CreateOpacityTransition(FastDuration) _root.Transitions = new Transitions
}; {
CreateOpacityTransition(FastDuration)
};
}
} }
public Control Root => _root; public Control Root => _root;

View File

@@ -12,6 +12,8 @@ public interface IAirAppLauncherService
{ {
void OpenWorldClock(string? sourcePlacementId); void OpenWorldClock(string? sourcePlacementId);
void OpenWorldClock(string sourceComponentId, string? sourcePlacementId);
void OpenWhiteboard(string componentId, string? sourcePlacementId); void OpenWhiteboard(string componentId, string? sourcePlacementId);
} }
@@ -24,11 +26,25 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
public void OpenWorldClock(string? sourcePlacementId) 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) public void OpenWhiteboard(string componentId, string? sourcePlacementId)
{ {
AppLogger.Info(
"AirAppLauncher",
$"Whiteboard Air APP requested. ComponentId='{componentId}'; PlacementId='{sourcePlacementId ?? string.Empty}'.");
_ = OpenAsync(WhiteboardAppId, componentId, sourcePlacementId); _ = OpenAsync(WhiteboardAppId, componentId, sourcePlacementId);
} }

View File

@@ -53,12 +53,20 @@ public static class AppDataPathProvider
private static string? ResolveDataRootFromArgs(string[] args) private static string? ResolveDataRootFromArgs(string[] args)
{ {
const string prefix = "--data-root="; 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)) if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{ {
return arg[prefix.Length..]; 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; return null;

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Shapes; using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading; using Avalonia.Threading;
@@ -15,7 +16,7 @@ using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Views.Components; namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentRuntimeContextAware
{ {
private static readonly IReadOnlyDictionary<string, string> ZhCityNames = private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -60,6 +61,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
private const double Center = DialSize / 2; private const double Center = DialSize / 2;
private string _componentId = BuiltInComponentIds.DesktopClock; private string _componentId = BuiltInComponentIds.DesktopClock;
private string _placementId = string.Empty; private string _placementId = string.Empty;
private DesktopComponentRenderMode _renderMode = DesktopComponentRenderMode.Live;
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings; private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new(); private readonly LocalizationService _localizationService = new();
@@ -83,6 +85,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
AttachedToVisualTree += OnAttachedToVisualTree; AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged; SizeChanged += OnSizeChanged;
PointerReleased += OnPointerReleased;
InitializeDialIfNeeded(); InitializeDialIfNeeded();
InitializeHandsIfNeeded(); InitializeHandsIfNeeded();
@@ -126,6 +129,15 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
RefreshFromSettings(); 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) private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{ {
InitializeDialIfNeeded(); InitializeDialIfNeeded();
@@ -156,6 +168,23 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
UpdateClock(); 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() private void InitializeDialIfNeeded()
{ {
if (_dialInitialized) if (_dialInitialized)

View File

@@ -340,6 +340,10 @@ public sealed class DesktopComponentRuntimeRegistry
BuiltInComponentIds.DesktopWorldClock, BuiltInComponentIds.DesktopWorldClock,
"component.world_clock", "component.world_clock",
() => new WorldClockWidget()), () => new WorldClockWidget()),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopStandbyDigitalClock,
"component.standby_digital_clock",
() => new StandbyDigitalClockWidget()),
new DesktopComponentRuntimeRegistration( new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopTimer, BuiltInComponentIds.DesktopTimer,
"component.desktop_timer", "component.desktop_timer",

View File

@@ -0,0 +1,149 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="384"
d:DesignHeight="192"
x:Class="LanMountainDesktop.Views.Components.StandbyDigitalClockWidget">
<Border x:Name="RootBorder"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True"
Padding="16">
<Viewbox Stretch="Uniform">
<Grid Width="420"
Height="210">
<!-- Irregular free-form digit layout: each digit has unique vertical offset,
horizontal offset, and rotation — like stickers casually placed -->
<StackPanel VerticalAlignment="Center"
HorizontalAlignment="Center"
Orientation="Horizontal"
Spacing="0">
<!-- H1 digit — tilted left, shifted right and up -->
<Border x:Name="H1Clip"
ClipToBounds="True"
Width="88"
Height="130"
Margin="6,-10,0,0">
<Border.RenderTransform>
<RotateTransform Angle="-4" />
</Border.RenderTransform>
<StackPanel x:Name="H1Stack"
Orientation="Vertical">
<TextBlock x:Name="H1Text"
Text="0"
FontSize="120"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveAccentBrush}"
Width="88"
Height="130"
TextAlignment="Center"
VerticalAlignment="Center"
LineHeight="130" />
</StackPanel>
</Border>
<!-- H2 digit — tilted right, shifted left and down -->
<Border x:Name="H2Clip"
ClipToBounds="True"
Width="88"
Height="130"
Margin="-2,10,0,0">
<Border.RenderTransform>
<RotateTransform Angle="3" />
</Border.RenderTransform>
<StackPanel x:Name="H2Stack"
Orientation="Vertical">
<TextBlock x:Name="H2Text"
Text="0"
FontSize="120"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveAccentBrush}"
Width="88"
Height="130"
TextAlignment="Center"
VerticalAlignment="Center"
LineHeight="130" />
</StackPanel>
</Border>
<!-- Colon — slightly rotated, a bit lower -->
<TextBlock x:Name="ColonText"
Text=":"
FontSize="100"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveAccentBrush}"
Width="36"
Height="130"
TextAlignment="Center"
VerticalAlignment="Center"
LineHeight="130"
Margin="0,8,0,0">
<TextBlock.RenderTransform>
<RotateTransform Angle="-1" />
</TextBlock.RenderTransform>
</TextBlock>
<!-- M1 digit — slightly tilted left, shifted right and slightly up -->
<Border x:Name="M1Clip"
ClipToBounds="True"
Width="88"
Height="130"
Margin="4,-3,0,0">
<Border.RenderTransform>
<RotateTransform Angle="-2" />
</Border.RenderTransform>
<StackPanel x:Name="M1Stack"
Orientation="Vertical">
<TextBlock x:Name="M1Text"
Text="0"
FontSize="120"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveAccentBrush}"
Width="88"
Height="130"
TextAlignment="Center"
VerticalAlignment="Center"
LineHeight="130" />
</StackPanel>
</Border>
<!-- M2 digit — tilted right more, shifted left and down -->
<Border x:Name="M2Clip"
ClipToBounds="True"
Width="88"
Height="130"
Margin="-2,12,0,0">
<Border.RenderTransform>
<RotateTransform Angle="5" />
</Border.RenderTransform>
<StackPanel x:Name="M2Stack"
Orientation="Vertical">
<TextBlock x:Name="M2Text"
Text="0"
FontSize="120"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveAccentBrush}"
Width="88"
Height="130"
TextAlignment="Center"
VerticalAlignment="Center"
LineHeight="130" />
</StackPanel>
</Border>
</StackPanel>
<!-- Date line -->
<TextBlock x:Name="DateTextBlock"
FontSize="15"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextMutedBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,6" />
</Grid>
</Viewbox>
</Border>
</UserControl>

View File

@@ -0,0 +1,489 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views.Components;
public partial class StandbyDigitalClockWidget : UserControl,
IDesktopComponentWidget,
ITimeZoneAwareComponentWidget,
IComponentPlacementContextAware,
IComponentRuntimeContextAware
{
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const double DigitHeight = 130d;
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private string _componentId = BuiltInComponentIds.DesktopStandbyDigitalClock;
private string _placementId = string.Empty;
private DesktopComponentRenderMode _renderMode = DesktopComponentRenderMode.Live;
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new();
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private TimeZoneInfo _clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal("China Standard Time");
private string _languageCode = "zh-CN";
private string? _componentColorScheme;
// Track previous digit chars to detect changes
private char _prevH1, _prevH2, _prevM1, _prevM2;
private bool _colonVisible = true;
private bool? _isNightModeApplied;
// Digit state: track the current TextBlock for each digit position
private TextBlock _h1Current, _h2Current, _m1Current, _m2Current;
private bool _isAnimatingH1, _isAnimatingH2, _isAnimatingM1, _isAnimatingM2;
public StandbyDigitalClockWidget()
{
InitializeComponent();
_h1Current = H1Text;
_h2Current = H2Text;
_m1Current = M1Text;
_m2Current = M2Text;
_timer.Tick += OnTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
PointerReleased += OnPointerReleased;
LoadClockSettings();
InitializeDigitsWithoutAnimation();
}
// ─── Interface implementations ───────────────────────────────
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
RootBorder.CornerRadius = mainRectangleCornerRadius;
var scale = ResolveScale();
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 6, 28));
ApplyModeVisualIfNeeded();
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
ClearTimeZoneService();
_timeZoneService = timeZoneService;
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
UpdateClock();
}
public void ClearTimeZoneService()
{
if (_timeZoneService is null) return;
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
_timeZoneService = null;
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopStandbyDigitalClock
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
LoadClockSettings();
UpdateClock();
}
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
{
_componentId = string.IsNullOrWhiteSpace(context.ComponentId)
? BuiltInComponentIds.DesktopStandbyDigitalClock
: context.ComponentId.Trim();
_placementId = context.PlacementId?.Trim() ?? string.Empty;
_renderMode = context.RenderMode;
}
// ─── Lifecycle ──────────────────────────────────────────────
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
LoadClockSettings();
InitializeDigitsWithoutAnimation();
_timer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
}
private void OnTimerTick(object? sender, EventArgs e)
{
UpdateClock();
}
private void OnTimeZoneChanged(object? sender, EventArgs e)
{
LoadClockSettings();
InitializeDigitsWithoutAnimation();
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (e.InitialPressMouseButton != MouseButton.Left ||
_renderMode != DesktopComponentRenderMode.Live)
{
return;
}
AppLogger.Info(
"AirAppLauncher",
$"StandBy digital clock clicked. ComponentId='{_componentId}'; PlacementId='{_placementId}'.");
AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_componentId, _placementId);
e.Handled = true;
}
// ─── Clock update ───────────────────────────────────────────
private void InitializeDigitsWithoutAnimation()
{
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clockTimeZone);
var h = now.Hour.ToString("D2", CultureInfo.InvariantCulture);
var m = now.Minute.ToString("D2", CultureInfo.InvariantCulture);
_prevH1 = h[0]; _prevH2 = h[1];
_prevM1 = m[0]; _prevM2 = m[1];
H1Text.Text = h[0].ToString();
H2Text.Text = h[1].ToString();
M1Text.Text = m[0].ToString();
M2Text.Text = m[1].ToString();
_h1Current = H1Text;
_h2Current = H2Text;
_m1Current = M1Text;
_m2Current = M2Text;
UpdateDateText(now);
ApplyModeVisualIfNeeded();
}
private void UpdateClock()
{
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _clockTimeZone);
var h = now.Hour.ToString("D2", CultureInfo.InvariantCulture);
var m = now.Minute.ToString("D2", CultureInfo.InvariantCulture);
ApplyModeVisualIfNeeded();
// Detect digit changes and animate
if (h[0] != _prevH1) AnimateDigit(H1Stack, _h1Current, h[0], _isAnimatingH1, value => _h1Current = value, value => _isAnimatingH1 = value);
if (h[1] != _prevH2) AnimateDigit(H2Stack, _h2Current, h[1], _isAnimatingH2, value => _h2Current = value, value => _isAnimatingH2 = value);
if (m[0] != _prevM1) AnimateDigit(M1Stack, _m1Current, m[0], _isAnimatingM1, value => _m1Current = value, value => _isAnimatingM1 = value);
if (m[1] != _prevM2) AnimateDigit(M2Stack, _m2Current, m[1], _isAnimatingM2, value => _m2Current = value, value => _isAnimatingM2 = value);
_prevH1 = h[0]; _prevH2 = h[1];
_prevM1 = m[0]; _prevM2 = m[1];
// Colon breathing
ToggleColonOpacity();
// Date
UpdateDateText(now);
}
// ─── Digit scroll animation ─────────────────────────────────
private void AnimateDigit(
StackPanel stack,
TextBlock currentTextBlock,
char newDigit,
bool isAnimating,
Action<TextBlock> setCurrentTextBlock,
Action<bool> setAnimating)
{
if (isAnimating)
{
// If still animating, just set the text directly and skip animation
currentTextBlock.Text = newDigit.ToString();
return;
}
setAnimating(true);
var oldText = currentTextBlock;
var newTextBlock = CreateDigitTextBlock(newDigit);
stack.Children.Add(newTextBlock);
// Apply TranslateTransform with transition for smooth animation
var transform = new TranslateTransform { Y = 0 };
stack.RenderTransform = transform;
stack.RenderTransformOrigin = new RelativePoint(0, 0, RelativeUnit.Relative);
stack.Transitions = new Transitions
{
new DoubleTransition
{
Property = TranslateTransform.YProperty,
Duration = FluttermotionToken.Standard,
Easing = new CubicEaseOut()
}
};
// Trigger the animation: slide up by one digit height
transform.Y = -DigitHeight;
// After animation completes, clean up
var cleanupTimer = new DispatcherTimer
{
Interval = FluttermotionToken.Standard + TimeSpan.FromMilliseconds(20)
};
cleanupTimer.Tick += (_, _) =>
{
cleanupTimer.Stop();
// Remove transitions to prevent re-animation on reset
stack.Transitions = null;
// Remove the old TextBlock and reset position
stack.Children.Remove(oldText);
transform.Y = 0;
setCurrentTextBlock(newTextBlock);
setAnimating(false);
};
cleanupTimer.Start();
}
private TextBlock CreateDigitTextBlock(char digit)
{
var accentBrush = ResolveAccentBrush();
return new TextBlock
{
Text = digit.ToString(),
FontSize = 120,
FontWeight = FontWeight.Bold,
Foreground = accentBrush,
Width = 88,
Height = DigitHeight,
TextAlignment = TextAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
LineHeight = DigitHeight
};
}
// ─── Colon breathing ────────────────────────────────────────
private void ToggleColonOpacity()
{
_colonVisible = !_colonVisible;
if (ColonText.Transitions is null)
{
ColonText.Transitions = new Transitions
{
new DoubleTransition
{
Property = OpacityProperty,
Duration = TimeSpan.FromMilliseconds(400),
Easing = new CubicEaseInOut()
}
};
}
ColonText.Opacity = _colonVisible ? 1.0 : 0.25;
}
// ─── Color system ───────────────────────────────────────────
private IBrush ResolveAccentBrush()
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
var isNight = ResolveIsNightMode();
if (useMonetColor)
{
// Use the Monet accent brush from dynamic resources
if (this.TryFindResource("AdaptiveAccentBrush", out var accentRes) && accentRes is IBrush accentBrush)
{
return accentBrush;
}
// Fallback: compute from SystemAccentColor
if (this.TryFindResource("SystemAccentColor", out var sysAccent) && sysAccent is Color sysColor)
{
return new SolidColorBrush(isNight ? Lighten(sysColor, 0.3) : sysColor);
}
}
// Native / fallback: warm orange-red accent (iPhone StandBy inspired)
return isNight
? CreateBrush("#FF8A65")
: CreateBrush("#E84530");
}
private static Color Lighten(Color color, double amount)
{
var r = (byte)Math.Min(255, color.R + (255 - color.R) * amount);
var g = (byte)Math.Min(255, color.G + (255 - color.G) * amount);
var b = (byte)Math.Min(255, color.B + (255 - color.B) * amount);
return new Color(color.A, r, g, b);
}
// ─── Night / Day mode ───────────────────────────────────────
private void ApplyModeVisualIfNeeded()
{
var isNightMode = ResolveIsNightMode();
if (_isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
return;
_isNightModeApplied = isNightMode;
ApplyModeVisual(isNightMode);
}
private void ApplyModeVisual(bool isNightMode)
{
RootBorder.Background = isNightMode
? CreateLinearGradientBrush("#1F2C4B", "#131B33")
: CreateLinearGradientBrush("#EEF2FA", "#E7ECF6");
var accentBrush = ResolveAccentBrush();
// Update current digit TextBlocks with accent color
foreach (var tb in new[] { _h1Current, _h2Current, _m1Current, _m2Current })
{
if (tb is not null) tb.Foreground = accentBrush;
}
// Also update the named XAML TextBlocks (in case they haven't been replaced yet)
H1Text.Foreground = accentBrush;
H2Text.Foreground = accentBrush;
M1Text.Foreground = accentBrush;
M2Text.Foreground = accentBrush;
ColonText.Foreground = accentBrush;
// Date text uses muted brush from dynamic resource
if (this.TryFindResource("AdaptiveTextMutedBrush", out var mutedRes) && mutedRes is IBrush mutedBrush)
{
DateTextBlock.Foreground = mutedBrush;
}
else
{
DateTextBlock.Foreground = CreateBrush(isNightMode ? "#7E8593" : "#7E8593");
}
}
private bool ResolveIsNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark) return true;
if (ActualThemeVariant == ThemeVariant.Light) return false;
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush solidBrush)
{
return CalculateRelativeLuminance(solidBrush.Color) < 0.45;
}
return false;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// ─── Date text ──────────────────────────────────────────────
private void UpdateDateText(DateTime now)
{
var culture = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? new CultureInfo("zh-CN")
: CultureInfo.CurrentUICulture;
var dateStr = now.ToString("M", culture);
var dayStr = now.ToString("dddd", culture);
DateTextBlock.Text = $"{dateStr} {dayStr}";
}
// ─── Settings ───────────────────────────────────────────────
private void LoadClockSettings()
{
var appSnapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var componentSnapshot = _settingsService.LoadSnapshot<ComponentSettingsSnapshot>(
SettingsScope.ComponentInstance,
_componentId,
_placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
? "China Standard Time"
: componentSnapshot.DesktopClockTimeZoneId.Trim();
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
_componentColorScheme = componentSnapshot.ColorSchemeSource;
}
// ─── Scaling ────────────────────────────────────────────────
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.60, 1.90);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / (BaseCellSize * BaseHeightCells), 0.58, 2.0) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / (BaseCellSize * BaseWidthCells), 0.58, 2.0) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
}
// ─── Brush helpers ──────────────────────────────────────────
private static IBrush CreateBrush(string colorHex)
{
return new SolidColorBrush(Color.Parse(colorHex));
}
private static IBrush CreateLinearGradientBrush(string fromColorHex, string toColorHex)
{
return new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops = new GradientStops
{
new GradientStop(Color.Parse(fromColorHex), 0),
new GradientStop(Color.Parse(toColorHex), 1)
}
};
}
}

View File

@@ -30,6 +30,8 @@ public enum WhiteboardWidgetSurfaceMode
AirApp AirApp
} }
internal readonly record struct WhiteboardViewportSizeResolution(Size Size, string Source, bool IsFallback);
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
{ {
private enum WhiteboardToolMode private enum WhiteboardToolMode
@@ -73,6 +75,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private int _noteLoadRevision; private int _noteLoadRevision;
private WhiteboardWidgetSurfaceMode _surfaceMode = WhiteboardWidgetSurfaceMode.Component; private WhiteboardWidgetSurfaceMode _surfaceMode = WhiteboardWidgetSurfaceMode.Component;
private Action? _airAppCloseAction; private Action? _airAppCloseAction;
private bool _isViewportLayoutSyncQueued;
private Size _lastSynchronizedViewportSize = default;
private string _lastViewportSizeSource = string.Empty;
private bool _disposed; private bool _disposed;
public WhiteboardWidget() public WhiteboardWidget()
@@ -95,6 +100,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
AttachedToVisualTree += OnAttachedToVisualTree; AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree; DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged; SizeChanged += OnSizeChanged;
ViewportRoot.SizeChanged += OnViewportRootSizeChanged;
ColorPickerPopup.Closed += OnColorPickerPopupClosed;
ActualThemeVariantChanged += OnActualThemeVariantChanged; ActualThemeVariantChanged += OnActualThemeVariantChanged;
_noteSaveTimer.Tick += OnNoteSaveTimerTick; _noteSaveTimer.Tick += OnNoteSaveTimerTick;
@@ -114,7 +121,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
if (InkColorPicker is not null) if (InkColorPicker is not null)
{ {
InkColorPicker.Color = new Color( InkColorPicker.Color = new Color(
_selectedInkColor.Alpha, byte.MaxValue,
_selectedInkColor.Red, _selectedInkColor.Red,
_selectedInkColor.Green, _selectedInkColor.Green,
_selectedInkColor.Blue); _selectedInkColor.Blue);
@@ -131,8 +138,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{ {
ApplyThemeVisual(force: true); ApplyThemeVisual(force: true);
EnsureLogicalCanvasSize(expandToViewport: true); SynchronizeViewportLayout("attached");
SetViewportState(_viewportState, queueSave: false); QueueViewportLayoutSync("attached-loaded");
SchedulePersistedNoteLoad(); SchedulePersistedNoteLoad();
} }
@@ -144,8 +151,14 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnSizeChanged(object? sender, SizeChangedEventArgs e) private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{ {
ApplyCellSize(_currentCellSize); ApplyCellSize(_currentCellSize);
EnsureLogicalCanvasSize(expandToViewport: true); QueueViewportLayoutSync("widget-size-changed");
SetViewportState(_viewportState, queueSave: false); }
private void OnViewportRootSizeChanged(object? sender, SizeChangedEventArgs e)
{
_ = sender;
_ = e;
QueueViewportLayoutSync("viewport-root-size-changed");
} }
private void OnActualThemeVariantChanged(object? sender, EventArgs e) private void OnActualThemeVariantChanged(object? sender, EventArgs e)
@@ -253,7 +266,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
if (InkColorPicker is not null) if (InkColorPicker is not null)
{ {
InkColorPicker.Color = new Color( InkColorPicker.Color = new Color(
_selectedInkColor.Alpha, byte.MaxValue,
_selectedInkColor.Red, _selectedInkColor.Red,
_selectedInkColor.Green, _selectedInkColor.Green,
_selectedInkColor.Blue); _selectedInkColor.Blue);
@@ -350,6 +363,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
_disposed = true; _disposed = true;
_noteSaveTimer.Stop(); _noteSaveTimer.Stop();
_noteSaveTimer.Tick -= OnNoteSaveTimerTick; _noteSaveTimer.Tick -= OnNoteSaveTimerTick;
ViewportRoot.SizeChanged -= OnViewportRootSizeChanged;
ColorPickerPopup.Closed -= OnColorPickerPopupClosed;
InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected; InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected;
InkCanvas.StrokeErased -= OnInkCanvasStrokeErased; InkCanvas.StrokeErased -= OnInkCanvasStrokeErased;
InkCanvas.PointerReleased -= OnInkCanvasPointerReleased; InkCanvas.PointerReleased -= OnInkCanvasPointerReleased;
@@ -461,7 +476,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void SetInkColor(SKColor color) private void SetInkColor(SKColor color)
{ {
_selectedInkColor = color; _selectedInkColor = NormalizeInkColor(color);
if (_toolMode == WhiteboardToolMode.Pen) if (_toolMode == WhiteboardToolMode.Pen)
{ {
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor; 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() private void RefreshToolButtonVisuals()
{ {
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode(); var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
@@ -543,12 +568,53 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e) private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
{ {
var color = e.NewColor; var skColor = ToOpaqueInkColor(e.NewColor);
var skColor = new SKColor(color.R, color.G, color.B, color.A);
_isUserCustomColor = skColor != SKColors.Black && skColor != SKColors.White; _isUserCustomColor = skColor != SKColors.Black && skColor != SKColors.White;
SetInkColor(skColor); 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) private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{ {
SetInkThickness((float)e.NewValue); SetInkThickness((float)e.NewValue);
@@ -1188,6 +1254,54 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
SetLogicalCanvasSize(normalizedCanvasSize); 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) private void SetLogicalCanvasSize(Size canvasSize)
{ {
_logicalCanvasSize = WhiteboardViewportHelper.NormalizeSize(canvasSize); _logicalCanvasSize = WhiteboardViewportHelper.NormalizeSize(canvasSize);
@@ -1197,14 +1311,53 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private Size GetViewportSize() private Size GetViewportSize()
{ {
var width = CanvasBorder.Bounds.Width > 1d return ResolveCurrentViewportSize().Size;
? 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 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) 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; 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() private WhiteboardNoteSnapshot BuildNoteSnapshot()
{ {
EnsureLogicalCanvasSize(expandToViewport: true); EnsureLogicalCanvasSize(expandToViewport: true);

View File

@@ -342,7 +342,10 @@ public partial class WorldClockWidget : UserControl,
return; return;
} }
AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_placementId); AppLogger.Info(
"AirAppLauncher",
$"World clock component clicked. ComponentId='{_componentId}'; PlacementId='{_placementId}'.");
AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_componentId, _placementId);
e.Handled = true; e.Handled = true;
} }

View File

@@ -2391,9 +2391,10 @@ public partial class MainWindow : Window
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); 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( return SnapSpanToScaleRules(
span, span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2)); new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
@@ -2875,7 +2876,6 @@ public partial class MainWindow : Window
{ {
if (!_isComponentLibraryOpen) if (!_isComponentLibraryOpen)
{ {
TryOpenAirAppFromDesktopComponent(sender, e);
return; return;
} }
@@ -2923,29 +2923,6 @@ public partial class MainWindow : Window
e.Handled = true; 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) private void SetSelectedDesktopComponent(Border? host)
{ {
ClearSelectedLauncherTile(refreshTaskbar: false); ClearSelectedLauncherTile(refreshTaskbar: false);