mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8643a2959 |
@@ -30,6 +30,7 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
|
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
|
||||||
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
||||||
public const string DesktopDailyWord = "DesktopDailyWord";
|
public const string DesktopDailyWord = "DesktopDailyWord";
|
||||||
|
public const string DesktopDailySentence = "DesktopDailySentence";
|
||||||
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
|
||||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||||
|
|||||||
@@ -234,6 +234,15 @@ public sealed class ComponentRegistry
|
|||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopDailySentence,
|
||||||
|
"Daily Sentence",
|
||||||
|
"TextQuote",
|
||||||
|
"Info",
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopCnrDailyNews,
|
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||||
"CNR Daily News",
|
"CNR Daily News",
|
||||||
@@ -242,7 +251,8 @@ public sealed class ComponentRegistry
|
|||||||
MinWidthCells: 4,
|
MinWidthCells: 4,
|
||||||
MinHeightCells: 2,
|
MinHeightCells: 2,
|
||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true),
|
AllowDesktopPlacement: true,
|
||||||
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.DesktopWhiteboard,
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
"Blackboard Portrait",
|
"Blackboard Portrait",
|
||||||
|
|||||||
@@ -283,6 +283,7 @@
|
|||||||
"component.daily_poetry": "Daily Poetry",
|
"component.daily_poetry": "Daily Poetry",
|
||||||
"component.daily_artwork": "Daily Artwork",
|
"component.daily_artwork": "Daily Artwork",
|
||||||
"component.daily_word": "Daily Word",
|
"component.daily_word": "Daily Word",
|
||||||
|
"component.daily_sentence": "English Sentence",
|
||||||
"component.cnr_daily_news": "CNR Headlines",
|
"component.cnr_daily_news": "CNR Headlines",
|
||||||
"component.whiteboard": "Blackboard (Portrait)",
|
"component.whiteboard": "Blackboard (Portrait)",
|
||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
@@ -328,12 +329,21 @@
|
|||||||
"dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.",
|
"dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.",
|
||||||
"dailyword.widget.fallback_example": "Tap the refresh button and try again.",
|
"dailyword.widget.fallback_example": "Tap the refresh button and try again.",
|
||||||
"dailyword.widget.fallback_example_translation": "It will retry when network recovers.",
|
"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": "Loading...",
|
||||||
"cnrnews.widget.loading_title": "Fetching CNR headlines",
|
"cnrnews.widget.loading_title": "Fetching CNR headlines",
|
||||||
"cnrnews.widget.loading_subtitle": "Please wait",
|
"cnrnews.widget.loading_subtitle": "Please wait",
|
||||||
"cnrnews.widget.fetch_failed": "News fetch failed",
|
"cnrnews.widget.fetch_failed": "News fetch failed",
|
||||||
"cnrnews.widget.fallback_title": "CNR news is temporarily unavailable",
|
"cnrnews.widget.fallback_title": "CNR news is temporarily unavailable",
|
||||||
"cnrnews.widget.fallback_subtitle": "Tap refresh and try again",
|
"cnrnews.widget.fallback_subtitle": "Tap refresh and try again",
|
||||||
|
"cnrnews.widget.hot_label": "Hot",
|
||||||
"artwork.settings.title": "Daily Artwork Settings",
|
"artwork.settings.title": "Daily Artwork Settings",
|
||||||
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
|
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
|
||||||
"artwork.settings.source_label": "Mirror Source",
|
"artwork.settings.source_label": "Mirror Source",
|
||||||
|
|||||||
@@ -283,6 +283,7 @@
|
|||||||
"component.daily_poetry": "每日诗词",
|
"component.daily_poetry": "每日诗词",
|
||||||
"component.daily_artwork": "每日名画",
|
"component.daily_artwork": "每日名画",
|
||||||
"component.daily_word": "每日单词",
|
"component.daily_word": "每日单词",
|
||||||
|
"component.daily_sentence": "英语句子",
|
||||||
"component.cnr_daily_news": "央广网头条",
|
"component.cnr_daily_news": "央广网头条",
|
||||||
"component.whiteboard": "竖向小黑板",
|
"component.whiteboard": "竖向小黑板",
|
||||||
"component.blackboard_landscape": "横向小黑板",
|
"component.blackboard_landscape": "横向小黑板",
|
||||||
@@ -328,12 +329,21 @@
|
|||||||
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
|
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
|
||||||
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
|
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
|
||||||
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
|
"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": "加载中...",
|
||||||
"cnrnews.widget.loading_title": "正在获取新闻热点",
|
"cnrnews.widget.loading_title": "正在获取新闻热点",
|
||||||
"cnrnews.widget.loading_subtitle": "请稍候",
|
"cnrnews.widget.loading_subtitle": "请稍候",
|
||||||
"cnrnews.widget.fetch_failed": "新闻获取失败",
|
"cnrnews.widget.fetch_failed": "新闻获取失败",
|
||||||
"cnrnews.widget.fallback_title": "央广网新闻暂不可用",
|
"cnrnews.widget.fallback_title": "央广网新闻暂不可用",
|
||||||
"cnrnews.widget.fallback_subtitle": "点击右上角稍后重试",
|
"cnrnews.widget.fallback_subtitle": "点击右上角稍后重试",
|
||||||
|
"cnrnews.widget.hot_label": "热点",
|
||||||
"artwork.settings.title": "每日图片设置",
|
"artwork.settings.title": "每日图片设置",
|
||||||
"artwork.settings.desc": "切换每日图片的数据源。",
|
"artwork.settings.desc": "切换每日图片的数据源。",
|
||||||
"artwork.settings.source_label": "镜像源",
|
"artwork.settings.source_label": "镜像源",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed record DailyPoetryQuery(
|
|||||||
|
|
||||||
public sealed record DailyNewsQuery(
|
public sealed record DailyNewsQuery(
|
||||||
string? Locale = null,
|
string? Locale = null,
|
||||||
|
int? ItemCount = null,
|
||||||
bool ForceRefresh = false);
|
bool ForceRefresh = false);
|
||||||
|
|
||||||
public sealed record DailyWordQuery(
|
public sealed record DailyWordQuery(
|
||||||
|
|||||||
@@ -181,14 +181,24 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var normalizedQuery = query ?? new DailyNewsQuery();
|
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<DailyNewsSnapshot>.Ok(cached);
|
var projectedSnapshot = cached with
|
||||||
|
{
|
||||||
|
Items = cached.Items.Take(targetCount).ToArray()
|
||||||
|
};
|
||||||
|
return RecommendationQueryResult<DailyNewsSnapshot>.Ok(projectedSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var items = await FetchCnrDailyNewsItemsAsync(cancellationToken);
|
var items = await FetchCnrDailyNewsItemsAsync(targetCount, cancellationToken);
|
||||||
if (items.Count == 0)
|
if (items.Count == 0)
|
||||||
{
|
{
|
||||||
return RecommendationQueryResult<DailyNewsSnapshot>.Fail(
|
return RecommendationQueryResult<DailyNewsSnapshot>.Fail(
|
||||||
@@ -196,7 +206,6 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
"No CNR news items were returned.");
|
"No CNR news items were returned.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetCount = Math.Clamp(_options.DefaultDailyNewsCount, 1, 4);
|
|
||||||
var snapshot = new DailyNewsSnapshot(
|
var snapshot = new DailyNewsSnapshot(
|
||||||
Provider: "CNR",
|
Provider: "CNR",
|
||||||
Source: "央广网·头条",
|
Source: "央广网·头条",
|
||||||
@@ -837,7 +846,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<DailyNewsItemSnapshot>> FetchCnrDailyNewsItemsAsync(CancellationToken cancellationToken)
|
private async Task<List<DailyNewsItemSnapshot>> FetchCnrDailyNewsItemsAsync(
|
||||||
|
int requestedItemCount,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var requestUrl = string.IsNullOrWhiteSpace(_options.CnrDailyNewsListUrl)
|
var requestUrl = string.IsNullOrWhiteSpace(_options.CnrDailyNewsListUrl)
|
||||||
? "https://www.cnr.cn/newscenter/native/gd/"
|
? "https://www.cnr.cn/newscenter/native/gd/"
|
||||||
@@ -848,7 +859,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
|
|||||||
}
|
}
|
||||||
|
|
||||||
var html = await FetchHtmlWithCnrEncodingAsync(requestUrl, cancellationToken);
|
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 candidateLimit = Math.Max(8, targetCount * 3);
|
||||||
var htmlCandidates = ParseCnrDailyNewsFromListPage(
|
var htmlCandidates = ParseCnrDailyNewsFromListPage(
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -9,17 +9,19 @@
|
|||||||
|
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
CornerRadius="34"
|
CornerRadius="34"
|
||||||
Background="#D5D5D5"
|
Background="Transparent"
|
||||||
ClipToBounds="True"
|
ClipToBounds="True"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
Padding="16,12,16,12">
|
Padding="0">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Border x:Name="CardBorder"
|
<Border x:Name="CardBorder"
|
||||||
Background="#F9F9F9"
|
Background="#FCFCFD"
|
||||||
CornerRadius="24"
|
CornerRadius="34"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
Padding="16,14,16,14">
|
Padding="16,14,16,14">
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto"
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto"
|
||||||
RowSpacing="10">
|
RowSpacing="8">
|
||||||
<Grid Grid.Row="0"
|
<Grid Grid.Row="0"
|
||||||
ColumnDefinitions="*,Auto"
|
ColumnDefinitions="*,Auto"
|
||||||
ColumnSpacing="10">
|
ColumnSpacing="10">
|
||||||
@@ -75,25 +77,16 @@
|
|||||||
ColumnDefinitions="*,Auto"
|
ColumnDefinitions="*,Auto"
|
||||||
ColumnSpacing="12"
|
ColumnSpacing="12"
|
||||||
PointerPressed="OnNewsItem1PointerPressed">
|
PointerPressed="OnNewsItem1PointerPressed">
|
||||||
<Grid ColumnDefinitions="Auto,*"
|
<TextBlock x:Name="News1TitleTextBlock"
|
||||||
ColumnSpacing="4"
|
Text="Headline"
|
||||||
VerticalAlignment="Center">
|
Foreground="#202327"
|
||||||
<TextBlock x:Name="News1PrefixTextBlock"
|
FontSize="21"
|
||||||
Text="热点 |"
|
FontWeight="SemiBold"
|
||||||
Foreground="#D6272E"
|
TextWrapping="Wrap"
|
||||||
FontSize="25"
|
TextTrimming="CharacterEllipsis"
|
||||||
FontWeight="SemiBold"
|
MaxLines="2"
|
||||||
VerticalAlignment="Top" />
|
VerticalAlignment="Top"
|
||||||
<TextBlock x:Name="News1TitleTextBlock"
|
LineHeight="24" />
|
||||||
Grid.Column="1"
|
|
||||||
Text="Headline"
|
|
||||||
Foreground="#202327"
|
|
||||||
FontSize="25"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
MaxLines="2" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border x:Name="News1ImageHost"
|
<Border x:Name="News1ImageHost"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
@@ -115,12 +108,13 @@
|
|||||||
<TextBlock x:Name="News2TitleTextBlock"
|
<TextBlock x:Name="News2TitleTextBlock"
|
||||||
Text="Headline"
|
Text="Headline"
|
||||||
Foreground="#202327"
|
Foreground="#202327"
|
||||||
FontSize="25"
|
FontSize="21"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
MaxLines="2"
|
MaxLines="2"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Top"
|
||||||
|
LineHeight="24" />
|
||||||
|
|
||||||
<Border x:Name="News2ImageHost"
|
<Border x:Name="News2ImageHost"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
@@ -133,6 +127,11 @@
|
|||||||
Stretch="UniformToFill" />
|
Stretch="UniformToFill" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel x:Name="ExtraNewsItemsPanel"
|
||||||
|
Grid.Row="3"
|
||||||
|
Spacing="6"
|
||||||
|
IsVisible="False" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -8,6 +9,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Documents;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
@@ -43,7 +45,39 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
private readonly AppSettingsService _settingsService = new();
|
private readonly AppSettingsService _settingsService = new();
|
||||||
private readonly LocalizationService _localizationService = new();
|
private readonly LocalizationService _localizationService = new();
|
||||||
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
|
private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2];
|
||||||
private readonly string?[] _newsUrls = new string?[2];
|
private readonly List<string?> _newsUrls = [];
|
||||||
|
private readonly List<ExtraNewsRowVisual> _extraNewsRows = [];
|
||||||
|
private IReadOnlyList<DailyNewsItemSnapshot> _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 IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||||
private CancellationTokenSource? _refreshCts;
|
private CancellationTokenSource? _refreshCts;
|
||||||
@@ -60,7 +94,6 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
|
BrandSecondaryTextBlock.FontFamily = MiSansFontFamily;
|
||||||
RefreshGlyphTextBlock.FontFamily = MiSansFontFamily;
|
RefreshGlyphTextBlock.FontFamily = MiSansFontFamily;
|
||||||
RefreshLabelTextBlock.FontFamily = MiSansFontFamily;
|
RefreshLabelTextBlock.FontFamily = MiSansFontFamily;
|
||||||
News1PrefixTextBlock.FontFamily = MiSansFontFamily;
|
|
||||||
News1TitleTextBlock.FontFamily = MiSansFontFamily;
|
News1TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||||
News2TitleTextBlock.FontFamily = MiSansFontFamily;
|
News2TitleTextBlock.FontFamily = MiSansFontFamily;
|
||||||
StatusTextBlock.FontFamily = MiSansFontFamily;
|
StatusTextBlock.FontFamily = MiSansFontFamily;
|
||||||
@@ -115,12 +148,33 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
_refreshTimer.Stop();
|
_refreshTimer.Stop();
|
||||||
CancelRefreshRequest();
|
CancelRefreshRequest();
|
||||||
DisposeNewsBitmaps();
|
DisposeNewsBitmaps();
|
||||||
|
ClearExtraNewsRows();
|
||||||
UpdateRefreshButtonState();
|
UpdateRefreshButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
{
|
{
|
||||||
ApplyCellSize(_currentCellSize);
|
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)
|
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -161,6 +215,19 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
e.Handled = true;
|
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)
|
private async Task RefreshNewsAsync(bool forceRefresh)
|
||||||
{
|
{
|
||||||
if (!_isAttached || _isRefreshing)
|
if (!_isAttached || _isRefreshing)
|
||||||
@@ -181,6 +248,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
{
|
{
|
||||||
var query = new DailyNewsQuery(
|
var query = new DailyNewsQuery(
|
||||||
Locale: _languageCode,
|
Locale: _languageCode,
|
||||||
|
ItemCount: ResolveDesiredNewsItemCount(),
|
||||||
ForceRefresh: forceRefresh);
|
ForceRefresh: forceRefresh);
|
||||||
var result = await _recommendationService.GetDailyNewsAsync(query, cts.Token);
|
var result = await _recommendationService.GetDailyNewsAsync(query, cts.Token);
|
||||||
if (!_isAttached || cts.IsCancellationRequested)
|
if (!_isAttached || cts.IsCancellationRequested)
|
||||||
@@ -222,90 +290,266 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
|
private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var desiredCount = ResolveDesiredNewsItemCount();
|
||||||
var items = snapshot.Items is null
|
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 item1 = items.Length > 0 ? items[0] : null;
|
||||||
var item2 = items.Length > 1 ? items[1] : null;
|
var item2 = items.Length > 1 ? items[1] : null;
|
||||||
|
|
||||||
News1PrefixTextBlock.IsVisible = item1 is not null;
|
UpdateHotHeadlineText(item1?.Title);
|
||||||
News1TitleTextBlock.Text = NormalizeCompactText(item1?.Title);
|
|
||||||
News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title);
|
News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title);
|
||||||
|
|
||||||
_newsUrls[0] = NormalizeHttpUrl(item1?.Url);
|
_newsUrls.Clear();
|
||||||
_newsUrls[1] = NormalizeHttpUrl(item2?.Url);
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
_newsUrls.Add(NormalizeHttpUrl(item.Url));
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderExtraNewsRows(items.Skip(2).ToArray());
|
||||||
UpdateNewsInteractionState();
|
UpdateNewsInteractionState();
|
||||||
|
|
||||||
StatusTextBlock.IsVisible = false;
|
StatusTextBlock.IsVisible = false;
|
||||||
UpdateAdaptiveLayout();
|
UpdateAdaptiveLayout();
|
||||||
|
|
||||||
var loadTasks = new[]
|
var loadTasks = items
|
||||||
{
|
.Select(item => TryDownloadBitmapAsync(item.ImageUrl, cancellationToken))
|
||||||
TryDownloadBitmapAsync(item1?.ImageUrl, cancellationToken),
|
.ToArray();
|
||||||
TryDownloadBitmapAsync(item2?.ImageUrl, cancellationToken)
|
|
||||||
};
|
|
||||||
var bitmaps = await Task.WhenAll(loadTasks);
|
var bitmaps = await Task.WhenAll(loadTasks);
|
||||||
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
||||||
{
|
{
|
||||||
bitmaps[0]?.Dispose();
|
foreach (var bitmap in bitmaps)
|
||||||
bitmaps[1]?.Dispose();
|
{
|
||||||
|
bitmap?.Dispose();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SetNewsBitmap(0, bitmaps[0]);
|
var consumed = new bool[bitmaps.Length];
|
||||||
SetNewsBitmap(1, bitmaps[1]);
|
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()
|
private void ApplyLoadingState()
|
||||||
{
|
{
|
||||||
_newsUrls[0] = null;
|
_activeNewsItems = [];
|
||||||
_newsUrls[1] = null;
|
_newsUrls.Clear();
|
||||||
News1PrefixTextBlock.IsVisible = true;
|
UpdateHotHeadlineText(L("cnrnews.widget.loading_title", "Loading headlines"));
|
||||||
News1TitleTextBlock.Text = L("cnrnews.widget.loading_title", "正在获取新闻热点");
|
News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "Please wait");
|
||||||
News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "请稍候");
|
StatusTextBlock.Text = L("cnrnews.widget.loading", "Loading...");
|
||||||
StatusTextBlock.Text = L("cnrnews.widget.loading", "加载中...");
|
|
||||||
StatusTextBlock.IsVisible = true;
|
StatusTextBlock.IsVisible = true;
|
||||||
|
SetNewsBitmap(0, null);
|
||||||
|
SetNewsBitmap(1, null);
|
||||||
|
RenderExtraNewsRows([]);
|
||||||
UpdateNewsInteractionState();
|
UpdateNewsInteractionState();
|
||||||
UpdateAdaptiveLayout();
|
UpdateAdaptiveLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyFailedState()
|
private void ApplyFailedState()
|
||||||
{
|
{
|
||||||
_newsUrls[0] = null;
|
_activeNewsItems = [];
|
||||||
_newsUrls[1] = null;
|
_newsUrls.Clear();
|
||||||
News1PrefixTextBlock.IsVisible = false;
|
News1TitleTextBlock.Inlines = null;
|
||||||
News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "央广网新闻暂不可用");
|
News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "CNR news is temporarily unavailable");
|
||||||
News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "点击右上角稍后重试");
|
News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "Tap refresh and try again");
|
||||||
StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "新闻获取失败");
|
StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "News fetch failed");
|
||||||
StatusTextBlock.IsVisible = true;
|
StatusTextBlock.IsVisible = true;
|
||||||
SetNewsBitmap(0, null);
|
SetNewsBitmap(0, null);
|
||||||
SetNewsBitmap(1, null);
|
SetNewsBitmap(1, null);
|
||||||
|
RenderExtraNewsRows([]);
|
||||||
UpdateNewsInteractionState();
|
UpdateNewsInteractionState();
|
||||||
UpdateAdaptiveLayout();
|
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<DailyNewsItemSnapshot> 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()
|
private void UpdateAdaptiveLayout()
|
||||||
{
|
{
|
||||||
var scale = ResolveScale();
|
var scale = ResolveScale();
|
||||||
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
|
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.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||||
RootBorder.Padding = new Thickness(
|
RootBorder.Padding = new Thickness(0);
|
||||||
Math.Clamp(16 * scale, 8, 28),
|
|
||||||
Math.Clamp(12 * scale, 6, 20),
|
|
||||||
Math.Clamp(16 * scale, 8, 28),
|
|
||||||
Math.Clamp(12 * scale, 6, 20));
|
|
||||||
|
|
||||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36));
|
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||||
CardBorder.Padding = new Thickness(
|
CardBorder.Padding = new Thickness(
|
||||||
Math.Clamp(16 * scale, 8, 24),
|
Math.Clamp(16 * scale, 8, 24),
|
||||||
Math.Clamp(14 * scale, 7, 22),
|
Math.Clamp(14 * scale, 7, 22),
|
||||||
Math.Clamp(16 * scale, 8, 24),
|
Math.Clamp(16 * scale, 8, 24),
|
||||||
Math.Clamp(14 * scale, 7, 22));
|
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;
|
BrandPrimaryTextBlock.FontSize = headlineFont;
|
||||||
BrandSecondaryTextBlock.FontSize = headlineFont;
|
BrandSecondaryTextBlock.FontSize = headlineFont;
|
||||||
|
|
||||||
@@ -314,10 +558,10 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
RefreshButton.Height = refreshHeight;
|
RefreshButton.Height = refreshHeight;
|
||||||
RefreshButton.Width = refreshWidth;
|
RefreshButton.Width = refreshWidth;
|
||||||
RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d);
|
RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d);
|
||||||
RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 26);
|
RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 24);
|
||||||
RefreshLabelTextBlock.FontSize = Math.Clamp(25 * scale, 12, 32);
|
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);
|
var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94);
|
||||||
News1ImageHost.Width = imageWidth;
|
News1ImageHost.Width = imageWidth;
|
||||||
News1ImageHost.Height = imageHeight;
|
News1ImageHost.Height = imageHeight;
|
||||||
@@ -332,19 +576,45 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth);
|
||||||
NewsItem2Grid.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;
|
News1TitleTextBlock.MaxWidth = availableTextWidth;
|
||||||
News2TitleTextBlock.MaxWidth = availableTextWidth;
|
News2TitleTextBlock.MaxWidth = availableTextWidth;
|
||||||
|
|
||||||
var newsFont = Math.Clamp(25 * scale, 11, 32);
|
var newsFont = Math.Clamp(21 * scale, 10.5, 28);
|
||||||
News1PrefixTextBlock.FontSize = newsFont;
|
|
||||||
News1TitleTextBlock.FontSize = newsFont;
|
News1TitleTextBlock.FontSize = newsFont;
|
||||||
News2TitleTextBlock.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);
|
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24);
|
||||||
|
News1TitleTextBlock.MaxLines = 2;
|
||||||
|
News2TitleTextBlock.MaxLines = 2;
|
||||||
|
|
||||||
var compactLayout = totalHeight < _currentCellSize * 1.7;
|
foreach (var row in _extraNewsRows)
|
||||||
News1TitleTextBlock.MaxLines = compactLayout ? 1 : 2;
|
{
|
||||||
News2TitleTextBlock.MaxLines = compactLayout ? 1 : 2;
|
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()
|
private void UpdateRefreshButtonState()
|
||||||
@@ -357,13 +627,24 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private void UpdateNewsInteractionState()
|
private void UpdateNewsInteractionState()
|
||||||
{
|
{
|
||||||
var item1Enabled = !string.IsNullOrWhiteSpace(_newsUrls[0]);
|
var item1Enabled = _newsUrls.Count > 0 && !string.IsNullOrWhiteSpace(_newsUrls[0]);
|
||||||
var item2Enabled = !string.IsNullOrWhiteSpace(_newsUrls[1]);
|
var item2Enabled = _newsUrls.Count > 1 && !string.IsNullOrWhiteSpace(_newsUrls[1]);
|
||||||
|
|
||||||
NewsItem1Grid.IsHitTestVisible = item1Enabled;
|
NewsItem1Grid.IsHitTestVisible = item1Enabled;
|
||||||
NewsItem2Grid.IsHitTestVisible = item2Enabled;
|
NewsItem2Grid.IsHitTestVisible = item2Enabled;
|
||||||
NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72;
|
NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72;
|
||||||
NewsItem2Grid.Opacity = item2Enabled ? 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<Bitmap?> TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
|
private static async Task<Bitmap?> TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
|
||||||
@@ -406,7 +687,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
|
|
||||||
private void TryOpenNewsUrl(int index)
|
private void TryOpenNewsUrl(int index)
|
||||||
{
|
{
|
||||||
if (index < 0 || index >= _newsUrls.Length)
|
if (index < 0 || index >= _newsUrls.Count)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -532,3 +813,4 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
|
|||||||
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
LanMountainDesktop/Views/Components/DailySentenceWidget.axaml
Normal file
126
LanMountainDesktop/Views/Components/DailySentenceWidget.axaml
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<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 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 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>
|
||||||
869
LanMountainDesktop/Views/Components/DailySentenceWidget.axaml.cs
Normal file
869
LanMountainDesktop/Views/Components/DailySentenceWidget.axaml.cs
Normal file
@@ -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<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);
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,16 @@
|
|||||||
|
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
CornerRadius="34"
|
CornerRadius="34"
|
||||||
Background="#D5D5D5"
|
Background="Transparent"
|
||||||
ClipToBounds="True"
|
ClipToBounds="True"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
Padding="16,12,16,12">
|
Padding="0">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Border x:Name="CardBorder"
|
<Border x:Name="CardBorder"
|
||||||
Background="#FBFAF8"
|
Background="#FCFBFA"
|
||||||
CornerRadius="24"
|
CornerRadius="34"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
Padding="16,14,16,14">
|
Padding="16,14,16,14">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid IsHitTestVisible="False">
|
<Grid IsHitTestVisible="False">
|
||||||
|
|||||||
@@ -223,13 +223,9 @@ public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRe
|
|||||||
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
|
||||||
|
|
||||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||||
RootBorder.Padding = new Thickness(
|
RootBorder.Padding = new Thickness(0);
|
||||||
Math.Clamp(16 * scale, 8, 26),
|
|
||||||
Math.Clamp(12 * scale, 6, 20),
|
|
||||||
Math.Clamp(16 * scale, 8, 26),
|
|
||||||
Math.Clamp(12 * scale, 6, 20));
|
|
||||||
|
|
||||||
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36));
|
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
||||||
CardBorder.Padding = new Thickness(
|
CardBorder.Padding = new Thickness(
|
||||||
Math.Clamp(16 * scale, 8, 24),
|
Math.Clamp(16 * scale, 8, 24),
|
||||||
Math.Clamp(14 * scale, 7, 22),
|
Math.Clamp(14 * scale, 7, 22),
|
||||||
|
|||||||
@@ -234,6 +234,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
|||||||
"component.daily_word",
|
"component.daily_word",
|
||||||
() => new DailyWordWidget(),
|
() => new DailyWordWidget(),
|
||||||
cellSize => Math.Clamp(cellSize * 0.34, 14, 30)),
|
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(
|
new DesktopComponentRuntimeRegistration(
|
||||||
BuiltInComponentIds.DesktopCnrDailyNews,
|
BuiltInComponentIds.DesktopCnrDailyNews,
|
||||||
"component.cnr_daily_news",
|
"component.cnr_daily_news",
|
||||||
|
|||||||
@@ -1366,6 +1366,14 @@ public partial class MainWindow
|
|||||||
new ComponentScaleRule(WidthUnit: 2, HeightUnit: 1, MinScale: 2));
|
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))
|
if (string.Equals(componentId, BuiltInComponentIds.DesktopStudyNoiseCurve, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// Keep noise curve widget in a 2:1 ratio with minimum 4x2.
|
// Keep noise curve widget in a 2:1 ratio with minimum 4x2.
|
||||||
|
|||||||
Reference in New Issue
Block a user