diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 9eed904..bd1c266 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -30,6 +30,7 @@ public static class BuiltInComponentIds public const string DesktopDailyPoetry = "DesktopDailyPoetry"; public const string DesktopDailyArtwork = "DesktopDailyArtwork"; public const string DesktopDailyWord = "DesktopDailyWord"; + public const string DesktopDailySentence = "DesktopDailySentence"; public const string DesktopCnrDailyNews = "DesktopCnrDailyNews"; public const string DesktopWhiteboard = "DesktopWhiteboard"; public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index e525393..4ff4186 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -234,6 +234,15 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopDailySentence, + "Daily Sentence", + "TextQuote", + "Info", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopCnrDailyNews, "CNR Daily News", @@ -242,7 +251,8 @@ public sealed class ComponentRegistry MinWidthCells: 4, MinHeightCells: 2, AllowStatusBarPlacement: false, - AllowDesktopPlacement: true), + AllowDesktopPlacement: true, + ResizeMode: DesktopComponentResizeMode.Free), new DesktopComponentDefinition( BuiltInComponentIds.DesktopWhiteboard, "Blackboard Portrait", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 1b8fcab..c9efa47 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -283,6 +283,7 @@ "component.daily_poetry": "Daily Poetry", "component.daily_artwork": "Daily Artwork", "component.daily_word": "Daily Word", + "component.daily_sentence": "English Sentence", "component.cnr_daily_news": "CNR Headlines", "component.whiteboard": "Blackboard (Portrait)", "component.blackboard_landscape": "Blackboard (Landscape)", @@ -328,12 +329,21 @@ "dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.", "dailyword.widget.fallback_example": "Tap the refresh button and try again.", "dailyword.widget.fallback_example_translation": "It will retry when network recovers.", + "dailysentence.widget.loading": "Loading...", + "dailysentence.widget.loading_sentence": "Fetching daily sentence...", + "dailysentence.widget.loading_translation": "Fetching translation...", + "dailysentence.widget.loading_source": "Youdao Dictionary", + "dailysentence.widget.fetch_failed": "Sentence fetch failed", + "dailysentence.widget.fallback_sentence": "Daily sentence is temporarily unavailable.", + "dailysentence.widget.fallback_translation": "Tap refresh and try again.", + "dailysentence.widget.source_default": "Youdao Dictionary", "cnrnews.widget.loading": "Loading...", "cnrnews.widget.loading_title": "Fetching CNR headlines", "cnrnews.widget.loading_subtitle": "Please wait", "cnrnews.widget.fetch_failed": "News fetch failed", "cnrnews.widget.fallback_title": "CNR news is temporarily unavailable", "cnrnews.widget.fallback_subtitle": "Tap refresh and try again", + "cnrnews.widget.hot_label": "Hot", "artwork.settings.title": "Daily Artwork Settings", "artwork.settings.desc": "Switch the data source used by Daily Artwork.", "artwork.settings.source_label": "Mirror Source", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 012d9cf..8189ce5 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -283,6 +283,7 @@ "component.daily_poetry": "每日诗词", "component.daily_artwork": "每日名画", "component.daily_word": "每日单词", + "component.daily_sentence": "英语句子", "component.cnr_daily_news": "央广网头条", "component.whiteboard": "竖向小黑板", "component.blackboard_landscape": "横向小黑板", @@ -328,12 +329,21 @@ "dailyword.widget.fallback_meaning": "有道词典暂不可用", "dailyword.widget.fallback_example": "请点击右上角刷新重试", "dailyword.widget.fallback_example_translation": "网络恢复后将自动更新", + "dailysentence.widget.loading": "加载中...", + "dailysentence.widget.loading_sentence": "正在获取英语句子", + "dailysentence.widget.loading_translation": "正在获取句子译文", + "dailysentence.widget.loading_source": "有道词典", + "dailysentence.widget.fetch_failed": "英语句子获取失败", + "dailysentence.widget.fallback_sentence": "今日英语句子暂不可用", + "dailysentence.widget.fallback_translation": "请点击右上角刷新重试", + "dailysentence.widget.source_default": "有道词典", "cnrnews.widget.loading": "加载中...", "cnrnews.widget.loading_title": "正在获取新闻热点", "cnrnews.widget.loading_subtitle": "请稍候", "cnrnews.widget.fetch_failed": "新闻获取失败", "cnrnews.widget.fallback_title": "央广网新闻暂不可用", "cnrnews.widget.fallback_subtitle": "点击右上角稍后重试", + "cnrnews.widget.hot_label": "热点", "artwork.settings.title": "每日图片设置", "artwork.settings.desc": "切换每日图片的数据源。", "artwork.settings.source_label": "镜像源", diff --git a/LanMountainDesktop/Services/IRecommendationDataService.cs b/LanMountainDesktop/Services/IRecommendationDataService.cs index a0fa43a..da44a96 100644 --- a/LanMountainDesktop/Services/IRecommendationDataService.cs +++ b/LanMountainDesktop/Services/IRecommendationDataService.cs @@ -17,6 +17,7 @@ public sealed record DailyPoetryQuery( public sealed record DailyNewsQuery( string? Locale = null, + int? ItemCount = null, bool ForceRefresh = false); public sealed record DailyWordQuery( diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index 8da45db..330899c 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -181,14 +181,24 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis CancellationToken cancellationToken = default) { var normalizedQuery = query ?? new DailyNewsQuery(); - if (!normalizedQuery.ForceRefresh && TryGetDailyNewsFromCache(out var cached)) + var targetCount = normalizedQuery.ItemCount.HasValue + ? Math.Clamp(normalizedQuery.ItemCount.Value, 1, 12) + : Math.Clamp(_options.DefaultDailyNewsCount, 1, 12); + + if (!normalizedQuery.ForceRefresh && + TryGetDailyNewsFromCache(out var cached) && + cached.Items.Count >= targetCount) { - return RecommendationQueryResult.Ok(cached); + var projectedSnapshot = cached with + { + Items = cached.Items.Take(targetCount).ToArray() + }; + return RecommendationQueryResult.Ok(projectedSnapshot); } try { - var items = await FetchCnrDailyNewsItemsAsync(cancellationToken); + var items = await FetchCnrDailyNewsItemsAsync(targetCount, cancellationToken); if (items.Count == 0) { return RecommendationQueryResult.Fail( @@ -196,7 +206,6 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis "No CNR news items were returned."); } - var targetCount = Math.Clamp(_options.DefaultDailyNewsCount, 1, 4); var snapshot = new DailyNewsSnapshot( Provider: "CNR", Source: "央广网·头条", @@ -837,7 +846,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis return null; } - private async Task> FetchCnrDailyNewsItemsAsync(CancellationToken cancellationToken) + private async Task> FetchCnrDailyNewsItemsAsync( + int requestedItemCount, + CancellationToken cancellationToken) { var requestUrl = string.IsNullOrWhiteSpace(_options.CnrDailyNewsListUrl) ? "https://www.cnr.cn/newscenter/native/gd/" @@ -848,7 +859,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } var html = await FetchHtmlWithCnrEncodingAsync(requestUrl, cancellationToken); - var targetCount = Math.Clamp(_options.DefaultDailyNewsCount, 1, 4); + var targetCount = Math.Clamp(requestedItemCount, 1, 12); var candidateLimit = Math.Max(8, targetCount * 3); var htmlCandidates = ParseCnrDailyNewsFromListPage( html, diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml index d5d1e29..5b16dbf 100644 --- a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml @@ -9,17 +9,19 @@ + Padding="0"> - + @@ -75,25 +77,16 @@ ColumnDefinitions="*,Auto" ColumnSpacing="12" PointerPressed="OnNewsItem1PointerPressed"> - - - - + + VerticalAlignment="Top" + LineHeight="24" /> + + diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs index 3f031ff..e74284b 100644 --- a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -8,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Documents; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; @@ -43,7 +45,39 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, private readonly AppSettingsService _settingsService = new(); private readonly LocalizationService _localizationService = new(); private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2]; - private readonly string?[] _newsUrls = new string?[2]; + private readonly List _newsUrls = []; + private readonly List _extraNewsRows = []; + private IReadOnlyList _activeNewsItems = []; + private int _renderedNewsCount = 2; + + private sealed class ExtraNewsRowVisual + { + public ExtraNewsRowVisual( + Grid rootGrid, + TextBlock titleTextBlock, + Border imageHost, + Image imageControl, + int newsIndex) + { + RootGrid = rootGrid; + TitleTextBlock = titleTextBlock; + ImageHost = imageHost; + ImageControl = imageControl; + NewsIndex = newsIndex; + } + + public Grid RootGrid { get; } + + public TextBlock TitleTextBlock { get; } + + public Border ImageHost { get; } + + public Image ImageControl { get; } + + public int NewsIndex { get; } + + public Bitmap? Bitmap { get; set; } + } private IRecommendationInfoService _recommendationService = DefaultRecommendationService; private CancellationTokenSource? _refreshCts; @@ -60,7 +94,6 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, BrandSecondaryTextBlock.FontFamily = MiSansFontFamily; RefreshGlyphTextBlock.FontFamily = MiSansFontFamily; RefreshLabelTextBlock.FontFamily = MiSansFontFamily; - News1PrefixTextBlock.FontFamily = MiSansFontFamily; News1TitleTextBlock.FontFamily = MiSansFontFamily; News2TitleTextBlock.FontFamily = MiSansFontFamily; StatusTextBlock.FontFamily = MiSansFontFamily; @@ -115,12 +148,33 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, _refreshTimer.Stop(); CancelRefreshRequest(); DisposeNewsBitmaps(); + ClearExtraNewsRows(); UpdateRefreshButtonState(); } private void OnSizeChanged(object? sender, SizeChangedEventArgs e) { ApplyCellSize(_currentCellSize); + var desiredCount = ResolveDesiredNewsItemCount(); + var previousRenderedCount = _renderedNewsCount; + if (_activeNewsItems.Count > 0 && desiredCount != previousRenderedCount) + { + RenderExtraNewsRows(_activeNewsItems.Take(desiredCount).Skip(2).ToArray()); + UpdateNewsInteractionState(); + } + + var shouldFetchMoreItems = desiredCount > _activeNewsItems.Count; + var shouldReloadExpandedImages = + desiredCount > previousRenderedCount && + desiredCount <= _activeNewsItems.Count; + + if (_isAttached && + !_isRefreshing && + _activeNewsItems.Count > 0 && + (shouldFetchMoreItems || shouldReloadExpandedImages)) + { + _ = RefreshNewsAsync(forceRefresh: false); + } } private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) @@ -161,6 +215,19 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, e.Handled = true; } + private void OnExtraNewsItemPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || + sender is not Control control || + control.Tag is not int index) + { + return; + } + + TryOpenNewsUrl(index); + e.Handled = true; + } + private async Task RefreshNewsAsync(bool forceRefresh) { if (!_isAttached || _isRefreshing) @@ -181,6 +248,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, { var query = new DailyNewsQuery( Locale: _languageCode, + ItemCount: ResolveDesiredNewsItemCount(), ForceRefresh: forceRefresh); var result = await _recommendationService.GetDailyNewsAsync(query, cts.Token); if (!_isAttached || cts.IsCancellationRequested) @@ -222,90 +290,266 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken) { + var desiredCount = ResolveDesiredNewsItemCount(); var items = snapshot.Items is null ? [] - : snapshot.Items.Take(2).ToArray(); + : snapshot.Items.Take(desiredCount).ToArray(); + _activeNewsItems = items; var item1 = items.Length > 0 ? items[0] : null; var item2 = items.Length > 1 ? items[1] : null; - News1PrefixTextBlock.IsVisible = item1 is not null; - News1TitleTextBlock.Text = NormalizeCompactText(item1?.Title); + UpdateHotHeadlineText(item1?.Title); News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title); - _newsUrls[0] = NormalizeHttpUrl(item1?.Url); - _newsUrls[1] = NormalizeHttpUrl(item2?.Url); + _newsUrls.Clear(); + foreach (var item in items) + { + _newsUrls.Add(NormalizeHttpUrl(item.Url)); + } + + RenderExtraNewsRows(items.Skip(2).ToArray()); UpdateNewsInteractionState(); StatusTextBlock.IsVisible = false; UpdateAdaptiveLayout(); - var loadTasks = new[] - { - TryDownloadBitmapAsync(item1?.ImageUrl, cancellationToken), - TryDownloadBitmapAsync(item2?.ImageUrl, cancellationToken) - }; + var loadTasks = items + .Select(item => TryDownloadBitmapAsync(item.ImageUrl, cancellationToken)) + .ToArray(); var bitmaps = await Task.WhenAll(loadTasks); if (cancellationToken.IsCancellationRequested || !_isAttached) { - bitmaps[0]?.Dispose(); - bitmaps[1]?.Dispose(); + foreach (var bitmap in bitmaps) + { + bitmap?.Dispose(); + } return; } - SetNewsBitmap(0, bitmaps[0]); - SetNewsBitmap(1, bitmaps[1]); + var consumed = new bool[bitmaps.Length]; + Bitmap? TakeBitmapAt(int index) + { + if (index < 0 || index >= bitmaps.Length) + { + return null; + } + + consumed[index] = true; + return bitmaps[index]; + } + + SetNewsBitmap(0, TakeBitmapAt(0)); + SetNewsBitmap(1, TakeBitmapAt(1)); + + for (var rowIndex = 0; rowIndex < _extraNewsRows.Count; rowIndex++) + { + SetExtraNewsBitmap(rowIndex, TakeBitmapAt(rowIndex + 2)); + } + + for (var i = 0; i < bitmaps.Length; i++) + { + if (!consumed[i]) + { + bitmaps[i]?.Dispose(); + } + } } private void ApplyLoadingState() { - _newsUrls[0] = null; - _newsUrls[1] = null; - News1PrefixTextBlock.IsVisible = true; - News1TitleTextBlock.Text = L("cnrnews.widget.loading_title", "正在获取新闻热点"); - News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "请稍候"); - StatusTextBlock.Text = L("cnrnews.widget.loading", "加载中..."); + _activeNewsItems = []; + _newsUrls.Clear(); + UpdateHotHeadlineText(L("cnrnews.widget.loading_title", "Loading headlines")); + News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "Please wait"); + StatusTextBlock.Text = L("cnrnews.widget.loading", "Loading..."); StatusTextBlock.IsVisible = true; + SetNewsBitmap(0, null); + SetNewsBitmap(1, null); + RenderExtraNewsRows([]); UpdateNewsInteractionState(); UpdateAdaptiveLayout(); } private void ApplyFailedState() { - _newsUrls[0] = null; - _newsUrls[1] = null; - News1PrefixTextBlock.IsVisible = false; - News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "央广网新闻暂不可用"); - News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "点击右上角稍后重试"); - StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "新闻获取失败"); + _activeNewsItems = []; + _newsUrls.Clear(); + News1TitleTextBlock.Inlines = null; + News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "CNR news is temporarily unavailable"); + News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "Tap refresh and try again"); + StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "News fetch failed"); StatusTextBlock.IsVisible = true; SetNewsBitmap(0, null); SetNewsBitmap(1, null); + RenderExtraNewsRows([]); UpdateNewsInteractionState(); UpdateAdaptiveLayout(); } + private int ResolveDesiredNewsItemCount() + { + var span = ResolveCurrentCellSpan(); + var baseEquivalentHeight = span.HeightCells * (double)BaseWidthCells / Math.Max(BaseWidthCells, span.WidthCells); + var effectiveHeightCells = (int)Math.Round(baseEquivalentHeight, MidpointRounding.AwayFromZero); + return Math.Clamp(Math.Max(BaseHeightCells, effectiveHeightCells), 2, 12); + } + + private (int WidthCells, int HeightCells) ResolveCurrentCellSpan() + { + var pitch = Math.Max(1, _currentCellSize); + var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; + var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; + + var normalizedWidth = totalWidth + Math.Clamp(pitch * 0.20, 6, 16); + var normalizedHeight = totalHeight + Math.Clamp(pitch * 0.18, 4, 12); + + var widthCells = Math.Max(BaseWidthCells, (int)Math.Round(normalizedWidth / pitch, MidpointRounding.AwayFromZero)); + var heightCells = Math.Max(BaseHeightCells, (int)Math.Round(normalizedHeight / pitch, MidpointRounding.AwayFromZero)); + return (widthCells, heightCells); + } + + private void UpdateHotHeadlineText(string? title) + { + var normalizedTitle = NormalizeCompactText(title); + var hotLabel = L("cnrnews.widget.hot_label", "Hot"); + if (News1TitleTextBlock.Inlines is null) + { + News1TitleTextBlock.Text = $"{hotLabel} | {normalizedTitle}"; + return; + } + + News1TitleTextBlock.Inlines.Clear(); + News1TitleTextBlock.Inlines.Add(new Run($"{hotLabel} | ") + { + Foreground = new SolidColorBrush(Color.Parse("#D6272E")), + FontWeight = FontWeight.SemiBold + }); + News1TitleTextBlock.Inlines.Add(new Run(normalizedTitle) + { + Foreground = new SolidColorBrush(Color.Parse("#202327")), + FontWeight = FontWeight.SemiBold + }); + } + + private void RenderExtraNewsRows(IReadOnlyList extraItems) + { + ClearExtraNewsRows(); + if (extraItems.Count == 0) + { + ExtraNewsItemsPanel.IsVisible = false; + _renderedNewsCount = 2; + return; + } + + for (var i = 0; i < extraItems.Count; i++) + { + var item = extraItems[i]; + var itemIndex = i + 2; + var rowGrid = new Grid + { + ColumnSpacing = 12, + Tag = itemIndex, + Cursor = new Cursor(StandardCursorType.Hand), + IsHitTestVisible = true + }; + rowGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star))); + rowGrid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + rowGrid.PointerPressed += OnExtraNewsItemPointerPressed; + + var textBlock = new TextBlock + { + Text = NormalizeCompactText(item.Title), + Foreground = new SolidColorBrush(Color.Parse("#202327")), + FontFamily = MiSansFontFamily, + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 2, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top, + IsHitTestVisible = false + }; + + var imageHost = new Border + { + Width = 160, + Height = 90, + CornerRadius = new CornerRadius(16), + ClipToBounds = true, + Background = new SolidColorBrush(Color.Parse("#E6E6E6")), + IsHitTestVisible = false + }; + var image = new Image + { + Stretch = Stretch.UniformToFill, + IsHitTestVisible = false + }; + imageHost.Child = image; + Grid.SetColumn(imageHost, 1); + + rowGrid.Children.Add(textBlock); + rowGrid.Children.Add(imageHost); + ExtraNewsItemsPanel.Children.Add(rowGrid); + _extraNewsRows.Add(new ExtraNewsRowVisual(rowGrid, textBlock, imageHost, image, itemIndex)); + } + + ExtraNewsItemsPanel.IsVisible = true; + _renderedNewsCount = 2 + extraItems.Count; + } + + private void ClearExtraNewsRows() + { + foreach (var row in _extraNewsRows) + { + row.RootGrid.PointerPressed -= OnExtraNewsItemPointerPressed; + if (ReferenceEquals(row.ImageControl.Source, row.Bitmap)) + { + row.ImageControl.Source = null; + } + + row.Bitmap?.Dispose(); + row.Bitmap = null; + } + + _extraNewsRows.Clear(); + ExtraNewsItemsPanel.Children.Clear(); + } + + private void SetExtraNewsBitmap(int rowIndex, Bitmap? bitmap) + { + if (rowIndex < 0 || rowIndex >= _extraNewsRows.Count) + { + bitmap?.Dispose(); + return; + } + + var row = _extraNewsRows[rowIndex]; + if (ReferenceEquals(row.ImageControl.Source, row.Bitmap)) + { + row.ImageControl.Source = null; + } + + row.Bitmap?.Dispose(); + row.Bitmap = bitmap; + row.ImageControl.Source = bitmap; + } + 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)); - RootBorder.Padding = new Thickness( - Math.Clamp(16 * scale, 8, 28), - Math.Clamp(12 * scale, 6, 20), - Math.Clamp(16 * scale, 8, 28), - Math.Clamp(12 * scale, 6, 20)); + RootBorder.Padding = new Thickness(0); - CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36)); + CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52)); CardBorder.Padding = new Thickness( Math.Clamp(16 * scale, 8, 24), Math.Clamp(14 * scale, 7, 22), Math.Clamp(16 * scale, 8, 24), Math.Clamp(14 * scale, 7, 22)); - var headlineFont = Math.Clamp(28 * scale, 13, 36); + var headlineFont = Math.Clamp(24 * scale, 12, 34); BrandPrimaryTextBlock.FontSize = headlineFont; BrandSecondaryTextBlock.FontSize = headlineFont; @@ -314,10 +558,10 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, RefreshButton.Height = refreshHeight; RefreshButton.Width = refreshWidth; RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d); - RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 26); - RefreshLabelTextBlock.FontSize = Math.Clamp(25 * scale, 12, 32); + RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 24); + RefreshLabelTextBlock.FontSize = Math.Clamp(22 * scale, 11, 29); - var imageWidth = Math.Clamp(totalWidth * 0.23, 68, 170); + var imageWidth = Math.Clamp(totalWidth * 0.20, 60, 170); var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94); News1ImageHost.Width = imageWidth; News1ImageHost.Height = imageHeight; @@ -332,19 +576,45 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth); NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth); - var availableTextWidth = Math.Max(72, totalWidth - RootBorder.Padding.Left - RootBorder.Padding.Right - imageWidth - columnGap - Math.Clamp(24 * scale, 12, 36)); + var availableTextWidth = Math.Max( + 84, + totalWidth - imageWidth - columnGap - Math.Clamp(20 * scale, 10, 32)); News1TitleTextBlock.MaxWidth = availableTextWidth; News2TitleTextBlock.MaxWidth = availableTextWidth; - var newsFont = Math.Clamp(25 * scale, 11, 32); - News1PrefixTextBlock.FontSize = newsFont; + var newsFont = Math.Clamp(21 * scale, 10.5, 28); News1TitleTextBlock.FontSize = newsFont; News2TitleTextBlock.FontSize = newsFont; + var mainNewsLineHeight = newsFont * 1.14; + News1TitleTextBlock.LineHeight = mainNewsLineHeight; + News2TitleTextBlock.LineHeight = mainNewsLineHeight; + var mainNewsMinHeight = mainNewsLineHeight * 2; + News1TitleTextBlock.MinHeight = mainNewsMinHeight; + News2TitleTextBlock.MinHeight = mainNewsMinHeight; StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24); + News1TitleTextBlock.MaxLines = 2; + News2TitleTextBlock.MaxLines = 2; - var compactLayout = totalHeight < _currentCellSize * 1.7; - News1TitleTextBlock.MaxLines = compactLayout ? 1 : 2; - News2TitleTextBlock.MaxLines = compactLayout ? 1 : 2; + foreach (var row in _extraNewsRows) + { + row.RootGrid.ColumnSpacing = columnGap; + if (row.RootGrid.ColumnDefinitions.Count > 1) + { + row.RootGrid.ColumnDefinitions[1].Width = new GridLength(imageWidth); + } + + row.ImageHost.Width = imageWidth; + row.ImageHost.Height = imageHeight; + row.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22)); + + row.TitleTextBlock.MaxWidth = availableTextWidth; + row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25); + row.TitleTextBlock.LineHeight = row.TitleTextBlock.FontSize * 1.12; + row.TitleTextBlock.MinHeight = row.TitleTextBlock.LineHeight * 2; + row.TitleTextBlock.MaxLines = 2; + } + + ExtraNewsItemsPanel.Spacing = Math.Clamp(6 * scale, 3, 10); } private void UpdateRefreshButtonState() @@ -357,13 +627,24 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, private void UpdateNewsInteractionState() { - var item1Enabled = !string.IsNullOrWhiteSpace(_newsUrls[0]); - var item2Enabled = !string.IsNullOrWhiteSpace(_newsUrls[1]); + var item1Enabled = _newsUrls.Count > 0 && !string.IsNullOrWhiteSpace(_newsUrls[0]); + var item2Enabled = _newsUrls.Count > 1 && !string.IsNullOrWhiteSpace(_newsUrls[1]); NewsItem1Grid.IsHitTestVisible = item1Enabled; NewsItem2Grid.IsHitTestVisible = item2Enabled; NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72; NewsItem2Grid.Opacity = item2Enabled ? 1.0 : 0.72; + + foreach (var row in _extraNewsRows) + { + var index = row.NewsIndex; + var enabled = index >= 0 && index < _newsUrls.Count && !string.IsNullOrWhiteSpace(_newsUrls[index]); + row.RootGrid.IsHitTestVisible = enabled; + row.RootGrid.Opacity = enabled ? 1.0 : 0.72; + row.RootGrid.Cursor = enabled + ? new Cursor(StandardCursorType.Hand) + : new Cursor(StandardCursorType.Arrow); + } } private static async Task TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken) @@ -406,7 +687,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, private void TryOpenNewsUrl(int index) { - if (index < 0 || index >= _newsUrls.Length) + if (index < 0 || index >= _newsUrls.Count) { return; } @@ -532,3 +813,4 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, return MultiWhitespaceRegex.Replace(text.Trim(), " "); } } + diff --git a/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml b/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml new file mode 100644 index 0000000..cea2470 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml.cs new file mode 100644 index 0000000..4e92887 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailySentenceWidget.axaml.cs @@ -0,0 +1,869 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +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 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; + + 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(); + 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(); + if (_isAttached) + { + _ = RefreshSentenceAsync(forceRefresh: true); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + UpdateRefreshButtonState(); + _refreshTimer.Start(); + _ = 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: false); + } + + 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 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 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); + + 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 topRowHeight = Math.Max(20, innerHeight * 0.22); + var bottomRowHeight = Math.Max(14, innerHeight * 0.14); + var middleHeight = Math.Max(24, innerHeight - topRowHeight - bottomRowHeight - ContentGrid.RowSpacing * 2); + + var topTextWidth = Math.Max(76, innerWidth - refreshSize - ContentGrid.RowSpacing); + var dayWidth = Math.Max(20, topTextWidth * 0.16); + var monthYearWidth = Math.Max(48, topTextWidth - dayWidth - 6 * scale); + 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 sentenceLineLimit = innerHeight < _currentCellSize * 1.78 ? 2 : 3; + var sentenceHeight = Math.Max(16, middleHeight * 0.66); + var translationHeight = Math.Max(14, middleHeight - sentenceHeight - Math.Clamp(8 * scale, 3, 12)); + + var sentenceLayout = FitAdaptiveTextLayout( + SentenceTextBlock.Text, + innerWidth, + sentenceHeight, + minLines: 1, + maxLines: sentenceLineLimit, + minFontSize: Math.Clamp(23 * scale, 10, 42), + 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: 2, + minFontSize: Math.Clamp(16 * scale, 8.5, 30), + 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; + + 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 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; } + } +} diff --git a/LanMountainDesktop/Views/Components/DailyWordWidget.axaml b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml index f0868d8..4f42248 100644 --- a/LanMountainDesktop/Views/Components/DailyWordWidget.axaml +++ b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml @@ -11,14 +11,16 @@ + Padding="0"> diff --git a/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs index 32f88c6..568afda 100644 --- a/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs @@ -223,13 +223,9 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52)); - RootBorder.Padding = new Thickness( - Math.Clamp(16 * scale, 8, 26), - Math.Clamp(12 * scale, 6, 20), - Math.Clamp(16 * scale, 8, 26), - Math.Clamp(12 * scale, 6, 20)); + RootBorder.Padding = new Thickness(0); - CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36)); + CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52)); CardBorder.Padding = new Thickness( Math.Clamp(16 * scale, 8, 24), Math.Clamp(14 * scale, 7, 22), diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index 35367d0..b0e5e68 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -234,6 +234,11 @@ 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", diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 1438ce4..9817d62 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -1366,6 +1366,14 @@ 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.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase)) { // Keep noise curve widget in a 2:1 ratio with minimum 4x2.