2026-03-04 02:02:34 +08:00
|
|
|
|
using System;
|
|
|
|
|
|
using System.Collections.Generic;
|
2026-03-05 17:09:46 +08:00
|
|
|
|
using System.Diagnostics;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
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;
|
2026-03-05 17:09:46 +08:00
|
|
|
|
using Avalonia.Input;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
using Avalonia.Media;
|
|
|
|
|
|
using Avalonia.Media.Imaging;
|
|
|
|
|
|
using Avalonia.Threading;
|
2026-03-08 04:22:19 +08:00
|
|
|
|
using LanMountainDesktop.ComponentSystem;
|
2026-03-04 15:22:52 +08:00
|
|
|
|
using LanMountainDesktop.Models;
|
|
|
|
|
|
using LanMountainDesktop.Services;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
2026-03-04 15:22:52 +08:00
|
|
|
|
namespace LanMountainDesktop.Views.Components;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
2026-03-08 04:22:19 +08:00
|
|
|
|
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
2026-03-04 02:02:34 +08:00
|
|
|
|
{
|
|
|
|
|
|
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
|
|
|
|
|
|
new Dictionary<DayOfWeek, string>
|
|
|
|
|
|
{
|
|
|
|
|
|
[DayOfWeek.Monday] = "星期一",
|
|
|
|
|
|
[DayOfWeek.Tuesday] = "星期二",
|
|
|
|
|
|
[DayOfWeek.Wednesday] = "星期三",
|
|
|
|
|
|
[DayOfWeek.Thursday] = "星期四",
|
|
|
|
|
|
[DayOfWeek.Friday] = "星期五",
|
|
|
|
|
|
[DayOfWeek.Saturday] = "星期六",
|
|
|
|
|
|
[DayOfWeek.Sunday] = "星期日"
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
|
2026-03-04 15:22:52 +08:00
|
|
|
|
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
|
2026-03-05 18:46:32 +08:00
|
|
|
|
private static readonly FontWeight[] TitleWeightCandidates = new[] { FontWeight.Bold, FontWeight.SemiBold, FontWeight.Medium, FontWeight.Normal };
|
|
|
|
|
|
private static readonly FontWeight[] ArtistWeightCandidates = new[] { FontWeight.SemiBold, FontWeight.Medium, FontWeight.Normal };
|
|
|
|
|
|
private static readonly FontWeight[] SecondaryWeightCandidates = new[] { FontWeight.Medium, FontWeight.Normal, FontWeight.Light };
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
private static readonly HttpClient ImageHttpClient = new()
|
|
|
|
|
|
{
|
|
|
|
|
|
Timeout = TimeSpan.FromSeconds(10)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-04 16:43:10 +08:00
|
|
|
|
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
private readonly DispatcherTimer _refreshTimer = new()
|
|
|
|
|
|
{
|
|
|
|
|
|
Interval = TimeSpan.FromHours(6)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
private readonly AppSettingsService _settingsService = new();
|
2026-03-08 04:22:19 +08:00
|
|
|
|
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
2026-03-04 02:02:34 +08:00
|
|
|
|
private readonly LocalizationService _localizationService = new();
|
|
|
|
|
|
|
|
|
|
|
|
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
|
|
|
|
|
private CancellationTokenSource? _refreshCts;
|
|
|
|
|
|
private Bitmap? _currentArtworkBitmap;
|
|
|
|
|
|
private string _languageCode = "zh-CN";
|
|
|
|
|
|
private double _currentCellSize = BaseCellSize;
|
|
|
|
|
|
private bool _isAttached;
|
|
|
|
|
|
private bool _isRefreshing;
|
2026-03-08 04:22:19 +08:00
|
|
|
|
private string _componentId = BuiltInComponentIds.DesktopDailyArtwork;
|
|
|
|
|
|
private string _placementId = string.Empty;
|
2026-03-05 17:09:46 +08:00
|
|
|
|
private string? _currentArtworkSourceUrl;
|
|
|
|
|
|
private string? _currentArtworkImageUrl;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
public DailyArtworkWidget()
|
|
|
|
|
|
{
|
|
|
|
|
|
InitializeComponent();
|
|
|
|
|
|
|
|
|
|
|
|
DateTextBlock.FontFamily = MiSansFontFamily;
|
|
|
|
|
|
WeekdayTextBlock.FontFamily = MiSansFontFamily;
|
|
|
|
|
|
PaintingTitleTextBlock.FontFamily = MiSansFontFamily;
|
|
|
|
|
|
ArtistTextBlock.FontFamily = MiSansFontFamily;
|
|
|
|
|
|
YearTextBlock.FontFamily = MiSansFontFamily;
|
|
|
|
|
|
|
|
|
|
|
|
_refreshTimer.Tick += OnRefreshTimerTick;
|
|
|
|
|
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
|
|
|
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
|
|
|
|
SizeChanged += OnSizeChanged;
|
|
|
|
|
|
|
|
|
|
|
|
ApplyCellSize(_currentCellSize);
|
|
|
|
|
|
UpdateLanguageCode();
|
|
|
|
|
|
UpdateDateLabels();
|
|
|
|
|
|
ApplyLoadingState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void ApplyCellSize(double cellSize)
|
|
|
|
|
|
{
|
|
|
|
|
|
_currentCellSize = Math.Max(1, cellSize);
|
|
|
|
|
|
var scale = ResolveScale();
|
|
|
|
|
|
|
|
|
|
|
|
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
|
|
|
|
|
|
|
|
|
|
|
|
InfoPanel.Padding = new Thickness(
|
|
|
|
|
|
Math.Clamp(18 * scale, 10, 28),
|
|
|
|
|
|
Math.Clamp(14 * scale, 8, 22),
|
|
|
|
|
|
Math.Clamp(18 * scale, 10, 28),
|
|
|
|
|
|
Math.Clamp(14 * scale, 8, 22));
|
|
|
|
|
|
|
|
|
|
|
|
DateInfoStack.Margin = new Thickness(
|
2026-03-05 13:02:15 +08:00
|
|
|
|
Math.Clamp(18 * scale, 8, 30),
|
2026-03-04 02:02:34 +08:00
|
|
|
|
0,
|
|
|
|
|
|
0,
|
2026-03-05 13:02:15 +08:00
|
|
|
|
Math.Clamp(16 * scale, 8, 26));
|
2026-03-05 17:09:46 +08:00
|
|
|
|
DateInfoStack.Spacing = Math.Clamp(4 * scale, 2, 10);
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 10, 24);
|
|
|
|
|
|
|
|
|
|
|
|
BrickPatternCanvas.Opacity = Math.Clamp(0.44 * scale, 0.20, 0.50);
|
|
|
|
|
|
|
|
|
|
|
|
UpdateAdaptiveLayout();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 16:43:10 +08:00
|
|
|
|
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
|
|
|
|
|
|
{
|
|
|
|
|
|
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
|
|
|
|
|
|
if (_isAttached)
|
|
|
|
|
|
{
|
|
|
|
|
|
_ = RefreshArtworkAsync(forceRefresh: false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 12:34:39 +08:00
|
|
|
|
public void RefreshFromSettings()
|
|
|
|
|
|
{
|
|
|
|
|
|
_recommendationService.ClearCache();
|
|
|
|
|
|
if (_isAttached)
|
|
|
|
|
|
{
|
|
|
|
|
|
_ = RefreshArtworkAsync(forceRefresh: true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 04:22:19 +08:00
|
|
|
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
|
|
|
|
|
{
|
|
|
|
|
|
_componentId = string.IsNullOrWhiteSpace(componentId)
|
|
|
|
|
|
? BuiltInComponentIds.DesktopDailyArtwork
|
|
|
|
|
|
: componentId.Trim();
|
|
|
|
|
|
_placementId = placementId?.Trim() ?? string.Empty;
|
|
|
|
|
|
RefreshFromSettings();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
|
|
|
|
|
{
|
|
|
|
|
|
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
|
|
|
|
|
RefreshFromSettings();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 02:02:34 +08:00
|
|
|
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
_isAttached = true;
|
|
|
|
|
|
_refreshTimer.Start();
|
|
|
|
|
|
_ = RefreshArtworkAsync(forceRefresh: false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
_isAttached = false;
|
|
|
|
|
|
_refreshTimer.Stop();
|
|
|
|
|
|
CancelRefreshRequest();
|
|
|
|
|
|
DisposeArtworkBitmap();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
ApplyCellSize(_currentCellSize);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async void OnRefreshTimerTick(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
await RefreshArtworkAsync(forceRefresh: false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 17:09:46 +08:00
|
|
|
|
private void OnArtworkPanelPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ = RefreshArtworkAsync(forceRefresh: true);
|
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void OnInfoPanelPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TryOpenArtworkSourceUrl();
|
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 02:02:34 +08:00
|
|
|
|
private async Task RefreshArtworkAsync(bool forceRefresh)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!_isAttached || _isRefreshing)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_isRefreshing = true;
|
|
|
|
|
|
UpdateLanguageCode();
|
|
|
|
|
|
UpdateDateLabels();
|
|
|
|
|
|
|
|
|
|
|
|
var cts = new CancellationTokenSource();
|
|
|
|
|
|
var previous = Interlocked.Exchange(ref _refreshCts, cts);
|
|
|
|
|
|
previous?.Cancel();
|
|
|
|
|
|
previous?.Dispose();
|
|
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var query = new DailyArtworkQuery(
|
|
|
|
|
|
Locale: _languageCode,
|
2026-03-08 04:22:19 +08:00
|
|
|
|
MirrorSource: ResolveMirrorSource(),
|
2026-03-04 02:02:34 +08:00
|
|
|
|
ForceRefresh: forceRefresh);
|
|
|
|
|
|
var result = await _recommendationService.GetDailyArtworkAsync(query, cts.Token);
|
|
|
|
|
|
if (!_isAttached || cts.IsCancellationRequested)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!result.Success || result.Data is null)
|
|
|
|
|
|
{
|
|
|
|
|
|
ApplyFailedState();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await ApplySnapshotAsync(result.Data, cts.Token);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Ignore canceled requests.
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
if (_isAttached && !cts.IsCancellationRequested)
|
|
|
|
|
|
{
|
|
|
|
|
|
ApplyFailedState();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
finally
|
|
|
|
|
|
{
|
|
|
|
|
|
if (ReferenceEquals(_refreshCts, cts))
|
|
|
|
|
|
{
|
|
|
|
|
|
_refreshCts = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cts.Dispose();
|
|
|
|
|
|
_isRefreshing = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async Task ApplySnapshotAsync(DailyArtworkSnapshot snapshot, CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
PaintingTitleTextBlock.Text = BuildQuotedTitle(snapshot.Title);
|
|
|
|
|
|
|
|
|
|
|
|
var artist = string.IsNullOrWhiteSpace(snapshot.Artist)
|
|
|
|
|
|
? L("artwork.widget.unknown_artist", "Unknown artist")
|
|
|
|
|
|
: snapshot.Artist.Trim();
|
|
|
|
|
|
ArtistTextBlock.Text = NormalizeCompactText(artist);
|
|
|
|
|
|
|
|
|
|
|
|
YearTextBlock.Text = ResolveYearText(snapshot);
|
2026-03-05 17:09:46 +08:00
|
|
|
|
_currentArtworkSourceUrl = snapshot.ArtworkUrl;
|
|
|
|
|
|
_currentArtworkImageUrl = snapshot.ImageUrl;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
StatusTextBlock.IsVisible = false;
|
|
|
|
|
|
|
|
|
|
|
|
UpdateAdaptiveLayout();
|
|
|
|
|
|
|
2026-03-05 12:34:39 +08:00
|
|
|
|
var bitmap = await TryLoadArtworkBitmapAsync(snapshot.ImageUrl, snapshot.ThumbnailDataUrl, cancellationToken);
|
2026-03-04 02:02:34 +08:00
|
|
|
|
if (cancellationToken.IsCancellationRequested || !_isAttached)
|
|
|
|
|
|
{
|
|
|
|
|
|
bitmap?.Dispose();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
SetArtworkBitmap(bitmap);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 12:34:39 +08:00
|
|
|
|
private static async Task<Bitmap?> TryLoadArtworkBitmapAsync(
|
|
|
|
|
|
string? imageUrl,
|
|
|
|
|
|
string? thumbnailDataUrl,
|
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var candidateUrl in BuildImageUrlCandidates(imageUrl))
|
|
|
|
|
|
{
|
|
|
|
|
|
var remoteBitmap = await TryDownloadBitmapAsync(candidateUrl, cancellationToken);
|
|
|
|
|
|
if (remoteBitmap is not null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return remoteBitmap;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return TryDecodeBitmapFromDataUrl(thumbnailDataUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static IEnumerable<string> BuildImageUrlCandidates(string? imageUrl)
|
2026-03-04 02:02:34 +08:00
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
2026-03-05 12:34:39 +08:00
|
|
|
|
{
|
|
|
|
|
|
yield break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var normalizedUrl = imageUrl.Trim();
|
|
|
|
|
|
yield return normalizedUrl;
|
|
|
|
|
|
|
|
|
|
|
|
const string preferredSizeSegment = "/full/843,/0/default.jpg";
|
|
|
|
|
|
if (normalizedUrl.Contains(preferredSizeSegment, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
{
|
|
|
|
|
|
yield return normalizedUrl.Replace(
|
|
|
|
|
|
preferredSizeSegment,
|
|
|
|
|
|
"/full/1024,/0/default.jpg",
|
|
|
|
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static async Task<Bitmap?> TryDownloadBitmapAsync(string imageUrl, CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
var withReferrer = await SendImageRequestAsync(imageUrl, includeReferrer: true, cancellationToken);
|
|
|
|
|
|
if (withReferrer is not null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return withReferrer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await SendImageRequestAsync(imageUrl, includeReferrer: false, cancellationToken);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static async Task<Bitmap?> SendImageRequestAsync(
|
|
|
|
|
|
string imageUrl,
|
|
|
|
|
|
bool includeReferrer,
|
|
|
|
|
|
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");
|
|
|
|
|
|
if (includeReferrer && Uri.TryCreate(imageUrl, UriKind.Absolute, out var imageUri))
|
|
|
|
|
|
{
|
|
|
|
|
|
request.Headers.Referrer = new Uri($"{imageUri.Scheme}://{imageUri.Host}/", UriKind.Absolute);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-04 02:02:34 +08:00
|
|
|
|
{
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-03-05 12:34:39 +08:00
|
|
|
|
}
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
2026-03-05 12:34:39 +08:00
|
|
|
|
private static Bitmap? TryDecodeBitmapFromDataUrl(string? dataUrl)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(dataUrl))
|
2026-03-04 02:02:34 +08:00
|
|
|
|
{
|
2026-03-05 12:34:39 +08:00
|
|
|
|
return null;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 12:34:39 +08:00
|
|
|
|
var trimmed = dataUrl.Trim();
|
|
|
|
|
|
var markerIndex = trimmed.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
|
if (markerIndex < 0 || markerIndex + 7 >= trimmed.Length)
|
2026-03-04 02:02:34 +08:00
|
|
|
|
{
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 12:34:39 +08:00
|
|
|
|
var base64Payload = trimmed[(markerIndex + 7)..];
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var bytes = Convert.FromBase64String(base64Payload);
|
|
|
|
|
|
return new Bitmap(new MemoryStream(bytes));
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-03-04 02:02:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void ApplyLoadingState()
|
|
|
|
|
|
{
|
2026-03-05 17:09:46 +08:00
|
|
|
|
_currentArtworkSourceUrl = null;
|
|
|
|
|
|
_currentArtworkImageUrl = null;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
StatusTextBlock.IsVisible = true;
|
|
|
|
|
|
StatusTextBlock.Text = L("artwork.widget.loading", "Loading...");
|
|
|
|
|
|
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.loading_title", "Daily Artwork"));
|
|
|
|
|
|
ArtistTextBlock.Text = L("artwork.widget.loading_subtitle", "Fetching today's masterpiece");
|
|
|
|
|
|
YearTextBlock.Text = "--";
|
|
|
|
|
|
UpdateAdaptiveLayout();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void ApplyFailedState()
|
|
|
|
|
|
{
|
2026-03-05 17:09:46 +08:00
|
|
|
|
_currentArtworkSourceUrl = null;
|
|
|
|
|
|
_currentArtworkImageUrl = null;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
StatusTextBlock.IsVisible = true;
|
|
|
|
|
|
StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed");
|
|
|
|
|
|
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.fallback_title", "Daily Artwork"));
|
2026-03-04 16:43:10 +08:00
|
|
|
|
ArtistTextBlock.Text = L("artwork.widget.fallback_artist", "Recommendation service unavailable");
|
2026-03-04 02:02:34 +08:00
|
|
|
|
YearTextBlock.Text = L("artwork.widget.fallback_year", "Try again later");
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
var leftStar = totalWidth < _currentCellSize * 4.2 ? 2.0 : 2.08;
|
|
|
|
|
|
MainLayoutGrid.ColumnDefinitions[0].Width = new GridLength(leftStar, GridUnitType.Star);
|
|
|
|
|
|
MainLayoutGrid.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star);
|
|
|
|
|
|
|
|
|
|
|
|
var rightPanelWidth = Math.Max(84, totalWidth / (leftStar + 1));
|
|
|
|
|
|
var rightContentWidth = Math.Max(58, rightPanelWidth - InfoPanel.Padding.Left - InfoPanel.Padding.Right);
|
|
|
|
|
|
var leftPanelWidth = Math.Max(84, totalWidth - rightPanelWidth);
|
|
|
|
|
|
var leftContentWidth = Math.Max(52, leftPanelWidth - DateInfoStack.Margin.Left - 10);
|
2026-03-05 17:09:46 +08:00
|
|
|
|
var leftContentHeight = Math.Max(30, totalHeight - DateInfoStack.Margin.Bottom - 10);
|
|
|
|
|
|
|
|
|
|
|
|
var dateStackSpacing = Math.Clamp(4 * scale, 2, 10);
|
|
|
|
|
|
DateInfoStack.Spacing = dateStackSpacing;
|
|
|
|
|
|
DateInfoStack.MaxWidth = leftContentWidth;
|
|
|
|
|
|
var leftSingleLineHeight = Math.Max(12, (leftContentHeight - dateStackSpacing) / 2d);
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
2026-03-05 13:02:15 +08:00
|
|
|
|
var dateBase = Math.Clamp(44 * scale, 16, 62);
|
2026-03-04 02:02:34 +08:00
|
|
|
|
DateTextBlock.FontSize = FitFontSize(
|
|
|
|
|
|
DateTextBlock.Text,
|
|
|
|
|
|
leftContentWidth,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
leftSingleLineHeight,
|
2026-03-04 02:02:34 +08:00
|
|
|
|
maxLines: 1,
|
2026-03-05 13:02:15 +08:00
|
|
|
|
minFontSize: Math.Max(12, dateBase * 0.68),
|
2026-03-04 02:02:34 +08:00
|
|
|
|
maxFontSize: dateBase,
|
|
|
|
|
|
weight: FontWeight.Bold,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
lineHeightFactor: 1.10);
|
|
|
|
|
|
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.10;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
WeekdayTextBlock.FontSize = FitFontSize(
|
|
|
|
|
|
WeekdayTextBlock.Text,
|
|
|
|
|
|
leftContentWidth,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
leftSingleLineHeight,
|
2026-03-04 02:02:34 +08:00
|
|
|
|
maxLines: 1,
|
2026-03-05 13:02:15 +08:00
|
|
|
|
minFontSize: Math.Max(12, dateBase * 0.68),
|
2026-03-04 02:02:34 +08:00
|
|
|
|
maxFontSize: dateBase,
|
|
|
|
|
|
weight: FontWeight.Bold,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
lineHeightFactor: 1.10);
|
|
|
|
|
|
WeekdayTextBlock.LineHeight = WeekdayTextBlock.FontSize * 1.10;
|
|
|
|
|
|
|
|
|
|
|
|
var rightContentHeight = Math.Max(42, totalHeight - InfoPanel.Padding.Top - InfoPanel.Padding.Bottom);
|
|
|
|
|
|
var titleBottomMargin = Math.Clamp(8 * scale, 4, 14);
|
|
|
|
|
|
var separatorBottomMargin = Math.Clamp(10 * scale, 4, 14);
|
|
|
|
|
|
var bottomStackSpacing = Math.Clamp(3 * scale, 2, 8);
|
|
|
|
|
|
var reservedHeight = titleBottomMargin + separatorBottomMargin + bottomStackSpacing + 3;
|
|
|
|
|
|
var textHeightBudget = Math.Max(24, rightContentHeight - reservedHeight);
|
2026-03-04 02:02:34 +08:00
|
|
|
|
var titleBase = Math.Clamp(44 * scale, 16, 58);
|
2026-03-05 18:46:32 +08:00
|
|
|
|
var artistBase = Math.Clamp(26 * scale, 11, 34);
|
|
|
|
|
|
var yearBase = Math.Clamp(22 * scale, 10, 30);
|
|
|
|
|
|
var titleMin = Math.Max(9.2, titleBase * 0.42);
|
|
|
|
|
|
var artistMin = Math.Max(8.4, artistBase * 0.50);
|
|
|
|
|
|
var yearMin = Math.Max(8.0, yearBase * 0.54);
|
|
|
|
|
|
|
|
|
|
|
|
var titleDemand = Math.Clamp(NormalizeCompactText(PaintingTitleTextBlock.Text).Length, 6, 96);
|
|
|
|
|
|
var artistDemand = Math.Clamp(NormalizeCompactText(ArtistTextBlock.Text).Length, 4, 72);
|
|
|
|
|
|
var yearDemand = Math.Clamp(NormalizeCompactText(YearTextBlock.Text).Length, 2, 48);
|
|
|
|
|
|
|
|
|
|
|
|
var minTitleHeight = Math.Max(10, titleMin * 1.10 * 2);
|
|
|
|
|
|
var minArtistHeight = Math.Max(8, artistMin * 1.14);
|
|
|
|
|
|
var minYearHeight = Math.Max(8, yearMin * 1.08);
|
|
|
|
|
|
var minTextHeightTotal = minTitleHeight + minArtistHeight + minYearHeight;
|
|
|
|
|
|
|
|
|
|
|
|
double titleHeightBudget;
|
|
|
|
|
|
double artistHeightBudget;
|
|
|
|
|
|
double yearHeightBudget;
|
|
|
|
|
|
if (textHeightBudget <= minTextHeightTotal + 0.6)
|
|
|
|
|
|
{
|
|
|
|
|
|
var compression = textHeightBudget / Math.Max(1, minTextHeightTotal);
|
|
|
|
|
|
titleHeightBudget = Math.Max(9, minTitleHeight * compression);
|
|
|
|
|
|
artistHeightBudget = Math.Max(7, minArtistHeight * compression);
|
|
|
|
|
|
yearHeightBudget = Math.Max(7, minYearHeight * compression);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
var extraHeight = textHeightBudget - minTextHeightTotal;
|
|
|
|
|
|
var titleWeight = titleDemand + 8d;
|
|
|
|
|
|
var artistWeight = artistDemand + 4d;
|
|
|
|
|
|
var yearWeight = yearDemand + 2d;
|
|
|
|
|
|
var weightSum = Math.Max(1d, titleWeight + artistWeight + yearWeight);
|
|
|
|
|
|
|
|
|
|
|
|
titleHeightBudget = minTitleHeight + extraHeight * (titleWeight / weightSum);
|
|
|
|
|
|
artistHeightBudget = minArtistHeight + extraHeight * (artistWeight / weightSum);
|
|
|
|
|
|
yearHeightBudget = minYearHeight + extraHeight * (yearWeight / weightSum);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var titleLayout = FitAdaptiveTextLayout(
|
2026-03-04 02:02:34 +08:00
|
|
|
|
PaintingTitleTextBlock.Text,
|
|
|
|
|
|
rightContentWidth,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
titleHeightBudget,
|
2026-03-05 18:46:32 +08:00
|
|
|
|
minLines: 2,
|
|
|
|
|
|
maxLines: 5,
|
|
|
|
|
|
minFontSize: titleMin,
|
2026-03-04 02:02:34 +08:00
|
|
|
|
maxFontSize: titleBase,
|
2026-03-05 18:46:32 +08:00
|
|
|
|
weightCandidates: TitleWeightCandidates,
|
|
|
|
|
|
lineHeightFactor: 1.10);
|
|
|
|
|
|
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
|
|
|
|
|
|
PaintingTitleTextBlock.Margin = new Thickness(0, 0, 0, titleBottomMargin);
|
|
|
|
|
|
PaintingTitleTextBlock.MaxLines = titleLayout.MaxLines;
|
|
|
|
|
|
PaintingTitleTextBlock.FontWeight = titleLayout.Weight;
|
|
|
|
|
|
PaintingTitleTextBlock.FontSize = titleLayout.FontSize;
|
|
|
|
|
|
PaintingTitleTextBlock.LineHeight = titleLayout.LineHeight;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
2026-03-05 17:09:46 +08:00
|
|
|
|
if (ArtistTextBlock.Parent is StackPanel artistInfoStack)
|
|
|
|
|
|
{
|
|
|
|
|
|
artistInfoStack.Spacing = bottomStackSpacing;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 18:46:32 +08:00
|
|
|
|
var artistLayout = FitAdaptiveTextLayout(
|
2026-03-04 02:02:34 +08:00
|
|
|
|
ArtistTextBlock.Text,
|
|
|
|
|
|
rightContentWidth,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
artistHeightBudget,
|
2026-03-05 18:46:32 +08:00
|
|
|
|
minLines: 1,
|
|
|
|
|
|
maxLines: 4,
|
|
|
|
|
|
minFontSize: artistMin,
|
2026-03-04 02:02:34 +08:00
|
|
|
|
maxFontSize: artistBase,
|
2026-03-05 18:46:32 +08:00
|
|
|
|
weightCandidates: ArtistWeightCandidates,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
lineHeightFactor: 1.14);
|
2026-03-05 18:46:32 +08:00
|
|
|
|
ArtistTextBlock.MaxWidth = rightContentWidth;
|
|
|
|
|
|
ArtistTextBlock.MaxLines = artistLayout.MaxLines;
|
|
|
|
|
|
ArtistTextBlock.FontWeight = artistLayout.Weight;
|
|
|
|
|
|
ArtistTextBlock.FontSize = artistLayout.FontSize;
|
|
|
|
|
|
ArtistTextBlock.LineHeight = artistLayout.LineHeight;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
2026-03-05 18:46:32 +08:00
|
|
|
|
var yearLayout = FitAdaptiveTextLayout(
|
2026-03-04 02:02:34 +08:00
|
|
|
|
YearTextBlock.Text,
|
|
|
|
|
|
rightContentWidth,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
yearHeightBudget,
|
2026-03-05 18:46:32 +08:00
|
|
|
|
minLines: 1,
|
|
|
|
|
|
maxLines: 3,
|
|
|
|
|
|
minFontSize: yearMin,
|
2026-03-04 02:02:34 +08:00
|
|
|
|
maxFontSize: yearBase,
|
2026-03-05 18:46:32 +08:00
|
|
|
|
weightCandidates: SecondaryWeightCandidates,
|
2026-03-05 17:09:46 +08:00
|
|
|
|
lineHeightFactor: 1.08);
|
2026-03-05 18:46:32 +08:00
|
|
|
|
YearTextBlock.MaxWidth = rightContentWidth;
|
|
|
|
|
|
YearTextBlock.MaxLines = yearLayout.MaxLines;
|
|
|
|
|
|
YearTextBlock.FontWeight = yearLayout.Weight;
|
|
|
|
|
|
YearTextBlock.FontSize = yearLayout.FontSize;
|
|
|
|
|
|
YearTextBlock.LineHeight = yearLayout.LineHeight;
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
RightPanelSeparator.Width = Math.Clamp(rightContentWidth * 0.58, 42, 136);
|
2026-03-05 17:09:46 +08:00
|
|
|
|
RightPanelSeparator.Margin = new Thickness(0, 0, 0, separatorBottomMargin);
|
2026-03-04 02:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
BrickPatternCanvas.Opacity = totalWidth < _currentCellSize * 4.2
|
|
|
|
|
|
? 0.34
|
|
|
|
|
|
: Math.Clamp(0.44 * scale, 0.24, 0.50);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void SetArtworkBitmap(Bitmap? bitmap)
|
|
|
|
|
|
{
|
|
|
|
|
|
DisposeArtworkBitmap();
|
|
|
|
|
|
_currentArtworkBitmap = bitmap;
|
|
|
|
|
|
ArtworkImage.Source = bitmap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void DisposeArtworkBitmap()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (_currentArtworkBitmap is null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (ReferenceEquals(ArtworkImage.Source, _currentArtworkBitmap))
|
|
|
|
|
|
{
|
|
|
|
|
|
ArtworkImage.Source = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_currentArtworkBitmap.Dispose();
|
|
|
|
|
|
_currentArtworkBitmap = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 17:09:46 +08:00
|
|
|
|
private void TryOpenArtworkSourceUrl()
|
|
|
|
|
|
{
|
|
|
|
|
|
var candidate = _currentArtworkSourceUrl;
|
|
|
|
|
|
if (!TryNormalizeHttpUrl(candidate, out var normalizedUrl) &&
|
|
|
|
|
|
!TryNormalizeHttpUrl(_currentArtworkImageUrl, out normalizedUrl))
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
|
|
|
|
{
|
|
|
|
|
|
FileName = normalizedUrl,
|
|
|
|
|
|
UseShellExecute = true
|
|
|
|
|
|
};
|
|
|
|
|
|
Process.Start(startInfo);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
// Ignore malformed URLs or shell launch failures.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static bool TryNormalizeHttpUrl(string? rawUrl, out string normalizedUrl)
|
|
|
|
|
|
{
|
|
|
|
|
|
normalizedUrl = string.Empty;
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(rawUrl))
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var candidate = rawUrl.Trim();
|
|
|
|
|
|
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
|
|
|
|
|
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalizedUrl = uri.ToString();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 02:02:34 +08:00
|
|
|
|
private void UpdateLanguageCode()
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var snapshot = _settingsService.Load();
|
|
|
|
|
|
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
_languageCode = "zh-CN";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 04:22:19 +08:00
|
|
|
|
private string ResolveMirrorSource()
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
|
|
|
|
|
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
return DailyArtworkMirrorSources.Overseas;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 02:02:34 +08:00
|
|
|
|
private void UpdateDateLabels()
|
|
|
|
|
|
{
|
|
|
|
|
|
var now = DateTime.Now;
|
|
|
|
|
|
DateTextBlock.Text = now.ToString("MM/dd", CultureInfo.InvariantCulture);
|
|
|
|
|
|
|
|
|
|
|
|
if (string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) &&
|
|
|
|
|
|
ZhWeekdays.TryGetValue(now.DayOfWeek, out var weekdayZh))
|
|
|
|
|
|
{
|
|
|
|
|
|
WeekdayTextBlock.Text = weekdayZh;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var culture = ResolveCulture();
|
|
|
|
|
|
WeekdayTextBlock.Text = culture.DateTimeFormat.GetDayName(now.DayOfWeek);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private string ResolveYearText(DailyArtworkSnapshot snapshot)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(snapshot.Year))
|
|
|
|
|
|
{
|
|
|
|
|
|
return snapshot.Year.Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(snapshot.Museum))
|
|
|
|
|
|
{
|
|
|
|
|
|
return snapshot.Museum.Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return "--";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string BuildQuotedTitle(string title)
|
|
|
|
|
|
{
|
|
|
|
|
|
var normalized = NormalizeCompactText(title);
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
|
|
|
|
{
|
|
|
|
|
|
normalized = "Untitled";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return $"“{normalized}”";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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.62, 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 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 18:46:32 +08:00
|
|
|
|
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
|
|
|
|
|
|
: new[] { 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 = ResolveLineCount(measuredSize.Height, 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 int ResolveLineCount(double measuredHeight, double lineHeight)
|
|
|
|
|
|
{
|
|
|
|
|
|
return Math.Max(1, (int)Math.Ceiling(measuredHeight / Math.Max(1, lineHeight)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 02:02:34 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|