mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 16:14:28 +08:00
0.2.3
小白板,天气,时钟
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
296
LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml
Normal file
296
LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml
Normal 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>
|
||||
1630
LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs
Normal file
1630
LanMontainDesktop/Views/Components/HourlyWeatherWidget.axaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
276
LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml
Normal file
276
LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml
Normal 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="北京"
|
||||
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="晴"
|
||||
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°"
|
||||
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="空气优 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="今天"
|
||||
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° / 28°"
|
||||
FontSize="30"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="HourlyTime1"
|
||||
Text="明天"
|
||||
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° / 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="周六"
|
||||
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° / 28°"
|
||||
FontSize="30"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="3"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="HourlyTime3"
|
||||
Text="周日"
|
||||
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° / 28°"
|
||||
FontSize="30"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="4"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="HourlyTime4"
|
||||
Text="周一"
|
||||
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° / 28°"
|
||||
FontSize="30"
|
||||
FontWeight="SemiBold"
|
||||
FontFeatures="tnum"
|
||||
HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
1572
LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs
Normal file
1572
LanMontainDesktop/Views/Components/MultiDayWeatherWidget.axaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
98
LanMontainDesktop/Views/Components/WeatherClockWidget.axaml
Normal file
98
LanMontainDesktop/Views/Components/WeatherClockWidget.axaml
Normal 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月14日"
|
||||
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>
|
||||
619
LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs
Normal file
619
LanMontainDesktop/Views/Components/WeatherClockWidget.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
150
LanMontainDesktop/Views/Components/WeatherWidget.axaml
Normal file
150
LanMontainDesktop/Views/Components/WeatherWidget.axaml
Normal 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>
|
||||
|
||||
1215
LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs
Normal file
1215
LanMontainDesktop/Views/Components/WeatherWidget.axaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
94
LanMontainDesktop/Views/Components/WhiteboardWidget.axaml
Normal file
94
LanMontainDesktop/Views/Components/WhiteboardWidget.axaml
Normal 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>
|
||||
361
LanMontainDesktop/Views/Components/WhiteboardWidget.axaml.cs
Normal file
361
LanMontainDesktop/Views/Components/WhiteboardWidget.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user