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

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ public interface IAirAppLauncherService
{
void OpenWorldClock(string? sourcePlacementId);
void OpenWorldClock(string sourceComponentId, string? sourcePlacementId);
void OpenWhiteboard(string componentId, string? sourcePlacementId);
}
@@ -24,11 +26,25 @@ internal sealed class AirAppLauncherService : IAirAppLauncherService
public void OpenWorldClock(string? sourcePlacementId)
{
_ = OpenAsync(WorldClockAppId, BuiltInComponentIds.DesktopWorldClock, sourcePlacementId);
OpenWorldClock(BuiltInComponentIds.DesktopWorldClock, sourcePlacementId);
}
public void OpenWorldClock(string sourceComponentId, string? sourcePlacementId)
{
var componentId = string.IsNullOrWhiteSpace(sourceComponentId)
? BuiltInComponentIds.DesktopWorldClock
: sourceComponentId.Trim();
AppLogger.Info(
"AirAppLauncher",
$"World Clock Air APP requested. ComponentId='{componentId}'; PlacementId='{sourcePlacementId ?? string.Empty}'.");
_ = OpenAsync(WorldClockAppId, componentId, sourcePlacementId);
}
public void OpenWhiteboard(string componentId, string? sourcePlacementId)
{
AppLogger.Info(
"AirAppLauncher",
$"Whiteboard Air APP requested. ComponentId='{componentId}'; PlacementId='{sourcePlacementId ?? string.Empty}'.");
_ = OpenAsync(WhiteboardAppId, componentId, sourcePlacementId);
}

View File

@@ -53,12 +53,20 @@ public static class AppDataPathProvider
private static string? ResolveDataRootFromArgs(string[] args)
{
const string prefix = "--data-root=";
foreach (var arg in args)
for (var index = 0; index < args.Length; index++)
{
var arg = args[index];
if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return arg[prefix.Length..];
}
if (string.Equals(arg, "--data-root", StringComparison.OrdinalIgnoreCase) &&
index + 1 < args.Length &&
!args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
return args[index + 1];
}
}
return null;

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
@@ -15,7 +16,7 @@ using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentRuntimeContextAware
{
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -60,6 +61,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
private const double Center = DialSize / 2;
private string _componentId = BuiltInComponentIds.DesktopClock;
private string _placementId = string.Empty;
private DesktopComponentRenderMode _renderMode = DesktopComponentRenderMode.Live;
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new();
@@ -83,6 +85,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
PointerReleased += OnPointerReleased;
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
@@ -126,6 +129,15 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
RefreshFromSettings();
}
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
{
_componentId = string.IsNullOrWhiteSpace(context.ComponentId)
? BuiltInComponentIds.DesktopClock
: context.ComponentId.Trim();
_placementId = context.PlacementId?.Trim() ?? string.Empty;
_renderMode = context.RenderMode;
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
InitializeDialIfNeeded();
@@ -156,6 +168,23 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
UpdateClock();
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
_ = sender;
if (e.InitialPressMouseButton != MouseButton.Left ||
_renderMode != DesktopComponentRenderMode.Live ||
!string.Equals(_componentId, BuiltInComponentIds.DesktopClock, StringComparison.OrdinalIgnoreCase))
{
return;
}
AppLogger.Info(
"AirAppLauncher",
$"Analog clock component clicked. ComponentId='{_componentId}'; PlacementId='{_placementId}'.");
AirAppLauncherServiceProvider.GetOrCreate().OpenWorldClock(_componentId, _placementId);
e.Handled = true;
}
private void InitializeDialIfNeeded()
{
if (_dialInitialized)

View File

@@ -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",

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

View File

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

View File

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