时钟组件的完善。
This commit is contained in:
lincube
2026-03-02 22:46:10 +08:00
parent 2436e43f65
commit 4c3ec920f9
16 changed files with 3002 additions and 154 deletions

View File

@@ -0,0 +1,78 @@
<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="320"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.AnalogClockWidget">
<Border x:Name="RootBorder"
CornerRadius="42"
ClipToBounds="True"
Padding="14">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#1F2C4B"
Offset="0" />
<GradientStop Color="#131B33"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="300"
Height="300">
<Grid Width="258"
Height="258"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Border x:Name="DialBorder"
CornerRadius="129"
Background="#F4F4F4"
BorderBrush="#E5E5E5"
BorderThickness="1" />
<Canvas x:Name="TickCanvas"
Width="258"
Height="258"
IsHitTestVisible="False" />
<Canvas x:Name="NumberCanvas"
Width="258"
Height="258"
IsHitTestVisible="False" />
<Canvas x:Name="HandsCanvas"
Width="258"
Height="258"
IsHitTestVisible="False" />
<TextBlock x:Name="CityTextBlock"
Text="&#x5317;&#x4EAC;"
FontSize="21"
FontWeight="SemiBold"
Foreground="#757575"
HorizontalAlignment="Center"
Margin="0,76,0,0" />
<Ellipse x:Name="CenterDotOuter"
Width="18"
Height="18"
Fill="#1E3C6A"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<Ellipse x:Name="CenterDotInner"
Width="8"
Height="8"
Fill="#1A74F2"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Grid>
</Viewbox>
</Border>
</UserControl>

View File

@@ -0,0 +1,330 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl
{
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private const double DialSize = 258;
private const double Center = DialSize / 2;
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private bool _dialInitialized;
private bool _handsInitialized;
private bool? _isNightModeApplied;
private readonly Line _hourHandLine = CreateHandLine("#1A2A46", 12);
private readonly Line _minuteHandLine = CreateHandLine("#29406B", 8);
private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 4);
public AnalogClockWidget()
{
InitializeComponent();
_timer.Tick += OnTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
UpdateClock();
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
if (_timeZoneService is not null)
{
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
}
_timeZoneService = timeZoneService;
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
UpdateClock();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
UpdateClock();
_timer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnTimerTick(object? sender, EventArgs e)
{
UpdateClock();
}
private void OnTimeZoneChanged(object? sender, EventArgs e)
{
UpdateClock();
}
private void InitializeDialIfNeeded()
{
if (_dialInitialized)
{
return;
}
BuildTicks(isNightMode: true);
BuildNumbers(isNightMode: true);
_dialInitialized = true;
}
private void InitializeHandsIfNeeded()
{
if (_handsInitialized)
{
return;
}
HandsCanvas.Children.Clear();
HandsCanvas.Children.Add(_hourHandLine);
HandsCanvas.Children.Add(_minuteHandLine);
HandsCanvas.Children.Add(_secondHandLine);
_handsInitialized = true;
}
private void BuildTicks(bool isNightMode)
{
TickCanvas.Children.Clear();
var majorBrush = CreateBrush(isNightMode ? "#1A1A1A" : "#1E2430");
var minorBrush = CreateBrush(isNightMode ? "#D0D0D0" : "#D7DCE5");
var majorThickness = isNightMode ? 3.0 : 2.8;
var minorThickness = isNightMode ? 1.4 : 1.2;
for (var i = 0; i < 60; i++)
{
var angle = (i * 6 - 90) * Math.PI / 180d;
var isHourTick = i % 5 == 0;
var outerRadius = Center - 7;
var innerRadius = outerRadius - (isHourTick ? 16 : 8);
var x1 = Center + Math.Cos(angle) * innerRadius;
var y1 = Center + Math.Sin(angle) * innerRadius;
var x2 = Center + Math.Cos(angle) * outerRadius;
var y2 = Center + Math.Sin(angle) * outerRadius;
var tick = new Line
{
StartPoint = new Point(x1, y1),
EndPoint = new Point(x2, y2),
Stroke = isHourTick ? majorBrush : minorBrush,
StrokeThickness = isHourTick ? majorThickness : minorThickness,
StrokeLineCap = PenLineCap.Round
};
TickCanvas.Children.Add(tick);
}
}
private void BuildNumbers(bool isNightMode)
{
NumberCanvas.Children.Clear();
var foreground = CreateBrush(isNightMode ? "#101010" : "#0F131A");
var fontWeight = isNightMode ? FontWeight.Bold : FontWeight.SemiBold;
for (var number = 1; number <= 12; number++)
{
var angle = (number * 30 - 90) * Math.PI / 180d;
var radius = 88;
var x = Center + Math.Cos(angle) * radius;
var y = Center + Math.Sin(angle) * radius;
var isDoubleDigit = number >= 10;
var width = isDoubleDigit ? 44 : 28;
var height = 34;
var text = new TextBlock
{
Text = number.ToString(CultureInfo.InvariantCulture),
Width = width,
Height = height,
TextAlignment = TextAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = 18,
FontWeight = fontWeight,
Foreground = foreground
};
Canvas.SetLeft(text, x - width / 2d);
Canvas.SetTop(text, y - height / 2d);
NumberCanvas.Children.Add(text);
}
}
private void UpdateClock()
{
ApplyModeVisualIfNeeded();
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var hourAngle = (now.Hour % 12 + now.Minute / 60d + now.Second / 3600d) * 30d;
var minuteAngle = (now.Minute + now.Second / 60d) * 6d;
var secondAngle = (now.Second + now.Millisecond / 1000d) * 6d;
SetHandGeometry(_hourHandLine, hourAngle, forwardLength: 52, backwardLength: 6);
SetHandGeometry(_minuteHandLine, minuteAngle, forwardLength: 76, backwardLength: 8);
SetHandGeometry(_secondHandLine, secondAngle, forwardLength: 94, backwardLength: 18);
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
CityTextBlock.Text = isZh ? "\u5317\u4eac" : "Beijing";
}
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");
DialBorder.Background = CreateBrush(isNightMode ? "#F4F4F4" : "#FEFEFF");
DialBorder.BorderBrush = CreateBrush(isNightMode ? "#E5E5E5" : "#DCE2EB");
CityTextBlock.Foreground = CreateBrush(isNightMode ? "#757575" : "#7E8593");
CenterDotOuter.Fill = CreateBrush(isNightMode ? "#1E3C6A" : "#30486E");
CenterDotInner.Fill = CreateBrush("#1A74F2");
_hourHandLine.Stroke = CreateBrush(isNightMode ? "#1A2A46" : "#2E3F5F");
_minuteHandLine.Stroke = CreateBrush(isNightMode ? "#29406B" : "#3E557E");
_secondHandLine.Stroke = CreateBrush("#1A74F2");
BuildTicks(isNightMode);
BuildNumbers(isNightMode);
}
private static void SetHandGeometry(Line hand, double angleDeg, double forwardLength, double backwardLength)
{
var radians = (angleDeg - 90) * Math.PI / 180d;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);
var start = new Point(
Center - cos * backwardLength,
Center - sin * backwardLength);
var end = new Point(
Center + cos * forwardLength,
Center + sin * forwardLength);
hand.StartPoint = start;
hand.EndPoint = end;
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(42 * scale, 16, 56));
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 26));
ApplyModeVisualIfNeeded();
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 1.90);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 300d, 0.58, 2.0) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 300d, 0.58, 2.0) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
}
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)
}
};
}
private static Line CreateHandLine(string strokeHex, double thickness)
{
return new Line
{
StartPoint = new Point(Center, Center),
EndPoint = new Point(Center, Center - 40),
Stroke = CreateBrush(strokeHex),
StrokeThickness = thickness,
StrokeLineCap = PenLineCap.Round
};
}
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;
}
}

View File

@@ -0,0 +1,97 @@
<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="300"
d:DesignHeight="300"
x:Class="LanMontainDesktop.Views.Components.HolidayCalendarWidget">
<Border x:Name="RootBorder"
Background="#DCE7FA"
CornerRadius="34"
ClipToBounds="True"
Padding="14">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="300"
Height="300"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="8">
<TextBlock x:Name="TitleTextBlock"
Grid.Row="0"
Text="Holiday countdown"
FontSize="24"
FontWeight="SemiBold"
Foreground="#61697C"
HorizontalAlignment="Center"
Margin="0,10,0,0" />
<TextBlock x:Name="CountTextBlock"
Grid.Row="1"
Text="0"
FontFeatures="tnum"
FontSize="120"
FontWeight="Bold"
Foreground="#0A0A0A"
HorizontalAlignment="Center" />
<Canvas Grid.Row="2"
Width="260"
Height="40"
HorizontalAlignment="Center">
<Path Data="M 10,16 C 68,11 192,11 250,16"
Stroke="#1A73F0"
StrokeThickness="12"
Opacity="0.97"
StrokeLineCap="Round" />
<Path Data="M 30,19 C 96,22 164,22 230,19"
Stroke="#5C9AF7"
StrokeThickness="3.2"
Opacity="0.55"
StrokeLineCap="Round" />
<Path Data="M 104,28 C 118,23 142,23 156,28 C 146,32 114,33 104,28 Z"
Fill="#1A73F0"
Opacity="0.92" />
<Ellipse Width="58"
Height="4.5"
Fill="#5C9AF7"
Canvas.Left="101"
Canvas.Top="27.5"
Opacity="0.35" />
</Canvas>
<Grid Grid.Row="3"
ColumnDefinitions="*,Auto,*"
Margin="8,0,8,0"
VerticalAlignment="Center">
<Border Grid.Column="0"
Height="2"
Margin="0,0,10,0"
VerticalAlignment="Center"
Background="#B0B9CB" />
<TextBlock x:Name="DayUnitTextBlock"
Grid.Column="1"
Text="Days"
FontSize="56"
Foreground="#7D869A"
FontWeight="Medium"
VerticalAlignment="Center" />
<Border Grid.Column="2"
Height="2"
Margin="10,0,0,0"
VerticalAlignment="Center"
Background="#B0B9CB" />
</Grid>
<TextBlock x:Name="DateTextBlock"
Grid.Row="4"
Text="2024-10-01"
FontSize="34"
Foreground="#596177"
HorizontalAlignment="Center"
Margin="0,2,0,8" />
</Grid>
</Viewbox>
</Border>
</UserControl>

View File

@@ -0,0 +1,215 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class HolidayCalendarWidget : UserControl
{
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromMinutes(15)
};
private static readonly HolidayCalendarService HolidayService = new();
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private CancellationTokenSource? _refreshCts;
private long _refreshVersion;
public HolidayCalendarWidget()
{
InitializeComponent();
_timer.Tick += OnTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
TriggerContentRefresh();
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
if (_timeZoneService is not null)
{
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
}
_timeZoneService = timeZoneService;
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
TriggerContentRefresh();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
TriggerContentRefresh();
_timer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
_refreshCts?.Cancel();
_refreshCts?.Dispose();
_refreshCts = null;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnTimerTick(object? sender, EventArgs e)
{
TriggerContentRefresh();
}
private void OnTimeZoneChanged(object? sender, EventArgs e)
{
TriggerContentRefresh();
}
private void TriggerContentRefresh()
{
_refreshCts?.Cancel();
_refreshCts?.Dispose();
_refreshCts = new CancellationTokenSource();
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
var version = Interlocked.Increment(ref _refreshVersion);
_ = UpdateContentAsync(now, isZh, version, _refreshCts.Token);
}
private async Task UpdateContentAsync(DateTime now, bool isZh, long refreshVersion, CancellationToken cancellationToken)
{
HolidayDisplayInfo displayInfo;
try
{
displayInfo = await HolidayService.GetDisplayInfoAsync(now, cancellationToken);
}
catch (OperationCanceledException)
{
return;
}
catch
{
var fallbackDayType = now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
? HolidayDayType.Weekend
: HolidayDayType.Workday;
displayInfo = new HolidayDisplayInfo(
NextHoliday: HolidayService.GetNextHoliday(now),
TodayStatus: new HolidayDayStatus(
Date: DateOnly.FromDateTime(now.Date),
DayType: fallbackDayType,
TypeNameZh: fallbackDayType == HolidayDayType.Weekend ? "\u5468\u672b" : "\u5de5\u4f5c\u65e5",
IsHoliday: false,
IsAdjustedWorkday: false,
NameZh: null,
NameEn: null,
TargetHolidayZh: null),
UsesOnlineData: false);
}
if (cancellationToken.IsCancellationRequested ||
refreshVersion != Volatile.Read(ref _refreshVersion))
{
return;
}
var holiday = displayInfo.NextHoliday;
if (holiday is null)
{
TitleTextBlock.Text = isZh
? "\u6682\u65e0\u8282\u5047\u65e5\u6570\u636e"
: "No holiday data";
CountTextBlock.Text = "--";
DayUnitTextBlock.Text = isZh ? "\u5929" : "Days";
DateTextBlock.Text = "--";
return;
}
var today = DateOnly.FromDateTime(now.Date);
var remainDays = Math.Max(0, holiday.Date.DayNumber - today.DayNumber);
CountTextBlock.Text = remainDays.ToString(CultureInfo.InvariantCulture);
if (isZh)
{
if (remainDays == 0)
{
TitleTextBlock.Text = $"{holiday.NameZh}\u4eca\u5929";
}
else
{
var adjustPrefix = displayInfo.TodayStatus.IsAdjustedWorkday
? string.IsNullOrWhiteSpace(displayInfo.TodayStatus.NameZh)
? "\u4eca\u65e5\u8c03\u4f11\u8865\u73ed\uff0c"
: string.Create(CultureInfo.InvariantCulture, $"\u4eca\u65e5{displayInfo.TodayStatus.NameZh}\uff0c")
: string.Empty;
TitleTextBlock.Text = string.Create(
CultureInfo.InvariantCulture,
$"{adjustPrefix}\u8ddd{holiday.NameZh}\u8fd8\u6709");
}
DayUnitTextBlock.Text = "\u5929";
var holidayDateText = HolidayCalendarService.FormatDate(holiday.Date, isZh: true);
DateTextBlock.Text = displayInfo.TodayStatus.IsAdjustedWorkday && remainDays > 0
? string.Create(CultureInfo.InvariantCulture, $"{holidayDateText} \u00b7 \u4eca\u65e5\u8865\u73ed")
: holidayDateText;
}
else
{
if (remainDays == 0)
{
TitleTextBlock.Text = $"{holiday.NameEn} is today";
}
else
{
var adjustPrefix = displayInfo.TodayStatus.IsAdjustedWorkday
? "Make-up workday today, "
: string.Empty;
TitleTextBlock.Text = $"{adjustPrefix}Days to {holiday.NameEn}";
}
DayUnitTextBlock.Text = "Days";
var holidayDateText = HolidayCalendarService.FormatDate(holiday.Date, isZh: false);
DateTextBlock.Text = displayInfo.TodayStatus.IsAdjustedWorkday && remainDays > 0
? $"{holidayDateText} - make-up workday"
: holidayDateText;
}
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 15, 50));
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 7, 22));
LayoutRoot.RowSpacing = Math.Clamp(8 * scale, 4, 14);
TitleTextBlock.FontSize = Math.Clamp(24 * scale, 11, 36);
CountTextBlock.FontSize = Math.Clamp(120 * scale, 36, 160);
DayUnitTextBlock.FontSize = Math.Clamp(56 * scale, 16, 78);
DateTextBlock.FontSize = Math.Clamp(34 * scale, 12, 50);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 1.95);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 300d, 0.58, 2.0) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 300d, 0.58, 2.0) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
}
}

View File

@@ -0,0 +1,160 @@
<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="320"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.TimerWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
ClipToBounds="True"
Padding="14"
Background="#E8EAEE">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="300"
Height="300">
<Border x:Name="TimerPanelBorder"
Width="224"
Height="224"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="32"
BorderThickness="1">
<Grid ColumnDefinitions="96,2,*">
<Grid Grid.Column="0"
Margin="18,18,8,18"
RowDefinitions="Auto,Auto,Auto,Auto,*">
<TextBlock x:Name="TopNumberTextBlock"
Grid.Row="0"
Text="60"
FontSize="38"
FontWeight="SemiBold"
Foreground="#AEB4C1"
Margin="0,0,0,10" />
<TextBlock x:Name="MainNumberTextBlock"
Grid.Row="1"
Text="0"
FontSize="64"
FontWeight="Bold"
Foreground="#0F141C" />
<TextBlock x:Name="NextNumberTextBlock"
Grid.Row="2"
Text="1"
FontSize="34"
FontWeight="Medium"
Foreground="#B2B8C4"
Margin="0,8,0,0" />
<TextBlock x:Name="NextNextNumberTextBlock"
Grid.Row="3"
Text="2"
FontSize="26"
FontWeight="Medium"
Foreground="#C8CDD7"
Margin="0,2,0,0" />
</Grid>
<Border x:Name="CenterDivider"
Grid.Column="1"
Width="2"
Margin="0,12"
Background="#D5DAE3" />
<Grid Grid.Column="2"
Margin="10,18,16,16"
RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
<Border x:Name="ScaleMark1"
Grid.Row="0"
Height="3"
Width="18"
CornerRadius="2"
HorizontalAlignment="Left"
Background="#D0D6E1" />
<Border x:Name="ScaleMark2"
Grid.Row="1"
Height="3"
Width="16"
CornerRadius="2"
Margin="0,18,0,0"
HorizontalAlignment="Left"
Background="#D0D6E1" />
<Border x:Name="ScaleMark3"
Grid.Row="2"
Height="3"
Width="14"
CornerRadius="2"
Margin="0,18,0,0"
HorizontalAlignment="Left"
Background="#D0D6E1" />
<Border x:Name="ScaleMark4"
Grid.Row="3"
Height="3"
Width="12"
CornerRadius="2"
Margin="0,18,0,0"
HorizontalAlignment="Left"
Background="#D0D6E1" />
<Border x:Name="PlayButtonBorder"
Grid.Row="5"
Width="42"
Height="42"
CornerRadius="21"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Background="#00000000"
BorderBrush="#D3D9E4"
BorderThickness="2"
Cursor="Hand">
<Viewbox Width="16"
Height="16"
Stretch="Uniform">
<Path x:Name="PlayIconPath"
Data="M 0,0 L 0,14 L 11,7 Z"
Fill="#98A2B8"
Stretch="Uniform" />
</Viewbox>
</Border>
</Grid>
</Grid>
</Border>
<Canvas Width="224"
Height="224"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False">
<Line x:Name="HandGlowLine"
StartPoint="112,112"
EndPoint="186,112"
Stroke="#FF7A78"
StrokeThickness="9"
Opacity="0.20"
StrokeLineCap="Round" />
<Line x:Name="HandLine"
StartPoint="112,112"
EndPoint="180,112"
Stroke="#FF4D63"
StrokeThickness="6"
StrokeLineCap="Round" />
<Ellipse x:Name="CenterDotRing"
Width="22"
Height="22"
Fill="#FDFEFF"
Stroke="#E3E8F0"
StrokeThickness="2"
Canvas.Left="101"
Canvas.Top="101" />
<Ellipse x:Name="CenterDotCore"
Width="8"
Height="8"
Fill="#FF4D63"
Canvas.Left="108"
Canvas.Top="108" />
</Canvas>
</Grid>
</Viewbox>
</Border>
</UserControl>

View File

@@ -0,0 +1,273 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
namespace LanMontainDesktop.Views.Components;
public partial class TimerWidget : UserControl
{
private const int MaxTimerSeconds = 60;
private const double DialSize = 224;
private const double Center = DialSize / 2;
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private double _currentCellSize = 48;
private bool _isRunning;
private int _remainingSeconds;
private bool? _isNightModeApplied;
public TimerWidget()
{
InitializeComponent();
_timer.Tick += OnTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
PlayButtonBorder.PointerPressed += OnPlayButtonPointerPressed;
UpdateVisual();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
UpdateVisual();
_timer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnTimerTick(object? sender, EventArgs e)
{
ApplyModeVisualIfNeeded();
if (!_isRunning)
{
return;
}
if (_remainingSeconds > 0)
{
_remainingSeconds--;
}
if (_remainingSeconds <= 0)
{
_remainingSeconds = 0;
_isRunning = false;
}
UpdateVisual();
}
private void OnPlayButtonPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
if (_isRunning)
{
_isRunning = false;
}
else
{
if (_remainingSeconds <= 0)
{
_remainingSeconds = MaxTimerSeconds;
}
_isRunning = true;
}
UpdateVisual();
e.Handled = true;
}
private void UpdateVisual()
{
ApplyModeVisualIfNeeded();
UpdateNumberVisual();
UpdateHandGeometry();
UpdatePlayButtonVisual();
}
private void UpdateNumberVisual()
{
var current = Math.Clamp(_remainingSeconds, 0, MaxTimerSeconds);
var top = current == 0 ? MaxTimerSeconds : current - 1;
var next = (current + 1) % (MaxTimerSeconds + 1);
var nextNext = (current + 2) % (MaxTimerSeconds + 1);
TopNumberTextBlock.Text = top.ToString(CultureInfo.InvariantCulture);
MainNumberTextBlock.Text = current.ToString(CultureInfo.InvariantCulture);
NextNumberTextBlock.Text = next.ToString(CultureInfo.InvariantCulture);
NextNextNumberTextBlock.Text = nextNext.ToString(CultureInfo.InvariantCulture);
}
private void UpdateHandGeometry()
{
var angleDeg = (_remainingSeconds % (MaxTimerSeconds + 1)) / 60d * 360d;
var radians = angleDeg * Math.PI / 180d;
var cos = Math.Cos(radians);
var sin = Math.Sin(radians);
var start = new Point(Center - cos * 2, Center - sin * 2);
var end = new Point(Center + cos * 68, Center + sin * 68);
var glowEnd = new Point(Center + cos * 74, Center + sin * 74);
HandLine.StartPoint = start;
HandLine.EndPoint = end;
HandGlowLine.StartPoint = start;
HandGlowLine.EndPoint = glowEnd;
}
private void UpdatePlayButtonVisual()
{
PlayIconPath.Data = Geometry.Parse(_isRunning
? "M 0,0 L 4,0 L 4,14 L 0,14 Z M 8,0 L 12,0 L 12,14 L 8,14 Z"
: "M 0,0 L 0,14 L 11,7 Z");
}
private void ApplyModeVisualIfNeeded()
{
var isNightMode = ResolveIsNightMode();
if (_isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
{
return;
}
_isNightModeApplied = isNightMode;
ApplyModeVisual(isNightMode);
}
private void ApplyModeVisual(bool isNightMode)
{
RootBorder.Background = CreateBrush(isNightMode ? "#313540" : "#E8EAEE");
TimerPanelBorder.Background = isNightMode
? CreateLinearGradientBrush("#2F3441", "#202632")
: CreateLinearGradientBrush("#FBFCFE", "#F3F5F9");
TimerPanelBorder.BorderBrush = CreateBrush(isNightMode ? "#3B4353" : "#E2E7F0");
CenterDivider.Background = CreateBrush(isNightMode ? "#434B5C" : "#D5DAE3");
TopNumberTextBlock.Foreground = CreateBrush(isNightMode ? "#7A8397" : "#AEB4C1");
MainNumberTextBlock.Foreground = CreateBrush(isNightMode ? "#F3F6FE" : "#0F141C");
NextNumberTextBlock.Foreground = CreateBrush(isNightMode ? "#8089A0" : "#B2B8C4");
NextNextNumberTextBlock.Foreground = CreateBrush(isNightMode ? "#6A7388" : "#C8CDD7");
var markBrush = CreateBrush(isNightMode ? "#5A657D" : "#D0D6E1");
ScaleMark1.Background = markBrush;
ScaleMark2.Background = markBrush;
ScaleMark3.Background = markBrush;
ScaleMark4.Background = markBrush;
PlayButtonBorder.BorderBrush = CreateBrush(isNightMode ? "#4A5367" : "#D3D9E4");
PlayIconPath.Fill = CreateBrush(isNightMode ? "#8E98AF" : "#98A2B8");
CenterDotRing.Fill = CreateBrush(isNightMode ? "#EAF0FF" : "#FDFEFF");
CenterDotRing.Stroke = CreateBrush(isNightMode ? "#A9B8D5" : "#E3E8F0");
CenterDotCore.Fill = CreateBrush("#FF4D63");
HandLine.Stroke = CreateBrush("#FF4D63");
HandGlowLine.Stroke = CreateBrush(isNightMode ? "#FF6A6E" : "#FF7A78");
HandGlowLine.Opacity = isNightMode ? 0.28 : 0.20;
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 12, 48));
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 7, 22));
TimerPanelBorder.CornerRadius = new CornerRadius(Math.Clamp(32 * scale, 12, 42));
PlayButtonBorder.Width = Math.Clamp(42 * scale, 28, 58);
PlayButtonBorder.Height = Math.Clamp(42 * scale, 28, 58);
PlayButtonBorder.CornerRadius = new CornerRadius(PlayButtonBorder.Width / 2d);
ApplyModeVisualIfNeeded();
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 1.90);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 300d, 0.58, 2.0) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 300d, 0.58, 2.0) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
}
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 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)
}
};
}
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;
}
}

View File

@@ -1065,6 +1065,21 @@ public partial class MainWindow
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopClock, StringComparison.OrdinalIgnoreCase))
{
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopTimer, StringComparison.OrdinalIgnoreCase))
{
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
}
if (string.Equals(componentId, BuiltInComponentIds.HolidayCalendar, StringComparison.OrdinalIgnoreCase))
{
return (Math.Max(2, span.WidthCells), Math.Max(2, span.HeightCells));
}
return (Math.Max(1, span.WidthCells), Math.Max(1, span.HeightCells));
}
@@ -1075,6 +1090,9 @@ public partial class MainWindow
BuiltInComponentIds.Date => 16,
BuiltInComponentIds.MonthCalendar => Math.Clamp(_currentDesktopCellSize * 0.26, 10, 22),
BuiltInComponentIds.LunarCalendar => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 26),
BuiltInComponentIds.DesktopClock => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 28),
BuiltInComponentIds.DesktopTimer => Math.Clamp(_currentDesktopCellSize * 0.30, 12, 28),
BuiltInComponentIds.HolidayCalendar => Math.Clamp(_currentDesktopCellSize * 0.32, 12, 28),
_ => Math.Clamp(_currentDesktopCellSize * 0.22, 8, 18)
};
}
@@ -1181,6 +1199,32 @@ public partial class MainWindow
return widget;
}
if (componentId == BuiltInComponentIds.DesktopClock)
{
var widget = new AnalogClockWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
}
if (componentId == BuiltInComponentIds.DesktopTimer)
{
var widget = new TimerWidget();
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
}
if (componentId == BuiltInComponentIds.HolidayCalendar)
{
var widget = new HolidayCalendarWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(_currentDesktopCellSize);
widget.Classes.Add(DesktopComponentClass);
return widget;
}
return null;
}
@@ -1911,38 +1955,19 @@ public partial class MainWindow
ComponentLibraryCategoryPagesContainer.Children.Clear();
ComponentLibraryCategoryPagesContainer.RowDefinitions.Clear();
ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Clear();
ComponentLibraryCategoryPagesContainer.Width = double.NaN;
ComponentLibraryCategoryPagesContainer.Height = double.NaN;
ComponentLibraryCategoryPagesHost.Width = double.NaN;
ComponentLibraryCategoryPagesHost.Height = double.NaN;
if (categoryCount == 0)
{
_componentLibraryCategoryIndex = 0;
_componentLibraryActiveCategoryId = null;
UpdateComponentLibraryComponentNavigationButtons();
return;
}
var viewportWidth = ComponentLibraryCategoryViewport.Bounds.Width;
if (viewportWidth <= 1 && ComponentLibraryWindow is not null)
{
viewportWidth = Math.Max(1, ComponentLibraryWindow.Bounds.Width - 48);
}
var viewportHeight = ComponentLibraryCategoryViewport.Bounds.Height;
if (viewportHeight <= 1 && ComponentLibraryWindow is not null)
{
viewportHeight = Math.Max(1, ComponentLibraryWindow.Bounds.Height - 120);
}
_componentLibraryCategoryPageWidth = Math.Max(1, viewportWidth);
ComponentLibraryCategoryPagesHost.Width = _componentLibraryCategoryPageWidth * categoryCount;
ComponentLibraryCategoryPagesHost.Height = viewportHeight;
ComponentLibraryCategoryPagesContainer.Width = ComponentLibraryCategoryPagesHost.Width;
ComponentLibraryCategoryPagesContainer.Height = viewportHeight;
ComponentLibraryCategoryPagesContainer.RowDefinitions.Add(new RowDefinition(new GridLength(viewportHeight, GridUnitType.Pixel)));
for (var i = 0; i < categoryCount; i++)
{
ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Add(
new ColumnDefinition(new GridLength(_componentLibraryCategoryPageWidth, GridUnitType.Pixel)));
}
if (!string.IsNullOrWhiteSpace(_componentLibraryActiveCategoryId))
{
var activeIndex = _componentLibraryCategories
@@ -1959,71 +1984,65 @@ public partial class MainWindow
_componentLibraryActiveCategoryId = _componentLibraryCategories[_componentLibraryCategoryIndex].Id;
ComponentLibraryCategoryPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star)));
for (var i = 0; i < categoryCount; i++)
{
var category = _componentLibraryCategories[i];
var page = new Grid
var isSelected = i == _componentLibraryCategoryIndex;
var row = new RowDefinition(GridLength.Auto);
ComponentLibraryCategoryPagesContainer.RowDefinitions.Add(row);
var icon = new SymbolIcon
{
Width = _componentLibraryCategoryPageWidth,
Height = viewportHeight,
Background = Brushes.Transparent
Symbol = category.Icon,
IconVariant = IconVariant.Regular,
FontSize = 18,
VerticalAlignment = VerticalAlignment.Center
};
var cardWidth = Math.Clamp(_componentLibraryCategoryPageWidth * 0.64, 160, 260);
var cardHeight = Math.Clamp(viewportHeight * 0.70, 140, 220);
var iconSize = Math.Clamp(cardHeight * 0.34, 30, 56);
var card = new Border
var title = new TextBlock
{
Classes = { "glass-panel" },
Width = cardWidth,
Height = cardHeight,
CornerRadius = new CornerRadius(36),
Padding = new Thickness(18),
HorizontalAlignment = HorizontalAlignment.Center,
Text = category.Title,
FontSize = 15,
FontWeight = isSelected ? FontWeight.Bold : FontWeight.SemiBold,
Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush"),
VerticalAlignment = VerticalAlignment.Center,
Child = new StackPanel
{
Spacing = 12,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
new SymbolIcon
{
Symbol = category.Icon,
IconVariant = IconVariant.Regular,
FontSize = iconSize,
HorizontalAlignment = HorizontalAlignment.Center
},
new TextBlock
{
Text = category.Title,
FontSize = Math.Clamp(cardHeight * 0.14, 12, 18),
FontWeight = FontWeight.SemiBold,
HorizontalAlignment = HorizontalAlignment.Center,
Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush")
}
}
}
TextTrimming = TextTrimming.CharacterEllipsis
};
page.Children.Add(card);
var contentGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 10,
Children = { icon, title }
};
Grid.SetColumn(icon, 0);
Grid.SetColumn(title, 1);
Grid.SetRow(page, 0);
Grid.SetColumn(page, i);
ComponentLibraryCategoryPagesContainer.Children.Add(page);
var itemButton = new Button
{
Tag = i,
Margin = new Thickness(0, 0, 0, i < categoryCount - 1 ? 8 : 0),
Padding = new Thickness(12, 10),
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalContentAlignment = VerticalAlignment.Center,
Background = isSelected
? GetThemeBrush("AdaptiveNavItemSelectedBackgroundBrush")
: GetThemeBrush("AdaptiveNavItemBackgroundBrush"),
BorderBrush = GetThemeBrush("AdaptiveButtonBorderBrush"),
BorderThickness = new Thickness(isSelected ? 1.5 : 1),
Content = contentGrid
};
itemButton.Click += OnComponentLibraryCategoryItemClick;
Grid.SetRow(itemButton, i);
Grid.SetColumn(itemButton, 0);
ComponentLibraryCategoryPagesContainer.Children.Add(itemButton);
}
_componentLibraryCategoryHostTransform = ComponentLibraryCategoryPagesHost.RenderTransform as TranslateTransform;
if (_componentLibraryCategoryHostTransform is null)
{
_componentLibraryCategoryHostTransform = new TranslateTransform();
ComponentLibraryCategoryPagesHost.RenderTransform = _componentLibraryCategoryHostTransform;
}
ApplyComponentLibraryCategoryOffset();
_componentLibraryCategoryHostTransform = null;
_componentLibraryCategoryPageWidth = 0;
if (ComponentLibraryBackTextBlock is not null)
{
@@ -2063,6 +2082,11 @@ public partial class MainWindow
private Symbol ResolveComponentLibraryCategoryIcon(string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Clock;
}
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase))
{
return Symbol.CalendarDate;
@@ -2073,6 +2097,11 @@ public partial class MainWindow
private string GetLocalizedComponentLibraryCategoryTitle(string categoryId)
{
if (string.Equals(categoryId, "Clock", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.clock", "Clock");
}
if (string.Equals(categoryId, "Date", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.date", "Calendar");
@@ -2099,6 +2128,31 @@ public partial class MainWindow
}
_componentLibraryComponentHostTransform.X = -_componentLibraryComponentIndex * _componentLibraryComponentPageWidth;
UpdateComponentLibraryComponentNavigationButtons();
}
private void UpdateComponentLibraryComponentNavigationButtons()
{
if (ComponentLibraryPrevComponentButton is null || ComponentLibraryNextComponentButton is null)
{
return;
}
var maxIndex = Math.Max(0, _componentLibraryActiveComponents.Count - 1);
var hasMultiplePages = maxIndex > 0;
ComponentLibraryPrevComponentButton.IsVisible = hasMultiplePages;
ComponentLibraryNextComponentButton.IsVisible = hasMultiplePages;
if (!hasMultiplePages)
{
ComponentLibraryPrevComponentButton.IsEnabled = false;
ComponentLibraryNextComponentButton.IsEnabled = false;
return;
}
ComponentLibraryPrevComponentButton.IsEnabled = _componentLibraryComponentIndex > 0;
ComponentLibraryNextComponentButton.IsEnabled = _componentLibraryComponentIndex < maxIndex;
}
private void OpenComponentLibraryCurrentCategory()
@@ -2134,19 +2188,35 @@ public partial class MainWindow
if (componentCount == 0)
{
_componentLibraryComponentIndex = 0;
UpdateComponentLibraryComponentNavigationButtons();
return;
}
var viewportWidth = ComponentLibraryComponentViewport.Bounds.Width;
if (viewportWidth <= 1 && ComponentLibraryWindow is not null)
if (viewportWidth <= 1)
{
viewportWidth = Math.Max(1, ComponentLibraryWindow.Bounds.Width - 48);
if (ComponentLibraryComponentViewport.Parent is Control parent && parent.Bounds.Width > 1)
{
// Parent includes left/right nav buttons; reserve space to get true viewport width.
viewportWidth = Math.Max(1, parent.Bounds.Width - 96);
}
else if (ComponentLibraryWindow is not null)
{
viewportWidth = Math.Max(1, ComponentLibraryWindow.Bounds.Width - 150);
}
}
var viewportHeight = ComponentLibraryComponentViewport.Bounds.Height;
if (viewportHeight <= 1 && ComponentLibraryWindow is not null)
if (viewportHeight <= 1)
{
viewportHeight = Math.Max(1, ComponentLibraryWindow.Bounds.Height - 160);
if (ComponentLibraryComponentViewport.Parent is Control parent && parent.Bounds.Height > 1)
{
viewportHeight = Math.Max(1, parent.Bounds.Height);
}
else if (ComponentLibraryWindow is not null)
{
viewportHeight = Math.Max(1, ComponentLibraryWindow.Bounds.Height - 170);
}
}
_componentLibraryComponentPageWidth = Math.Max(1, viewportWidth);
@@ -2180,19 +2250,19 @@ public partial class MainWindow
};
// Fit the preview to the page while preserving component cell span proportions.
var previewMaxWidth = _componentLibraryComponentPageWidth * 0.86;
var previewMaxHeight = viewportHeight * 0.72;
var previewMaxWidth = _componentLibraryComponentPageWidth * 0.94;
var previewMaxHeight = viewportHeight * 0.86;
var previewSpan = NormalizeComponentCellSpan(
resolved.Id,
(resolved.MinWidthCells, resolved.MinHeightCells));
var previewCellSize = Math.Min(
previewMaxWidth / Math.Max(1, previewSpan.WidthCells),
previewMaxHeight / Math.Max(1, previewSpan.HeightCells));
previewCellSize = Math.Clamp(previewCellSize, 20, 72);
previewCellSize = Math.Clamp(previewCellSize, 24, 96);
var previewWidth = previewSpan.WidthCells * previewCellSize;
var previewHeight = previewSpan.HeightCells * previewCellSize;
var renderCellSize = Math.Clamp(previewCellSize * 1.35, 28, 82);
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
var previewControl = CreateComponentLibraryPreviewControl(resolved.Id, renderCellSize);
if (previewControl is null)
@@ -2220,8 +2290,7 @@ public partial class MainWindow
{
Width = previewWidth,
Height = previewHeight,
CornerRadius = new CornerRadius(20),
ClipToBounds = true,
ClipToBounds = false,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Child = previewViewbox,
@@ -2248,18 +2317,12 @@ public partial class MainWindow
var stack = new StackPanel
{
Spacing = 10,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
new Border
{
Classes = { "glass-panel" },
CornerRadius = new CornerRadius(28),
Padding = new Thickness(12),
Child = previewBorder
},
previewBorder,
label,
hint
}
@@ -2280,6 +2343,7 @@ public partial class MainWindow
}
ApplyComponentLibraryComponentOffset();
UpdateComponentLibraryComponentNavigationButtons();
}
private Control? CreateComponentLibraryPreviewControl(string componentId, double cellSize)
@@ -2308,6 +2372,29 @@ public partial class MainWindow
return widget;
}
if (componentId == BuiltInComponentIds.DesktopClock)
{
var widget = new AnalogClockWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(cellSize);
return widget;
}
if (componentId == BuiltInComponentIds.DesktopTimer)
{
var widget = new TimerWidget();
widget.ApplyCellSize(cellSize);
return widget;
}
if (componentId == BuiltInComponentIds.HolidayCalendar)
{
var widget = new HolidayCalendarWidget();
widget.SetTimeZoneService(_timeZoneService);
widget.ApplyCellSize(cellSize);
return widget;
}
return null;
}
@@ -2328,6 +2415,21 @@ public partial class MainWindow
return L("component.lunar_calendar", definition.DisplayName);
}
if (string.Equals(definition.Id, BuiltInComponentIds.DesktopClock, StringComparison.OrdinalIgnoreCase))
{
return L("component.desktop_clock", definition.DisplayName);
}
if (string.Equals(definition.Id, BuiltInComponentIds.DesktopTimer, StringComparison.OrdinalIgnoreCase))
{
return L("component.desktop_timer", definition.DisplayName);
}
if (string.Equals(definition.Id, BuiltInComponentIds.HolidayCalendar, StringComparison.OrdinalIgnoreCase))
{
return L("component.holiday_calendar", definition.DisplayName);
}
return definition.DisplayName;
}
@@ -2418,6 +2520,42 @@ public partial class MainWindow
BuildComponentLibraryCategoryPages();
}
private void OnComponentLibraryCategoryItemClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button button ||
button.Tag is not int categoryIndex ||
_componentLibraryCategories.Count == 0)
{
return;
}
_componentLibraryCategoryIndex = Math.Clamp(categoryIndex, 0, Math.Max(0, _componentLibraryCategories.Count - 1));
OpenComponentLibraryCurrentCategory();
}
private void OnComponentLibraryPrevComponentClick(object? sender, RoutedEventArgs e)
{
if (_componentLibraryActiveComponents.Count <= 1)
{
return;
}
_componentLibraryComponentIndex = Math.Max(0, _componentLibraryComponentIndex - 1);
ApplyComponentLibraryComponentOffset();
}
private void OnComponentLibraryNextComponentClick(object? sender, RoutedEventArgs e)
{
var maxIndex = Math.Max(0, _componentLibraryActiveComponents.Count - 1);
if (maxIndex <= 0)
{
return;
}
_componentLibraryComponentIndex = Math.Min(maxIndex, _componentLibraryComponentIndex + 1);
ApplyComponentLibraryComponentOffset();
}
private void OnComponentLibraryCategoryViewportPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!_isComponentLibraryOpen ||

View File

@@ -1163,11 +1163,11 @@
Classes="glass-strong"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Width="520"
MinWidth="360"
MaxWidth="720"
Height="260"
MinHeight="220"
Width="620"
MinWidth="420"
MaxWidth="860"
Height="320"
MinHeight="260"
Margin="24,24,24,100"
CornerRadius="36"
Padding="14"
@@ -1211,37 +1211,26 @@
<Grid>
<!-- Category picker (outer) -->
<Grid x:Name="ComponentLibraryCategoriesView">
<Border x:Name="ComponentLibraryCategoryViewport"
Background="Transparent"
ClipToBounds="True"
PointerPressed="OnComponentLibraryCategoryViewportPointerPressed"
PointerMoved="OnComponentLibraryCategoryViewportPointerMoved"
PointerReleased="OnComponentLibraryCategoryViewportPointerReleased"
PointerCaptureLost="OnComponentLibraryCategoryViewportPointerCaptureLost">
<Grid>
<Grid x:Name="ComponentLibraryCategoryPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<Grid.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="0:0:0.22" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform>
<Grid RowDefinitions="*">
<Border x:Name="ComponentLibraryCategoryViewport"
Background="Transparent"
ClipToBounds="True">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<Grid x:Name="ComponentLibraryCategoryPagesHost"
HorizontalAlignment="Stretch"
VerticalAlignment="Top">
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
</Grid>
</ScrollViewer>
</Border>
<Grid x:Name="ComponentLibraryCategoryPagesContainer" />
</Grid>
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No components." />
</Grid>
</Border>
<TextBlock x:Name="ComponentLibraryEmptyTextBlock"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="No components." />
</Grid>
</Grid>
<!-- Component picker (inner) -->
@@ -1265,32 +1254,64 @@
</StackPanel>
</Button>
<Border x:Name="ComponentLibraryComponentViewport"
Grid.Row="1"
Background="Transparent"
ClipToBounds="True"
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
<Grid>
<Grid x:Name="ComponentLibraryComponentPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<Grid.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="0:0:0.22" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<Button x:Name="ComponentLibraryPrevComponentButton"
Grid.Column="0"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryPrevComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronLeft"
IconVariant="Regular" />
</Button>
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
<Border x:Name="ComponentLibraryComponentViewport"
Grid.Column="1"
Background="Transparent"
ClipToBounds="True"
PointerPressed="OnComponentLibraryComponentViewportPointerPressed"
PointerMoved="OnComponentLibraryComponentViewportPointerMoved"
PointerReleased="OnComponentLibraryComponentViewportPointerReleased"
PointerCaptureLost="OnComponentLibraryComponentViewportPointerCaptureLost">
<Grid>
<Grid x:Name="ComponentLibraryComponentPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<Grid.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="0:0:0.22" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform>
<Grid x:Name="ComponentLibraryComponentPagesContainer" />
</Grid>
</Grid>
</Grid>
</Border>
</Border>
<Button x:Name="ComponentLibraryNextComponentButton"
Grid.Column="2"
Width="36"
Height="36"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="18"
Click="OnComponentLibraryNextComponentClick"
IsVisible="False">
<fi:SymbolIcon Symbol="ChevronRight"
IconVariant="Regular" />
</Button>
</Grid>
</Grid>
</Grid>
</Border>