Files
LanMountainDesktop/LanMontainDesktop/Views/Components/DailyPoetryWidget.axaml.cs
lincube e8276c4d1e 0.2.7
修改天气组件,ci工作流
2026-03-04 02:02:34 +08:00

1040 lines
35 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMontainDesktop.Models;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly char[] NaturalBreakChars =
[
'\uFF0C',
'\u3002',
'\uFF01',
'\uFF1F',
'\uFF1B',
'\u3001',
'\uFF1A',
',',
'.',
'!',
'?',
';',
':',
'-',
'\u00B7'
];
private static readonly HashSet<char> NaturalBreakCharSet = new(NaturalBreakChars);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMontainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationBackendService();
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const double MinPoetryFontSize = 12;
private const double MinAuthorFontSize = 10.5;
private readonly record struct TextFitResult(double FontSize, FontWeight FontWeight, double LineHeight);
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 string _languageCode = "zh-CN";
private double _currentCellSize = 48;
private bool _isAttached;
private bool _isRefreshing;
private bool? _isNightModeApplied;
private string _poetryRawText = string.Empty;
private string _authorRawText = string.Empty;
public DailyPoetryWidget()
{
InitializeComponent();
PoetryContentTextBlock.FontFamily = MiSansFontFamily;
AuthorTextBlock.FontFamily = MiSansFontFamily;
_refreshTimer.Tick += OnRefreshTimerTick;
RefreshButton.Click += OnRefreshButtonClick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
SizeChanged += OnSizeChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyLoadingState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
RootBorder.Padding = new Thickness(
Math.Clamp(20 * scale, 10, 34),
Math.Clamp(16 * scale, 8, 28),
Math.Clamp(20 * scale, 10, 34),
Math.Clamp(14 * scale, 7, 24));
QuoteMarkTextBlock.FontSize = Math.Clamp(80 * scale, 32, 120);
QuoteMarkTextBlock.LineHeight = Math.Clamp(68 * scale, 26, 100);
QuoteMarkTextBlock.Margin = new Thickness(Math.Clamp(1 * scale, 0, 3), 0, 0, 0);
PoetryContentTextBlock.Margin = new Thickness(
Math.Clamp(8 * scale, 4, 16),
Math.Clamp(2 * scale, 0, 8),
0,
0);
AuthorPanel.Margin = new Thickness(0, Math.Clamp(5 * scale, 2, 10), Math.Clamp(4 * scale, 2, 8), 0);
AuthorAccent.Width = Math.Clamp(6 * scale, 3.2, 9.5);
AuthorAccent.Height = Math.Clamp(24 * scale, 12, 34);
AuthorAccent.Margin = new Thickness(0, 0, Math.Clamp(8 * scale, 4, 13), 0);
AuthorAccent.CornerRadius = new CornerRadius(Math.Clamp(3 * scale, 1.5, 4.5));
StatusTextBlock.FontSize = Math.Clamp(17 * scale, 9, 26);
DayDecorationCanvas.Width = Math.Clamp(170 * scale, 88, 248);
DayDecorationCanvas.Height = Math.Clamp(118 * scale, 62, 174);
DayDecorationCanvas.Margin = new Thickness(
0,
Math.Clamp(36 * scale, 16, 56),
Math.Clamp(16 * scale, 8, 24),
0);
var refreshTouchSize = Math.Clamp(42 * scale, 24, 52);
RefreshButton.Width = refreshTouchSize;
RefreshButton.Height = refreshTouchSize;
RefreshButton.CornerRadius = new CornerRadius(refreshTouchSize / 2d);
RefreshButton.Margin = new Thickness(
0,
Math.Clamp(12 * scale, 4, 20),
Math.Clamp(16 * scale, 6, 24),
0);
RefreshGlyphTextBlock.FontSize = Math.Clamp(26 * scale, 14, 34);
RefreshGlyphTextBlock.LineHeight = RefreshGlyphTextBlock.FontSize;
WavePath.StrokeThickness = Math.Clamp(3.0 * scale, 1.2, 4.2);
ApplyModeVisualIfNeeded(force: true);
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
UpdateRefreshButtonState();
ApplyModeVisualIfNeeded();
_refreshTimer.Start();
_ = RefreshPoetryAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
UpdateRefreshButtonState();
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
if (_isRefreshing)
{
return;
}
await RefreshPoetryAsync(forceRefresh: true);
e.Handled = true;
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
ApplyModeVisualIfNeeded(force: true);
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshPoetryAsync(forceRefresh: false);
}
private async Task RefreshPoetryAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateRefreshButtonState();
UpdateLanguageCode();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new DailyPoetryQuery(
Locale: _languageCode,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetDailyPoetryAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
ApplySnapshot(result.Data);
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
UpdateRefreshButtonState();
}
}
private void ApplySnapshot(DailyPoetrySnapshot snapshot)
{
_poetryRawText = NormalizePoetryContent(snapshot.Content);
_authorRawText = ResolveAuthor(snapshot);
StatusTextBlock.IsVisible = false;
ApplyModeVisualIfNeeded(force: true);
}
private string ResolveAuthor(DailyPoetrySnapshot snapshot)
{
if (!string.IsNullOrWhiteSpace(snapshot.Author))
{
if (!string.IsNullOrWhiteSpace(snapshot.Origin))
{
return $"{snapshot.Origin.Trim()} \u00B7 {snapshot.Author.Trim()}";
}
return snapshot.Author.Trim();
}
if (!string.IsNullOrWhiteSpace(snapshot.Origin))
{
return snapshot.Origin.Trim();
}
return L("poetry.widget.unknown_author", "Unknown");
}
private static string NormalizePoetryContent(string? content)
{
if (string.IsNullOrWhiteSpace(content))
{
return string.Empty;
}
return content
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Trim();
}
private void ApplyLoadingState()
{
_poetryRawText = L("poetry.widget.loading_content", "Loading...");
_authorRawText = L("poetry.widget.loading_author", "...");
StatusTextBlock.IsVisible = false;
ApplyModeVisualIfNeeded(force: true);
}
private void ApplyFailedState()
{
_poetryRawText = L("poetry.widget.fallback_content", "Poetry is temporarily unavailable.");
_authorRawText = L("poetry.widget.fallback_author", "Try again later");
StatusTextBlock.Text = L("poetry.widget.fetch_failed", "Poetry fetch failed");
StatusTextBlock.IsVisible = true;
ApplyModeVisualIfNeeded(force: true);
}
private void ApplyModeVisualIfNeeded(bool force = false)
{
var isNightMode = ResolveIsNightMode();
if (!force && _isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
{
return;
}
_isNightModeApplied = isNightMode;
ApplyModeVisual(isNightMode);
}
private void ApplyModeVisual(bool isNightMode)
{
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
var scale = ResolveScale();
if (isNightMode)
{
RootBorder.Background = CreateBrush("#C5070D");
RootBorder.Padding = new Thickness(
Math.Clamp(20 * scale, 10, 34),
Math.Clamp(15 * scale, 7, 24),
Math.Clamp(20 * scale, 10, 34),
Math.Clamp(14 * scale, 7, 24));
QuoteMarkTextBlock.IsVisible = true;
QuoteMarkTextBlock.Foreground = CreateBrush("#4AF4C5A6");
QuoteMarkTextBlock.FontWeight = ToVariableWeight(610);
PoetryContentTextBlock.Foreground = CreateBrush("#F4D7A7");
PoetryContentTextBlock.VerticalAlignment = totalHeight >= _currentCellSize * 1.88
? Avalonia.Layout.VerticalAlignment.Center
: Avalonia.Layout.VerticalAlignment.Top;
PoetryContentTextBlock.Margin = new Thickness(Math.Clamp(10 * scale, 4, 18), Math.Clamp(2 * scale, 0, 6), 0, 0);
AuthorTextBlock.Foreground = CreateBrush("#F4D7A7");
AuthorAccent.Background = CreateBrush("#63F2AF90");
AuthorPanel.Margin = new Thickness(
0,
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(1 * scale, 0, 3));
DayDecorationCanvas.IsVisible = false;
RefreshButton.IsVisible = true;
RefreshButton.Background = CreateBrush("#24F8D7B2");
RefreshGlyphTextBlock.Foreground = CreateBrush("#EED7B2");
StatusTextBlock.Foreground = CreateBrush("#D9FFFFFF");
}
else
{
RootBorder.Background = CreateBrush("#F2F2F3");
RootBorder.Padding = new Thickness(
Math.Clamp(20 * scale, 10, 34),
Math.Clamp(14 * scale, 6, 24),
Math.Clamp(20 * scale, 10, 34),
Math.Clamp(14 * scale, 7, 24));
QuoteMarkTextBlock.IsVisible = false;
PoetryContentTextBlock.Foreground = CreateBrush("#0F1218");
PoetryContentTextBlock.VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
PoetryContentTextBlock.Margin = new Thickness(Math.Clamp(6 * scale, 2, 12), 0, 0, 0);
AuthorTextBlock.Foreground = CreateBrush("#272D38");
AuthorAccent.Background = CreateBrush("#C8090D");
AuthorPanel.Margin = new Thickness(
0,
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(2 * scale, 0, 4));
DayDecorationCanvas.IsVisible = true;
RefreshButton.IsVisible = true;
RefreshButton.Background = CreateBrush("#0DA6ADB7");
RefreshGlyphTextBlock.Foreground = CreateBrush("#90959D");
WavePath.Stroke = CreateBrush("#B0B6BE");
MountainBackPath.Fill = CreateBrush("#112A2E36");
MountainFrontPath.Fill = CreateBrush("#182A2E36");
StatusTextBlock.Foreground = CreateBrush("#8A8F98");
}
UpdateRefreshButtonState();
ApplyAdaptiveTextLayout(isNightMode, scale, totalWidth, totalHeight);
}
private bool ResolveIsNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush solidBrush)
{
return CalculateRelativeLuminance(solidBrush.Color) < 0.45;
}
return false;
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
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 double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.52, 2.2);
var widthScale = Bounds.Width > 1
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.52, 2.2)
: 1;
var heightScale = Bounds.Height > 1
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.52, 2.2)
: 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.52, 2.2);
}
private void ApplyAdaptiveTextLayout(bool isNightMode, double scale, double totalWidth, double totalHeight)
{
var padding = RootBorder.Padding;
var innerWidth = Math.Max(84, totalWidth - padding.Left - padding.Right);
var innerHeight = Math.Max(56, totalHeight - padding.Top - padding.Bottom);
var showDayDecorations = !isNightMode &&
innerWidth >= Math.Max(_currentCellSize * 2.75, 146) &&
innerHeight >= Math.Max(_currentCellSize * 1.02, 62);
DayDecorationCanvas.IsVisible = showDayDecorations;
RefreshButton.IsVisible = true;
var refreshReservedWidth = RefreshButton.Width + Math.Clamp(8 * scale, 5, 14);
var decorationReservedWidth = showDayDecorations
? Math.Clamp(innerWidth * 0.24, 34, 96)
: 0;
var quoteReservedWidth = QuoteMarkTextBlock.IsVisible
? Math.Clamp(10 * scale, 5, 16)
: 0;
var poemReservedRight = Math.Max(refreshReservedWidth, decorationReservedWidth);
var poemWidth = innerWidth - poemReservedRight - quoteReservedWidth;
var poemMinWidth = Math.Max(66, innerWidth * 0.56);
if (poemWidth < poemMinWidth)
{
poemWidth = poemMinWidth;
}
poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth);
var authorMaxLines = innerWidth < Math.Max(_currentCellSize * 5.2, 252) ? 2 : 1;
var authorUnitsTarget = authorMaxLines == 1 ? 20 : 12;
var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8));
var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, authorMaxLines);
var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 12, 34);
var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.72, MinAuthorFontSize, authorPreferredFontSize);
var authorMinWeight = isNightMode ? 500 : 470;
var authorMaxWeight = isNightMode ? 650 : 600;
authorPrepared = EnsureTextFitsAtMinSize(
preparedText: authorPrepared,
sourceText: _authorRawText,
targetUnits: authorUnitsTarget,
maxLines: authorMaxLines,
maxWidth: authorWidth,
maxHeight: Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)),
minFontSize: authorMinFontSize,
minFontWeight: ToVariableWeight(authorMinWeight),
lineHeightFactor: 1.12);
var authorFit = FitTextStable(
authorPrepared,
authorWidth,
Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)),
minFontSize: authorMinFontSize,
maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42),
maxLines: authorMaxLines,
lineHeightFactor: 1.12,
minWeight: authorMinWeight,
maxWeight: authorMaxWeight);
AuthorTextBlock.Text = authorPrepared;
AuthorTextBlock.TextWrapping = authorMaxLines > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
AuthorTextBlock.MaxLines = authorMaxLines;
AuthorTextBlock.MaxWidth = authorWidth;
AuthorTextBlock.FontSize = authorFit.FontSize;
AuthorTextBlock.LineHeight = authorFit.LineHeight;
AuthorTextBlock.FontWeight = authorFit.FontWeight;
AuthorPanel.MaxWidth = authorWidth + AuthorAccent.Width + AuthorAccent.Margin.Right + Math.Clamp(4 * scale, 2, 8);
var authorMeasured = MeasureTextSize(
authorPrepared,
authorFit.FontSize,
authorFit.FontWeight,
authorWidth,
authorFit.LineHeight);
var authorHeight = Math.Min(authorMeasured.Height, authorFit.LineHeight * authorMaxLines);
var authorBlockHeight = Math.Max(authorHeight, AuthorAccent.Height) +
AuthorPanel.Margin.Top +
AuthorPanel.Margin.Bottom +
Math.Clamp(4 * scale, 2, 8);
var poemMaxLines = innerHeight < _currentCellSize * 1.58
? 4
: innerHeight < _currentCellSize * 2.05
? 3
: 2;
var poemUnitsTarget = EstimateTargetUnitsPerLine(poemWidth, scale, isNightMode);
var poemPrepared = PreparePoetryText(_poetryRawText, poemUnitsTarget, poemMaxLines);
var poemHeight = Math.Max(30, innerHeight - authorBlockHeight);
var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 16, 56);
var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.72, MinPoetryFontSize, poemPreferredFontSize);
var poemMinWeight = isNightMode ? 540 : 500;
var poemMaxWeight = isNightMode ? 760 : 680;
poemPrepared = EnsureTextFitsAtMinSize(
preparedText: poemPrepared,
sourceText: _poetryRawText,
targetUnits: poemUnitsTarget,
maxLines: poemMaxLines,
maxWidth: poemWidth,
maxHeight: poemHeight,
minFontSize: poemMinFontSize,
minFontWeight: ToVariableWeight(poemMinWeight),
lineHeightFactor: 1.1);
var poemFit = FitTextStable(
poemPrepared,
poemWidth,
poemHeight,
minFontSize: poemMinFontSize,
maxFontSize: Math.Clamp(poemPreferredFontSize * 1.20, poemMinFontSize, 62),
maxLines: poemMaxLines,
lineHeightFactor: 1.10,
minWeight: poemMinWeight,
maxWeight: poemMaxWeight);
PoetryContentTextBlock.Text = poemPrepared;
PoetryContentTextBlock.MaxWidth = poemWidth;
PoetryContentTextBlock.MaxLines = poemMaxLines;
PoetryContentTextBlock.FontSize = poemFit.FontSize;
PoetryContentTextBlock.LineHeight = poemFit.LineHeight;
PoetryContentTextBlock.FontWeight = poemFit.FontWeight;
}
private void UpdateRefreshButtonState()
{
RefreshButton.IsEnabled = !_isRefreshing;
RefreshGlyphTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0;
RefreshButton.Opacity = _isAttached ? 1.0 : 0.85;
}
private static string PrepareAuthorText(string? rawText, int targetUnits, int maxLines)
{
var normalized = NormalizeCompactText(rawText);
if (string.IsNullOrWhiteSpace(normalized))
{
return string.Empty;
}
var separatorIndex = normalized.IndexOf(" \u00B7 ", StringComparison.Ordinal);
if (separatorIndex > 0 && maxLines > 1)
{
normalized = string.Concat(
normalized.AsSpan(0, separatorIndex),
" ",
normalized.AsSpan(separatorIndex + 3));
}
var wrapped = WrapByUnits(RemoveLineBreaks(normalized), targetUnits, maxLines);
if (!string.IsNullOrWhiteSpace(wrapped))
{
return wrapped;
}
var compact = RemoveLineBreaks(normalized);
var fallbackLength = Math.Max(2, Math.Min(compact.Length, Math.Max(4, targetUnits)));
return compact[..fallbackLength];
}
private static string PreparePoetryText(string? rawText, int targetUnits, int maxLines)
{
var normalized = NormalizePoetryContent(rawText);
if (string.IsNullOrWhiteSpace(normalized))
{
return string.Empty;
}
var compact = RemoveLineBreaks(normalized);
var wrapped = WrapByUnits(compact, targetUnits, maxLines);
if (!string.IsNullOrWhiteSpace(wrapped))
{
return wrapped;
}
var fallbackLength = Math.Max(4, Math.Min(compact.Length, Math.Max(8, targetUnits * maxLines)));
return compact[..fallbackLength];
}
private static string EnsureTextFitsAtMinSize(
string preparedText,
string? sourceText,
int targetUnits,
int maxLines,
double maxWidth,
double maxHeight,
double minFontSize,
FontWeight minFontWeight,
double lineHeightFactor)
{
var compactPrepared = RemoveLineBreaks(preparedText);
var compactSource = RemoveLineBreaks(sourceText);
var effectiveSource = string.IsNullOrWhiteSpace(compactSource) ? compactPrepared : compactSource;
if (string.IsNullOrWhiteSpace(effectiveSource))
{
return string.Empty;
}
var safeTargetUnits = Math.Max(4, targetUnits);
var safeMaxLines = Math.Max(1, maxLines);
var candidate = string.IsNullOrWhiteSpace(compactPrepared)
? WrapByUnits(effectiveSource, safeTargetUnits, safeMaxLines)
: WrapByUnits(compactPrepared, safeTargetUnits, safeMaxLines);
if (DoesTextFit(candidate, maxWidth, maxHeight, safeMaxLines, minFontSize, minFontWeight, lineHeightFactor))
{
return candidate;
}
var budget = Math.Max(
safeTargetUnits + 1,
Math.Min(effectiveSource.Length, safeTargetUnits * safeMaxLines + 4));
var minimumBudget = Math.Max(
4,
Math.Min(effectiveSource.Length, (int)Math.Ceiling(safeTargetUnits * (safeMaxLines - 0.35))));
var step = Math.Max(1, safeTargetUnits / 3);
while (budget > minimumBudget)
{
budget -= step;
candidate = WrapByUnits(
TruncateAtNaturalBoundary(effectiveSource, budget),
safeTargetUnits,
safeMaxLines);
if (DoesTextFit(candidate, maxWidth, maxHeight, safeMaxLines, minFontSize, minFontWeight, lineHeightFactor))
{
return candidate;
}
}
var tightenedUnits = Math.Max(3, safeTargetUnits - 1);
var tightenedBudget = Math.Max(3, Math.Min(effectiveSource.Length, tightenedUnits * safeMaxLines - 1));
candidate = WrapByUnits(
TruncateAtNaturalBoundary(effectiveSource, tightenedBudget),
tightenedUnits,
safeMaxLines);
if (string.IsNullOrWhiteSpace(candidate))
{
var fallbackLength = Math.Max(2, Math.Min(effectiveSource.Length, tightenedUnits));
candidate = WrapByUnits(effectiveSource[..fallbackLength], tightenedUnits, safeMaxLines);
}
return candidate;
}
private static string WrapByUnits(string? text, int targetUnitsPerLine, int maxLines)
{
var normalized = RemoveLineBreaks(text);
if (string.IsNullOrWhiteSpace(normalized))
{
return string.Empty;
}
var target = Math.Max(4, targetUnitsPerLine);
var lineLimit = Math.Max(1, maxLines);
var clauses = SplitIntoClauses(normalized);
var lines = new List<string>(lineLimit);
var current = new StringBuilder();
var truncated = false;
foreach (var clause in clauses)
{
var remain = clause.Trim();
while (remain.Length > 0)
{
if (lines.Count >= lineLimit)
{
truncated = true;
break;
}
if (current.Length == 0)
{
if (EstimateDisplayUnits(remain) <= target || lines.Count == lineLimit - 1)
{
current.Append(remain);
remain = string.Empty;
}
else
{
var splitIndex = FindSplitIndexByUnits(remain, target);
if (splitIndex <= 0 || splitIndex >= remain.Length)
{
splitIndex = Math.Max(1, remain.Length / 2);
}
current.Append(remain.AsSpan(0, splitIndex));
lines.Add(current.ToString().Trim());
current.Clear();
remain = remain[splitIndex..].TrimStart();
}
continue;
}
var merged = current + remain;
if (EstimateDisplayUnits(merged) <= target || lines.Count == lineLimit - 1)
{
current.Append(remain);
remain = string.Empty;
}
else
{
lines.Add(current.ToString().Trim());
current.Clear();
}
}
if (truncated)
{
break;
}
}
if (current.Length > 0 && lines.Count < lineLimit)
{
lines.Add(current.ToString().Trim());
}
lines = lines.Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
if (lines.Count == 0)
{
lines.Add(normalized);
}
if (lines.Count > lineLimit)
{
var prefix = lines.Take(lineLimit - 1).ToList();
var tail = string.Concat(lines.Skip(lineLimit - 1));
prefix.Add(tail);
lines = prefix;
truncated = true;
}
if (truncated && lines.Count > 0)
{
lines[^1] = AppendEllipsis(lines[^1]);
}
return string.Join("\n", lines);
}
private static List<string> SplitIntoClauses(string text)
{
var clauses = new List<string>();
var builder = new StringBuilder();
foreach (var ch in text)
{
builder.Append(ch);
if (NaturalBreakCharSet.Contains(ch))
{
clauses.Add(builder.ToString());
builder.Clear();
}
}
if (builder.Length > 0)
{
clauses.Add(builder.ToString());
}
return clauses;
}
private static int EstimateTargetUnitsPerLine(double width, double scale, bool isNightMode)
{
var referenceFont = Math.Clamp((isNightMode ? 20 : 19) * scale, 11, 32);
var target = (int)Math.Floor(width / Math.Max(7.2, referenceFont * 0.74));
return Math.Clamp(target, 6, 36);
}
private static string TruncateAtNaturalBoundary(string? text, int maxChars)
{
var normalized = RemoveLineBreaks(text);
if (string.IsNullOrWhiteSpace(normalized))
{
return string.Empty;
}
if (normalized.Length <= maxChars)
{
return normalized;
}
var budget = Math.Max(1, maxChars - 1);
var head = normalized[..Math.Min(budget, normalized.Length)];
var cut = head.LastIndexOfAny(NaturalBreakChars);
if (cut < (int)(head.Length * 0.55))
{
cut = head.Length;
}
var trimmed = head[..Math.Max(1, cut)].TrimEnd(NaturalBreakChars);
if (string.IsNullOrWhiteSpace(trimmed))
{
trimmed = head.Trim();
}
return AppendEllipsis(trimmed);
}
private static string AppendEllipsis(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return "…";
}
var trimmed = text.TrimEnd(NaturalBreakChars).TrimEnd();
return trimmed.EndsWith("…", StringComparison.Ordinal)
? trimmed
: $"{trimmed}…";
}
private static bool DoesTextFit(
string text,
double maxWidth,
double maxHeight,
int maxLines,
double fontSize,
FontWeight fontWeight,
double lineHeightFactor)
{
if (string.IsNullOrWhiteSpace(text))
{
return true;
}
var lineHeight = fontSize * lineHeightFactor;
var measured = MeasureTextSize(text, fontSize, fontWeight, maxWidth, lineHeight);
var lineCount = Math.Max(1, (int)Math.Ceiling(measured.Height / Math.Max(1, lineHeight)));
return measured.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
private static string RemoveLineBreaks(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return text
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal)
.Trim();
}
private static int FindSplitIndexByUnits(string text, double targetUnits)
{
var units = 0d;
for (var i = 0; i < text.Length; i++)
{
units += text[i] <= 127 ? 0.56 : 1d;
if (units >= targetUnits)
{
return i + 1;
}
}
return text.Length;
}
private static double EstimateDisplayUnits(string text)
{
var units = 0d;
foreach (var ch in text)
{
units += ch <= 127 ? 0.56 : 1d;
}
return units;
}
private static TextFitResult FitTextStable(
string? text,
double maxWidth,
double maxHeight,
double minFontSize,
double maxFontSize,
int maxLines,
double lineHeightFactor,
double minWeight,
double maxWeight)
{
var normalizedText = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
var min = Math.Max(6, minFontSize);
var max = Math.Max(min, maxFontSize);
var low = min;
var high = max;
var bestSize = min;
var bestWeight = ToVariableWeight(minWeight);
for (var i = 0; i < 22; i++)
{
var candidate = (low + high) / 2d;
var progress = max <= min
? 0
: Math.Clamp((candidate - min) / (max - min), 0, 1);
var candidateWeight = ToVariableWeight(Lerp(minWeight, maxWeight, progress));
var lineHeight = candidate * lineHeightFactor;
var measured = MeasureTextSize(normalizedText, candidate, candidateWeight, Math.Max(1, maxWidth), lineHeight);
var lineCount = Math.Max(1, (int)Math.Ceiling(measured.Height / Math.Max(1, lineHeight)));
var fits = measured.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
if (fits)
{
bestSize = candidate;
bestWeight = candidateWeight;
low = candidate;
}
else
{
high = candidate;
}
}
var lineHeightResult = bestSize * lineHeightFactor;
return new TextFitResult(bestSize, bestWeight, lineHeightResult);
}
private static Size MeasureTextSize(
string text,
double fontSize,
FontWeight fontWeight,
double maxWidth,
double lineHeight)
{
var probe = new TextBlock
{
Text = text,
FontFamily = MiSansFontFamily,
FontSize = fontSize,
FontWeight = fontWeight,
TextWrapping = TextWrapping.Wrap,
LineHeight = lineHeight
};
probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
return probe.DesiredSize;
}
private static FontWeight ToVariableWeight(double weight)
{
return (FontWeight)(int)Math.Clamp(Math.Round(weight), 1, 1000);
}
private static double Lerp(double from, double to, double t)
{
return from + (to - from) * Math.Clamp(t, 0, 1);
}
private static IBrush CreateBrush(string colorHex)
{
return new SolidColorBrush(Color.Parse(colorHex));
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
}