bilibili热搜组件
This commit is contained in:
lincube
2026-03-06 00:29:40 +08:00
parent e917a1e4af
commit 5d35e0d21c
22 changed files with 2033 additions and 1459 deletions

View File

@@ -0,0 +1,196 @@
<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="LanMountainDesktop.Views.Components.BilibiliHotSearchWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="6">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<Border x:Name="SearchBoxBorder"
Height="38"
CornerRadius="19"
Background="#F1F2F4"
BorderBrush="Transparent"
BorderThickness="0"
Padding="10,0"
HorizontalAlignment="Left"
PointerPressed="OnSearchBoxPointerPressed">
<Grid ColumnDefinitions="Auto,Auto"
ColumnSpacing="6"
VerticalAlignment="Center">
<fi:SymbolIcon x:Name="SearchGlyphIcon"
Symbol="Search"
IconVariant="Regular"
Foreground="#7A8088"
FontSize="17"
VerticalAlignment="Center" />
<TextBlock x:Name="SearchEntryTextBlock"
Grid.Column="1"
Text="Search"
Foreground="#7A8088"
FontSize="18"
FontWeight="Medium"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<TextBlock x:Name="TopRightTitleTextBlock"
Grid.Column="1"
Text="bilibili热搜"
Foreground="#F44C9F"
FontSize="24"
FontWeight="Bold"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
<Border x:Name="HotItem1Host"
Grid.Row="1"
Tag="0"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem1Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem1IndexTextBlock"
Text="1"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem1TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem2Host"
Grid.Row="2"
Tag="1"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem2Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem2IndexTextBlock"
Text="2"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem2TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem3Host"
Grid.Row="3"
Tag="2"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem3Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem3IndexTextBlock"
Text="3"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem3TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem4Host"
Grid.Row="4"
Tag="3"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem4Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem4IndexTextBlock"
Text="4"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem4TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,521 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const int MaxDisplayItemCount = 4;
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(15)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly List<BilibiliHotSearchItemSnapshot> _activeItems = [];
private readonly List<HotItemVisual> _hotItemVisuals = [];
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private string _languageCode = "zh-CN";
private string? _searchPageUrl;
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private sealed record HotItemVisual(
Border Host,
Grid RowGrid,
TextBlock IndexTextBlock,
TextBlock TitleTextBlock);
public BilibiliHotSearchWidget()
{
InitializeComponent();
SearchEntryTextBlock.FontFamily = MiSansFontFamily;
TopRightTitleTextBlock.FontFamily = MiSansFontFamily;
HotItem1IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem2IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem3IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem4IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem1TextBlock.FontFamily = MiSansFontFamily;
HotItem2TextBlock.FontFamily = MiSansFontFamily;
HotItem3TextBlock.FontFamily = MiSansFontFamily;
HotItem4TextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_hotItemVisuals.Add(new HotItemVisual(HotItem1Host, HotItem1Grid, HotItem1IndexTextBlock, HotItem1TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem2Host, HotItem2Grid, HotItem2IndexTextBlock, HotItem2TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem3Host, HotItem3Grid, HotItem3IndexTextBlock, HotItem3TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem4Host, HotItem4Grid, HotItem4IndexTextBlock, HotItem4TextBlock));
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyLoadingState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
_refreshTimer.Start();
_ = RefreshHotSearchAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshHotSearchAsync(forceRefresh: false);
}
private async Task RefreshHotSearchAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new BilibiliHotSearchQuery(
Locale: _languageCode,
ItemCount: MaxDisplayItemCount,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetBilibiliHotSearchAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
ApplySnapshot(result.Data);
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
}
}
private void ApplySnapshot(BilibiliHotSearchSnapshot snapshot)
{
SearchEntryTextBlock.Text = ResolveSearchEntryText(snapshot.SearchPlaceholder);
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
_searchPageUrl = NormalizeHttpUrl(snapshot.SearchUrl) ?? BuildDefaultSearchPageUrl();
_activeItems.Clear();
foreach (var item in snapshot.Items)
{
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
{
continue;
}
_activeItems.Add(item);
if (_activeItems.Count >= MaxDisplayItemCount)
{
break;
}
}
var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = i < _activeItems.Count
? NormalizeCompactText(_activeItems[i].Title)
: fallbackText;
}
StatusTextBlock.IsVisible = false;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyLoadingState()
{
SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索");
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
_searchPageUrl = BuildDefaultSearchPageUrl();
_activeItems.Clear();
var loadingText = L("bilihot.widget.loading_item", "加载中...");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = loadingText;
}
StatusTextBlock.Text = L("bilihot.widget.loading", "加载中...");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索");
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
_searchPageUrl = BuildDefaultSearchPageUrl();
_activeItems.Clear();
var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = fallbackText;
}
StatusTextBlock.Text = L("bilihot.widget.fetch_failed", "热搜获取失败");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private string ResolveSearchEntryText(string? placeholder)
{
var compact = NormalizeCompactText(placeholder);
if (string.IsNullOrWhiteSpace(compact))
{
return L("bilihot.widget.search_entry", "搜索");
}
return compact;
}
private void OnSearchBoxPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
TryOpenUrl(_searchPageUrl);
e.Handled = true;
}
private void OnHotItemPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
sender is not Border host ||
host.Tag is null ||
!int.TryParse(host.Tag.ToString(), out var index) ||
index < 0 ||
index >= _activeItems.Count)
{
return;
}
TryOpenUrl(_activeItems[index].Url);
e.Handled = true;
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var softScale = Math.Clamp(scale, 0.84, 1.26);
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
RootBorder.Padding = new Thickness(0);
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
var innerHeight = Math.Max(72, totalHeight - (verticalPadding * 2d));
var rowSpacing = Math.Clamp(6 * softScale, 2, 9);
ContentGrid.RowSpacing = rowSpacing;
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
var availableRowsHeight = Math.Max(40, innerHeight - rowSpacing * 4d);
var minTopRowHeight = Math.Clamp(20 * softScale, 18, 34);
var topRowHeight = Math.Clamp(availableRowsHeight * 0.27, minTopRowHeight, 52);
var lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
var minLineRowHeight = Math.Clamp(13 * softScale, 11, 24);
if (lineRowHeight < minLineRowHeight)
{
lineRowHeight = minLineRowHeight;
topRowHeight = Math.Max(minTopRowHeight, availableRowsHeight - lineRowHeight * 4d);
lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
}
if (ContentGrid.RowDefinitions.Count >= 5)
{
ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight);
for (var i = 1; i <= 4; i++)
{
ContentGrid.RowDefinitions[i].Height = new GridLength(lineRowHeight);
}
}
var searchBoxHeight = Math.Clamp(topRowHeight * 0.84, 20, 46);
SearchBoxBorder.Height = searchBoxHeight;
SearchBoxBorder.Width = Math.Clamp(innerWidth * 0.30, 80, 180);
SearchBoxBorder.CornerRadius = new CornerRadius(searchBoxHeight / 2d);
SearchBoxBorder.Padding = new Thickness(
Math.Clamp(searchBoxHeight * 0.24, 5, 10),
0,
Math.Clamp(searchBoxHeight * 0.24, 5, 10),
0);
SearchGlyphIcon.FontSize = Math.Clamp(searchBoxHeight * 0.45, 10, 20);
SearchEntryTextBlock.FontSize = Math.Clamp(searchBoxHeight * 0.44, 10, 18);
TopRightTitleTextBlock.MaxWidth = Math.Max(80, innerWidth - SearchBoxBorder.Width - HeaderGrid.ColumnSpacing);
TopRightTitleTextBlock.FontSize = Math.Clamp(topRowHeight * 0.46, 11, 22);
var lineColumnGap = Math.Clamp(lineRowHeight * 0.34, 5, 12);
var indexWidth = Math.Clamp(lineRowHeight * 1.02, 16, 28);
var indexFont = Math.Clamp(lineRowHeight * 0.50, 10, 16);
var itemFont = Math.Clamp(lineRowHeight * 0.62, 12, 24);
var rowPadding = Math.Clamp(lineRowHeight * 0.08, 1, 4);
var itemTextWidth = Math.Max(56, innerWidth - indexWidth - lineColumnGap);
foreach (var visual in _hotItemVisuals)
{
visual.RowGrid.ColumnSpacing = lineColumnGap;
if (visual.RowGrid.ColumnDefinitions.Count > 0)
{
visual.RowGrid.ColumnDefinitions[0].Width = new GridLength(indexWidth, GridUnitType.Pixel);
}
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
visual.IndexTextBlock.FontSize = indexFont;
visual.IndexTextBlock.MaxWidth = indexWidth;
visual.IndexTextBlock.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
visual.IndexTextBlock.TextAlignment = TextAlignment.Right;
visual.TitleTextBlock.FontSize = itemFont;
visual.TitleTextBlock.MaxWidth = itemTextWidth;
visual.TitleTextBlock.TextAlignment = TextAlignment.Left;
}
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
}
private void UpdateInteractionState()
{
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
visual.Host.IsHitTestVisible = enabled;
visual.Host.Opacity = enabled ? 1.0 : 0.68;
visual.Host.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
var searchEnabled = !string.IsNullOrWhiteSpace(_searchPageUrl);
SearchBoxBorder.IsHitTestVisible = searchEnabled;
SearchBoxBorder.Opacity = searchEnabled ? 1.0 : 0.72;
SearchBoxBorder.Cursor = searchEnabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
private static string BuildDefaultSearchPageUrl()
{
return "https://search.bilibili.com/all";
}
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.ToString();
}
private void TryOpenUrl(string? rawUrl)
{
var normalizedUrl = NormalizeHttpUrl(rawUrl);
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = normalizedUrl,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
private double ResolveScale()
{
var expectedWidth = _currentCellSize * BaseWidthCells;
var expectedHeight = _currentCellSize * BaseHeightCells;
if (expectedWidth <= 0 || expectedHeight <= 0)
{
return 1d;
}
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
var scaleX = actualWidth / expectedWidth;
var scaleY = actualHeight / expectedHeight;
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.8);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
}

View File

@@ -1,87 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="300"
x:Class="LanMountainDesktop.Views.Components.DailySentenceSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="Daily sentence settings"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="Configure auto-rotation and refresh interval."
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="AutoRotateLabelTextBlock"
Text="Auto-rotation"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<CheckBox x:Name="AutoRotateCheckBox"
Content="Enable auto-rotation"
Checked="OnAutoRotateChanged"
Unchecked="OnAutoRotateChanged" />
</StackPanel>
</Border>
<Border x:Name="FrequencyCardBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12"
IsVisible="False">
<StackPanel Spacing="6">
<TextBlock x:Name="FrequencyLabelTextBlock"
Text="Rotation interval"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="FrequencyComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnFrequencySelectionChanged">
<ComboBoxItem x:Name="Frequency5mItem"
Tag="5"
Content="5 min" />
<ComboBoxItem x:Name="Frequency10mItem"
Tag="10"
Content="10 min" />
<ComboBoxItem x:Name="Frequency40mItem"
Tag="40"
Content="40 min" />
<ComboBoxItem x:Name="Frequency1hItem"
Tag="60"
Content="1 hour" />
<ComboBoxItem x:Name="Frequency12hItem"
Tag="720"
Content="12 hours" />
<ComboBoxItem x:Name="Frequency24hItem"
Tag="1440"
Content="24 hours" />
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -1,137 +0,0 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class DailySentenceSettingsWindow : UserControl
{
private static readonly int[] SupportedIntervals = [5, 10, 40, 60, 720, 1440];
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private bool _suppressEvents;
private string _languageCode = "zh-CN";
public event EventHandler? SettingsChanged;
public DailySentenceSettingsWindow()
{
InitializeComponent();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var enabled = snapshot.DailySentenceAutoRotateEnabled;
var interval = NormalizeInterval(snapshot.DailySentenceAutoRotateIntervalMinutes);
_suppressEvents = true;
AutoRotateCheckBox.IsChecked = enabled;
SelectInterval(interval);
FrequencyCardBorder.IsVisible = enabled;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("daily_sentence.settings.title", "Daily sentence settings");
DescriptionTextBlock.Text = L("daily_sentence.settings.desc", "Configure auto-rotation and refresh interval.");
AutoRotateLabelTextBlock.Text = L("daily_sentence.settings.auto_rotate_label", "Auto-rotation");
AutoRotateCheckBox.Content = L("daily_sentence.settings.auto_rotate_enabled", "Enable auto-rotation");
FrequencyLabelTextBlock.Text = L("daily_sentence.settings.frequency_label", "Rotation interval");
Frequency5mItem.Content = L("daily_sentence.settings.frequency_5m", "5 min");
Frequency10mItem.Content = L("daily_sentence.settings.frequency_10m", "10 min");
Frequency40mItem.Content = L("daily_sentence.settings.frequency_40m", "40 min");
Frequency1hItem.Content = L("daily_sentence.settings.frequency_1h", "1 hour");
Frequency12hItem.Content = L("daily_sentence.settings.frequency_12h", "12 hours");
Frequency24hItem.Content = L("daily_sentence.settings.frequency_24h", "24 hours");
}
private void OnAutoRotateChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var enabled = AutoRotateCheckBox.IsChecked == true;
FrequencyCardBorder.IsVisible = enabled;
SaveState();
}
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var snapshot = _appSettingsService.Load();
snapshot.DailySentenceAutoRotateEnabled = AutoRotateCheckBox.IsChecked == true;
snapshot.DailySentenceAutoRotateIntervalMinutes = GetSelectedInterval();
_appSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private int GetSelectedInterval()
{
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes))
{
return NormalizeInterval(minutes);
}
return 60;
}
private void SelectInterval(int intervalMinutes)
{
var selected = FrequencyComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes) &&
minutes == intervalMinutes);
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static int NormalizeInterval(int minutes)
{
if (minutes <= 0)
{
return 60;
}
if (SupportedIntervals.Contains(minutes))
{
return minutes;
}
return SupportedIntervals
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(60);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -1,128 +0,0 @@
<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="LanMountainDesktop.Views.Components.DailySentenceWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
ClipToBounds="True"
BorderThickness="0"
Background="#6F7B8D">
<Grid>
<Image x:Name="BackgroundImage"
Stretch="UniformToFill" />
<Border x:Name="OverlayBorder">
<Border.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="0,1">
<GradientStop Offset="0"
Color="#56000000" />
<GradientStop Offset="0.52"
Color="#7A000000" />
<GradientStop Offset="1"
Color="#8F000000" />
</LinearGradientBrush>
</Border.Background>
</Border>
<Grid x:Name="ContentGrid"
Margin="16,14,16,14"
RowDefinitions="Auto,*,Auto"
RowSpacing="8">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="8">
<TextBlock x:Name="DayTextBlock"
Text="3"
Foreground="#F6F8FB"
FontSize="72"
FontWeight="Bold"
FontFeatures="tnum"
LineHeight="68"
VerticalAlignment="Top"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<TextBlock x:Name="MonthYearTextBlock"
Grid.Column="1"
Text="March 2026"
Foreground="#ECF0F6"
FontSize="44"
FontWeight="Medium"
VerticalAlignment="Center"
Margin="4,0,0,0"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
<Button x:Name="RefreshButton"
Grid.Column="2"
Width="42"
Height="42"
HorizontalAlignment="Right"
VerticalAlignment="Top"
CornerRadius="21"
Background="#12FFFFFF"
BorderBrush="#3AFFFFFF"
BorderThickness="1"
Padding="0"
Focusable="False">
<fi:SymbolIcon x:Name="RefreshIcon"
Symbol="ArrowClockwise"
IconVariant="Regular"
FontSize="21"
Foreground="#F0F4FA" />
</Button>
</Grid>
<StackPanel x:Name="SentenceStack"
Grid.Row="1"
VerticalAlignment="Center"
Spacing="8">
<TextBlock x:Name="SentenceTextBlock"
Text="Heard melodies are sweet, but those unheard are sweeter."
Foreground="#F7F9FC"
FontSize="58"
FontWeight="SemiBold"
LineHeight="60"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="3" />
<TextBlock x:Name="TranslationTextBlock"
Text="听见的旋律是美妙的,但听不见的会更美。"
Foreground="#DDE3EC"
FontSize="40"
FontWeight="Medium"
LineHeight="44"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2" />
</StackPanel>
<TextBlock x:Name="SourceTextBlock"
Grid.Row="2"
Text="Youdao Dictionary"
Foreground="#C7CFDA"
FontSize="30"
FontWeight="Medium"
TextTrimming="CharacterEllipsis"
MaxLines="1" />
</Grid>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#E7EDF6"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -1,986 +0,0 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class DailySentenceWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly FontWeight[] HeadlineWeightCandidates = [FontWeight.Bold, FontWeight.SemiBold, FontWeight.Medium];
private static readonly FontWeight[] BodyWeightCandidates = [FontWeight.Medium, FontWeight.Normal];
private static readonly FontWeight[] MetaWeightCandidates = [FontWeight.Medium, FontWeight.Normal, FontWeight.Light];
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private static readonly HttpClient ImageHttpClient = new()
{
Timeout = TimeSpan.FromSeconds(8)
};
private const string BrowserUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36";
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private static readonly int[] SupportedAutoRotateIntervalsMinutes = [5, 10, 40, 60, 720, 1440];
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromHours(6)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private Bitmap? _backgroundBitmap;
private string? _currentSourceUrl;
private string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRotateEnabled = true;
public DailySentenceWidget()
{
InitializeComponent();
DayTextBlock.FontFamily = MiSansFontFamily;
MonthYearTextBlock.FontFamily = MiSansFontFamily;
SentenceTextBlock.FontFamily = MiSansFontFamily;
TranslationTextBlock.FontFamily = MiSansFontFamily;
SourceTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_refreshTimer.Tick += OnRefreshTimerTick;
RefreshButton.Click += OnRefreshButtonClick;
SourceTextBlock.PointerPressed += OnSourceTextBlockPointerPressed;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyAutoRotateSettings();
UpdateDateText();
ApplyLoadingState();
UpdateRefreshButtonState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshSentenceAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRotateSettings();
if (_isAttached)
{
_ = RefreshSentenceAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRotateSettings();
UpdateRefreshButtonState();
_ = RefreshSentenceAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
DisposeBackgroundBitmap();
UpdateRefreshButtonState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
if (_isRefreshing)
{
return;
}
await RefreshSentenceAsync(forceRefresh: true);
e.Handled = true;
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshSentenceAsync(forceRefresh: true);
}
private void OnSourceTextBlockPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
TryOpenSourceUrl();
e.Handled = true;
}
private async Task RefreshSentenceAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateRefreshButtonState();
UpdateLanguageCode();
UpdateDateText();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var sentenceQuery = new DailyWordQuery(
Locale: _languageCode,
ForceRefresh: forceRefresh);
var sentenceResult = await _recommendationService.GetDailyWordAsync(sentenceQuery, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!sentenceResult.Success || sentenceResult.Data is null)
{
ApplyFailedState();
}
else
{
ApplySentenceSnapshot(sentenceResult.Data);
}
var artworkQuery = new DailyArtworkQuery(
Locale: _languageCode,
ForceRefresh: forceRefresh);
var artworkResult = await _recommendationService.GetDailyArtworkAsync(artworkQuery, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (artworkResult.Success && artworkResult.Data is not null)
{
await ApplyBackgroundSnapshotAsync(artworkResult.Data, cts.Token);
}
else if (_backgroundBitmap is null)
{
BackgroundImage.Source = null;
}
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
UpdateRefreshButtonState();
}
}
private void ApplySentenceSnapshot(DailyWordSnapshot snapshot)
{
var sentence = NormalizeCompactText(snapshot.ExampleSentence);
if (string.IsNullOrWhiteSpace(sentence))
{
sentence = NormalizeCompactText(snapshot.Meaning);
}
if (string.IsNullOrWhiteSpace(sentence))
{
sentence = L("dailysentence.widget.fallback_sentence", "No sentence available.");
}
var translation = NormalizeCompactText(snapshot.ExampleTranslation);
if (string.IsNullOrWhiteSpace(translation))
{
translation = NormalizeCompactText(snapshot.Meaning);
}
if (string.IsNullOrWhiteSpace(translation))
{
translation = L("dailysentence.widget.fallback_translation", "Tap refresh and try again.");
}
var sourceWord = NormalizeCompactText(snapshot.Word);
if (string.IsNullOrWhiteSpace(sourceWord))
{
sourceWord = L("dailysentence.widget.source_default", "Youdao Dictionary");
}
SentenceTextBlock.Text = sentence;
TranslationTextBlock.Text = translation;
SourceTextBlock.Text = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase)
? $"有道词典 · {sourceWord}"
: $"Youdao Dictionary · {sourceWord}";
_currentSourceUrl = NormalizeHttpUrl(snapshot.SourceUrl);
StatusTextBlock.IsVisible = false;
UpdateSourceInteractionState();
UpdateAdaptiveLayout();
}
private async Task ApplyBackgroundSnapshotAsync(DailyArtworkSnapshot snapshot, CancellationToken cancellationToken)
{
var bitmap = await TryLoadBackgroundBitmapAsync(snapshot.ImageUrl, snapshot.ThumbnailDataUrl, cancellationToken);
if (cancellationToken.IsCancellationRequested || !_isAttached)
{
bitmap?.Dispose();
return;
}
SetBackgroundBitmap(bitmap);
}
private static async Task<Bitmap?> TryLoadBackgroundBitmapAsync(
string? imageUrl,
string? thumbnailDataUrl,
CancellationToken cancellationToken)
{
var normalizedUrl = NormalizeHttpUrl(imageUrl);
if (!string.IsNullOrWhiteSpace(normalizedUrl))
{
var remote = await TryDownloadBitmapAsync(normalizedUrl, cancellationToken);
if (remote is not null)
{
return remote;
}
}
return TryDecodeBitmapFromDataUrl(thumbnailDataUrl);
}
private static async Task<Bitmap?> TryDownloadBitmapAsync(string imageUrl, CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl);
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
using var response = await ImageHttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var memory = new MemoryStream();
await stream.CopyToAsync(memory, cancellationToken);
memory.Position = 0;
return new Bitmap(memory);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return null;
}
}
private static Bitmap? TryDecodeBitmapFromDataUrl(string? dataUrl)
{
if (string.IsNullOrWhiteSpace(dataUrl))
{
return null;
}
var trimmed = dataUrl.Trim();
var markerIndex = trimmed.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0 || markerIndex + 7 >= trimmed.Length)
{
return null;
}
var payload = trimmed[(markerIndex + 7)..];
try
{
var bytes = Convert.FromBase64String(payload);
return new Bitmap(new MemoryStream(bytes));
}
catch
{
return null;
}
}
private void ApplyLoadingState()
{
_currentSourceUrl = null;
SentenceTextBlock.Text = L("dailysentence.widget.loading_sentence", "Loading sentence...");
TranslationTextBlock.Text = L("dailysentence.widget.loading_translation", "Loading translation...");
SourceTextBlock.Text = L("dailysentence.widget.loading_source", "Youdao Dictionary");
StatusTextBlock.Text = L("dailysentence.widget.loading", "Loading...");
StatusTextBlock.IsVisible = true;
UpdateSourceInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
_currentSourceUrl = null;
SentenceTextBlock.Text = L("dailysentence.widget.fallback_sentence", "No sentence available.");
TranslationTextBlock.Text = L("dailysentence.widget.fallback_translation", "Tap refresh and try again.");
SourceTextBlock.Text = L("dailysentence.widget.source_default", "Youdao Dictionary");
StatusTextBlock.Text = L("dailysentence.widget.fetch_failed", "Sentence fetch failed");
StatusTextBlock.IsVisible = true;
UpdateSourceInteractionState();
UpdateAdaptiveLayout();
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
ContentGrid.Margin = new Thickness(
Math.Clamp(16 * scale, 8, 28),
Math.Clamp(14 * scale, 7, 24),
Math.Clamp(16 * scale, 8, 28),
Math.Clamp(14 * scale, 7, 24));
ContentGrid.RowSpacing = Math.Clamp(8 * scale, 4, 12);
HeaderGrid.ColumnSpacing = Math.Clamp(8 * scale, 4, 14);
var refreshSize = Math.Clamp(42 * scale, 24, 54);
RefreshButton.Width = refreshSize;
RefreshButton.Height = refreshSize;
RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d);
RefreshIcon.FontSize = Math.Clamp(21 * scale, 12, 28);
var innerWidth = Math.Max(100, totalWidth - ContentGrid.Margin.Left - ContentGrid.Margin.Right);
var innerHeight = Math.Max(56, totalHeight - ContentGrid.Margin.Top - ContentGrid.Margin.Bottom);
var rowSpacingTotal = ContentGrid.RowSpacing * 2d;
var availableRowsHeight = Math.Max(1, innerHeight - rowSpacingTotal);
var topRowHeight = Math.Clamp(innerHeight * 0.24, 18, 98);
var bottomRowHeight = Math.Clamp(innerHeight * 0.15, 11, 54);
var minTopRowHeight = Math.Max(Math.Clamp(14 * scale, 10, 28), refreshSize * 0.60);
var minBottomRowHeight = Math.Clamp(10 * scale, 7, 22);
var minMiddleRowHeight = Math.Clamp(22 * scale, 14, 46);
if (topRowHeight + bottomRowHeight + minMiddleRowHeight > availableRowsHeight)
{
var scaling = availableRowsHeight / Math.Max(1, topRowHeight + bottomRowHeight + minMiddleRowHeight);
topRowHeight = Math.Max(minTopRowHeight, topRowHeight * scaling);
bottomRowHeight = Math.Max(minBottomRowHeight, bottomRowHeight * scaling);
}
var middleHeight = Math.Max(
minMiddleRowHeight,
availableRowsHeight - topRowHeight - bottomRowHeight);
if (ContentGrid.RowDefinitions.Count >= 3)
{
ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight, GridUnitType.Pixel);
ContentGrid.RowDefinitions[1].Height = new GridLength(middleHeight, GridUnitType.Pixel);
ContentGrid.RowDefinitions[2].Height = new GridLength(bottomRowHeight, GridUnitType.Pixel);
}
var topTextWidth = Math.Max(76, innerWidth - refreshSize - ContentGrid.RowSpacing);
var dayWidth = Math.Clamp(topTextWidth * 0.18, 20, Math.Max(24, topTextWidth * 0.32));
var monthYearWidth = Math.Max(48, topTextWidth - dayWidth - Math.Clamp(6 * scale, 2, 12));
DayTextBlock.MaxWidth = dayWidth;
MonthYearTextBlock.MaxWidth = monthYearWidth;
var dayLayout = FitAdaptiveTextLayout(
DayTextBlock.Text,
dayWidth,
topRowHeight,
minLines: 1,
maxLines: 1,
minFontSize: Math.Clamp(26 * scale, 12, 44),
maxFontSize: Math.Clamp(72 * scale, 20, 96),
weightCandidates: HeadlineWeightCandidates,
lineHeightFactor: 0.94);
DayTextBlock.FontSize = dayLayout.FontSize;
DayTextBlock.FontWeight = dayLayout.Weight;
DayTextBlock.LineHeight = dayLayout.LineHeight;
var monthLayout = FitAdaptiveTextLayout(
MonthYearTextBlock.Text,
monthYearWidth,
topRowHeight,
minLines: 1,
maxLines: 1,
minFontSize: Math.Clamp(18 * scale, 9, 32),
maxFontSize: Math.Clamp(44 * scale, 14, 62),
weightCandidates: BodyWeightCandidates,
lineHeightFactor: 1.00);
MonthYearTextBlock.FontSize = monthLayout.FontSize;
MonthYearTextBlock.FontWeight = monthLayout.Weight;
MonthYearTextBlock.LineHeight = monthLayout.LineHeight;
var sentenceTextDemand = Math.Clamp(NormalizeCompactText(SentenceTextBlock.Text).Length, 12, 360);
var translationTextDemand = Math.Clamp(NormalizeCompactText(TranslationTextBlock.Text).Length, 8, 260);
var sentenceMinLines = innerHeight >= _currentCellSize * 1.78 ? 2 : 1;
var sentenceMaxLines = innerHeight >= _currentCellSize * 2.7
? 4
: innerHeight >= _currentCellSize * 1.92
? 3
: 2;
var translationMaxLines = innerHeight >= _currentCellSize * 2.35 ? 3 : 2;
var sentenceMinFont = Math.Clamp(23 * scale, 10, 42);
var translationMinFont = Math.Clamp(16 * scale, 8.5, 30);
var sentenceMinHeight = sentenceMinFont * 1.06 * sentenceMinLines;
var translationMinHeight = translationMinFont * 1.06;
var bodyGap = Math.Clamp(8 * scale, 3, 12);
SentenceStack.Spacing = bodyGap;
var minBodyHeight = sentenceMinHeight + translationMinHeight + bodyGap;
double sentenceHeight;
double translationHeight;
if (middleHeight <= minBodyHeight + 0.6)
{
var compression = middleHeight / Math.Max(1, minBodyHeight);
sentenceHeight = Math.Max(10, sentenceMinHeight * compression);
translationHeight = Math.Max(8, translationMinHeight * compression);
}
else
{
var extraHeight = middleHeight - minBodyHeight;
var sentenceWeight = sentenceTextDemand + 16d;
var translationWeight = translationTextDemand + 8d;
var totalWeight = Math.Max(1d, sentenceWeight + translationWeight);
sentenceHeight = sentenceMinHeight + extraHeight * (sentenceWeight / totalWeight);
translationHeight = translationMinHeight + extraHeight * (translationWeight / totalWeight);
}
var sentenceLayout = FitAdaptiveTextLayout(
SentenceTextBlock.Text,
innerWidth,
sentenceHeight,
minLines: sentenceMinLines,
maxLines: sentenceMaxLines,
minFontSize: sentenceMinFont,
maxFontSize: Math.Clamp(58 * scale, 18, 80),
weightCandidates: HeadlineWeightCandidates,
lineHeightFactor: 1.06);
SentenceTextBlock.MaxWidth = innerWidth;
SentenceTextBlock.MaxLines = sentenceLayout.MaxLines;
SentenceTextBlock.FontSize = sentenceLayout.FontSize;
SentenceTextBlock.FontWeight = sentenceLayout.Weight;
SentenceTextBlock.LineHeight = sentenceLayout.LineHeight;
var translationLayout = FitAdaptiveTextLayout(
TranslationTextBlock.Text,
innerWidth,
translationHeight,
minLines: 1,
maxLines: translationMaxLines,
minFontSize: translationMinFont,
maxFontSize: Math.Clamp(40 * scale, 12, 54),
weightCandidates: BodyWeightCandidates,
lineHeightFactor: 1.06);
TranslationTextBlock.MaxWidth = innerWidth;
TranslationTextBlock.MaxLines = translationLayout.MaxLines;
TranslationTextBlock.FontSize = translationLayout.FontSize;
TranslationTextBlock.FontWeight = translationLayout.Weight;
TranslationTextBlock.LineHeight = translationLayout.LineHeight;
SentenceTextBlock.MinHeight = Math.Max(0, sentenceLayout.LineHeight * Math.Min(sentenceLayout.MaxLines, Math.Max(1, sentenceMinLines)));
TranslationTextBlock.MinHeight = Math.Max(0, translationLayout.LineHeight);
var sourceLayout = FitAdaptiveTextLayout(
SourceTextBlock.Text,
innerWidth,
bottomRowHeight,
minLines: 1,
maxLines: 1,
minFontSize: Math.Clamp(14 * scale, 8, 26),
maxFontSize: Math.Clamp(30 * scale, 10, 40),
weightCandidates: MetaWeightCandidates,
lineHeightFactor: 1.02);
SourceTextBlock.MaxWidth = innerWidth;
SourceTextBlock.FontSize = sourceLayout.FontSize;
SourceTextBlock.FontWeight = sourceLayout.Weight;
SourceTextBlock.LineHeight = sourceLayout.LineHeight;
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
}
private void UpdateRefreshButtonState()
{
RefreshButton.IsEnabled = !_isRefreshing;
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
RefreshIcon.Opacity = _isRefreshing ? 0.56 : 1.0;
}
private void UpdateSourceInteractionState()
{
var enabled = !string.IsNullOrWhiteSpace(_currentSourceUrl);
SourceTextBlock.IsHitTestVisible = enabled;
SourceTextBlock.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
SourceTextBlock.Opacity = enabled ? 1.0 : 0.86;
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void ApplyAutoRotateSettings()
{
var enabled = true;
var intervalMinutes = 60;
try
{
var snapshot = _settingsService.Load();
enabled = snapshot.DailySentenceAutoRotateEnabled;
intervalMinutes = NormalizeAutoRotateIntervalMinutes(snapshot.DailySentenceAutoRotateIntervalMinutes);
}
catch
{
// Keep fallback defaults.
}
_autoRotateEnabled = enabled;
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
if (!_isAttached)
{
return;
}
if (_autoRotateEnabled)
{
if (!_refreshTimer.IsEnabled)
{
_refreshTimer.Start();
}
}
else if (_refreshTimer.IsEnabled)
{
_refreshTimer.Stop();
}
}
private static int NormalizeAutoRotateIntervalMinutes(int minutes)
{
if (minutes <= 0)
{
return 60;
}
if (SupportedAutoRotateIntervalsMinutes.Contains(minutes))
{
return minutes;
}
return SupportedAutoRotateIntervalsMinutes
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(60);
}
private void UpdateDateText()
{
var now = DateTime.Now;
var culture = ResolveCulture();
DayTextBlock.Text = now.Day.ToString(CultureInfo.InvariantCulture);
MonthYearTextBlock.Text = now.ToString("MMMM yyyy", culture);
}
private void SetBackgroundBitmap(Bitmap? bitmap)
{
if (ReferenceEquals(BackgroundImage.Source, _backgroundBitmap))
{
BackgroundImage.Source = null;
}
_backgroundBitmap?.Dispose();
_backgroundBitmap = bitmap;
BackgroundImage.Source = bitmap;
}
private void DisposeBackgroundBitmap()
{
SetBackgroundBitmap(null);
}
private void TryOpenSourceUrl()
{
var normalized = NormalizeHttpUrl(_currentSourceUrl);
if (string.IsNullOrWhiteSpace(normalized))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = normalized,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private CultureInfo ResolveCulture()
{
try
{
return CultureInfo.GetCultureInfo(_languageCode);
}
catch
{
return CultureInfo.InvariantCulture;
}
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0);
var widthScale = Bounds.Width > 1
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0)
: 1;
var heightScale = Bounds.Height > 1
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0)
: 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.ToString();
}
private static AdaptiveTextLayout FitAdaptiveTextLayout(
string? text,
double maxWidth,
double maxHeight,
int minLines,
int maxLines,
double minFontSize,
double maxFontSize,
FontWeight[] weightCandidates,
double lineHeightFactor)
{
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
var safeMinLines = Math.Max(1, minLines);
var safeMaxLines = Math.Max(safeMinLines, maxLines);
var linesByHeight = ResolveMaxLinesByHeight(maxHeight, minFontSize, lineHeightFactor, safeMinLines, safeMaxLines);
var candidates = weightCandidates is { Length: > 0 }
? weightCandidates
: [FontWeight.Normal];
AdaptiveTextLayout? best = null;
foreach (var weight in candidates)
{
for (var lineLimit = linesByHeight; lineLimit >= safeMinLines; lineLimit--)
{
var fontSize = FitFontSize(
content,
maxWidth,
maxHeight,
lineLimit,
minFontSize,
maxFontSize,
weight,
lineHeightFactor);
var lineHeight = fontSize * lineHeightFactor;
var measuredSize = MeasureTextSize(content, fontSize, weight, Math.Max(1, maxWidth), lineHeight);
var measuredLineCount = Math.Max(1, (int)Math.Ceiling(measuredSize.Height / Math.Max(1, lineHeight)));
var overflowLines = Math.Max(0, measuredLineCount - lineLimit);
var overflowHeight = Math.Max(0, measuredSize.Height - maxHeight);
var overflowScore = overflowLines * 1000d + overflowHeight;
var fitsCompletely = overflowLines == 0 && overflowHeight <= 0.6;
var candidate = new AdaptiveTextLayout(fontSize, weight, lineLimit, lineHeight, overflowScore, fitsCompletely);
if (best is null || IsBetterAdaptiveTextCandidate(candidate, best.Value))
{
best = candidate;
}
}
}
if (best is not null)
{
return best.Value;
}
var fallbackFontSize = Math.Max(6, minFontSize);
return new AdaptiveTextLayout(
fallbackFontSize,
FontWeight.Normal,
safeMinLines,
fallbackFontSize * lineHeightFactor,
double.MaxValue,
fitsCompletely: false);
}
private static bool IsBetterAdaptiveTextCandidate(AdaptiveTextLayout candidate, AdaptiveTextLayout best)
{
if (candidate.FitsCompletely && !best.FitsCompletely)
{
return true;
}
if (!candidate.FitsCompletely && best.FitsCompletely)
{
return false;
}
if (candidate.FitsCompletely && best.FitsCompletely)
{
if (candidate.FontSize > best.FontSize + 0.12)
{
return true;
}
if (Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 && candidate.MaxLines < best.MaxLines)
{
return true;
}
return false;
}
if (candidate.OverflowScore < best.OverflowScore - 0.2)
{
return true;
}
if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 &&
candidate.FontSize > best.FontSize + 0.12)
{
return true;
}
if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 &&
Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 &&
candidate.MaxLines > best.MaxLines)
{
return true;
}
return false;
}
private static int ResolveMaxLinesByHeight(
double maxHeight,
double minFontSize,
double lineHeightFactor,
int minLines,
int maxLines)
{
var safeMinLines = Math.Max(1, minLines);
var safeMaxLines = Math.Max(safeMinLines, maxLines);
var lineHeight = Math.Max(1, Math.Max(6, minFontSize) * lineHeightFactor);
var maxHeightWithTolerance = Math.Max(1, maxHeight + 0.6);
var linesByHeight = (int)Math.Floor(maxHeightWithTolerance / lineHeight);
return Math.Clamp(linesByHeight, safeMinLines, safeMaxLines);
}
private static double FitFontSize(
string? text,
double maxWidth,
double maxHeight,
int maxLines,
double minFontSize,
double maxFontSize,
FontWeight weight,
double lineHeightFactor)
{
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
var min = Math.Max(6, minFontSize);
var max = Math.Max(min, maxFontSize);
var low = min;
var high = max;
var best = min;
for (var i = 0; i < 18; i++)
{
var candidate = (low + high) / 2d;
var lineHeight = candidate * lineHeightFactor;
var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight);
var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight)));
var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
if (fits)
{
best = candidate;
low = candidate;
}
else
{
high = candidate;
}
}
return best;
}
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
{
var probe = new TextBlock
{
Text = text,
FontFamily = MiSansFontFamily,
FontSize = fontSize,
FontWeight = weight,
TextWrapping = TextWrapping.Wrap,
LineHeight = lineHeight
};
probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
return probe.DesiredSize;
}
private readonly struct AdaptiveTextLayout
{
public AdaptiveTextLayout(
double fontSize,
FontWeight weight,
int maxLines,
double lineHeight,
double overflowScore,
bool fitsCompletely)
{
FontSize = fontSize;
Weight = weight;
MaxLines = Math.Max(1, maxLines);
LineHeight = lineHeight;
OverflowScore = overflowScore;
FitsCompletely = fitsCompletely;
}
public double FontSize { get; }
public FontWeight Weight { get; }
public int MaxLines { get; }
public double LineHeight { get; }
public double OverflowScore { get; }
public bool FitsCompletely { get; }
}
}

View File

@@ -41,7 +41,8 @@ public sealed class DesktopComponentRuntimeDescriptor
double cellSize,
TimeZoneService timeZoneService,
IWeatherInfoService weatherInfoService,
IRecommendationInfoService recommendationInfoService)
IRecommendationInfoService recommendationInfoService,
ICalculatorDataService calculatorDataService)
{
var control = _controlFactory();
if (control is IDesktopComponentWidget sizedComponent)
@@ -64,6 +65,11 @@ public sealed class DesktopComponentRuntimeDescriptor
recommendationInfoAwareComponent.SetRecommendationInfoService(recommendationInfoService);
}
if (control is ICalculatorInfoAwareComponentWidget calculatorInfoAwareComponent)
{
calculatorInfoAwareComponent.SetCalculatorDataService(calculatorDataService);
}
return control;
}
@@ -234,16 +240,21 @@ public sealed class DesktopComponentRuntimeRegistry
"component.daily_word",
() => new DailyWordWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopDailySentence,
"component.daily_sentence",
() => new DailySentenceWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopCnrDailyNews,
"component.cnr_daily_news",
() => new CnrDailyNewsWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"component.bilibili_hot_search",
() => new BilibiliHotSearchWidget(),
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopExchangeRateCalculator,
"component.exchange_rate_converter",
() => new ExchangeRateCalculatorWidget(),
cellSize => Math.Clamp(cellSize * 0.28, 12, 26)),
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.DesktopWhiteboard,
"component.whiteboard",

View File

@@ -0,0 +1,194 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignWidth="320"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.ExchangeRateCalculatorWidget">
<UserControl.Styles>
<Style Selector="Button">
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Background" Value="#F8F9FB" />
<Setter Property="BorderBrush" Value="#00000000" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FontSize" Value="26" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="#111723" />
<Setter Property="Padding" Value="0" />
</Style>
</UserControl.Styles>
<Border x:Name="RootBorder"
CornerRadius="34"
ClipToBounds="True"
Padding="12"
Background="#ECEDEF">
<Viewbox Stretch="Uniform">
<Grid x:Name="LayoutRoot"
Width="304"
Height="304"
RowDefinitions="Auto,Auto,*"
RowSpacing="8">
<Grid Grid.Row="0"
ColumnDefinitions="*,62"
RowDefinitions="Auto,Auto"
RowSpacing="8"
ColumnSpacing="8">
<Border x:Name="FromCurrencyRowBorder"
Grid.Row="0"
Grid.Column="0"
CornerRadius="16"
Background="#F8F9FB"
Padding="12,8"
PointerPressed="OnFromCurrencyRowPointerPressed">
<Grid ColumnDefinitions="Auto,Auto,*"
ColumnSpacing="8">
<StackPanel Orientation="Vertical"
Spacing="1"
VerticalAlignment="Center">
<TextBlock x:Name="FromCurrencyCodeTextBlock"
Text="USD"
FontSize="18"
FontWeight="SemiBold"
Foreground="#121722" />
<TextBlock x:Name="FromCurrencyNameTextBlock"
Text="美元"
FontSize="13"
Foreground="#6C7382" />
</StackPanel>
<TextBlock Grid.Column="1"
Text=">"
FontSize="18"
Foreground="#A3A9B6"
VerticalAlignment="Center" />
<TextBlock x:Name="InputAmountTextBlock"
Grid.Column="2"
Text="100"
FontSize="42"
FontWeight="Bold"
Foreground="#F08D20"
TextAlignment="Right"
VerticalAlignment="Center"
HorizontalAlignment="Right"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<Border x:Name="ToCurrencyRowBorder"
Grid.Row="1"
Grid.Column="0"
CornerRadius="16"
Background="#F8F9FB"
Padding="12,8"
PointerPressed="OnToCurrencyRowPointerPressed">
<Grid ColumnDefinitions="Auto,Auto,*"
ColumnSpacing="8">
<StackPanel Orientation="Vertical"
Spacing="1"
VerticalAlignment="Center">
<TextBlock x:Name="ToCurrencyCodeTextBlock"
Text="CNY"
FontSize="18"
FontWeight="SemiBold"
Foreground="#121722" />
<TextBlock x:Name="ToCurrencyNameTextBlock"
Text="人民币"
FontSize="13"
Foreground="#6C7382" />
</StackPanel>
<TextBlock Grid.Column="1"
Text=">"
FontSize="18"
Foreground="#A3A9B6"
VerticalAlignment="Center" />
<TextBlock x:Name="ConvertedAmountTextBlock"
Grid.Column="2"
Text="0"
FontSize="42"
FontWeight="Bold"
Foreground="#0F1622"
TextAlignment="Right"
VerticalAlignment="Center"
HorizontalAlignment="Right"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<Button x:Name="SwapCurrencyButton"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="1"
CornerRadius="16"
Background="#F8F9FB"
BorderBrush="#00000000"
BorderThickness="0"
FontSize="30"
Content="⇅"
Click="OnSwapCurrencyButtonClick" />
</Grid>
<TextBlock x:Name="RateTextBlock"
Grid.Row="1"
Text="1 USD = 0 CNY"
FontSize="14"
Foreground="#646D7D"
Margin="4,0,0,0"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<Grid Grid.Row="2"
ColumnDefinitions="*,*,*,84"
RowDefinitions="*,*,*,*"
RowSpacing="8"
ColumnSpacing="8">
<Button Grid.Row="0" Grid.Column="0" Content="7" Tag="7" Click="OnInputButtonClick" />
<Button Grid.Row="0" Grid.Column="1" Content="8" Tag="8" Click="OnInputButtonClick" />
<Button Grid.Row="0" Grid.Column="2" Content="9" Tag="9" Click="OnInputButtonClick" />
<Button Grid.Row="1" Grid.Column="0" Content="4" Tag="4" Click="OnInputButtonClick" />
<Button Grid.Row="1" Grid.Column="1" Content="5" Tag="5" Click="OnInputButtonClick" />
<Button Grid.Row="1" Grid.Column="2" Content="6" Tag="6" Click="OnInputButtonClick" />
<Button Grid.Row="2" Grid.Column="0" Content="1" Tag="1" Click="OnInputButtonClick" />
<Button Grid.Row="2" Grid.Column="1" Content="2" Tag="2" Click="OnInputButtonClick" />
<Button Grid.Row="2" Grid.Column="2" Content="3" Tag="3" Click="OnInputButtonClick" />
<Button Grid.Row="3" Grid.Column="0" Content="00" Tag="00" Click="OnInputButtonClick" />
<Button Grid.Row="3" Grid.Column="1" Content="0" Tag="0" Click="OnInputButtonClick" />
<Button Grid.Row="3" Grid.Column="2" Content="." Tag="." Click="OnInputButtonClick" />
<Button x:Name="ClearButton"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="3"
Content="AC"
Background="#D9DDE4"
Tag="AC"
Click="OnInputButtonClick" />
<Button x:Name="BackspaceButton"
Grid.Row="2"
Grid.RowSpan="2"
Grid.Column="3"
Content="⌫"
Background="#D9DDE4"
Tag="BACK"
Click="OnInputButtonClick" />
</Grid>
<TextBlock x:Name="StatusTextBlock"
Grid.RowSpan="3"
IsVisible="False"
Text="Loading"
Foreground="#5E6677"
FontSize="15"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Viewbox>
</Border>
</UserControl>

View File

@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class ExchangeRateCalculatorWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget, ICalculatorInfoAwareComponentWidget
{
private sealed record CurrencyItem(string Code, string ZhName, string EnName);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly CurrencyItem[] CurrencyItems =
[
new("USD", "美元", "US Dollar"),
new("CNY", "人民币", "Chinese Yuan"),
new("EUR", "欧元", "Euro"),
new("JPY", "日元", "Japanese Yen"),
new("HKD", "港币", "Hong Kong Dollar"),
new("GBP", "英镑", "British Pound")
];
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private static readonly ICalculatorDataService DefaultCalculatorService = new CalculatorDataService();
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(30)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private ICalculatorDataService _calculatorDataService = DefaultCalculatorService;
private string _languageCode = "zh-CN";
private string _fromCurrency = "USD";
private string _toCurrency = "CNY";
private string _inputText = "100";
private decimal _currentRate = 0m;
private CancellationTokenSource? _refreshCts;
private double _currentCellSize = 48d;
private bool _isAttached;
private bool _isRefreshing;
public ExchangeRateCalculatorWidget()
{
InitializeComponent();
FromCurrencyCodeTextBlock.FontFamily = MiSansFontFamily;
FromCurrencyNameTextBlock.FontFamily = MiSansFontFamily;
ToCurrencyCodeTextBlock.FontFamily = MiSansFontFamily;
ToCurrencyNameTextBlock.FontFamily = MiSansFontFamily;
InputAmountTextBlock.FontFamily = MiSansFontFamily;
ConvertedAmountTextBlock.FontFamily = MiSansFontFamily;
RateTextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
_refreshTimer.Tick += OnRefreshTimerTick;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
UpdateCurrencyLabels();
UpdateAmounts();
ApplyLoadingState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 14, 48));
RootBorder.Padding = new Thickness(Math.Clamp(12 * scale, 6, 18));
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshExchangeRateAsync(forceRefresh: false);
}
}
public void SetCalculatorDataService(ICalculatorDataService calculatorDataService)
{
_calculatorDataService = calculatorDataService ?? DefaultCalculatorService;
UpdateAmounts();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
_refreshTimer.Start();
_ = RefreshExchangeRateAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshExchangeRateAsync(forceRefresh: false);
}
private async void OnSwapCurrencyButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
_ = sender;
var from = _fromCurrency;
_fromCurrency = _toCurrency;
_toCurrency = from;
UpdateCurrencyLabels();
await RefreshExchangeRateAsync(forceRefresh: false);
}
private async void OnFromCurrencyRowPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
_fromCurrency = GetNextCurrencyCode(_fromCurrency, _toCurrency);
UpdateCurrencyLabels();
await RefreshExchangeRateAsync(forceRefresh: false);
e.Handled = true;
}
private async void OnToCurrencyRowPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
_toCurrency = GetNextCurrencyCode(_toCurrency, _fromCurrency);
UpdateCurrencyLabels();
await RefreshExchangeRateAsync(forceRefresh: false);
e.Handled = true;
}
private void OnInputButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is null)
{
return;
}
var token = button.Tag.ToString() ?? string.Empty;
_inputText = _calculatorDataService.ApplyInputToken(_inputText, token);
UpdateAmounts();
e.Handled = true;
}
private async Task RefreshExchangeRateAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new ExchangeRateQuery(
BaseCurrency: _fromCurrency,
TargetCurrency: _toCurrency,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetExchangeRateAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
_currentRate = result.Data.Rate;
StatusTextBlock.IsVisible = false;
UpdateAmounts();
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
}
}
private void UpdateCurrencyLabels()
{
var from = ResolveCurrency(_fromCurrency);
var to = ResolveCurrency(_toCurrency);
FromCurrencyCodeTextBlock.Text = from.Code;
FromCurrencyNameTextBlock.Text = IsZh() ? from.ZhName : from.EnName;
ToCurrencyCodeTextBlock.Text = to.Code;
ToCurrencyNameTextBlock.Text = IsZh() ? to.ZhName : to.EnName;
}
private void UpdateAmounts()
{
var amount = _calculatorDataService.ParseAmountOrZero(_inputText);
var converted = amount * Math.Max(0m, _currentRate);
InputAmountTextBlock.Text = _inputText;
ConvertedAmountTextBlock.Text = _calculatorDataService.FormatAmount(converted, maxFractionDigits: 4);
RateTextBlock.Text = string.Format(
CultureInfo.InvariantCulture,
"1 {0} = {1} {2}",
_fromCurrency,
_calculatorDataService.FormatAmount(_currentRate, maxFractionDigits: 6),
_toCurrency);
}
private void ApplyLoadingState()
{
StatusTextBlock.Text = L("exchange.widget.loading", "正在加载汇率...");
StatusTextBlock.IsVisible = true;
}
private void ApplyFailedState()
{
StatusTextBlock.Text = L("exchange.widget.fetch_failed", "汇率获取失败");
StatusTextBlock.IsVisible = true;
UpdateAmounts();
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private string GetNextCurrencyCode(string current, string avoid)
{
var currentIndex = Array.FindIndex(
CurrencyItems,
item => string.Equals(item.Code, current, StringComparison.OrdinalIgnoreCase));
if (currentIndex < 0)
{
currentIndex = 0;
}
for (var step = 1; step <= CurrencyItems.Length; step++)
{
var next = CurrencyItems[(currentIndex + step) % CurrencyItems.Length].Code;
if (!string.Equals(next, avoid, StringComparison.OrdinalIgnoreCase))
{
return next;
}
}
return current;
}
private static CurrencyItem ResolveCurrency(string code)
{
return CurrencyItems.FirstOrDefault(item =>
string.Equals(item.Code, code, StringComparison.OrdinalIgnoreCase))
?? CurrencyItems[0];
}
private bool IsZh()
{
return string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 1.8);
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 304d, 0.72, 2.0) : 1;
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 304d, 0.72, 2.0) : 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 1.95);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
}

View File

@@ -23,6 +23,11 @@ public interface IRecommendationInfoAwareComponentWidget
void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService);
}
public interface ICalculatorInfoAwareComponentWidget
{
void SetCalculatorDataService(ICalculatorDataService calculatorDataService);
}
public interface IDesktopPageVisibilityAwareComponentWidget
{
void SetDesktopPageContext(bool isOnActivePage, bool isEditMode);

View File

@@ -725,12 +725,6 @@ public partial class MainWindow
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopDailySentence)
{
OpenDailySentenceComponentSettings();
return;
}
if (placement.ComponentId == BuiltInComponentIds.DesktopCnrDailyNews)
{
OpenCnrDailyNewsComponentSettings();
@@ -840,22 +834,6 @@ public partial class MainWindow
ComponentSettingsWindow.Opacity = 1;
}
private void OpenDailySentenceComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
{
return;
}
var settingsContent = new DailySentenceSettingsWindow();
settingsContent.SettingsChanged += OnDailySentenceSettingsChanged;
ComponentSettingsContentHost.Content = settingsContent;
ComponentSettingsWindow.IsVisible = true;
ComponentSettingsWindow.Opacity = 0;
ComponentSettingsWindow.Opacity = 1;
}
private void OpenCnrDailyNewsComponentSettings()
{
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
@@ -976,30 +954,6 @@ public partial class MainWindow
PersistSettings();
}
private void OnDailySentenceSettingsChanged(object? sender, EventArgs e)
{
_ = sender;
_ = e;
foreach (var pageGrid in _desktopPageComponentGrids.Values)
{
foreach (var host in pageGrid.Children.OfType<Border>())
{
if (!host.Classes.Contains(DesktopComponentHostClass))
{
continue;
}
if (TryGetContentHost(host)?.Child is DailySentenceWidget widget)
{
widget.RefreshFromSettings();
}
}
}
PersistSettings();
}
private void OnCnrDailyNewsSettingsChanged(object? sender, EventArgs e)
{
_ = sender;
@@ -1056,11 +1010,6 @@ public partial class MainWindow
worldClockSettingsWindow.SettingsChanged -= OnWorldClockSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is DailySentenceSettingsWindow dailySentenceSettingsWindow)
{
dailySentenceSettingsWindow.SettingsChanged -= OnDailySentenceSettingsChanged;
}
if (ComponentSettingsContentHost?.Content is CnrDailyNewsSettingsWindow cnrDailyNewsSettingsWindow)
{
cnrDailyNewsSettingsWindow.SettingsChanged -= OnCnrDailyNewsSettingsChanged;
@@ -1469,14 +1418,6 @@ public partial class MainWindow
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopDailySentence, StringComparison.OrdinalIgnoreCase))
{
// Keep daily sentence widget at a 2:1 ratio: 4x2, 6x3, 8x4...
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopCnrDailyNews, StringComparison.OrdinalIgnoreCase))
{
// Keep CNR widget at a 2:1 ratio: 4x2, 6x3, 8x4...
@@ -1485,6 +1426,22 @@ public partial class MainWindow
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopBilibiliHotSearch, StringComparison.OrdinalIgnoreCase))
{
// Keep Bilibili hot search widget at a 2:1 ratio: 4x2, 6x3, 8x4...
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopExchangeRateCalculator, StringComparison.OrdinalIgnoreCase))
{
// Keep exchange rate converter square with minimum size 4x4.
return SnapSpanToScaleRules(
span,
new ComponentScaleRule(WidthUnit: 1, HeightUnit: 1, MinScale: 4));
}
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase))
{
// Keep noise curve widget in a 2:1 ratio with minimum 4x2.
@@ -1722,7 +1679,8 @@ public partial class MainWindow
_currentDesktopCellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService);
_recommendationInfoService,
_calculatorDataService);
component.Classes.Add(DesktopComponentClass);
return component;
}
@@ -2648,6 +2606,11 @@ public partial class MainWindow
return Symbol.Apps;
}
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Calculator;
}
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return Symbol.Apps;
@@ -2688,6 +2651,11 @@ public partial class MainWindow
return L("component_category.info", "Info");
}
if (string.Equals(categoryId, "Calculator", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.calculator", "Calculator");
}
if (string.Equals(categoryId, "Study", StringComparison.OrdinalIgnoreCase))
{
return L("component_category.study", "Study");
@@ -2856,7 +2824,8 @@ public partial class MainWindow
renderCellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService);
_recommendationInfoService,
_calculatorDataService);
var previewSurface = new Border
{

View File

@@ -94,6 +94,7 @@ public partial class MainWindow : Window
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
.CreateDefault()
.RegisterExtensions(