小白板,天气,时钟
This commit is contained in:
lincube
2026-03-03 04:56:04 +08:00
parent 4c3ec920f9
commit 5dc2d680fb
57 changed files with 8776 additions and 387 deletions

View File

@@ -10,7 +10,7 @@ using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{

View File

@@ -14,7 +14,7 @@ public enum ClockDisplayFormat
HourMinute // HH:mm
}
public partial class ClockWidget : UserControl
public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{

View File

@@ -13,7 +13,8 @@
ClipToBounds="True"
Padding="12">
<Viewbox Stretch="Uniform">
<Grid Width="460"
<Grid x:Name="LayoutRoot"
Width="460"
Height="220"
ColumnDefinitions="1.2*,1*"
ColumnSpacing="12">
@@ -24,85 +25,135 @@
RowSpacing="8">
<TextBlock x:Name="GregorianHeadlineTextBlock"
Grid.Row="0"
FontSize="22"
FontSize="30"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<UniformGrid Grid.Row="1"
<UniformGrid x:Name="WeekdayHeaderGrid"
Grid.Row="1"
Columns="7">
<TextBlock x:Name="WeekdayText0" Text="日" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText1" Text="一" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText2" Text="二" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText3" Text="三" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText4" Text="四" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText5" Text="五" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText6" Text="六" HorizontalAlignment="Center" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" FontSize="13" FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText0"
Text="S"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText1"
Text="M"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText2"
Text="T"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText3"
Text="W"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText4"
Text="T"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText5"
Text="F"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
<TextBlock x:Name="WeekdayText6"
Text="S"
HorizontalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
FontSize="17"
FontWeight="SemiBold" />
</UniformGrid>
<Grid x:Name="CalendarGrid"
Grid.Row="2"
RowDefinitions="*,*,*,*,*"
RowDefinitions="*,*,*,*,*,*"
ColumnDefinitions="*,*,*,*,*,*,*" />
</Grid>
<Border x:Name="LunarCardBorder"
Grid.Column="1"
Background="{DynamicResource AdaptiveLayer2Brush}"
BorderThickness="1"
CornerRadius="24"
BoxShadow="0 8 18 #1A000000"
Padding="14">
<Grid x:Name="RightPanelGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="10">
RowDefinitions="Auto,Auto,Auto,*,*"
RowSpacing="8">
<TextBlock x:Name="LunarDateTextBlock"
Grid.Row="0"
FontSize="28"
FontWeight="Bold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="LunarMetaTextBlock"
Grid.Row="1"
FontSize="14"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Opacity="0.88"
TextWrapping="Wrap" />
Opacity="0.86"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<Border Grid.Row="2"
<Border x:Name="DividerBorder"
Grid.Row="2"
Height="1"
Margin="0,2,0,2"
Margin="0,1,0,1"
Background="{DynamicResource AdaptiveStrokeBrush}" />
<Grid Grid.Row="3"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
ColumnSpacing="8"
VerticalAlignment="Top">
<TextBlock x:Name="YiLabelTextBlock"
Grid.Column="0"
Text=""
Text="Yi"
FontSize="18"
FontWeight="Bold"
Foreground="#4E7D3A" />
Foreground="#4E7D3A"
VerticalAlignment="Top" />
<TextBlock x:Name="YiItemsTextBlock"
Grid.Column="1"
FontSize="16"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis" />
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
</Grid>
<Grid Grid.Row="4"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
ColumnSpacing="8"
VerticalAlignment="Top">
<TextBlock x:Name="JiLabelTextBlock"
Grid.Column="0"
Text=""
Text="Ji"
FontSize="18"
FontWeight="Bold"
Foreground="#A1473E" />
Foreground="#A1473E"
VerticalAlignment="Top" />
<TextBlock x:Name="JiItemsTextBlock"
Grid.Column="1"
FontSize="16"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis" />
TextTrimming="CharacterEllipsis"
MaxLines="2"
VerticalAlignment="Top" />
</Grid>
</Grid>
</Border>

View File

@@ -4,26 +4,97 @@ using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class DateWidget : UserControl
public partial class DateWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromMinutes(1)
};
private static readonly LunarCalendarService LunarCalendarService = new();
private static readonly string[] ZhWeekdayHeaders = ["", "", "", "", "", "", ""];
private static readonly string[] ZhWeekdayHeaders = ["\u65e5", "\u4e00", "\u4e8c", "\u4e09", "\u56db", "\u4e94", "\u516d"];
private static readonly string[] EnWeekdayHeaders = ["S", "M", "T", "W", "T", "F", "S"];
private static readonly string[] ZhYiCandidates =
[
"\u796d\u7940",
"\u7948\u798f",
"\u4f1a\u53cb",
"\u51fa\u884c",
"\u6c42\u8d22",
"\u5f00\u5e02",
"\u4ea4\u6613",
"\u5ac1\u5a36",
"\u6c42\u5b66",
"\u4fee\u9020",
"\u5b89\u5e8a",
"\u7eb3\u91c7"
];
private static readonly string[] ZhJiCandidates =
[
"\u52a8\u571f",
"\u8bc9\u8bbc",
"\u8fdc\u822a",
"\u4e89\u6267",
"\u7834\u571f",
"\u5b89\u846c",
"\u4f10\u6728",
"\u6398\u4e95",
"\u8fc1\u5f99",
"\u5f00\u4ed3",
"\u7f6e\u4ea7",
"\u5f00\u6e20"
];
private static readonly string[] EnYiCandidates =
[
"Worship",
"Blessing",
"Travel",
"Meetings",
"Trade",
"Business",
"Study",
"Build",
"Gathering",
"Planning"
];
private static readonly string[] EnJiCandidates =
[
"Dispute",
"Lawsuit",
"Major move",
"Groundwork",
"Burial",
"Long voyage",
"Contract rush",
"Risky purchase",
"Heavy repair",
"Conflict"
];
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 64;
private double _calendarDayFontSize = 14;
private double _calendarTodayDotSize = 28;
private double _weekdayFontSize = 17;
private FontWeight _weekdayFontWeight = FontWeight.SemiBold;
private double _calendarDayFontSize = 18;
private FontWeight _calendarDayFontWeight = FontWeight.SemiBold;
private double _calendarTodayDotSize = 32;
private int _lunarItemCount = 3;
private int _calendarVisibleRows = 6;
private bool? _isNightModeApplied;
private double _weekdayHeaderOpacity = 0.60;
private double _weekdayNumberOpacity = 0.90;
private double _weekendNumberOpacity = 0.58;
public DateWidget()
{
@@ -38,7 +109,7 @@ public partial class DateWidget : UserControl
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
if (_timeZoneService != null)
if (_timeZoneService is not null)
{
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
}
@@ -82,56 +153,67 @@ public partial class DateWidget : UserControl
var lunar = LunarCalendarService.GetLunarInfo(now);
GregorianHeadlineTextBlock.Text = isZh
? $"{now.Month}{now.Day}日 {ToChineseWeekday(now.DayOfWeek)}"
: now.ToString("MMM d ddd", culture);
? $"{now.Month}\u6708{now.Day}\u65e5"
: now.ToString("MMM d", culture);
ApplyAdaptiveTypography();
if (isZh)
{
LunarDateTextBlock.Text = $"农历 {lunar.LunarDateZh}";
LunarMetaTextBlock.Text = $"{lunar.GanzhiYearZh}年({lunar.ZodiacZh}年)";
YiLabelTextBlock.Text = "";
JiLabelTextBlock.Text = "";
YiItemsTextBlock.Text = "祭祀 祈福 出行 会友";
JiItemsTextBlock.Text = "动土 诉讼 远航 争执";
LunarDateTextBlock.Text = lunar.LunarDateZh;
LunarMetaTextBlock.Text = $"{lunar.GanzhiYearZh}\u5e74 {lunar.ZodiacZh}";
YiLabelTextBlock.Text = "\u5b9c";
JiLabelTextBlock.Text = "\u5fcc";
}
else
{
LunarDateTextBlock.Text = $"Lunar {lunar.LunarDateEn}";
LunarMetaTextBlock.Text = $"Ganzhi year: {lunar.GanzhiYearEn} ({lunar.ZodiacEn})";
LunarMetaTextBlock.Text = $"{lunar.GanzhiYearEn} {lunar.ZodiacEn}";
YiLabelTextBlock.Text = "Do";
JiLabelTextBlock.Text = "Avoid";
YiItemsTextBlock.Text = "Worship Blessing Travel Meet";
JiItemsTextBlock.Text = "Groundwork Lawsuit Voyage Dispute";
}
UpdateWeekdayHeaders(isZh);
GenerateCalendar(now);
}
var itemCount = isZh ? _lunarItemCount : Math.Max(1, _lunarItemCount - 1);
YiItemsTextBlock.Text = BuildDailySelection(
now.Date,
isZh ? ZhYiCandidates : EnYiCandidates,
count: itemCount,
salt: 17,
useChineseSpacing: isZh);
JiItemsTextBlock.Text = BuildDailySelection(
now.Date,
isZh ? ZhJiCandidates : EnJiCandidates,
count: itemCount,
salt: 29,
useChineseSpacing: isZh);
private static string ToChineseWeekday(DayOfWeek dayOfWeek)
{
return dayOfWeek switch
{
DayOfWeek.Sunday => "周日",
DayOfWeek.Monday => "周一",
DayOfWeek.Tuesday => "周二",
DayOfWeek.Wednesday => "周三",
DayOfWeek.Thursday => "周四",
DayOfWeek.Friday => "周五",
_ => "周六"
};
UpdateWeekdayHeaders(isZh);
ApplyModeVisualIfNeeded();
GenerateCalendar(now);
}
private void UpdateWeekdayHeaders(bool isZh)
{
var headers = isZh ? ZhWeekdayHeaders : EnWeekdayHeaders;
WeekdayText0.Text = headers[0];
WeekdayText1.Text = headers[1];
WeekdayText2.Text = headers[2];
WeekdayText3.Text = headers[3];
WeekdayText4.Text = headers[4];
WeekdayText5.Text = headers[5];
WeekdayText6.Text = headers[6];
var blocks = GetWeekdayHeaderBlocks();
for (var i = 0; i < blocks.Count; i++)
{
blocks[i].Text = headers[i];
}
}
private IReadOnlyList<TextBlock> GetWeekdayHeaderBlocks()
{
return
[
WeekdayText0,
WeekdayText1,
WeekdayText2,
WeekdayText3,
WeekdayText4,
WeekdayText5,
WeekdayText6
];
}
private void GenerateCalendar(DateTime currentDate)
@@ -139,7 +221,8 @@ public partial class DateWidget : UserControl
var removeList = new List<Control>();
foreach (var child in CalendarGrid.Children)
{
if (child is Control control && control.Tag is string tag &&
if (child is Control control &&
control.Tag is string tag &&
(tag == "day" || tag == "today-dot"))
{
removeList.Add(control);
@@ -158,12 +241,19 @@ public partial class DateWidget : UserControl
var firstDayOfMonth = new DateTime(year, month, 1);
var daysInMonth = DateTime.DaysInMonth(year, month);
var startDayOfWeek = (int)firstDayOfMonth.DayOfWeek;
_calendarVisibleRows = GetCalendarRowCount(startDayOfWeek, daysInMonth);
EnsureCalendarRows(_calendarVisibleRows);
// 4x2 widget has less vertical space than 2x2. Compress only on 6-row months.
var rowDensity = _calendarVisibleRows >= 6 ? 0.84 : 1.0;
var dayFontSize = Math.Clamp(_calendarDayFontSize * rowDensity, 8, 24);
var todayDotSize = Math.Clamp(_calendarTodayDotSize * rowDensity, 13.5, 32);
for (var day = 1; day <= daysInMonth; day++)
{
var row = (day + startDayOfWeek - 1) / 7;
var col = (day + startDayOfWeek - 1) % 7;
if (row > 4)
if (row >= _calendarVisibleRows)
{
continue;
}
@@ -173,8 +263,9 @@ public partial class DateWidget : UserControl
Text = day.ToString(CultureInfo.CurrentCulture),
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = _calendarDayFontSize,
FontWeight = FontWeight.SemiBold,
FontSize = dayFontSize,
FontWeight = _calendarDayFontWeight,
LineHeight = dayFontSize * 1.04,
Tag = "day"
};
@@ -190,9 +281,9 @@ public partial class DateWidget : UserControl
dayText.Foreground = onAccentBrush;
var dot = new Border
{
Width = _calendarTodayDotSize,
Height = _calendarTodayDotSize,
CornerRadius = new CornerRadius(_calendarTodayDotSize * 0.5),
Width = todayDotSize,
Height = todayDotSize,
CornerRadius = new CornerRadius(todayDotSize * 0.5),
Background = accentBrush,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
@@ -208,8 +299,8 @@ public partial class DateWidget : UserControl
{
var isWeekend = col is 0 or 6;
dayText.Foreground = isWeekend
? GetThemeBrush("AdaptiveTextSecondaryBrush", 0.82)
: GetThemeBrush("AdaptiveTextPrimaryBrush", 0.92);
? GetThemeBrush("AdaptiveTextSecondaryBrush", _weekendNumberOpacity)
: GetThemeBrush("AdaptiveTextPrimaryBrush", _weekdayNumberOpacity);
Grid.SetRow(dayText, row);
Grid.SetColumn(dayText, col);
CalendarGrid.Children.Add(dayText);
@@ -220,44 +311,155 @@ public partial class DateWidget : UserControl
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateDate();
}
private void ApplyAdaptiveTypography()
{
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(28 * scale, 16, 40));
RootBorder.Padding = new Thickness(Math.Clamp(12 * scale, 8, 18));
RootBorder.Padding = new Thickness(Math.Clamp(11 * scale, 7, 17));
LayoutRoot.ColumnSpacing = Math.Clamp(10 * scale, 6, 16);
LeftPanelGrid.RowSpacing = Math.Clamp(5.2 * scale, 2.5, 10);
WeekdayHeaderGrid.Margin = new Thickness(
0,
Math.Clamp(0.5 * scale, 0, 2),
0,
Math.Clamp(2.4 * scale, 1, 4));
CalendarGrid.Margin = new Thickness(0, 0, 0, Math.Clamp(0.8 * scale, 0, 2));
LeftPanelGrid.RowSpacing = Math.Clamp(8 * scale, 5, 14);
RightPanelGrid.RowSpacing = Math.Clamp(10 * scale, 6, 16);
LunarCardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 14, 34));
LunarCardBorder.Padding = new Thickness(Math.Clamp(14 * scale, 9, 20));
LunarCardBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 20));
RightPanelGrid.RowSpacing = Math.Clamp(7.5 * scale, 3.5, 11);
DividerBorder.Margin = new Thickness(0, Math.Clamp(1 * scale, 0, 2), 0, Math.Clamp(1 * scale, 0, 2));
GregorianHeadlineTextBlock.FontSize = Math.Clamp(22 * scale, 14, 34);
WeekdayText0.FontSize = Math.Clamp(13 * scale, 9, 18);
WeekdayText1.FontSize = WeekdayText0.FontSize;
WeekdayText2.FontSize = WeekdayText0.FontSize;
WeekdayText3.FontSize = WeekdayText0.FontSize;
WeekdayText4.FontSize = WeekdayText0.FontSize;
WeekdayText5.FontSize = WeekdayText0.FontSize;
WeekdayText6.FontSize = WeekdayText0.FontSize;
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
var headerTextLength = Math.Max(1, GregorianHeadlineTextBlock.Text?.Length ?? (isZh ? 5 : 6));
var headerCompression = headerTextLength >= 8 ? 0.90 : headerTextLength >= 6 ? 0.95 : 1.0;
var densityBoost = scale <= 0.74 ? 0.90 : scale <= 0.90 ? 0.95 : scale >= 1.45 ? 1.05 : 1.0;
LunarDateTextBlock.FontSize = Math.Clamp(28 * scale, 17, 44);
LunarMetaTextBlock.FontSize = Math.Clamp(14 * scale, 10, 22);
YiLabelTextBlock.FontSize = Math.Clamp(18 * scale, 12, 28);
GregorianHeadlineTextBlock.FontSize = Math.Clamp(29 * scale * headerCompression * densityBoost, 12.5, 42);
GregorianHeadlineTextBlock.FontWeight = ToVariableWeight(Lerp(560, 720, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
GregorianHeadlineTextBlock.LineHeight = GregorianHeadlineTextBlock.FontSize * 1.03;
_weekdayFontSize = Math.Clamp(14.8 * scale * densityBoost, 7, 20);
_weekdayFontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
foreach (var block in GetWeekdayHeaderBlocks())
{
block.FontSize = _weekdayFontSize;
block.FontWeight = _weekdayFontWeight;
block.LineHeight = _weekdayFontSize * 1.02;
}
_calendarDayFontSize = Math.Clamp(15.4 * scale * densityBoost, 8, 22);
_calendarDayFontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
_calendarTodayDotSize = Math.Clamp(_calendarDayFontSize * 1.30, 13.5, 31);
var rightDensity = scale <= 0.72 ? 0.90 : scale <= 0.90 ? 0.95 : scale >= 1.38 ? 1.03 : 1.0;
LunarDateTextBlock.FontSize = Math.Clamp(30 * scale * rightDensity, 14, 44);
LunarMetaTextBlock.FontSize = Math.Clamp(12.5 * scale * rightDensity, 8.8, 18);
YiLabelTextBlock.FontSize = Math.Clamp(16.5 * scale * rightDensity, 10, 23);
JiLabelTextBlock.FontSize = YiLabelTextBlock.FontSize;
YiItemsTextBlock.FontSize = Math.Clamp(16 * scale, 11, 24);
YiItemsTextBlock.FontSize = Math.Clamp(13.8 * scale * rightDensity, 8.5, 19);
JiItemsTextBlock.FontSize = YiItemsTextBlock.FontSize;
YiItemsTextBlock.LineHeight = YiItemsTextBlock.FontSize * 1.15;
JiItemsTextBlock.LineHeight = JiItemsTextBlock.FontSize * 1.15;
_calendarDayFontSize = Math.Clamp(14 * scale, 9, 22);
_calendarTodayDotSize = Math.Clamp(28 * scale, 17, 38);
LunarDateTextBlock.FontWeight = ToVariableWeight(Lerp(640, 760, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
LunarMetaTextBlock.FontWeight = ToVariableWeight(Lerp(500, 620, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
YiLabelTextBlock.FontWeight = ToVariableWeight(Lerp(620, 740, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
JiLabelTextBlock.FontWeight = YiLabelTextBlock.FontWeight;
YiItemsTextBlock.FontWeight = ToVariableWeight(Lerp(520, 660, Math.Clamp((scale - 0.60) / 1.2, 0, 1)));
JiItemsTextBlock.FontWeight = YiItemsTextBlock.FontWeight;
UpdateDate();
var maxLines = scale <= 0.82 ? 1 : 2;
YiItemsTextBlock.MaxLines = maxLines;
JiItemsTextBlock.MaxLines = maxLines;
_lunarItemCount = scale switch
{
<= 0.72 => 2,
<= 0.96 => 3,
<= 1.32 => 4,
_ => 5
};
if (maxLines == 1)
{
_lunarItemCount = Math.Min(_lunarItemCount, 3);
}
}
private void ApplyModeVisualIfNeeded()
{
var isNightMode = ResolveIsNightMode();
if (_isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
{
return;
}
_isNightModeApplied = isNightMode;
ApplyModeVisual(isNightMode);
}
private void ApplyModeVisual(bool isNightMode)
{
LunarCardBorder.BorderBrush = isNightMode
? CreateBrush("#3FFFFFFF")
: CreateBrush("#14000000");
LunarCardBorder.BoxShadow = BoxShadows.Parse(isNightMode
? "0 10 26 #42000000"
: "0 8 20 #1A000000");
_weekdayHeaderOpacity = isNightMode ? 0.66 : 0.60;
_weekdayNumberOpacity = isNightMode ? 0.93 : 0.90;
_weekendNumberOpacity = isNightMode ? 0.68 : 0.58;
GregorianHeadlineTextBlock.Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? 0.97 : 0.95);
LunarDateTextBlock.Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? 0.97 : 0.95);
LunarMetaTextBlock.Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush", isNightMode ? 0.92 : 0.86);
YiItemsTextBlock.Foreground = GetThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? 0.95 : 0.92);
JiItemsTextBlock.Foreground = YiItemsTextBlock.Foreground;
foreach (var block in GetWeekdayHeaderBlocks())
{
block.Foreground = GetThemeBrush("AdaptiveTextSecondaryBrush", _weekdayHeaderOpacity);
}
YiLabelTextBlock.Foreground = CreateBrush(isNightMode ? "#8CB57D" : "#4E7D3A");
JiLabelTextBlock.Foreground = CreateBrush(isNightMode ? "#C98981" : "#A1473E");
DividerBorder.Opacity = isNightMode ? 0.48 : 0.72;
}
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 double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 1.55);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.65, 1.65) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 460d, 0.65, 1.65) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.08), 0.65, 1.6);
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.62, 1.8);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.62, 1.85) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 460d, 0.62, 1.85) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.08), 0.62, 1.8);
}
private IBrush GetThemeBrush(string key, double opacity)
@@ -274,4 +476,88 @@ public partial class DateWidget : UserControl
return new SolidColorBrush(Colors.Gray, opacity);
}
private static IBrush CreateBrush(string colorHex)
{
return new SolidColorBrush(Color.Parse(colorHex));
}
private static string BuildDailySelection(
DateTime date,
string[] pool,
int count,
int salt,
bool useChineseSpacing)
{
if (pool.Length == 0 || count <= 0)
{
return string.Empty;
}
var target = Math.Min(count, pool.Length);
var selected = new List<string>(target);
var usedIndices = new HashSet<int>();
var cursor = Math.Abs(date.Year * 1009 + date.DayOfYear * 37 + salt * 211);
var step = (salt % Math.Max(1, pool.Length - 1)) + 1;
for (var i = 0; i < pool.Length * 3 && selected.Count < target; i++)
{
var index = (cursor + i * step) % pool.Length;
if (usedIndices.Add(index))
{
selected.Add(pool[index]);
}
}
if (selected.Count == 0)
{
return string.Empty;
}
return string.Join(useChineseSpacing ? " " : ", ", selected);
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
private static int GetCalendarRowCount(int startDayOfWeek, int daysInMonth)
{
return Math.Max(5, (int)Math.Ceiling((startDayOfWeek + daysInMonth) / 7d));
}
private void EnsureCalendarRows(int rowCount)
{
if (CalendarGrid.RowDefinitions.Count == rowCount)
{
return;
}
CalendarGrid.RowDefinitions.Clear();
for (var i = 0; i < rowCount; i++)
{
CalendarGrid.RowDefinitions.Add(new RowDefinition(GridLength.Star));
}
}
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,179 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using LanMontainDesktop.ComponentSystem;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public sealed record DesktopComponentRuntimeRegistration(
string ComponentId,
string DisplayNameLocalizationKey,
Func<Control> ControlFactory,
Func<double, double>? CornerRadiusResolver = null);
public sealed class DesktopComponentRuntimeDescriptor
{
private static readonly Func<double, double> DefaultCornerRadiusResolver =
cellSize => Math.Clamp(cellSize * 0.22, 8, 18);
private readonly Func<Control> _controlFactory;
private readonly Func<double, double> _cornerRadiusResolver;
internal DesktopComponentRuntimeDescriptor(
DesktopComponentDefinition definition,
string displayNameLocalizationKey,
Func<Control> controlFactory,
Func<double, double>? cornerRadiusResolver)
{
Definition = definition;
DisplayNameLocalizationKey = displayNameLocalizationKey;
_controlFactory = controlFactory;
_cornerRadiusResolver = cornerRadiusResolver ?? DefaultCornerRadiusResolver;
}
public DesktopComponentDefinition Definition { get; }
public string DisplayNameLocalizationKey { get; }
public Control CreateControl(double cellSize, TimeZoneService timeZoneService, IWeatherInfoService weatherInfoService)
{
var control = _controlFactory();
if (control is IDesktopComponentWidget sizedComponent)
{
sizedComponent.ApplyCellSize(cellSize);
}
if (control is ITimeZoneAwareComponentWidget timeZoneAwareComponent)
{
timeZoneAwareComponent.SetTimeZoneService(timeZoneService);
}
if (control is IWeatherInfoAwareComponentWidget weatherInfoAwareComponent)
{
weatherInfoAwareComponent.SetWeatherInfoService(weatherInfoService);
}
return control;
}
public double ResolveCornerRadius(double cellSize)
{
return _cornerRadiusResolver(Math.Max(1, cellSize));
}
}
public sealed class DesktopComponentRuntimeRegistry
{
private readonly Dictionary<string, DesktopComponentRuntimeDescriptor> _descriptors;
public DesktopComponentRuntimeRegistry(
ComponentRegistry componentRegistry,
IEnumerable<DesktopComponentRuntimeRegistration> registrations)
{
var registrationMap = registrations
.Where(r => !string.IsNullOrWhiteSpace(r.ComponentId) && r.ControlFactory is not null)
.GroupBy(r => r.ComponentId.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Last(), StringComparer.OrdinalIgnoreCase);
_descriptors = componentRegistry
.GetAll()
.Where(definition => registrationMap.ContainsKey(definition.Id))
.ToDictionary(
definition => definition.Id,
definition =>
{
var registration = registrationMap[definition.Id];
return new DesktopComponentRuntimeDescriptor(
definition,
registration.DisplayNameLocalizationKey,
registration.ControlFactory,
registration.CornerRadiusResolver);
},
StringComparer.OrdinalIgnoreCase);
}
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
{
return new DesktopComponentRuntimeRegistry(
componentRegistry,
new[]
{
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.Date,
"component.date",
() => new DateWidget(),
_ => 16),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.MonthCalendar,
"component.month_calendar",
() => new MonthCalendarWidget(),
cellSize => Math.Clamp(cellSize * 0.26, 10, 22)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.LunarCalendar,
"component.lunar_calendar",
() => new LunarCalendarWidget(),
cellSize => Math.Clamp(cellSize * 0.30, 12, 26)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopClock,
"component.desktop_clock",
() => new AnalogClockWidget(),
cellSize => Math.Clamp(cellSize * 0.30, 12, 28)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWeatherClock,
"component.weather_clock",
() => new WeatherClockWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopTimer,
"component.desktop_timer",
() => new TimerWidget(),
cellSize => Math.Clamp(cellSize * 0.30, 12, 28)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWeather,
"component.desktop_weather",
() => new WeatherWidget(),
cellSize => Math.Clamp(cellSize * 0.45, 24, 44)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopHourlyWeather,
"component.hourly_weather",
() => new HourlyWeatherWidget(),
cellSize => Math.Clamp(cellSize * 0.45, 24, 44)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopMultiDayWeather,
"component.multiday_weather",
() => new MultiDayWeatherWidget(),
cellSize => Math.Clamp(cellSize * 0.45, 24, 44)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWhiteboard,
"component.whiteboard",
() => new WhiteboardWidget(),
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBlackboardLandscape,
"component.blackboard_landscape",
() => new WhiteboardWidget(baseWidthCells: 4),
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.HolidayCalendar,
"component.holiday_calendar",
() => new HolidayCalendarWidget(),
cellSize => Math.Clamp(cellSize * 0.32, 12, 28))
});
}
public bool TryGetDescriptor(string componentId, out DesktopComponentRuntimeDescriptor descriptor)
{
return _descriptors.TryGetValue(componentId, out descriptor!);
}
public IReadOnlyList<DesktopComponentRuntimeDescriptor> GetDesktopComponents()
{
return _descriptors.Values
.Where(descriptor => descriptor.Definition.AllowDesktopPlacement)
.OrderBy(descriptor => descriptor.Definition.Category, StringComparer.OrdinalIgnoreCase)
.ThenBy(descriptor => descriptor.Definition.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}

View File

@@ -9,7 +9,7 @@ using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class HolidayCalendarWidget : UserControl
public partial class HolidayCalendarWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{

View File

@@ -0,0 +1,296 @@
<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"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.HourlyWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.05"
ScaleY="1.05" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#5BFFFFFF"
Offset="0" />
<GradientStop Color="#1FFFFFFF"
Offset="0.30" />
<GradientStop Color="#00000000"
Offset="0.55" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="#00040A16"
Offset="0.50" />
<GradientStop Color="#2E0B1C34"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer"
IsHitTestVisible="False"
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Background="Transparent">
<Grid x:Name="LayoutRoot">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="北京"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Grid.Column="2"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</Grid>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="26°"
FontSize="108"
FontWeight="Bold"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel x:Name="ConditionRangeStack"
Grid.Column="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Spacing="8"
Margin="0,0,0,10">
<TextBlock x:Name="ConditionTextBlock"
Text="晴"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="20° / 28°"
FontSize="36"
FontWeight="SemiBold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Grid>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,8">
<Border x:Name="HourlyPanelBorder"
Background="#1CFFFFFF"
CornerRadius="18"
ClipToBounds="True"
Padding="12,8">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*,*">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime0"
Text="现在"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon0"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp0"
Text="26°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime1"
Text="09:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon1"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp1"
Text="28°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime2"
Text="10:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon2"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp2"
Text="26°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime3"
Text="11:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon3"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp3"
Text="24°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime4"
Text="12:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon4"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp4"
Text="24°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="5"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime5"
Text="13:00"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon5"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp5"
Text="23°"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public interface IDesktopComponentWidget
{
void ApplyCellSize(double cellSize);
}
public interface ITimeZoneAwareComponentWidget
{
void SetTimeZoneService(TimeZoneService timeZoneService);
}
public interface IWeatherInfoAwareComponentWidget
{
void SetWeatherInfoService(IWeatherInfoService weatherInfoService);
}

View File

@@ -3,12 +3,13 @@ using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class LunarCalendarWidget : UserControl
public partial class LunarCalendarWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{
@@ -79,6 +80,11 @@ public partial class LunarCalendarWidget : UserControl
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private FontWeight _gregorianLineWeight = FontWeight.SemiBold;
private FontWeight _lunarDateWeight = FontWeight.Bold;
private FontWeight _labelWeight = FontWeight.Bold;
private FontWeight _itemsWeight = FontWeight.SemiBold;
private int _auspiciousItemCount = 4;
public LunarCalendarWidget()
{
@@ -131,6 +137,8 @@ public partial class LunarCalendarWidget : UserControl
private void UpdateContent()
{
ApplyAdaptiveTypography();
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var culture = CultureInfo.CurrentCulture;
var isZh = culture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
@@ -146,13 +154,13 @@ public partial class LunarCalendarWidget : UserControl
YiItemsTextBlock.Text = BuildDailySelection(
now.Date,
isZh ? ZhYiCandidates : EnYiCandidates,
count: 4,
count: _auspiciousItemCount,
salt: 17,
useChineseSpacing: isZh);
JiItemsTextBlock.Text = BuildDailySelection(
now.Date,
isZh ? ZhJiCandidates : EnJiCandidates,
count: 4,
count: _auspiciousItemCount,
salt: 29,
useChineseSpacing: isZh);
}
@@ -160,6 +168,11 @@ public partial class LunarCalendarWidget : UserControl
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateContent();
}
private void ApplyAdaptiveTypography()
{
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(30 * scale, 16, 44));
@@ -172,12 +185,33 @@ public partial class LunarCalendarWidget : UserControl
Math.Clamp(2 * scale, 1, 6));
AuspiciousGrid.RowSpacing = Math.Clamp(12 * scale, 6, 20);
GregorianLineTextBlock.FontSize = Math.Clamp(24 * scale, 11, 36);
LunarDateTextBlock.FontSize = Math.Clamp(88 * scale, 30, 130);
YiLabelTextBlock.FontSize = Math.Clamp(30 * scale, 13, 44);
var densityBoost = scale <= 0.72 ? 0.90 : scale <= 0.88 ? 0.95 : scale >= 1.42 ? 1.04 : 1.0;
GregorianLineTextBlock.FontSize = Math.Clamp(24 * scale * densityBoost, 10, 38);
LunarDateTextBlock.FontSize = Math.Clamp(88 * scale * densityBoost, 28, 134);
YiLabelTextBlock.FontSize = Math.Clamp(30 * scale * densityBoost, 12, 46);
JiLabelTextBlock.FontSize = YiLabelTextBlock.FontSize;
YiItemsTextBlock.FontSize = Math.Clamp(24 * scale, 11, 36);
YiItemsTextBlock.FontSize = Math.Clamp(24 * scale * densityBoost, 10, 36);
JiItemsTextBlock.FontSize = YiItemsTextBlock.FontSize;
_gregorianLineWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.58) / 1.2, 0, 1)));
_lunarDateWeight = ToVariableWeight(Lerp(650, 780, Math.Clamp((scale - 0.58) / 1.2, 0, 1)));
_labelWeight = ToVariableWeight(Lerp(620, 760, Math.Clamp((scale - 0.58) / 1.2, 0, 1)));
_itemsWeight = ToVariableWeight(Lerp(520, 670, Math.Clamp((scale - 0.58) / 1.2, 0, 1)));
GregorianLineTextBlock.FontWeight = _gregorianLineWeight;
LunarDateTextBlock.FontWeight = _lunarDateWeight;
YiLabelTextBlock.FontWeight = _labelWeight;
JiLabelTextBlock.FontWeight = _labelWeight;
YiItemsTextBlock.FontWeight = _itemsWeight;
JiItemsTextBlock.FontWeight = _itemsWeight;
_auspiciousItemCount = scale switch
{
<= 0.72 => 2,
<= 0.92 => 3,
<= 1.30 => 4,
_ => 5
};
}
private double ResolveScale()
@@ -188,6 +222,16 @@ public partial class LunarCalendarWidget : UserControl
return Math.Clamp(Math.Min(cellScale, Math.Min(heightScale, widthScale) * 1.05), 0.58, 1.95);
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
private static string ToChineseWeekday(DayOfWeek dayOfWeek)
{
return dayOfWeek switch

View File

@@ -9,7 +9,7 @@ using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class MonthCalendarWidget : UserControl
public partial class MonthCalendarWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
{
private readonly DispatcherTimer _timer = new()
{
@@ -21,7 +21,10 @@ public partial class MonthCalendarWidget : UserControl
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
private double _weekdayFontSize = 20;
private FontWeight _weekdayFontWeight = FontWeight.SemiBold;
private double _calendarDayFontSize = 22;
private FontWeight _calendarDayFontWeight = FontWeight.SemiBold;
private double _calendarTodayDotSize = 44;
public MonthCalendarWidget()
@@ -83,6 +86,8 @@ public partial class MonthCalendarWidget : UserControl
? $"{now.Month}\u6708{now.Day}\u65e5"
: now.ToString("MMM d", culture);
// Locale changes the header width; re-balance typography on every refresh.
ApplyAdaptiveTypography();
UpdateWeekdayHeaders(isZh);
GenerateCalendar(now);
}
@@ -152,7 +157,7 @@ public partial class MonthCalendarWidget : UserControl
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
FontSize = _calendarDayFontSize,
FontWeight = FontWeight.SemiBold,
FontWeight = _calendarDayFontWeight,
Tag = "day"
};
@@ -198,24 +203,40 @@ public partial class MonthCalendarWidget : UserControl
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateCalendar();
}
private void ApplyAdaptiveTypography()
{
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(28 * scale, 14, 40));
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 22));
LayoutRoot.RowSpacing = Math.Clamp(10 * scale, 5, 16);
LayoutRoot.Width = Math.Clamp(280 * scale, 220, 420);
LayoutRoot.Height = Math.Clamp(280 * scale, 220, 420);
HeaderTextBlock.FontSize = Math.Clamp(42 * scale, 14, 58);
var isZh = CultureInfo.CurrentCulture.TwoLetterISOLanguageName.Equals("zh", StringComparison.OrdinalIgnoreCase);
var headerTextLength = Math.Max(1, HeaderTextBlock.Text?.Length ?? (isZh ? 5 : 6));
var headerCompression = headerTextLength >= 8 ? 0.90 : headerTextLength >= 6 ? 0.95 : 1.0;
var densityBoost = scale <= 0.74 ? 0.90 : scale <= 0.90 ? 0.95 : scale >= 1.45 ? 1.05 : 1.0;
var weekdayFontSize = Math.Clamp(20 * scale, 8, 26);
HeaderTextBlock.FontSize = Math.Clamp(42 * scale * headerCompression * densityBoost, 13, 62);
HeaderTextBlock.FontWeight = ToVariableWeight(Lerp(560, 720, Math.Clamp((scale - 0.62) / 1.2, 0, 1)));
HeaderTextBlock.LineHeight = HeaderTextBlock.FontSize * 1.05;
_weekdayFontSize = Math.Clamp(20 * scale * densityBoost, 7.5, 27);
_weekdayFontWeight = ToVariableWeight(Lerp(500, 640, Math.Clamp((scale - 0.60) / 1.3, 0, 1)));
foreach (var block in GetWeekdayHeaderBlocks())
{
block.FontSize = weekdayFontSize;
block.FontSize = _weekdayFontSize;
block.FontWeight = _weekdayFontWeight;
block.LineHeight = _weekdayFontSize * 1.06;
}
_calendarDayFontSize = Math.Clamp(22 * scale, 8, 30);
_calendarTodayDotSize = Math.Clamp(44 * scale, 16, 58);
UpdateCalendar();
_calendarDayFontSize = Math.Clamp(22 * scale * densityBoost, 8, 32);
_calendarDayFontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.60) / 1.3, 0, 1)));
_calendarTodayDotSize = Math.Clamp(_calendarDayFontSize * 1.95, 16, 62);
}
private double ResolveScale()
@@ -240,5 +261,14 @@ public partial class MonthCalendarWidget : UserControl
return new SolidColorBrush(Colors.Gray, opacity);
}
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
}

View File

@@ -0,0 +1,276 @@
<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"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.MultiDayWeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.05"
ScaleY="1.05" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#5BFFFFFF"
Offset="0" />
<GradientStop Color="#1FFFFFFF"
Offset="0.30" />
<GradientStop Color="#00000000"
Offset="0.55" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="#00040A16"
Offset="0.50" />
<GradientStop Color="#2E0B1C34"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer"
IsHitTestVisible="False"
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Background="Transparent">
<Grid x:Name="LayoutRoot">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="&#x5317;&#x4EAC;"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel x:Name="ConditionIconStack"
Grid.Column="2"
Orientation="Horizontal"
Spacing="6"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<TextBlock x:Name="ConditionTextBlock"
Text="&#x6674;"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
<Grid Grid.Row="1"
ColumnDefinitions="Auto,*,Auto">
<TextBlock x:Name="TemperatureTextBlock"
Grid.Column="0"
Text="26&#176;"
FontSize="108"
FontWeight="Bold"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Grid.Column="2"
Text="&#x7A7A;&#x6C14;&#x4F18; 22"
FontSize="36"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Grid>
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,8">
<Border x:Name="HourlyPanelBorder"
Background="#1CFFFFFF"
CornerRadius="18"
ClipToBounds="True"
Padding="12,8">
<Grid x:Name="HourlyGrid"
ColumnDefinitions="*,*,*,*,*">
<StackPanel Grid.Column="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime0"
Text="&#x4ECA;&#x5929;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon0"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp0"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime1"
Text="&#x660E;&#x5929;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon1"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp1"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime2"
Text="&#x5468;&#x516D;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon2"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp2"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="3"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime3"
Text="&#x5468;&#x65E5;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon3"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp3"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="HourlyTime4"
Text="&#x5468;&#x4E00;"
FontSize="24"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<fi:SymbolIcon x:Name="HourlyIcon4"
Symbol="WeatherSunny"
FontSize="28"
IconVariant="Regular"
HorizontalAlignment="Center" />
<TextBlock x:Name="HourlyTemp4"
Text="20&#176; / 28&#176;"
FontSize="30"
FontWeight="SemiBold"
FontFeatures="tnum"
HorizontalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</Border>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ using Avalonia.Threading;
namespace LanMontainDesktop.Views.Components;
public partial class TimerWidget : UserControl
public partial class TimerWidget : UserControl, IDesktopComponentWidget
{
private const int MaxTimerSeconds = 60;
private const double DialSize = 224;

View File

@@ -0,0 +1,98 @@
<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"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="260"
d:DesignHeight="120"
x:Class="LanMontainDesktop.Views.Components.WeatherClockWidget">
<Border x:Name="RootBorder"
Background="#FFFFFF"
BorderBrush="#14000000"
BorderThickness="1"
CornerRadius="22"
ClipToBounds="True"
Padding="12,8">
<Grid x:Name="ContentGrid"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<StackPanel x:Name="LeftStack"
ClipToBounds="True"
VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="TimeTextBlock"
Text="15:07"
FontSize="36"
FontWeight="Bold"
FontFeatures="tnum"
Foreground="#10131A"
MaxLines="1"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
<StackPanel x:Name="DateWeatherStack"
Orientation="Horizontal"
Spacing="6"
VerticalAlignment="Center">
<TextBlock x:Name="DateTextBlock"
Text="8&#x6708;14&#x65E5;"
FontSize="18"
FontWeight="SemiBold"
Foreground="#7A7E87"
VerticalAlignment="Center"
MaxLines="1"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Symbol="WeatherPartlyCloudyDay"
FontSize="18"
Foreground="#5A9CFF"
VerticalAlignment="Center"
IconVariant="Regular" />
</StackPanel>
</StackPanel>
<Border x:Name="AnalogDialBorder"
Grid.Column="1"
Width="52"
Height="52"
CornerRadius="26"
Background="#F8FAFF"
BorderBrush="#12000000"
BorderThickness="1"
VerticalAlignment="Center">
<Viewbox Stretch="Uniform">
<Grid Width="104"
Height="104">
<Canvas x:Name="TickCanvas"
Width="104"
Height="104"
IsHitTestVisible="False" />
<Canvas x:Name="HandsCanvas"
Width="104"
Height="104"
IsHitTestVisible="False" />
<Ellipse x:Name="CenterDotOuter"
Width="12"
Height="12"
Fill="#4F7CC0"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<Ellipse x:Name="CenterDotInner"
Width="5"
Height="5"
Fill="#1A74F2"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Viewbox>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,619 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using FluentIcons.Common;
using LanMontainDesktop.Models;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
{
private sealed record WeatherClockConfig(
string LanguageCode,
string Locale,
string LocationKey,
double Latitude,
double Longitude);
private const double DialDesignSize = 104;
private const double DialCenter = DialDesignSize / 2d;
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
private readonly DispatcherTimer _clockTimer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private readonly DispatcherTimer _weatherRefreshTimer = new()
{
Interval = TimeSpan.FromMinutes(12)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly Line _hourHandLine = CreateHandLine("#232938", 4.0);
private readonly Line _minuteHandLine = CreateHandLine("#2F3749", 2.8);
private readonly Line _secondHandLine = CreateHandLine("#1A74F2", 1.9);
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
private TimeZoneService? _timeZoneService;
private CancellationTokenSource? _refreshCts;
private double _currentCellSize = 48;
private bool _isAttached;
private bool _dialInitialized;
private bool _handsInitialized;
private bool _isRefreshing;
private bool? _isNightModeApplied;
private string _languageCode = "zh-CN";
private Symbol _activeWeatherSymbol = Symbol.WeatherPartlyCloudyDay;
public WeatherClockWidget()
{
InitializeComponent();
_clockTimer.Tick += OnClockTimerTick;
_weatherRefreshTimer.Tick += OnWeatherRefreshTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
InitializeDialIfNeeded();
InitializeHandsIfNeeded();
ApplyCellSize(_currentCellSize);
ApplyDefaultWeatherIcon();
UpdateClockVisual();
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
if (_timeZoneService is not null)
{
_timeZoneService.TimeZoneChanged -= OnTimeZoneChanged;
}
_timeZoneService = timeZoneService;
_timeZoneService.TimeZoneChanged += OnTimeZoneChanged;
UpdateClockVisual();
}
public void SetWeatherInfoService(IWeatherInfoService weatherInfoService)
{
_weatherInfoService = weatherInfoService ?? DefaultWeatherInfoService;
if (_isAttached)
{
_ = RefreshWeatherAsync(forceRefresh: false);
}
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
var targetHeight = Bounds.Height > 1
? Math.Clamp(Bounds.Height, 38, 160)
: Math.Clamp(_currentCellSize * 0.92, 38, 120);
var targetWidth = Bounds.Width > 1
? Math.Clamp(Bounds.Width, 48, 520)
: Math.Clamp(_currentCellSize * 2.15, 88, 260);
var compactness = Math.Clamp((170 - targetWidth) / 78d, 0, 1);
var compactFactor = Lerp(1, 0.72, compactness);
var cornerRadius = Math.Clamp(targetHeight * 0.40, 15, 36);
var horizontalPadding = Math.Clamp(targetHeight * Lerp(0.18, 0.12, compactness), 5, 30);
var verticalPadding = Math.Clamp(targetHeight * Lerp(0.14, 0.10, compactness), 3, 20);
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
RootBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var columnSpacing = Math.Clamp(targetHeight * Lerp(0.16, 0.08, compactness), 3, 22);
ContentGrid.ColumnSpacing = columnSpacing;
LeftStack.Spacing = Math.Clamp(targetHeight * Lerp(0.06, 0.04, compactness), 1.5, 10);
DateWeatherStack.Spacing = Math.Clamp(targetHeight * Lerp(0.10, 0.06, compactness), 3, 14);
TimeTextBlock.FontSize = Math.Clamp(31 * scale * compactFactor, 14, 62);
DateTextBlock.FontSize = Math.Clamp(15.5 * scale * compactFactor, 9, 30);
WeatherIconSymbol.FontSize = Math.Clamp(17 * scale * compactFactor, 10, 32);
TimeTextBlock.FontWeight = ToVariableWeight(Lerp(620, 760, Math.Clamp((scale - 0.68) / 1.35, 0, 1)));
DateTextBlock.FontWeight = ToVariableWeight(Lerp(540, 680, Math.Clamp((scale - 0.68) / 1.35, 0, 1)));
var contentHeight = Math.Max(24, targetHeight - (verticalPadding * 2));
var contentWidth = Math.Max(48, targetWidth - (horizontalPadding * 2));
var minimumLeftWidth = Math.Clamp(contentWidth * Lerp(0.56, 0.64, compactness), 52, 360);
var maxDialByWidth = Math.Max(18, contentWidth - minimumLeftWidth - columnSpacing);
var dialByHeight = contentHeight * Lerp(0.94, 0.84, compactness);
var dialSize = Math.Clamp(Math.Min(dialByHeight, maxDialByWidth), 20, 140);
var leftContentWidth = Math.Max(26, contentWidth - dialSize - columnSpacing);
LeftStack.MaxWidth = leftContentWidth;
DateWeatherStack.MaxWidth = leftContentWidth;
TimeTextBlock.MaxWidth = leftContentWidth;
DateTextBlock.MaxWidth = Math.Max(18, leftContentWidth - WeatherIconSymbol.FontSize - DateWeatherStack.Spacing);
AnalogDialBorder.Width = dialSize;
AnalogDialBorder.Height = dialSize;
AnalogDialBorder.CornerRadius = new CornerRadius(dialSize / 2d);
ApplyModeVisualIfNeeded();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
UpdateClockVisual();
_clockTimer.Start();
_weatherRefreshTimer.Start();
_ = RefreshWeatherAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_clockTimer.Stop();
_weatherRefreshTimer.Stop();
CancelRefreshRequest();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnClockTimerTick(object? sender, EventArgs e)
{
UpdateClockVisual();
}
private async void OnWeatherRefreshTick(object? sender, EventArgs e)
{
await RefreshWeatherAsync(forceRefresh: false);
}
private void OnTimeZoneChanged(object? sender, EventArgs e)
{
UpdateClockVisual();
}
private async Task RefreshWeatherAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
var config = LoadConfig();
_languageCode = config.LanguageCode;
if (string.IsNullOrWhiteSpace(config.LocationKey))
{
ApplyDefaultWeatherIcon();
_isRefreshing = false;
UpdateClockVisual();
return;
}
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new WeatherQuery(
LocationKey: config.LocationKey,
Latitude: config.Latitude,
Longitude: config.Longitude,
ForecastDays: 1,
Locale: config.Locale,
ForceRefresh: forceRefresh);
var result = await _weatherInfoService.GetWeatherAsync(query, cts.Token);
if (cts.IsCancellationRequested || !_isAttached)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyDefaultWeatherIcon();
return;
}
ApplyWeatherSnapshot(result.Data);
}
catch (OperationCanceledException)
{
// Ignore canceled refresh requests.
}
catch
{
if (!cts.IsCancellationRequested && _isAttached)
{
ApplyDefaultWeatherIcon();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
}
}
private void ApplyWeatherSnapshot(WeatherSnapshot snapshot)
{
var isNight = ResolveIsNight(snapshot);
_activeWeatherSymbol = ResolveWeatherSymbol(snapshot.Current.WeatherCode, isNight);
WeatherIconSymbol.Symbol = _activeWeatherSymbol;
WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNight));
}
private void ApplyDefaultWeatherIcon()
{
var isNight = IsNightNow();
_activeWeatherSymbol = isNight ? Symbol.WeatherMoon : Symbol.WeatherPartlyCloudyDay;
WeatherIconSymbol.Symbol = _activeWeatherSymbol;
WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNight));
}
private void UpdateClockVisual()
{
ApplyModeVisualIfNeeded();
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
TimeTextBlock.Text = now.ToString("HH:mm", CultureInfo.CurrentCulture);
DateTextBlock.Text = FormatDate(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: 23.5, backwardLength: 5.0);
SetHandGeometry(_minuteHandLine, minuteAngle, forwardLength: 33.5, backwardLength: 6.5);
SetHandGeometry(_secondHandLine, secondAngle, forwardLength: 39.0, backwardLength: 10.0);
}
private void InitializeDialIfNeeded()
{
if (_dialInitialized)
{
return;
}
BuildTicks(isNightMode: false);
_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 tickColor = isNightMode ? "#CED7EA" : "#1C2333";
for (var i = 0; i < 12; i++)
{
var angle = (i * 30 - 90) * Math.PI / 180d;
var isMajor = i % 3 == 0;
var outerRadius = DialCenter - 8;
var innerRadius = outerRadius - (isMajor ? 13.5 : 9.5);
var x1 = DialCenter + Math.Cos(angle) * innerRadius;
var y1 = DialCenter + Math.Sin(angle) * innerRadius;
var x2 = DialCenter + Math.Cos(angle) * outerRadius;
var y2 = DialCenter + Math.Sin(angle) * outerRadius;
TickCanvas.Children.Add(new Line
{
StartPoint = new Point(x1, y1),
EndPoint = new Point(x2, y2),
Stroke = CreateBrush(tickColor),
StrokeThickness = isMajor ? 2.8 : 1.9,
StrokeLineCap = PenLineCap.Round
});
}
}
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
? CreateGradientBrush("#2A3346", "#202A3B")
: CreateGradientBrush("#FFFFFF", "#F6F8FC");
RootBorder.BorderBrush = CreateBrush(isNightMode ? "#36F2F5FF" : "#14000000");
AnalogDialBorder.Background = isNightMode
? CreateBrush("#1B2434")
: CreateBrush("#F8FAFF");
AnalogDialBorder.BorderBrush = CreateBrush(isNightMode ? "#34DDE7FF" : "#12000000");
TimeTextBlock.Foreground = CreateBrush(isNightMode ? "#F8FBFF" : "#10131A");
DateTextBlock.Foreground = CreateBrush(isNightMode ? "#BCC8DD" : "#7A7E87");
_hourHandLine.Stroke = CreateBrush(isNightMode ? "#F1F5FF" : "#232938");
_minuteHandLine.Stroke = CreateBrush(isNightMode ? "#D6E0F2" : "#2F3749");
_secondHandLine.Stroke = CreateBrush("#1A74F2");
CenterDotOuter.Fill = CreateBrush(isNightMode ? "#7BAAE8" : "#4F7CC0");
CenterDotInner.Fill = CreateBrush("#1A74F2");
BuildTicks(isNightMode);
WeatherIconSymbol.Foreground = CreateBrush(ResolveWeatherIconColor(_activeWeatherSymbol, isNightMode));
}
private WeatherClockConfig LoadConfig()
{
var snapshot = _settingsService.Load();
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var locale = string.Equals(languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? "zh_cn"
: "en_us";
var latitude = NormalizeLatitude(snapshot.WeatherLatitude);
var longitude = NormalizeLongitude(snapshot.WeatherLongitude);
var locationKey = snapshot.WeatherLocationKey?.Trim() ?? string.Empty;
var modeIsCoordinates = string.Equals(
snapshot.WeatherLocationMode,
"Coordinates",
StringComparison.OrdinalIgnoreCase);
if (modeIsCoordinates && string.IsNullOrWhiteSpace(locationKey))
{
locationKey = BuildCoordinateLocationKey(latitude, longitude);
}
return new WeatherClockConfig(
LanguageCode: languageCode,
Locale: locale,
LocationKey: locationKey,
Latitude: latitude,
Longitude: longitude);
}
private string FormatDate(DateTime dateTime)
{
var isZh = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
if (isZh)
{
return string.Create(CultureInfo.InvariantCulture, $"{dateTime.Month}\u6708{dateTime.Day}\u65e5");
}
try
{
var culture = CultureInfo.GetCultureInfo(_languageCode);
return dateTime.ToString("MMM d", culture);
}
catch
{
return dateTime.ToString("MMM d", CultureInfo.InvariantCulture);
}
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 44d, 0.60, 2.20);
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 56d, 0.65, 2.80) : 1;
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 180d, 0.65, 2.80) : 1;
return Math.Clamp(Math.Min(heightScale, widthScale) * 1.02 * cellScale, 0.62, 2.40);
}
private bool ResolveIsNight(WeatherSnapshot snapshot)
{
if (snapshot.ObservationTime.HasValue)
{
var observed = snapshot.ObservationTime.Value;
try
{
if (_timeZoneService is not null)
{
var zoned = TimeZoneInfo.ConvertTime(observed, _timeZoneService.CurrentTimeZone);
return zoned.Hour < 6 || zoned.Hour >= 18;
}
}
catch
{
// Fall through to local observation.
}
return observed.Hour < 6 || observed.Hour >= 18;
}
return IsNightNow();
}
private bool IsNightNow()
{
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
return now.Hour < 6 || now.Hour >= 18;
}
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 Symbol ResolveWeatherSymbol(int? weatherCode, bool isNight)
{
return weatherCode switch
{
0 => isNight ? Symbol.WeatherMoon : Symbol.WeatherSunny,
1 or 2 => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay,
3 or 7 => Symbol.WeatherRainShowersDay,
8 or 9 => Symbol.WeatherRain,
4 => Symbol.WeatherThunderstorm,
13 or 14 or 15 or 16 => Symbol.WeatherSnow,
18 or 32 => Symbol.WeatherFog,
_ => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay
};
}
private static string ResolveWeatherIconColor(Symbol symbol, bool isNightMode)
{
return symbol switch
{
Symbol.WeatherSunny => isNightMode ? "#FFD978" : "#F7B500",
Symbol.WeatherMoon => "#F6D98F",
Symbol.WeatherPartlyCloudyDay => "#5A9CFF",
Symbol.WeatherPartlyCloudyNight => "#8AB6FF",
Symbol.WeatherRainShowersDay => "#5F96E8",
Symbol.WeatherRain => "#4B84DA",
Symbol.WeatherThunderstorm => "#F1C24D",
Symbol.WeatherSnow => "#8EBFE5",
_ => isNightMode ? "#A9BDD7" : "#93A2B8"
};
}
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);
hand.StartPoint = new Point(
DialCenter - (cos * backwardLength),
DialCenter - (sin * backwardLength));
hand.EndPoint = new Point(
DialCenter + (cos * forwardLength),
DialCenter + (sin * forwardLength));
}
private static Line CreateHandLine(string colorHex, double thickness)
{
return new Line
{
StartPoint = new Point(DialCenter, DialCenter),
EndPoint = new Point(DialCenter, DialCenter - 32),
Stroke = CreateBrush(colorHex),
StrokeThickness = thickness,
StrokeLineCap = PenLineCap.Round
};
}
private static IBrush CreateBrush(string colorHex)
{
return new SolidColorBrush(Color.Parse(colorHex));
}
private static IBrush CreateGradientBrush(string fromHex, string toHex)
{
return new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops = new GradientStops
{
new GradientStop(Color.Parse(fromHex), 0),
new GradientStop(Color.Parse(toHex), 1)
}
};
}
private static double Lerp(double from, double to, double t)
{
return from + ((to - from) * t);
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
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;
}
private static string BuildCoordinateLocationKey(double latitude, double longitude)
{
return string.Create(CultureInfo.InvariantCulture, $"coord:{latitude:F4},{longitude:F4}");
}
private static double NormalizeLatitude(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 39.9042;
}
return Math.Clamp(value, -90, 90);
}
private static double NormalizeLongitude(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 116.4074;
}
return Math.Clamp(value, -180, 180);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
cts?.Cancel();
cts?.Dispose();
}
}

View File

@@ -0,0 +1,150 @@
<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"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="320"
d:DesignHeight="320"
x:Class="LanMontainDesktop.Views.Components.WeatherWidget">
<Border x:Name="RootBorder"
CornerRadius="28"
ClipToBounds="True"
Background="#68A9EC">
<Grid>
<Border x:Name="BackgroundImageLayer"
CornerRadius="28"
ClipToBounds="True" />
<Border x:Name="BackgroundMotionLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.24"
RenderTransformOrigin="0.5,0.5">
<Border.RenderTransform>
<TransformGroup>
<ScaleTransform ScaleX="1.05"
ScaleY="1.05" />
<TranslateTransform />
</TransformGroup>
</Border.RenderTransform>
</Border>
<Border x:Name="BackgroundTintLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.20" />
<Border x:Name="BackgroundLightLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.66">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,1">
<GradientStop Color="#5BFFFFFF"
Offset="0" />
<GradientStop Color="#1FFFFFFF"
Offset="0.30" />
<GradientStop Color="#00000000"
Offset="0.55" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Border x:Name="BackgroundShadeLayer"
CornerRadius="28"
ClipToBounds="True"
Opacity="0.78">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Color="#00040A16"
Offset="0.50" />
<GradientStop Color="#2E0B1C34"
Offset="1" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Canvas x:Name="ParticleLayer"
IsHitTestVisible="False"
ClipToBounds="True" />
<Border x:Name="ContentPaddingBorder"
Padding="18"
Background="Transparent">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="300"
Height="300">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">
<Grid x:Name="TopRowGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<fi:SymbolIcon x:Name="LocationIcon"
Symbol="Location"
FontSize="20"
VerticalAlignment="Center" />
<TextBlock x:Name="CityTextBlock"
Grid.Column="1"
Text="Beijing"
FontSize="30"
FontWeight="SemiBold"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<fi:SymbolIcon x:Name="WeatherIconSymbol"
Grid.Column="2"
Symbol="WeatherSunny"
IconVariant="Regular"
FontSize="40"
HorizontalAlignment="Right"
VerticalAlignment="Center" />
</Grid>
<TextBlock x:Name="TemperatureTextBlock"
Grid.Row="1"
Text="26"
FontSize="108"
FontWeight="Bold"
FontFeatures="tnum"
VerticalAlignment="Center"
Margin="0,4,0,10"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<StackPanel x:Name="BottomInfoStack"
Grid.Row="2"
VerticalAlignment="Bottom"
Spacing="4"
Margin="0,0,0,10">
<TextBlock x:Name="ConditionTextBlock"
Text="Clear"
FontSize="30"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="RangeTextBlock"
Text="20 / 28"
FontSize="36"
FontWeight="SemiBold"
FontFeatures="tnum"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</StackPanel>
</Grid>
</Grid>
</Viewbox>
</Border>
</Grid>
</Border>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
<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"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:inking="using:DotNetCampus.Inking"
mc:Ignorable="d"
d:DesignWidth="240"
d:DesignHeight="480"
x:Class="LanMontainDesktop.Views.Components.WhiteboardWidget">
<Border x:Name="RootBorder"
Background="#F1F4F9"
CornerRadius="20"
ClipToBounds="True"
Padding="8">
<Grid RowDefinitions="*,Auto"
RowSpacing="8">
<Border x:Name="CanvasBorder"
Grid.Row="0"
Background="#FFFFFF"
BorderBrush="#24000000"
BorderThickness="1"
CornerRadius="14"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas" />
</Border>
<Border x:Name="ToolbarBorder"
Grid.Row="1"
HorizontalAlignment="Center"
Background="#E6FFFFFF"
BorderBrush="#16000000"
BorderThickness="1"
CornerRadius="14"
Padding="8,6">
<StackPanel x:Name="ToolbarButtonsPanel"
Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8">
<Button x:Name="PenButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Pen"
Click="OnPenButtonClick">
<fi:SymbolIcon x:Name="PenIcon"
Symbol="Pen"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="EraserButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Eraser"
Click="OnEraserButtonClick">
<fi:SymbolIcon x:Name="EraserIcon"
Symbol="EraserTool"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ClearButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Clear"
Click="OnClearButtonClick">
<fi:SymbolIcon x:Name="ClearIcon"
Symbol="Delete"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ExportButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Export SVG"
Click="OnExportButtonClick">
<fi:SymbolIcon x:Name="ExportIcon"
Symbol="ArrowExport"
IconVariant="Regular"
FontSize="14" />
</Button>
</StackPanel>
</Border>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,361 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using DotNetCampus.Inking;
using FluentIcons.Avalonia;
using SkiaSharp;
namespace LanMontainDesktop.Views.Components;
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
{
private enum WhiteboardToolMode
{
Pen,
Eraser
}
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
private readonly int _baseWidthCells;
private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied;
private SKColor _currentInkColor = SKColors.Black;
public WhiteboardWidget()
: this(baseWidthCells: 2)
{
}
public WhiteboardWidget(int baseWidthCells)
{
_baseWidthCells = Math.Max(1, baseWidthCells);
InitializeComponent();
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ConfigureInkCanvas();
ApplyCellSize(_currentCellSize);
ApplyThemeVisual(force: true);
SetToolMode(WhiteboardToolMode.Pen);
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
ApplyThemeVisual(force: true);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
// Keep all state in-memory for lightweight re-attach scenarios.
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
ApplyThemeVisual(force: false);
}
private void ConfigureInkCanvas()
{
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.IgnorePressure = true;
settings.InkThickness = 2.5f;
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var availableWidth = Bounds.Width > 1 ? Bounds.Width : (_currentCellSize * _baseWidthCells);
var buttonSize = Math.Clamp(availableWidth * 0.15, 24, 40);
var buttonCornerRadius = buttonSize * 0.5;
var toolbarSpacing = Math.Clamp(buttonSize * 0.25, 4, 10);
var toolbarPaddingHorizontal = Math.Clamp(buttonSize * 0.36, 6, 12);
var toolbarPaddingVertical = Math.Clamp(buttonSize * 0.24, 4, 8);
RootBorder.Padding = new Thickness(Math.Clamp(_currentCellSize * 0.14, 6, 14));
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
CanvasBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
ToolbarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
ToolbarBorder.Padding = new Thickness(toolbarPaddingHorizontal, toolbarPaddingVertical);
ToolbarButtonsPanel.Spacing = toolbarSpacing;
foreach (var button in new[] { PenButton, EraserButton, ClearButton, ExportButton })
{
button.Width = buttonSize;
button.Height = buttonSize;
button.CornerRadius = new CornerRadius(buttonCornerRadius);
}
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
settings.InkThickness = (float)Math.Clamp(_currentCellSize * 0.06, 2.0, 6.0);
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
settings.EraserSize = new Size(eraserSize, eraserSize);
}
private void ApplyThemeVisual(bool force)
{
var isNightMode = ResolveIsNightMode();
if (!force && _isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
{
return;
}
_isNightModeApplied = isNightMode;
_currentInkColor = isNightMode ? SKColors.White : SKColors.Black;
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
CanvasBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#30FFFFFF") : Color.Parse("#24000000"));
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
RecolorAllStrokes(_currentInkColor);
RefreshToolButtonVisuals();
}
private void RecolorAllStrokes(SKColor targetColor)
{
for (var i = 0; i < InkCanvas.Strokes.Count; i++)
{
TrySetStrokeColor(InkCanvas.Strokes[i], targetColor);
}
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
private static void TrySetStrokeColor(SkiaStroke stroke, SKColor color)
{
if (StrokeColorProperty is null)
{
return;
}
try
{
StrokeColorProperty.SetValue(stroke, color);
}
catch
{
// Keep current stroke color when reflection is unavailable.
}
}
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 brush)
{
return CalculateRelativeLuminance(brush.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);
}
private void SetToolMode(WhiteboardToolMode mode)
{
_toolMode = mode;
InkCanvas.EditingMode = mode == WhiteboardToolMode.Pen
? InkCanvasEditingMode.Ink
: InkCanvasEditingMode.EraseByPoint;
if (mode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _currentInkColor;
}
RefreshToolButtonVisuals();
}
private void RefreshToolButtonVisuals()
{
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
var activeBackground = ResolveThemeBrush("AdaptiveAccentBrush", isNightMode ? Color.Parse("#FF93C5FD") : Color.Parse("#FF3B82F6"));
var activeForeground = ResolveThemeBrush("AdaptiveOnAccentBrush", Colors.White);
var idleForeground = ResolveThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? Color.Parse("#FFE5E7EB") : Color.Parse("#FF0F172A"));
var idleBackground = new SolidColorBrush(isNightMode ? Color.Parse("#33FFFFFF") : Color.Parse("#14000000"));
ApplyToolButtonVisual(PenButton, _toolMode == WhiteboardToolMode.Pen, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(EraserButton, _toolMode == WhiteboardToolMode.Eraser, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(ClearButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(ExportButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
}
private static void ApplyToolButtonVisual(
Button button,
bool isActive,
IBrush activeBackground,
IBrush activeForeground,
IBrush idleBackground,
IBrush idleForeground)
{
button.Background = isActive ? activeBackground : idleBackground;
button.Foreground = isActive ? activeForeground : idleForeground;
button.BorderThickness = new Thickness(0);
if (button.Content is SymbolIcon symbolIcon)
{
symbolIcon.Foreground = button.Foreground;
}
}
private IBrush ResolveThemeBrush(string key, Color fallback)
{
if (this.TryFindResource(key, out var resource) && resource is IBrush brush)
{
return brush;
}
return new SolidColorBrush(fallback);
}
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
{
SetToolMode(WhiteboardToolMode.Pen);
}
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
{
SetToolMode(WhiteboardToolMode.Eraser);
}
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
{
var strokeList = InkCanvas.Strokes.ToList();
foreach (var stroke in strokeList)
{
try
{
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
{
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
}
}
catch
{
// Keep the widget alive even if one stroke removal fails.
}
}
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
{
var fileName = $"whiteboard-{DateTime.Now:yyyyMMdd-HHmmss}.svg";
var topLevel = TopLevel.GetTopLevel(this);
var storageProvider = topLevel?.StorageProvider;
if (storageProvider is not null)
{
var saveFile = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export Whiteboard SVG",
SuggestedFileName = fileName,
DefaultExtension = "svg",
FileTypeChoices =
[
new FilePickerFileType("SVG image")
{
Patterns = ["*.svg"],
MimeTypes = ["image/svg+xml"]
}
]
});
if (saveFile is null)
{
return;
}
await using var saveStream = await saveFile.OpenWriteAsync();
ExportSvgToStream(saveStream);
return;
}
var exportFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMontainDesktop",
"Exports");
Directory.CreateDirectory(exportFolder);
var savePath = Path.Combine(exportFolder, fileName);
await using var fileStream = File.Create(savePath);
ExportSvgToStream(fileStream);
}
private void ExportSvgToStream(Stream stream)
{
var width = Math.Max(1d, CanvasBorder.Bounds.Width);
var height = Math.Max(1d, CanvasBorder.Bounds.Height);
var bounds = SKRect.Create((float)width, (float)height);
using var svgCanvas = SKSvgCanvas.Create(bounds, stream);
using var backgroundPaint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Fill,
Color = (_isNightModeApplied ?? false) ? SKColors.Black : SKColors.White
};
svgCanvas.DrawRect(bounds, backgroundPaint);
using var strokePaint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Fill
};
foreach (var stroke in InkCanvas.Strokes)
{
strokePaint.Color = stroke.Color;
svgCanvas.DrawPath(stroke.Path, strokePaint);
}
svgCanvas.Flush();
}
}