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 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(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 SplitIntoClauses(string text) { var clauses = new List(); 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; } }